Compare commits

...

91 Commits

Author SHA1 Message Date
openhands
af3c2a6742 chore(docker): remove system pip from app image
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-31 22:31:55 +00:00
openhands
cacf0d13a7 chore(docker): pin venv pip and remove system pip
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-31 22:27:07 +00:00
dependabot[bot]
849548a132 chore(deps): bump actions/stale from 9 to 10 (#12261)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: aivong-openhands <ai.vong@openhands.dev>
Co-authored-by: mamoodi <mamoodiha@gmail.com>
2026-03-31 16:34:21 -04:00
dependabot[bot]
c73e22d7cd chore(deps): bump actions/download-artifact from 6 to 7 (#12260)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: aivong-openhands <ai.vong@openhands.dev>
2026-03-31 16:25:58 -04:00
dependabot[bot]
6304f9f4c5 chore(deps): bump actions/checkout from 4 to 6 (#12259)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: aivong-openhands <ai.vong@openhands.dev>
2026-03-31 16:25:24 -04:00
dependabot[bot]
93be4d9d0b chore(deps): bump peter-evans/find-comment from 3 to 4 (#12190)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: aivong-openhands <ai.vong@openhands.dev>
2026-03-31 16:23:51 -04:00
Hiep Le
ec66250e74 feat(backend): develop api to retrieve git organizations for the current organization (#13676) 2026-04-01 01:31:14 +07:00
Engel Nyst
dbd199e77c Validate selected branch names before checkout (#13667)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-31 18:21:21 +02:00
Jamie Chicago
f0c454caf1 Improve README trusted-by logos across light and dark themes (#13659)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-31 12:18:24 -04:00
Hiep Le
df3360005c feat(frontend): add Git Conversation Routing section for org claims UI (#13668) 2026-03-31 22:14:45 +07:00
Jamie Chicago
df4fea6aca Revert "[fix] maintainer doc" (#13673) 2026-03-31 11:09:58 -04:00
Hiep Le
2b3868ddc3 feat(frontend): add feature flag for organization claims resolver routing (#13669) 2026-03-31 21:39:36 +07:00
Joe Laverty
e3c9fa9d05 Remove unused KEYCLOAK_PROVIDER_NAME constant (#13663)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-31 09:46:25 -04:00
Hiep Le
2fec71320a fix(frontend): pin axios version to mitigate supply chain attack (#13670) 2026-03-31 19:29:02 +07:00
Hiep Le
9c0f5d785e fix(backend): persist disabled_skills in SaaS settings store (#13658) 2026-03-31 02:23:08 +07:00
Tim O'Farrell
73ba66faea Handling the new server error event (#13643)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-30 10:56:37 -06:00
aivong-openhands
a198599d91 docs(AGENTS.md): add guidance to preserve tool versions when regenerating lockfiles (#13561)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-30 12:23:39 -04:00
mamoodi
7e20bd51f9 Release 1.6.0 (#13604)
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-30 12:01:16 -04:00
Hiep Le
b75c83d92a fix(frontend): prevent duplicate payment successful toast after Stripe checkout (#13649) 2026-03-30 22:36:35 +07:00
Hiep Le
5528b01c18 refactor(frontend): replace loading spinner with static icon for task tracking (#13625) 2026-03-30 20:32:11 +07:00
Hiep Le
ed5ab11fcc fix: planning agent auth error due to missing base_url (#13638) 2026-03-30 20:32:02 +07:00
Hiep Le
e1afc95b6c fix(frontend): hide right panel when active tab is unpinned (#13648) 2026-03-30 20:31:48 +07:00
Tim O'Farrell
6dd9046ba2 Fix issue where git setup fails on remote sandboxed when grouping. (#13646)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-30 12:58:42 +00:00
Xingyao Wang
9ad47bf43f fix: prevent V0 conversation creation due to settings race condition (#13628)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-30 13:11:25 +01:00
Jathin Sreenivas
b0d8244ad5 fix(frontend): prevent "Unknown event" shown for actions with empty d… (#13639)
Co-authored-by: Jathin Sreenivas <sjathin@amazon.com>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-03-30 16:49:25 +07:00
Karanja
c210d5294f feat: add /new to slash command menu for V1 conversations (#13599) 2026-03-30 15:39:35 +07:00
Tim O'Farrell
c7190ddb30 APP-1153 Fix for issue where popup menu does not display (#13635)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-28 07:47:13 -06:00
Hiep Le
df64ce9668 fix(frontend): reduce padding and gap for chat status indicator (#13624) 2026-03-28 01:39:02 +07:00
Jamie Chicago
f72a9622f6 [fix] maintainer doc (#13632)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-27 14:33:48 -04:00
Tim O'Farrell
193eb34dc7 fix(migration): serialize dict to JSON string in migration 103 (#13634)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-27 18:22:31 +00:00
Hiep Le
87f582db6a fix(frontend): tab icon overflow on mobile devices (#13627) 2026-03-28 00:25:39 +07:00
Hiep Le
4b69370c73 fix(frontend): set max width for toast messages (#13623) 2026-03-28 00:25:26 +07:00
Hiep Le
74ac6e06a1 refactor(frontend): add white background color on learn more button hover (user journey project) (#13621) 2026-03-28 00:25:12 +07:00
Hiep Le
a91dceacfb fix(frontend): add missing border radius to diff view (#13620) 2026-03-28 00:25:01 +07:00
Joe Laverty
98c61e1ee4 feat(enterprise): acquire pg_advisory_lock before running database migrations (#13608) 2026-03-27 23:24:49 +07:00
Tim O'Farrell
3268c29945 APP-1152 Add legacy fallback variable when finding persistence directory (#13629) 2026-03-27 10:18:13 -06:00
Engel Nyst
239e40da75 Fix: restore conversation link in PR bodies created via MCP (#13092)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-27 11:25:34 -04:00
Jamie Chicago
d190d8ee50 Add trusted-by logos to top of README (#13613)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-27 15:32:39 +01:00
aivong-openhands
5f064fa88b PLTF-330: log module funcName and lineno in enterprise (#13612)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-27 09:18:02 -05:00
Vasco Schiavo
8f87ef59c7 feat(frontend): Add view mode toggle (old/diff/new) to file changes viewer (#13519)
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-03-27 19:16:20 +07:00
Vasco Schiavo
fdc6ba82c9 feat(frontend): Display skill ready events as expandable skill list in chat (#13511)
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-03-27 18:57:47 +07:00
Hiep Le
a75038bee0 fix: user does not immediately appear in org after accepting invite in openhands cloud (#13562) 2026-03-27 14:37:38 +07:00
Hiep Le
fbe6eb30cb feat(backend): add organization members financial data endpoint (#13595) 2026-03-27 12:18:46 +07:00
Hiep Le
aeda0ea762 feat(frontend): display toast notification when switching organizations (#13598) 2026-03-27 12:18:17 +07:00
Hiep Le
30b7af31b9 feat(frontend): add contextual info messages on LLM settings page (org project) (#13601) 2026-03-27 12:17:58 +07:00
Hiep Le
05a3916c98 feat(frontend): use LoginCTA in device verify with source-specific Learn more behavior (#13606) 2026-03-27 12:17:38 +07:00
Tim O'Farrell
eba1f60c1d Reduced thrash on sandbox service (#13610)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: OpenHands Bot <contact@all-hands.dev>
2026-03-26 15:29:59 -06:00
OpenHands Bot
024f4d3326 Bump SDK packages to v1.15.0 (#13602)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: tofarr <tofarr@gmail.com>
2026-03-26 14:34:17 -06:00
Ray Myers
3e38f13d12 perf: speed up Docker builds — amd64-only PRs, eliminate cross-layer chmod/chown bloat (#13590)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
2026-03-26 11:57:31 -06:00
Tim O'Farrell
8a61fc824b Fix for issue where messages is null and error occurs (#13592)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-26 08:02:46 -06:00
Hiep Le
6794603963 feat(frontend): update settings UI with section headers and dividers (org project) (#13584) 2026-03-26 12:37:53 +07:00
Hiep Le
9be60bc286 fix: make MCP settings user-specific within organization (#13591) 2026-03-26 11:42:08 +07:00
Xingyao Wang
f7b53283b5 fix(frontend): guard against undefined matcher.hooks in hooks modal (#13589)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-25 18:20:46 +00:00
Tim O'Farrell
3cd85a07b7 APP-1093 fix(frontend): display 'Starting' status when server reports STARTING on conversation resume (#13580)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-25 08:55:39 -04:00
Hiep Le
0b935669f3 fix(backend): clean up orphaned Keycloak users on duplicate email rejection (#13495) 2026-03-25 16:46:20 +07:00
Hiep Le
889754abfd fix: use API key's org_id when creating conversations via API key auth (#13568) 2026-03-25 16:46:06 +07:00
Tim O'Farrell
06cd53d752 APP-1113 fix: Increase polling time for SetTitleCallbackProcessor (#13577)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-24 17:40:40 -06:00
Tim O'Farrell
eb189144f2 APP-1115 Fix for AWS config (Minio) for feature branches (#13579)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-24 17:40:04 -06:00
statxc
c9b2ce2fb9 feat: add user-configurable enable/disable of default global skills w… (#13046)
Co-authored-by: intelliking <intelliking@users.noreply.github.com>
2026-03-24 14:48:22 -06:00
HeyItsChloe
abdc58cd28 feat(frontend): lead capture form (#13496)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-03-24 13:41:35 -07:00
aivong-openhands
9f47727da5 PLTF-330: add timestamp to enterprise JSON logger formatter (#13555)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-24 14:53:14 -05:00
Ash Clarke
19da63aae6 Log all terminal states (error, stuck) in V1 callback processors (#13549)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-24 13:04:39 -05:00
Rohit Malhotra
f1b65d9534 Rename env name (#13570) 2026-03-24 16:38:49 +00:00
aivong-openhands
3516c3cdbe chore(deps): make pythonnet Windows-only dependency (#13515)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-24 11:21:25 -05:00
Tim O'Farrell
1f275a7cfe fix: reuse db session in migrate_customer call causing FK violation (#13558)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 21:10:45 -06:00
Tim O'Farrell
ff240c968b fix: add 30s timeout to LiteLlmManager HTTP client (#13557)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 21:43:02 +00:00
aivong-openhands
36039d2bb8 upgrade setuptools in /enterprise for updated wheel CVE-2026-24049 (#13509)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 16:37:20 -05:00
Tim O'Farrell
45529fa451 Added Falsy check for base url (#13553) 2026-03-23 13:06:25 -06:00
Tim O'Farrell
0fc4b0fb55 Add infinite scroll pagination and filesystem storage support to public share page (#13545)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 12:18:07 -06:00
Tim O'Farrell
810fc340fc Fix count endpoint 500 error (#13548)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 17:40:56 +00:00
Tim O'Farrell
33a0f95dac Small typo fix (#13546) 2026-03-23 15:36:17 +00:00
aivong-openhands
bdd0214266 chore: increase dependabot open-pull-requests-limit to 5 (#13538)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 10:28:32 -05:00
Saurya Velagapudi
7fbb499f03 feat: switch default base image to nikolaik slim variant (#13244)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 10:26:04 -05:00
aivong-openhands
abbfbda450 chore(frontend): update flatted to 3.4.2 (#13503)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 10:26:30 -04:00
John-Mason P. Shackelford
7774f43ca1 feat(frontend): Add /launch route for starting conversations with plugins (#12699)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
Co-authored-by: allhands-bot <allhands-bot@users.noreply.github.com>
2026-03-23 15:06:42 +07:00
Vasco Schiavo
b705b015fa fix(frontend): rounded corners on diff viewer bottom in Changes tab (#13521) 2026-03-23 14:06:23 +07:00
Jathin Sreenivas
1581b95ab9 fix(frontend): Ensure error and status messages wrap correctly within containers (#13522)
Co-authored-by: Jathin Sreenivas <sjathin@amazon.com>
2026-03-23 13:55:49 +07:00
aivong-openhands
94b45c6c36 PLTF-327: upgrade enterprise nodejs to v24 LTS (#13507)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-20 14:42:03 -05:00
dependabot[bot]
cbc380fe49 chore(deps): bump node from 25.2-trixie-slim to 25.8-trixie-slim in /containers/app (#13316)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: aivong-openhands <ai.vong@openhands.dev>
2026-03-20 14:40:23 -05:00
Vasco Schiavo
fb776ef650 feat(frontend): Add copy button to code blocks (#13458)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-20 18:20:25 +07:00
Abi
a75b576f1c fix: treat llm_base_url="" as explicit clear in store_llm_settings (#13471)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 11:14:15 +01:00
Rohit Malhotra
63956c3292 Fix FastAPI Query parameter validation: lte -> le (#13502)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-19 20:27:10 -04:00
chuckbutkus
f75141af3e fix: prevent secrets deletion across organizations when storing secrets (#13500)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-19 19:34:12 -04:00
dependabot[bot]
e4515b21eb chore(deps): bump socket.io-parser from 4.2.5 to 4.2.6 in /frontend in the security-all group across 1 directory (#13474)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-19 17:28:15 -04:00
aivong-openhands
a8f6a35341 fix: patch GLib CVE-2025-14087 in runtime Docker images (#13403)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-19 16:21:24 -05:00
Joe Laverty
f706a217d0 fix: Use commit SHA instead of mutable branch tag for enterprise base (#13498) 2026-03-19 16:24:07 -04:00
aivong-openhands
0137201903 fix: remove vulnerable VSCode extensions in build_from_scratch path (#13399)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Ray Myers <ray.myers@gmail.com>
2026-03-19 19:36:22 +00:00
aivong-openhands
49a98885ab chore: Update OpenSSL in Debian images for security patches (#13401)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-19 14:33:23 -05:00
Hiep Le
38648bddb3 fix(frontend): use correct git path based on sandbox grouping strategy (#13488) 2026-03-20 00:13:02 +07:00
Hiep Le
b44774d2be refactor(frontend): extract AddCreditsModal into separate component file (#13490) 2026-03-20 00:12:48 +07:00
Hiep Le
04330898b6 refactor(frontend): add delay before closing user context menu (#13491) 2026-03-20 00:12:38 +07:00
276 changed files with 15598 additions and 2420 deletions

View File

@@ -4,7 +4,7 @@ updates:
directory: "/"
schedule:
interval: "daily"
open-pull-requests-limit: 1
open-pull-requests-limit: 5
groups:
# put packages in their own group if they have a history of breaking the build or needing to be reverted
pre-commit:
@@ -29,7 +29,7 @@ updates:
directory: "/frontend"
schedule:
interval: "daily"
open-pull-requests-limit: 1
open-pull-requests-limit: 5
groups:
docusaurus:
patterns:
@@ -51,7 +51,7 @@ updates:
schedule:
interval: "weekly"
day: "wednesday"
open-pull-requests-limit: 1
open-pull-requests-limit: 5
groups:
docusaurus:
patterns:
@@ -72,9 +72,11 @@ updates:
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
- package-ecosystem: "docker"
directories:
- "containers/*"
schedule:
interval: "weekly"
open-pull-requests-limit: 5

View File

@@ -12,7 +12,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6

View File

@@ -19,7 +19,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install poetry via pipx
uses: abatilo/actions-poetry@v4

View File

@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout PR branch
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
@@ -34,7 +34,7 @@ jobs:
fi
- name: Find Comment
uses: peter-evans/find-comment@v3
uses: peter-evans/find-comment@v4
id: find-comment
with:
issue-number: ${{ github.event.pull_request.number }}

View File

@@ -24,7 +24,7 @@ jobs:
fail-fast: true
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Node.js
uses: useblacksmith/setup-node@v5
with:

View File

@@ -28,7 +28,7 @@ jobs:
fail-fast: true
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Node.js
uses: useblacksmith/setup-node@v5
with:

View File

@@ -33,34 +33,39 @@ jobs:
runs-on: blacksmith
outputs:
base_image: ${{ steps.define-base-images.outputs.base_image }}
platforms: ${{ steps.define-base-images.outputs.platforms }}
steps:
- name: Define base images
shell: bash
id: define-base-images
run: |
if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
json=$(jq -n -c '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" }
platforms="linux/amd64"
json=$(jq -n -c --arg platforms "$platforms" '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22-slim", tag: "nikolaik", platforms: $platforms }
]')
else
json=$(jq -n -c '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" },
{ image: "ubuntu:24.04", tag: "ubuntu" }
platforms="linux/amd64,linux/arm64"
json=$(jq -n -c --arg platforms "$platforms" '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22-slim", tag: "nikolaik", platforms: $platforms },
{ image: "ubuntu:24.04", tag: "ubuntu", platforms: $platforms }
]')
fi
echo "base_image=$json" >> "$GITHUB_OUTPUT"
echo "platforms=$platforms" >> "$GITHUB_OUTPUT"
# Builds the OpenHands Docker images
ghcr_build_app:
name: Build App Image
runs-on: blacksmith-4vcpu-ubuntu-2204
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
needs: define-matrix
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
@@ -82,7 +87,7 @@ jobs:
- name: Build and push app image
if: "!github.event.pull_request.head.repo.fork"
run: |
./containers/build.sh -i openhands -o ${{ env.REPO_OWNER }} --push
./containers/build.sh -i openhands -o ${{ env.REPO_OWNER }} --push -p ${{ needs.define-matrix.outputs.platforms }}
# Builds the runtime Docker images
ghcr_build_runtime:
@@ -98,7 +103,7 @@ jobs:
base_image: ${{ fromJson(needs.define-matrix.outputs.base_image) }}
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
@@ -136,7 +141,7 @@ jobs:
shell: bash
run: |
./containers/build.sh -i runtime -o ${{ env.REPO_OWNER }} -t ${{ matrix.base_image.tag }} --dry
./containers/build.sh -i runtime -o ${{ env.REPO_OWNER }} -t ${{ matrix.base_image.tag }} --dry -p ${{ matrix.base_image.platforms }}
DOCKER_BUILD_JSON=$(jq -c . < docker-build-dry.json)
echo "DOCKER_TAGS=$(echo "$DOCKER_BUILD_JSON" | jq -r '.tags | join(",")')" >> $GITHUB_ENV
@@ -180,7 +185,7 @@ jobs:
if: github.event.pull_request.head.repo.fork != true
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
@@ -219,11 +224,9 @@ jobs:
- name: Determine app image tag
shell: bash
run: |
# Duplicated with build.sh
sanitized_ref_name=$(echo "$GITHUB_REF_NAME" | sed 's/[^a-zA-Z0-9.-]\+/-/g')
OPENHANDS_BUILD_VERSION=$sanitized_ref_name
sanitized_ref_name=$(echo "$sanitized_ref_name" | tr '[:upper:]' '[:lower:]') # lower case is required in tagging
echo "OPENHANDS_DOCKER_TAG=${sanitized_ref_name}" >> $GITHUB_ENV
# Use the commit SHA to pin the exact app image built by ghcr_build_app,
# rather than a mutable branch tag like "main" which can serve stale cached layers.
echo "OPENHANDS_DOCKER_TAG=${RELEVANT_SHA}" >> $GITHUB_ENV
- name: Build and push Docker image
uses: useblacksmith/build-push-action@v1
with:
@@ -256,7 +259,7 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Get short SHA
id: short_sha

View File

@@ -14,7 +14,7 @@ jobs:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
ref: ${{ github.head_ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
@@ -63,7 +63,7 @@ jobs:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
ref: ${{ github.head_ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}

View File

@@ -21,7 +21,7 @@ jobs:
name: Lint frontend
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Install Node.js 22
uses: useblacksmith/setup-node@v5
with:
@@ -42,7 +42,7 @@ jobs:
name: Lint python
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up python
@@ -59,7 +59,7 @@ jobs:
name: Lint enterprise python
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up python

View File

@@ -27,7 +27,7 @@ jobs:
current-version: ${{ steps.version-check.outputs.current-version }}
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 2 # Need previous commit to compare
@@ -63,7 +63,7 @@ jobs:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Bun
uses: oven-sh/setup-bun@v2

View File

@@ -86,7 +86,7 @@ jobs:
runs-on: "${{ inputs.runner || 'ubuntu-latest' }}"
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6

View File

@@ -30,7 +30,7 @@ jobs:
pull-requests: write
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
@@ -78,7 +78,7 @@ jobs:
matrix:
python-version: ["3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
@@ -111,9 +111,9 @@ jobs:
pull-requests: write
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/download-artifact@v6
- uses: actions/download-artifact@v7
id: download
with:
pattern: coverage-*

View File

@@ -23,7 +23,7 @@ jobs:
(github.event_name == 'workflow_dispatch' && github.event.inputs.reason == 'app server')
|| (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-cli'))
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: useblacksmith/setup-python@v6
with:
python-version: 3.12

View File

@@ -11,7 +11,7 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2204
if: github.repository == 'OpenHands/OpenHands'
steps:
- uses: actions/stale@v9
- uses: actions/stale@v10
with:
stale-issue-message: 'This issue is stale because it has been open for 40 days with no activity. Remove the stale label or leave a comment, otherwise it will be closed in 10 days.'
stale-pr-message: 'This PR is stale because it has been open for 40 days with no activity. Remove the stale label or leave a comment, otherwise it will be closed in 10 days.'

View File

@@ -22,7 +22,7 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2
with:
bun-version-file: "openhands-ui/.bun-version"

View File

@@ -36,6 +36,42 @@ then re-run the command to ensure it passes. Common issues include:
- Be especially careful with `git reset --hard` after staging files, as it will remove accidentally staged files
- When remote has new changes, use `git fetch upstream && git rebase upstream/<branch>` on the same branch
## Lockfile Regeneration (Preserve Original Tool Versions)
When regenerating lockfiles (poetry.lock, uv.lock, etc.), you MUST use the same tool version that originally generated the lockfile to avoid unnecessary diff noise. Each lockfile contains a version header indicating which tool version was used.
### Poetry (poetry.lock)
1. Extract the version from the lockfile header:
```bash
POETRY_VERSION=$(grep -m1 "^# This file is automatically @generated by Poetry" poetry.lock | sed 's/.*Poetry \([0-9.]*\).*/\1/')
```
2. If a version is found, install that specific version:
```bash
pipx install poetry==$POETRY_VERSION --force
```
3. Then regenerate the lockfile:
```bash
poetry lock --no-update
```
### uv (uv.lock)
1. Extract the version from the lockfile header:
```bash
UV_VERSION=$(grep -m1 "^# This file was autogenerated by uv" uv.lock | sed 's/.*uv version \([0-9.]*\).*/\1/')
```
2. If a version is found, install that specific version:
```bash
pipx install uv==$UV_VERSION --force
```
3. Then regenerate the lockfile:
```bash
uv lock
```
This ensures that lockfile updates only contain actual dependency changes, not tool version migration artifacts.
## PR-Specific Artifacts (`.pr/` directory)
When working on a PR that requires design documents, scripts meant for development-only, or other temporary artifacts that should NOT be merged to main, store them in a `.pr/` directory at the repository root.

View File

@@ -23,11 +23,9 @@
<a href="https://www.readme-i18n.com/OpenHands/OpenHands?lang=pt">Português</a> |
<a href="https://www.readme-i18n.com/OpenHands/OpenHands?lang=ru">Русский</a> |
<a href="https://www.readme-i18n.com/OpenHands/OpenHands?lang=zh">中文</a>
</div>
<hr>
🙌 Welcome to OpenHands, a [community](COMMUNITY.md) focused on AI-driven development. Wed love for you to [join us on Slack](https://dub.sh/openhands).
There are a few ways to work with OpenHands:
@@ -84,3 +82,58 @@ All our work is available under the MIT license, except for the `enterprise/` di
The core `openhands` and `agent-server` Docker images are fully MIT-licensed as well.
If you need help with anything, or just want to chat, [come find us on Slack](https://dub.sh/openhands).
<hr>
<div align="center">
<strong>Trusted by engineers at</strong>
<br/><br/>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/tiktok.svg">
<img src="https://assets.openhands.dev/logos/external/black/tiktok.svg" alt="TikTok" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/vmware.svg">
<img src="https://assets.openhands.dev/logos/external/black/vmware.svg" alt="VMware" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/roche.svg">
<img src="https://assets.openhands.dev/logos/external/black/roche.svg" alt="Roche" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/amazon.svg">
<img src="https://assets.openhands.dev/logos/external/black/amazon.svg" alt="Amazon" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/c3-ai.svg">
<img src="https://assets.openhands.dev/logos/external/black/c3-ai.svg" alt="C3 AI" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/netflix.svg">
<img src="https://assets.openhands.dev/logos/external/black/netflix.svg" alt="Netflix" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/mastercard.svg">
<img src="https://assets.openhands.dev/logos/external/black/mastercard.svg" alt="Mastercard" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/red-hat.svg">
<img src="https://assets.openhands.dev/logos/external/black/red-hat.svg" alt="Red Hat" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/mongodb.svg">
<img src="https://assets.openhands.dev/logos/external/black/mongodb.svg" alt="MongoDB" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/apple.svg">
<img src="https://assets.openhands.dev/logos/external/black/apple.svg" alt="Apple" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/nvidia.svg">
<img src="https://assets.openhands.dev/logos/external/black/nvidia.svg" alt="NVIDIA" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/google.svg">
<img src="https://assets.openhands.dev/logos/external/black/google.svg" alt="Google" height="17" hspace="5">
</picture>
</div>

View File

@@ -296,7 +296,7 @@ classpath = "my_package.my_module.MyCustomAgent"
#user_id = 1000
# Container image to use for the sandbox
#base_container_image = "nikolaik/python-nodejs:python3.12-nodejs22"
#base_container_image = "nikolaik/python-nodejs:python3.12-nodejs22-slim"
# Use host network
#use_host_network = false

View File

@@ -1,5 +1,5 @@
ARG OPENHANDS_BUILD_VERSION=dev
FROM node:25.2-trixie-slim AS frontend-builder
FROM node:25.8-trixie-slim AS frontend-builder
WORKDIR /app
@@ -73,6 +73,35 @@ ENV VIRTUAL_ENV=/app/.venv \
COPY --chown=openhands:openhands --chmod=770 --from=backend-builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
# Remove system pip from the image (leave venv pip intact).
# The runtime uses the venv pip because PATH is prefixed with ${VIRTUAL_ENV}/bin.
RUN sudo /usr/local/bin/python3 - <<'PY'
import ensurepip
import shutil
import sysconfig
from pathlib import Path
# Remove the system pip installation to reduce attack surface and avoid scanning both
# system + venv pip. The app uses the venv pip via PATH.
purelib = Path(sysconfig.get_paths()["purelib"])
for pattern in ("pip", "pip-*.dist-info", "pip-*.egg-info"):
for p in purelib.glob(pattern):
if p.is_dir():
shutil.rmtree(p, ignore_errors=True)
else:
p.unlink(missing_ok=True)
bin_dir = Path("/usr/local/bin")
for p in [bin_dir / "pip", bin_dir / "pip3", *bin_dir.glob("pip3.*")]:
p.unlink(missing_ok=True)
bundled = Path(ensurepip.__file__).parent / "_bundled"
if bundled.exists():
for whl in bundled.glob("pip-*.whl"):
whl.unlink(missing_ok=True)
PY
COPY --chown=openhands:openhands --chmod=770 ./skills ./skills
COPY --chown=openhands:openhands --chmod=770 ./openhands ./openhands
COPY --chown=openhands:openhands --chmod=777 ./openhands/runtime/plugins ./openhands/runtime/plugins

View File

@@ -8,15 +8,17 @@ push=0
load=0
tag_suffix=""
dry_run=0
platform_override=""
# Function to display usage information
usage() {
echo "Usage: $0 -i <image_name> [-o <org_name>] [--push] [--load] [-t <tag_suffix>] [--dry]"
echo "Usage: $0 -i <image_name> [-o <org_name>] [--push] [--load] [-t <tag_suffix>] [-p <platform>] [--dry]"
echo " -i: Image name (required)"
echo " -o: Organization name"
echo " --push: Push the image"
echo " --load: Load the image"
echo " -t: Tag suffix"
echo " -p: Platform(s) to build for (e.g. linux/amd64 or linux/amd64,linux/arm64)"
echo " --dry: Don't build, only create build-args.json"
exit 1
}
@@ -29,6 +31,7 @@ while [[ $# -gt 0 ]]; do
--push) push=1; shift ;;
--load) load=1; shift ;;
-t) tag_suffix="$2"; shift 2 ;;
-p) platform_override="$2"; shift 2 ;;
--dry) dry_run=1; shift ;;
*) usage ;;
esac
@@ -134,8 +137,10 @@ fi
echo "Args: $args"
# Modify the platform selection based on --load flag
if [[ $load -eq 1 ]]; then
# Determine the platform(s) to build for
if [[ -n "$platform_override" ]]; then
platform="$platform_override"
elif [[ $load -eq 1 ]]; then
# When loading, build only for the current platform
platform=$(docker version -f '{{.Server.Os}}/{{.Server.Arch}}')
else

View File

@@ -13,7 +13,7 @@ services:
- DOCKER_HOST_ADDR=host.docker.internal
#
- AGENT_SERVER_IMAGE_REPOSITORY=${AGENT_SERVER_IMAGE_REPOSITORY:-ghcr.io/openhands/agent-server}
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-1.12.0-python}
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-1.15.0-python}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -8,7 +8,7 @@ services:
container_name: openhands-app-${DATE:-}
environment:
- AGENT_SERVER_IMAGE_REPOSITORY=${AGENT_SERVER_IMAGE_REPOSITORY:-ghcr.io/openhands/agent-server}
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-1.12.0-python}
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-1.15.0-python}
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -10,7 +10,7 @@ LABEL com.datadoghq.tags.env="${DD_ENV}"
# Apply security updates to fix CVEs
RUN apt-get update && \
apt-get install -y curl && \
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
curl -fsSL https://deb.nodesource.com/setup_24.x | bash - && \
apt-get install -y nodejs && \
apt-get install -y jq gettext && \
# Apply security updates for packages with available fixes

View File

@@ -43,15 +43,20 @@ class GithubV1CallbackProcessor(EventCallbackProcessor):
event: Event,
) -> EventCallbackResult | None:
"""Process events for GitHub V1 integration."""
# Only handle ConversationStateUpdateEvent
# Only handle ConversationStateUpdateEvent for execution_status
if not isinstance(event, ConversationStateUpdateEvent):
return None
# Only act when execution has finished
if not (event.key == 'execution_status' and event.value == 'finished'):
if event.key != 'execution_status':
return None
# Log ALL terminal states for monitoring (finished, error, stuck)
_logger.info('[GitHub V1] Callback agent state was %s', event)
# Only request summary when execution has finished successfully
if event.value != 'finished':
return None
_logger.info(
'[GitHub V1] Should request summary: %s', self.should_request_summary
)

View File

@@ -41,15 +41,20 @@ class GitlabV1CallbackProcessor(EventCallbackProcessor):
event: Event,
) -> EventCallbackResult | None:
"""Process events for GitLab V1 integration."""
# Only handle ConversationStateUpdateEvent
# Only handle ConversationStateUpdateEvent for execution_status
if not isinstance(event, ConversationStateUpdateEvent):
return None
# Only act when execution has finished
if not (event.key == 'execution_status' and event.value == 'finished'):
if event.key != 'execution_status':
return None
# Log ALL terminal states for monitoring (finished, error, stuck)
_logger.info('[GitLab V1] Callback agent state was %s', event)
# Only request summary when execution has finished successfully
if event.value != 'finished':
return None
_logger.info(
'[GitLab V1] Should request summary: %s', self.should_request_summary
)

View File

@@ -40,16 +40,20 @@ class SlackV1CallbackProcessor(EventCallbackProcessor):
event: Event,
) -> EventCallbackResult | None:
"""Process events for Slack V1 integration."""
# Only handle ConversationStateUpdateEvent
# Only handle ConversationStateUpdateEvent for execution_status
if not isinstance(event, ConversationStateUpdateEvent):
return None
# Only act when execution has finished
if not (event.key == 'execution_status' and event.value == 'finished'):
if event.key != 'execution_status':
return None
# Log ALL terminal states for monitoring (finished, error, stuck)
_logger.info('[Slack V1] Callback agent state was %s', event)
# Only request summary when execution has finished successfully
if event.value != 'finished':
return None
try:
summary = await self._request_summary(conversation_id)
await self._post_summary_to_slack(summary)

View File

@@ -100,27 +100,25 @@ async def has_payment_method_by_user_id(user_id: str) -> bool:
return bool(payment_methods.data)
async def migrate_customer(user_id: str, org: Org):
async with a_session_maker() as session:
result = await session.execute(
select(StripeCustomer).where(StripeCustomer.keycloak_user_id == user_id)
)
stripe_customer = result.scalar_one_or_none()
if stripe_customer is None:
return
stripe_customer.org_id = org.id
customer = await stripe.Customer.modify_async(
id=stripe_customer.stripe_customer_id,
email=org.contact_email,
metadata={'user_id': '', 'org_id': str(org.id)},
)
async def migrate_customer(session, user_id: str, org: Org):
result = await session.execute(
select(StripeCustomer).where(StripeCustomer.keycloak_user_id == user_id)
)
stripe_customer = result.scalar_one_or_none()
if stripe_customer is None:
return
stripe_customer.org_id = org.id
customer = await stripe.Customer.modify_async(
id=stripe_customer.stripe_customer_id,
email=org.contact_email,
metadata={'user_id': '', 'org_id': str(org.id)},
)
logger.info(
'migrated_customer',
extra={
'user_id': user_id,
'org_id': str(org.id),
'stripe_customer_id': customer.id,
},
)
await session.commit()
logger.info(
'migrated_customer',
extra={
'user_id': user_id,
'org_id': str(org.id),
'stripe_customer_id': customer.id,
},
)

View File

@@ -8,7 +8,7 @@ logging.getLogger('alembic.runtime.plugins').setLevel(logging.WARNING)
from alembic import context # noqa: E402
from google.cloud.sql.connector import Connector # noqa: E402
from sqlalchemy import create_engine # noqa: E402
from sqlalchemy import create_engine, text # noqa: E402
from storage.base import Base # noqa: E402
target_metadata = Base.metadata
@@ -109,6 +109,10 @@ def run_migrations_online() -> None:
version_table_schema=target_metadata.schema,
)
# Lock number must be unique — md5 hash of 'openhands_enterprise_migrations'
# Lock is released when the connection context manager exits
connection.execute(text('SELECT pg_advisory_lock(3617572382373537863)'))
with context.begin_transaction():
context.run_migrations()

View File

@@ -0,0 +1,28 @@
"""Add disabled_skills to user_settings.
Revision ID: 102
Revises: 101
Create Date: 2026-02-25
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '102'
down_revision: Union[str, None] = '101'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'user_settings', sa.Column('disabled_skills', sa.JSON(), nullable=True)
)
def downgrade() -> None:
op.drop_column('user_settings', 'disabled_skills')

View File

@@ -0,0 +1,42 @@
"""Add mcp_config to org_member for user-specific MCP settings.
Revision ID: 103
Revises: 102
Create Date: 2026-03-26
"""
import json
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '103'
down_revision: Union[str, None] = '102'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('org_member', sa.Column('mcp_config', sa.JSON(), nullable=True))
# Migrate existing org-level MCP configs to all members in each org.
# This preserves existing configurations while transitioning to user-specific settings.
conn = op.get_bind()
orgs_with_config = conn.execute(
sa.text('SELECT id, mcp_config FROM org WHERE mcp_config IS NOT NULL')
).fetchall()
for org_id, mcp_config in orgs_with_config:
conn.execute(
sa.text(
'UPDATE org_member SET mcp_config = :config WHERE org_id = :org_id'
),
{'config': json.dumps(mcp_config), 'org_id': str(org_id)},
)
def downgrade() -> None:
op.drop_column('org_member', 'mcp_config')

View File

@@ -0,0 +1,29 @@
"""Add disabled_skills column to user table.
Migration 102 added disabled_skills to the legacy user_settings table,
but the active SaaS flow (SaasSettingsStore) reads from/writes to the
user table. This migration adds the column where it is actually needed.
Revision ID: 104
Revises: 103
Create Date: 2026-03-31
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '104'
down_revision: Union[str, None] = '103'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('user', sa.Column('disabled_skills', sa.JSON(), nullable=True))
def downgrade() -> None:
op.drop_column('user', 'disabled_skills')

214
enterprise/poetry.lock generated
View File

@@ -1341,6 +1341,7 @@ description = "Generic pure Python loader for .NET runtimes"
optional = false
python-versions = ">=3.7"
groups = ["main"]
markers = "sys_platform == \"win32\""
files = [
{file = "clr_loader-0.2.10-py3-none-any.whl", hash = "sha256:ebbbf9d511a7fe95fa28a95a4e04cd195b097881dfe66158dc2c281d3536f282"},
{file = "clr_loader-0.2.10.tar.gz", hash = "sha256:81f114afbc5005bafc5efe5af1341d400e22137e275b042a8979f3feb9fc9446"},
@@ -2598,6 +2599,21 @@ files = [
{file = "fqdn-1.5.1.tar.gz", hash = "sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f"},
]
[[package]]
name = "freezegun"
version = "1.5.5"
description = "Let your Python tests travel through time"
optional = false
python-versions = ">=3.8"
groups = ["test"]
files = [
{file = "freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2"},
{file = "freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a"},
]
[package.dependencies]
python-dateutil = ">=2.7"
[[package]]
name = "frozenlist"
version = "1.8.0"
@@ -3411,96 +3427,87 @@ protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4
[[package]]
name = "grpcio"
version = "1.76.0"
version = "1.67.1"
description = "HTTP/2-based RPC framework"
optional = false
python-versions = ">=3.9"
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "grpcio-1.76.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:65a20de41e85648e00305c1bb09a3598f840422e522277641145a32d42dcefcc"},
{file = "grpcio-1.76.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:40ad3afe81676fd9ec6d9d406eda00933f218038433980aa19d401490e46ecde"},
{file = "grpcio-1.76.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:035d90bc79eaa4bed83f524331d55e35820725c9fbb00ffa1904d5550ed7ede3"},
{file = "grpcio-1.76.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4215d3a102bd95e2e11b5395c78562967959824156af11fa93d18fdd18050990"},
{file = "grpcio-1.76.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:49ce47231818806067aea3324d4bf13825b658ad662d3b25fada0bdad9b8a6af"},
{file = "grpcio-1.76.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8cc3309d8e08fd79089e13ed4819d0af72aa935dd8f435a195fd152796752ff2"},
{file = "grpcio-1.76.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:971fd5a1d6e62e00d945423a567e42eb1fa678ba89072832185ca836a94daaa6"},
{file = "grpcio-1.76.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d9adda641db7207e800a7f089068f6f645959f2df27e870ee81d44701dd9db3"},
{file = "grpcio-1.76.0-cp310-cp310-win32.whl", hash = "sha256:063065249d9e7e0782d03d2bca50787f53bd0fb89a67de9a7b521c4a01f1989b"},
{file = "grpcio-1.76.0-cp310-cp310-win_amd64.whl", hash = "sha256:a6ae758eb08088d36812dd5d9af7a9859c05b1e0f714470ea243694b49278e7b"},
{file = "grpcio-1.76.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2e1743fbd7f5fa713a1b0a8ac8ebabf0ec980b5d8809ec358d488e273b9cf02a"},
{file = "grpcio-1.76.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a8c2cf1209497cf659a667d7dea88985e834c24b7c3b605e6254cbb5076d985c"},
{file = "grpcio-1.76.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08caea849a9d3c71a542827d6df9d5a69067b0a1efbea8a855633ff5d9571465"},
{file = "grpcio-1.76.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f0e34c2079d47ae9f6188211db9e777c619a21d4faba6977774e8fa43b085e48"},
{file = "grpcio-1.76.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8843114c0cfce61b40ad48df65abcfc00d4dba82eae8718fab5352390848c5da"},
{file = "grpcio-1.76.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8eddfb4d203a237da6f3cc8a540dad0517d274b5a1e9e636fd8d2c79b5c1d397"},
{file = "grpcio-1.76.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:32483fe2aab2c3794101c2a159070584e5db11d0aa091b2c0ea9c4fc43d0d749"},
{file = "grpcio-1.76.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dcfe41187da8992c5f40aa8c5ec086fa3672834d2be57a32384c08d5a05b4c00"},
{file = "grpcio-1.76.0-cp311-cp311-win32.whl", hash = "sha256:2107b0c024d1b35f4083f11245c0e23846ae64d02f40b2b226684840260ed054"},
{file = "grpcio-1.76.0-cp311-cp311-win_amd64.whl", hash = "sha256:522175aba7af9113c48ec10cc471b9b9bd4f6ceb36aeb4544a8e2c80ed9d252d"},
{file = "grpcio-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:81fd9652b37b36f16138611c7e884eb82e0cec137c40d3ef7c3f9b3ed00f6ed8"},
{file = "grpcio-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280"},
{file = "grpcio-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d388087771c837cdb6515539f43b9d4bf0b0f23593a24054ac16f7a960be16f4"},
{file = "grpcio-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f8f757bebaaea112c00dba718fc0d3260052ce714e25804a03f93f5d1c6cc11"},
{file = "grpcio-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:980a846182ce88c4f2f7e2c22c56aefd515daeb36149d1c897f83cf57999e0b6"},
{file = "grpcio-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f92f88e6c033db65a5ae3d97905c8fea9c725b63e28d5a75cb73b49bda5024d8"},
{file = "grpcio-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4baf3cbe2f0be3289eb68ac8ae771156971848bb8aaff60bad42005539431980"},
{file = "grpcio-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:615ba64c208aaceb5ec83bfdce7728b80bfeb8be97562944836a7a0a9647d882"},
{file = "grpcio-1.76.0-cp312-cp312-win32.whl", hash = "sha256:45d59a649a82df5718fd9527ce775fd66d1af35e6d31abdcdc906a49c6822958"},
{file = "grpcio-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:c088e7a90b6017307f423efbb9d1ba97a22aa2170876223f9709e9d1de0b5347"},
{file = "grpcio-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:26ef06c73eb53267c2b319f43e6634c7556ea37672029241a056629af27c10e2"},
{file = "grpcio-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:45e0111e73f43f735d70786557dc38141185072d7ff8dc1829d6a77ac1471468"},
{file = "grpcio-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83d57312a58dcfe2a3a0f9d1389b299438909a02db60e2f2ea2ae2d8034909d3"},
{file = "grpcio-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3e2a27c89eb9ac3d81ec8835e12414d73536c6e620355d65102503064a4ed6eb"},
{file = "grpcio-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61f69297cba3950a524f61c7c8ee12e55c486cb5f7db47ff9dcee33da6f0d3ae"},
{file = "grpcio-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a15c17af8839b6801d554263c546c69c4d7718ad4321e3166175b37eaacca77"},
{file = "grpcio-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:25a18e9810fbc7e7f03ec2516addc116a957f8cbb8cbc95ccc80faa072743d03"},
{file = "grpcio-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:931091142fd8cc14edccc0845a79248bc155425eee9a98b2db2ea4f00a235a42"},
{file = "grpcio-1.76.0-cp313-cp313-win32.whl", hash = "sha256:5e8571632780e08526f118f74170ad8d50fb0a48c23a746bef2a6ebade3abd6f"},
{file = "grpcio-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:f9f7bd5faab55f47231ad8dba7787866b69f5e93bc306e3915606779bbfb4ba8"},
{file = "grpcio-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:ff8a59ea85a1f2191a0ffcc61298c571bc566332f82e5f5be1b83c9d8e668a62"},
{file = "grpcio-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:06c3d6b076e7b593905d04fdba6a0525711b3466f43b3400266f04ff735de0cd"},
{file = "grpcio-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd5ef5932f6475c436c4a55e4336ebbe47bd3272be04964a03d316bbf4afbcbc"},
{file = "grpcio-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b331680e46239e090f5b3cead313cc772f6caa7d0fc8de349337563125361a4a"},
{file = "grpcio-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2229ae655ec4e8999599469559e97630185fdd53ae1e8997d147b7c9b2b72cba"},
{file = "grpcio-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:490fa6d203992c47c7b9e4a9d39003a0c2bcc1c9aa3c058730884bbbb0ee9f09"},
{file = "grpcio-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:479496325ce554792dba6548fae3df31a72cef7bad71ca2e12b0e58f9b336bfc"},
{file = "grpcio-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c9b93f79f48b03ada57ea24725d83a30284a012ec27eab2cf7e50a550cbbbcc"},
{file = "grpcio-1.76.0-cp314-cp314-win32.whl", hash = "sha256:747fa73efa9b8b1488a95d0ba1039c8e2dca0f741612d80415b1e1c560febf4e"},
{file = "grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e"},
{file = "grpcio-1.76.0-cp39-cp39-linux_armv7l.whl", hash = "sha256:8ebe63ee5f8fa4296b1b8cfc743f870d10e902ca18afc65c68cf46fd39bb0783"},
{file = "grpcio-1.76.0-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:3bf0f392c0b806905ed174dcd8bdd5e418a40d5567a05615a030a5aeddea692d"},
{file = "grpcio-1.76.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0b7604868b38c1bfd5cf72d768aedd7db41d78cb6a4a18585e33fb0f9f2363fd"},
{file = "grpcio-1.76.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e6d1db20594d9daba22f90da738b1a0441a7427552cc6e2e3d1297aeddc00378"},
{file = "grpcio-1.76.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d099566accf23d21037f18a2a63d323075bebace807742e4b0ac210971d4dd70"},
{file = "grpcio-1.76.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ebea5cc3aa8ea72e04df9913492f9a96d9348db876f9dda3ad729cfedf7ac416"},
{file = "grpcio-1.76.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0c37db8606c258e2ee0c56b78c62fc9dee0e901b5dbdcf816c2dd4ad652b8b0c"},
{file = "grpcio-1.76.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ebebf83299b0cb1721a8859ea98f3a77811e35dce7609c5c963b9ad90728f886"},
{file = "grpcio-1.76.0-cp39-cp39-win32.whl", hash = "sha256:0aaa82d0813fd4c8e589fac9b65d7dd88702555f702fb10417f96e2a2a6d4c0f"},
{file = "grpcio-1.76.0-cp39-cp39-win_amd64.whl", hash = "sha256:acab0277c40eff7143c2323190ea57b9ee5fd353d8190ee9652369fae735668a"},
{file = "grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73"},
{file = "grpcio-1.67.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:8b0341d66a57f8a3119b77ab32207072be60c9bf79760fa609c5609f2deb1f3f"},
{file = "grpcio-1.67.1-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:f5a27dddefe0e2357d3e617b9079b4bfdc91341a91565111a21ed6ebbc51b22d"},
{file = "grpcio-1.67.1-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:43112046864317498a33bdc4797ae6a268c36345a910de9b9c17159d8346602f"},
{file = "grpcio-1.67.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9b929f13677b10f63124c1a410994a401cdd85214ad83ab67cc077fc7e480f0"},
{file = "grpcio-1.67.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7d1797a8a3845437d327145959a2c0c47c05947c9eef5ff1a4c80e499dcc6fa"},
{file = "grpcio-1.67.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:0489063974d1452436139501bf6b180f63d4977223ee87488fe36858c5725292"},
{file = "grpcio-1.67.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9fd042de4a82e3e7aca44008ee2fb5da01b3e5adb316348c21980f7f58adc311"},
{file = "grpcio-1.67.1-cp310-cp310-win32.whl", hash = "sha256:638354e698fd0c6c76b04540a850bf1db27b4d2515a19fcd5cf645c48d3eb1ed"},
{file = "grpcio-1.67.1-cp310-cp310-win_amd64.whl", hash = "sha256:608d87d1bdabf9e2868b12338cd38a79969eaf920c89d698ead08f48de9c0f9e"},
{file = "grpcio-1.67.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:7818c0454027ae3384235a65210bbf5464bd715450e30a3d40385453a85a70cb"},
{file = "grpcio-1.67.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ea33986b70f83844cd00814cee4451055cd8cab36f00ac64a31f5bb09b31919e"},
{file = "grpcio-1.67.1-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:c7a01337407dd89005527623a4a72c5c8e2894d22bead0895306b23c6695698f"},
{file = "grpcio-1.67.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80b866f73224b0634f4312a4674c1be21b2b4afa73cb20953cbbb73a6b36c3cc"},
{file = "grpcio-1.67.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fff78ba10d4250bfc07a01bd6254a6d87dc67f9627adece85c0b2ed754fa96"},
{file = "grpcio-1.67.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:8a23cbcc5bb11ea7dc6163078be36c065db68d915c24f5faa4f872c573bb400f"},
{file = "grpcio-1.67.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1a65b503d008f066e994f34f456e0647e5ceb34cfcec5ad180b1b44020ad4970"},
{file = "grpcio-1.67.1-cp311-cp311-win32.whl", hash = "sha256:e29ca27bec8e163dca0c98084040edec3bc49afd10f18b412f483cc68c712744"},
{file = "grpcio-1.67.1-cp311-cp311-win_amd64.whl", hash = "sha256:786a5b18544622bfb1e25cc08402bd44ea83edfb04b93798d85dca4d1a0b5be5"},
{file = "grpcio-1.67.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:267d1745894200e4c604958da5f856da6293f063327cb049a51fe67348e4f953"},
{file = "grpcio-1.67.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:85f69fdc1d28ce7cff8de3f9c67db2b0ca9ba4449644488c1e0303c146135ddb"},
{file = "grpcio-1.67.1-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:f26b0b547eb8d00e195274cdfc63ce64c8fc2d3e2d00b12bf468ece41a0423a0"},
{file = "grpcio-1.67.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4422581cdc628f77302270ff839a44f4c24fdc57887dc2a45b7e53d8fc2376af"},
{file = "grpcio-1.67.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d7616d2ded471231c701489190379e0c311ee0a6c756f3c03e6a62b95a7146e"},
{file = "grpcio-1.67.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8a00efecde9d6fcc3ab00c13f816313c040a28450e5e25739c24f432fc6d3c75"},
{file = "grpcio-1.67.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:699e964923b70f3101393710793289e42845791ea07565654ada0969522d0a38"},
{file = "grpcio-1.67.1-cp312-cp312-win32.whl", hash = "sha256:4e7b904484a634a0fff132958dabdb10d63e0927398273917da3ee103e8d1f78"},
{file = "grpcio-1.67.1-cp312-cp312-win_amd64.whl", hash = "sha256:5721e66a594a6c4204458004852719b38f3d5522082be9061d6510b455c90afc"},
{file = "grpcio-1.67.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:aa0162e56fd10a5547fac8774c4899fc3e18c1aa4a4759d0ce2cd00d3696ea6b"},
{file = "grpcio-1.67.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:beee96c8c0b1a75d556fe57b92b58b4347c77a65781ee2ac749d550f2a365dc1"},
{file = "grpcio-1.67.1-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:a93deda571a1bf94ec1f6fcda2872dad3ae538700d94dc283c672a3b508ba3af"},
{file = "grpcio-1.67.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e6f255980afef598a9e64a24efce87b625e3e3c80a45162d111a461a9f92955"},
{file = "grpcio-1.67.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e838cad2176ebd5d4a8bb03955138d6589ce9e2ce5d51c3ada34396dbd2dba8"},
{file = "grpcio-1.67.1-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:a6703916c43b1d468d0756c8077b12017a9fcb6a1ef13faf49e67d20d7ebda62"},
{file = "grpcio-1.67.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:917e8d8994eed1d86b907ba2a61b9f0aef27a2155bca6cbb322430fc7135b7bb"},
{file = "grpcio-1.67.1-cp313-cp313-win32.whl", hash = "sha256:e279330bef1744040db8fc432becc8a727b84f456ab62b744d3fdb83f327e121"},
{file = "grpcio-1.67.1-cp313-cp313-win_amd64.whl", hash = "sha256:fa0c739ad8b1996bd24823950e3cb5152ae91fca1c09cc791190bf1627ffefba"},
{file = "grpcio-1.67.1-cp38-cp38-linux_armv7l.whl", hash = "sha256:178f5db771c4f9a9facb2ab37a434c46cb9be1a75e820f187ee3d1e7805c4f65"},
{file = "grpcio-1.67.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0f3e49c738396e93b7ba9016e153eb09e0778e776df6090c1b8c91877cc1c426"},
{file = "grpcio-1.67.1-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:24e8a26dbfc5274d7474c27759b54486b8de23c709d76695237515bc8b5baeab"},
{file = "grpcio-1.67.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b6c16489326d79ead41689c4b84bc40d522c9a7617219f4ad94bc7f448c5085"},
{file = "grpcio-1.67.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:60e6a4dcf5af7bbc36fd9f81c9f372e8ae580870a9e4b6eafe948cd334b81cf3"},
{file = "grpcio-1.67.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:95b5f2b857856ed78d72da93cd7d09b6db8ef30102e5e7fe0961fe4d9f7d48e8"},
{file = "grpcio-1.67.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b49359977c6ec9f5d0573ea4e0071ad278ef905aa74e420acc73fd28ce39e9ce"},
{file = "grpcio-1.67.1-cp38-cp38-win32.whl", hash = "sha256:f5b76ff64aaac53fede0cc93abf57894ab2a7362986ba22243d06218b93efe46"},
{file = "grpcio-1.67.1-cp38-cp38-win_amd64.whl", hash = "sha256:804c6457c3cd3ec04fe6006c739579b8d35c86ae3298ffca8de57b493524b771"},
{file = "grpcio-1.67.1-cp39-cp39-linux_armv7l.whl", hash = "sha256:a25bdea92b13ff4d7790962190bf6bf5c4639876e01c0f3dda70fc2769616335"},
{file = "grpcio-1.67.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cdc491ae35a13535fd9196acb5afe1af37c8237df2e54427be3eecda3653127e"},
{file = "grpcio-1.67.1-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:85f862069b86a305497e74d0dc43c02de3d1d184fc2c180993aa8aa86fbd19b8"},
{file = "grpcio-1.67.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ec74ef02010186185de82cc594058a3ccd8d86821842bbac9873fd4a2cf8be8d"},
{file = "grpcio-1.67.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01f616a964e540638af5130469451cf580ba8c7329f45ca998ab66e0c7dcdb04"},
{file = "grpcio-1.67.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:299b3d8c4f790c6bcca485f9963b4846dd92cf6f1b65d3697145d005c80f9fe8"},
{file = "grpcio-1.67.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:60336bff760fbb47d7e86165408126f1dded184448e9a4c892189eb7c9d3f90f"},
{file = "grpcio-1.67.1-cp39-cp39-win32.whl", hash = "sha256:5ed601c4c6008429e3d247ddb367fe8c7259c355757448d7c1ef7bd4a6739e8e"},
{file = "grpcio-1.67.1-cp39-cp39-win_amd64.whl", hash = "sha256:5db70d32d6703b89912af16d6d45d78406374a8b8ef0d28140351dd0ec610e98"},
{file = "grpcio-1.67.1.tar.gz", hash = "sha256:3dc2ed4cabea4dc14d5e708c2b426205956077cc5de419b4d4079315017e9732"},
]
[package.dependencies]
typing-extensions = ">=4.12,<5.0"
[package.extras]
protobuf = ["grpcio-tools (>=1.76.0)"]
protobuf = ["grpcio-tools (>=1.67.1)"]
[[package]]
name = "grpcio-status"
version = "1.71.2"
version = "1.67.1"
description = "Status proto mapping for gRPC"
optional = false
python-versions = ">=3.9"
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "grpcio_status-1.71.2-py3-none-any.whl", hash = "sha256:803c98cb6a8b7dc6dbb785b1111aed739f241ab5e9da0bba96888aa74704cfd3"},
{file = "grpcio_status-1.71.2.tar.gz", hash = "sha256:c7a97e176df71cdc2c179cd1847d7fc86cca5832ad12e9798d7fed6b7a1aab50"},
{file = "grpcio_status-1.67.1-py3-none-any.whl", hash = "sha256:16e6c085950bdacac97c779e6a502ea671232385e6e37f258884d6883392c2bd"},
{file = "grpcio_status-1.67.1.tar.gz", hash = "sha256:2bf38395e028ceeecfd8866b081f61628114b384da7d51ae064ddc8d766a5d11"},
]
[package.dependencies]
googleapis-common-protos = ">=1.5.5"
grpcio = ">=1.71.2"
grpcio = ">=1.67.1"
protobuf = ">=5.26.1,<6.0dev"
[[package]]
@@ -4783,25 +4790,25 @@ valkey = ["valkey (>=6)"]
[[package]]
name = "litellm"
version = "1.80.16"
version = "1.80.10"
description = "Library to easily interface with LLM API providers"
optional = false
python-versions = "<4.0,>=3.9"
groups = ["main"]
files = [
{file = "litellm-1.80.16-py3-none-any.whl", hash = "sha256:21be641b350561b293b831addb25249676b72ebff973a5a1d73b5d7cf35bcd1d"},
{file = "litellm-1.80.16.tar.gz", hash = "sha256:f96233649f99ab097f7d8a3ff9898680207b9eea7d2e23f438074a3dbcf50cca"},
{file = "litellm-1.80.10-py3-none-any.whl", hash = "sha256:9b3e561efaba0eb1291cb1555d3dcb7283cf7f3cb65aadbcdb42e2a8765898c8"},
{file = "litellm-1.80.10.tar.gz", hash = "sha256:4a4aff7558945c2f7e5c6523e67c1b5525a46b10b0e1ad6b8f847cb13b16779e"},
]
[package.dependencies]
aiohttp = ">=3.10"
click = "*"
fastuuid = ">=0.13.0"
grpcio = {version = ">=1.62.3,<1.68.dev0 || >1.71.0,<1.71.1 || >1.71.1,<1.72.0 || >1.72.0,<1.72.1 || >1.72.1,<1.73.0 || >1.73.0", markers = "python_version < \"3.14\""}
grpcio = {version = ">=1.62.3,<1.68.0", markers = "python_version < \"3.14\""}
httpx = ">=0.23.0"
importlib-metadata = ">=6.8.0"
jinja2 = ">=3.1.2,<4.0.0"
jsonschema = ">=4.23.0,<5.0.0"
jsonschema = ">=4.22.0,<5.0.0"
openai = ">=2.8.0"
pydantic = ">=2.5.0,<3.0.0"
python-dotenv = ">=0.2.0"
@@ -4812,7 +4819,7 @@ tokenizers = "*"
caching = ["diskcache (>=5.6.1,<6.0.0)"]
extra-proxy = ["azure-identity (>=1.15.0,<2.0.0) ; python_version >= \"3.9\"", "azure-keyvault-secrets (>=4.8.0,<5.0.0)", "google-cloud-iam (>=2.19.1,<3.0.0)", "google-cloud-kms (>=2.21.3,<3.0.0)", "prisma (==0.11.0)", "redisvl (>=0.4.1,<0.5.0) ; python_version >= \"3.9\" and python_version < \"3.14\"", "resend (>=0.8.0)"]
mlflow = ["mlflow (>3.1.4) ; python_version >= \"3.10\""]
proxy = ["PyJWT (>=2.10.1,<3.0.0) ; python_version >= \"3.9\"", "apscheduler (>=3.10.4,<4.0.0)", "azure-identity (>=1.15.0,<2.0.0) ; python_version >= \"3.9\"", "azure-storage-blob (>=12.25.1,<13.0.0)", "backoff", "boto3 (==1.36.0)", "cryptography", "fastapi (>=0.120.1)", "fastapi-sso (>=0.16.0,<0.17.0)", "gunicorn (>=23.0.0,<24.0.0)", "litellm-enterprise (==0.1.27)", "litellm-proxy-extras (==0.4.21)", "mcp (>=1.21.2,<2.0.0) ; python_version >= \"3.10\"", "orjson (>=3.9.7,<4.0.0)", "polars (>=1.31.0,<2.0.0) ; python_version >= \"3.10\"", "pynacl (>=1.5.0,<2.0.0)", "python-multipart (>=0.0.18,<0.0.19)", "pyyaml (>=6.0.1,<7.0.0)", "rich (==13.7.1)", "rq", "soundfile (>=0.12.1,<0.13.0)", "uvicorn (>=0.31.1,<0.32.0)", "uvloop (>=0.21.0,<0.22.0) ; sys_platform != \"win32\"", "websockets (>=15.0.1,<16.0.0)"]
proxy = ["PyJWT (>=2.10.1,<3.0.0) ; python_version >= \"3.9\"", "apscheduler (>=3.10.4,<4.0.0)", "azure-identity (>=1.15.0,<2.0.0) ; python_version >= \"3.9\"", "azure-storage-blob (>=12.25.1,<13.0.0)", "backoff", "boto3 (==1.36.0)", "cryptography", "fastapi (>=0.120.1)", "fastapi-sso (>=0.16.0,<0.17.0)", "gunicorn (>=23.0.0,<24.0.0)", "litellm-enterprise (==0.1.25)", "litellm-proxy-extras (==0.4.14)", "mcp (>=1.21.2,<2.0.0) ; python_version >= \"3.10\"", "orjson (>=3.9.7,<4.0.0)", "polars (>=1.31.0,<2.0.0) ; python_version >= \"3.10\"", "pynacl (>=1.5.0,<2.0.0)", "python-multipart (>=0.0.18,<0.0.19)", "pyyaml (>=6.0.1,<7.0.0)", "rich (==13.7.1)", "rq", "soundfile (>=0.12.1,<0.13.0)", "uvicorn (>=0.31.1,<0.32.0)", "uvloop (>=0.21.0,<0.22.0) ; sys_platform != \"win32\"", "websockets (>=15.0.1,<16.0.0)"]
semantic-router = ["semantic-router (>=0.1.12) ; python_version >= \"3.9\" and python_version < \"3.14\""]
utils = ["numpydoc"]
@@ -6190,14 +6197,14 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
[[package]]
name = "openhands-agent-server"
version = "1.14.0"
version = "1.15.0"
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_agent_server-1.14.0-py3-none-any.whl", hash = "sha256:b1374b50d0ce93d825ba5ea907fcb8840b5ddc594c6752570c7c4c27be1a9fd1"},
{file = "openhands_agent_server-1.14.0.tar.gz", hash = "sha256:396de8d878c0a6c1c23d830f7407e34801ac850f4283ba296d7fe436d8b61488"},
{file = "openhands_agent_server-1.15.0-py3-none-any.whl", hash = "sha256:84f0d130cc2c10044d3dcdfecef1eb8f6793bf05c6633ca645cabd354ed038fa"},
{file = "openhands_agent_server-1.15.0.tar.gz", hash = "sha256:faf588900a58ff80575cc499f0aa0eaf9b8648d9448185411041f42e2cb2c612"},
]
[package.dependencies]
@@ -6227,7 +6234,7 @@ aiohttp = ">=3.13.3"
anthropic = {version = "*", extras = ["vertex"]}
anyio = "4.9"
asyncpg = ">=0.30"
authlib = ">=1.6.7"
authlib = ">=1.6.9"
bashlex = ">=0.18"
boto3 = "*"
browsergym-core = "0.13.3"
@@ -6259,9 +6266,9 @@ memory-profiler = ">=0.61"
numpy = "*"
openai = "2.8"
openhands-aci = "0.3.3"
openhands-agent-server = "1.14"
openhands-sdk = "1.14"
openhands-tools = "1.14"
openhands-agent-server = "1.15"
openhands-sdk = "1.15"
openhands-tools = "1.15"
opentelemetry-api = ">=1.33.1"
opentelemetry-exporter-otlp-proto-grpc = ">=1.33.1"
orjson = ">=3.11.6"
@@ -6276,9 +6283,9 @@ protobuf = ">=5.29.6,<6"
psutil = "*"
pybase62 = ">=1"
pygithub = ">=2.5"
pyjwt = ">=2.12.0"
pyjwt = ">=2.12"
pylatexenc = "*"
pypdf = ">=6.7.2"
pypdf = ">=6.9.1"
python-docx = "*"
python-dotenv = "*"
python-frontmatter = ">=1.1"
@@ -6286,7 +6293,7 @@ python-json-logger = ">=3.2.1"
python-multipart = ">=0.0.22"
python-pptx = "*"
python-socketio = "5.14"
pythonnet = "*"
pythonnet = {version = "*", markers = "sys_platform == \"win32\""}
pyyaml = ">=6.0.2"
qtconsole = ">=5.6.1"
rapidfuzz = ">=3.9"
@@ -6316,14 +6323,14 @@ url = ".."
[[package]]
name = "openhands-sdk"
version = "1.14.0"
version = "1.15.0"
description = "OpenHands SDK - Core functionality for building AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_sdk-1.14.0-py3-none-any.whl", hash = "sha256:64305b3a24445fd9480b63129e8e02f3a75fdbf8f4fcbf970760b7dc1d392090"},
{file = "openhands_sdk-1.14.0.tar.gz", hash = "sha256:30bda4b10291420f753d14aaa4ee67c87ba8d59ef3908bca999aa76daa033615"},
{file = "openhands_sdk-1.15.0-py3-none-any.whl", hash = "sha256:760473a0a35301e5c3fde9e5a5921c8f24d95e9c4694fc01d81fac828f2cca27"},
{file = "openhands_sdk-1.15.0.tar.gz", hash = "sha256:d0f479db1a14e10ac922c9000c0c059ce0515fda8666ba10c7f8c64490cca565"},
]
[package.dependencies]
@@ -6333,7 +6340,7 @@ fakeredis = {version = ">=2.32.1", extras = ["lua"]}
fastmcp = ">=3.0.0"
filelock = ">=3.20.1"
httpx = ">=0.27.0"
litellm = ">=1.80.10"
litellm = "1.80.10"
lmnr = ">=0.7.24"
pydantic = ">=2.12.5"
python-frontmatter = ">=1.1.0"
@@ -6346,14 +6353,14 @@ boto3 = ["boto3 (>=1.35.0)"]
[[package]]
name = "openhands-tools"
version = "1.14.0"
version = "1.15.0"
description = "OpenHands Tools - Runtime tools for AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_tools-1.14.0-py3-none-any.whl", hash = "sha256:4df477fa53eafa15082d081143c80383aeb6d52b4448b989b86b811c297e5615"},
{file = "openhands_tools-1.14.0.tar.gz", hash = "sha256:2655a7de839b171539464fa39729b6a338dc37f914b58bd551378c4fc0ec71b5"},
{file = "openhands_tools-1.15.0-py3-none-any.whl", hash = "sha256:041f2f5483a0f5caa967067a1964c4ae0716236a360c9acaa51675d85853d453"},
{file = "openhands_tools-1.15.0.tar.gz", hash = "sha256:e1cb1962573b3847642960f561414391f3a31e345c5e7094ae674baadf343a50"},
]
[package.dependencies]
@@ -11908,6 +11915,7 @@ description = ".NET and Mono integration for Python"
optional = false
python-versions = "<3.14,>=3.7"
groups = ["main"]
markers = "sys_platform == \"win32\""
files = [
{file = "pythonnet-3.0.5-py3-none-any.whl", hash = "sha256:f6702d694d5d5b163c9f3f5cc34e0bed8d6857150237fae411fefb883a656d20"},
{file = "pythonnet-3.0.5.tar.gz", hash = "sha256:48e43ca463941b3608b32b4e236db92d8d40db4c58a75ace902985f76dac21cf"},
@@ -13083,24 +13091,24 @@ test = ["pytest (>=8)"]
[[package]]
name = "setuptools"
version = "80.9.0"
description = "Easily download, build, install, upgrade, and uninstall Python packages"
version = "82.0.1"
description = "Most extensible Python build backend with support for C/C++ extension modules"
optional = false
python-versions = ">=3.9"
groups = ["main", "test"]
files = [
{file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"},
{file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"},
{file = "setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb"},
{file = "setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9"},
]
[package.extras]
check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""]
core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"]
check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.13.0) ; sys_platform != \"cygwin\""]
core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"]
cover = ["pytest-cov"]
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"]
enabler = ["pytest-enabler (>=2.2)"]
test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"]
type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"]
type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.18.*)", "pytest-mypy"]
[[package]]
name = "shap"
@@ -14997,4 +15005,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
content-hash = "ef037f6d6085d26166d35c56ce266439f8f1a4fea90bc43ccf15cfeaf116cae5"
content-hash = "c468b13e2d26e31e0e8f84518bcb8379234d431ca3819625f49b91aa3589359c"

View File

@@ -64,6 +64,7 @@ pytest-asyncio = "*"
pytest-forked = "*"
pytest-xdist = "*"
flake8 = "*"
freezegun = "^1.5.1"
openai = "*"
opencv-python = "*"
pandas = "*"

View File

@@ -41,7 +41,7 @@ from storage.role import Role
from storage.role_store import RoleStore
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_id
from openhands.server.user_auth import get_user_auth, get_user_id
class Permission(str, Enum):
@@ -311,3 +311,96 @@ def require_permission(permission: Permission):
return user_id
return permission_checker
async def require_financial_data_access(
request: Request,
org_id: UUID,
user_id: str | None = Depends(get_user_id),
) -> str:
"""
Authorization dependency for accessing organization financial data.
Allows access if ANY of these conditions are met:
1. User has Admin or Owner role in the organization
2. User has @openhands.dev email domain
This is used for the organization members financial data endpoint.
Args:
request: FastAPI request object
org_id: Organization UUID from path parameter
user_id: User ID from authentication
Returns:
str: User ID if authorized
Raises:
HTTPException: 401 if not authenticated, 403 if not authorized
"""
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='User not authenticated',
)
# Validate API key organization binding
api_key_org_id = await get_api_key_org_id_from_request(request)
if api_key_org_id is not None:
if api_key_org_id != org_id:
logger.warning(
'API key organization mismatch for financial data access',
extra={
'user_id': user_id,
'api_key_org_id': str(api_key_org_id),
'target_org_id': str(org_id),
},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='API key is not authorized for this organization',
)
# Check if user has @openhands.dev email
user_auth = await get_user_auth(request)
user_email = await user_auth.get_user_email()
if user_email and user_email.endswith('@openhands.dev'):
logger.debug(
'Financial data access granted via @openhands.dev email',
extra={'user_id': user_id, 'org_id': str(org_id)},
)
return user_id
# Check if user has Admin or Owner role in the organization
user_role = await get_user_org_role(user_id, org_id)
if not user_role:
logger.warning(
'Financial data access denied - user not a member of organization',
extra={'user_id': user_id, 'org_id': str(org_id)},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='User is not a member of this organization',
)
if user_role.name not in (RoleName.OWNER.value, RoleName.ADMIN.value):
logger.warning(
'Financial data access denied - insufficient role',
extra={
'user_id': user_id,
'org_id': str(org_id),
'user_role': user_role.name,
},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Access restricted to organization admins, owners, or OpenHands members',
)
logger.debug(
'Financial data access granted via admin/owner role',
extra={'user_id': user_id, 'org_id': str(org_id), 'role': user_role.name},
)
return user_id

View File

@@ -6,7 +6,6 @@ GITHUB_APP_WEBHOOK_SECRET = os.getenv('GITHUB_APP_WEBHOOK_SECRET', '')
GITHUB_APP_PRIVATE_KEY = os.getenv('GITHUB_APP_PRIVATE_KEY', '').replace('\\n', '\n')
KEYCLOAK_SERVER_URL = os.getenv('KEYCLOAK_SERVER_URL', '').rstrip('/')
KEYCLOAK_REALM_NAME = os.getenv('KEYCLOAK_REALM_NAME', '')
KEYCLOAK_PROVIDER_NAME = os.getenv('KEYCLOAK_PROVIDER_NAME', '')
KEYCLOAK_CLIENT_ID = os.getenv('KEYCLOAK_CLIENT_ID', '')
KEYCLOAK_CLIENT_SECRET = os.getenv('KEYCLOAK_CLIENT_SECRET', '')
KEYCLOAK_SERVER_URL_EXT = os.getenv(

View File

@@ -4,7 +4,6 @@ from server.auth.constants import (
KEYCLOAK_ADMIN_PASSWORD,
KEYCLOAK_CLIENT_ID,
KEYCLOAK_CLIENT_SECRET,
KEYCLOAK_PROVIDER_NAME,
KEYCLOAK_REALM_NAME,
KEYCLOAK_SERVER_URL,
KEYCLOAK_SERVER_URL_EXT,
@@ -12,7 +11,7 @@ from server.auth.constants import (
from server.logger import logger
logger.debug(
f'KEYCLOAK_SERVER_URL:{KEYCLOAK_SERVER_URL}, KEYCLOAK_SERVER_URL_EXT:{KEYCLOAK_SERVER_URL_EXT}, KEYCLOAK_PROVIDER_NAME:{KEYCLOAK_PROVIDER_NAME}, KEYCLOAK_CLIENT_ID:{KEYCLOAK_CLIENT_ID}'
f'KEYCLOAK_SERVER_URL:{KEYCLOAK_SERVER_URL}, KEYCLOAK_SERVER_URL_EXT:{KEYCLOAK_SERVER_URL_EXT}, KEYCLOAK_CLIENT_ID:{KEYCLOAK_CLIENT_ID}'
)
_keycloak_instances = {}

View File

@@ -80,10 +80,11 @@ def setup_json_logger(
handler.setLevel(level)
formatter = JsonFormatter(
'{message}{levelname}',
style='{',
'%(message)s%(levelname)s%(module)s%(funcName)s%(lineno)d',
rename_fields={'levelname': 'severity'},
json_serializer=custom_json_serializer,
# Use 'ts' for consistency with LOG_JSON_FOR_CONSOLE mode (skip when console mode to avoid duplicates)
timestamp='ts' if not LOG_JSON_FOR_CONSOLE else False,
)
handler.setFormatter(formatter)

View File

@@ -172,6 +172,23 @@ async def keycloak_callback(
authorization = await user_authorizer.authorize_user(user_info)
if not authorization.success:
# For duplicate_email errors, clean up the newly created Keycloak user
# (only if they're not already in our UserStore, i.e., they're a new user)
if authorization.error_detail == 'duplicate_email':
try:
existing_user = await UserStore.get_user_by_id(user_info.sub)
if not existing_user:
# New user created during OAuth should be deleted from Keycloak
await token_manager.delete_keycloak_user(user_info.sub)
logger.info(
f'Deleted orphaned Keycloak user {user_info.sub} '
'after duplicate_email rejection'
)
except Exception as e:
# Log but don't fail - user should still get 401 response
logger.warning(
f'Failed to clean up orphaned Keycloak user {user_info.sub}: {e}'
)
# Return unauthorized
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,

View File

@@ -120,3 +120,18 @@ class BatchInvitationResponse(BaseModel):
successful: list[InvitationResponse]
failed: list[InvitationFailure]
class AcceptInvitationRequest(BaseModel):
"""Request model for accepting an invitation via POST."""
token: str
class AcceptInvitationResponse(BaseModel):
"""Response model for successful invitation acceptance."""
success: bool
org_id: str
org_name: str
role: str

View File

@@ -5,6 +5,8 @@ from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse
from server.routes.org_invitation_models import (
AcceptInvitationRequest,
AcceptInvitationResponse,
BatchInvitationResponse,
EmailMismatchError,
InsufficientPermissionError,
@@ -17,10 +19,11 @@ from server.routes.org_invitation_models import (
)
from server.services.org_invitation_service import OrgInvitationService
from server.utils.rate_limit_utils import check_rate_limit_by_user_id
from storage.org_store import OrgStore
from storage.role_store import RoleStore
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_id
from openhands.server.user_auth.user_auth import get_user_auth
# Router for invitation operations on an organization (requires org_id)
invitation_router = APIRouter(prefix='/api/organizations/{org_id}/members')
@@ -123,70 +126,93 @@ async def create_invitation(
@accept_router.get('/accept')
async def accept_invitation(
async def accept_invitation_redirect(
token: str,
request: Request,
):
"""Accept an organization invitation via token.
"""Redirect invitation acceptance to frontend.
This endpoint is accessed via the link in the invitation email.
It always redirects to the home page with the invitation token,
allowing the frontend to handle the acceptance flow via a modal.
Flow:
1. If user is authenticated: Accept invitation directly and redirect to home
2. If user is not authenticated: Redirect to login page with invitation token
- Frontend stores token and includes it in OAuth state during login
- After authentication, keycloak_callback processes the invitation
This approach works with SameSite='strict' cookies because:
- Cross-site navigation (clicking email link) doesn't send cookies
- But same-origin POST requests (from frontend) DO send cookies
Args:
token: The invitation token from the email link
request: FastAPI request
Returns:
RedirectResponse: Redirect to home page on success, or login page if not authenticated,
or home page with error query params on failure
RedirectResponse: Redirect to home page with invitation_token query param
"""
base_url = str(request.base_url).rstrip('/')
# Try to get user_id from auth (may not be authenticated)
user_id = None
try:
user_auth = await get_user_auth(request)
if user_auth:
user_id = await user_auth.get_user_id()
except Exception:
pass
logger.info(
'Invitation accept: redirecting to frontend for acceptance',
extra={'token_prefix': token[:10] + '...'},
)
if not user_id:
# User not authenticated - redirect to login page with invitation token
# Frontend will store the token and include it in OAuth state during login
logger.info(
'Invitation accept: redirecting unauthenticated user to login',
extra={'token_prefix': token[:10] + '...'},
)
login_url = f'{base_url}/login?invitation_token={token}'
return RedirectResponse(login_url, status_code=302)
return RedirectResponse(f'{base_url}/?invitation_token={token}', status_code=302)
@accept_router.post('/accept', response_model=AcceptInvitationResponse)
async def accept_invitation(
request_data: AcceptInvitationRequest,
user_id: str = Depends(get_user_id),
):
"""Accept an organization invitation via authenticated POST request.
This endpoint is called by the frontend after displaying the acceptance modal.
Requires authentication - cookies are sent because this is a same-origin request.
Args:
request_data: Contains the invitation token
user_id: Authenticated user ID (from dependency)
Returns:
AcceptInvitationResponse: Success response with organization details
Raises:
HTTPException 400: Invalid or expired token
HTTPException 403: Email mismatch
HTTPException 409: User already a member
"""
token = request_data.token
# User is authenticated - process the invitation directly
try:
await OrgInvitationService.accept_invitation(token, UUID(user_id))
invitation = await OrgInvitationService.accept_invitation(token, UUID(user_id))
# Get organization and role details for response
org = await OrgStore.get_org_by_id(invitation.org_id)
role = await RoleStore.get_role_by_id(invitation.role_id)
logger.info(
'Invitation accepted successfully',
'Invitation accepted via API',
extra={
'token_prefix': token[:10] + '...',
'user_id': user_id,
'org_id': str(invitation.org_id),
},
)
# Redirect to home page on success
return RedirectResponse(f'{base_url}/', status_code=302)
return AcceptInvitationResponse(
success=True,
org_id=str(invitation.org_id),
org_name=org.name if org else '',
role=role.name if role else '',
)
except InvitationExpiredError:
logger.warning(
'Invitation accept failed: expired',
extra={'token_prefix': token[:10] + '...', 'user_id': user_id},
)
return RedirectResponse(f'{base_url}/?invitation_expired=true', status_code=302)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='invitation_expired',
)
except InvitationInvalidError as e:
logger.warning(
@@ -197,14 +223,20 @@ async def accept_invitation(
'error': str(e),
},
)
return RedirectResponse(f'{base_url}/?invitation_invalid=true', status_code=302)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='invitation_invalid',
)
except UserAlreadyMemberError:
logger.info(
'Invitation accept: user already member',
extra={'token_prefix': token[:10] + '...', 'user_id': user_id},
)
return RedirectResponse(f'{base_url}/?already_member=true', status_code=302)
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='already_member',
)
except EmailMismatchError as e:
logger.warning(
@@ -215,15 +247,21 @@ async def accept_invitation(
'error': str(e),
},
)
return RedirectResponse(f'{base_url}/?email_mismatch=true', status_code=302)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='email_mismatch',
)
except Exception as e:
logger.exception(
'Unexpected error accepting invitation',
'Unexpected error accepting invitation via API',
extra={
'token_prefix': token[:10] + '...',
'user_id': user_id,
'error': str(e),
},
)
return RedirectResponse(f'{base_url}/?invitation_error=true', status_code=302)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='An unexpected error occurred',
)

View File

@@ -241,7 +241,6 @@ class OrgUpdate(BaseModel):
enable_proactive_conversation_starters: bool | None = None
sandbox_base_container_image: str | None = None
sandbox_runtime_container_image: str | None = None
mcp_config: dict | None = None
sandbox_api_key: str | None = None
max_budget_per_task: float | None = Field(default=None, gt=0)
enable_solvability_analysis: bool | None = None
@@ -484,3 +483,22 @@ class OrgAppSettingsUpdate(BaseModel):
if v is not None and v <= 0:
raise ValueError('max_budget_per_task must be greater than 0')
return v
class OrgMemberFinancialResponse(BaseModel):
"""Financial data for a single organization member."""
user_id: str
email: str | None
lifetime_spend: float # Total amount spent (from LiteLLM)
current_budget: float # Remaining budget (max_budget - spend)
max_budget: float | None # Total allocated budget (None = unlimited)
class OrgMemberFinancialPage(BaseModel):
"""Paginated response for organization member financial data."""
items: list[OrgMemberFinancialResponse]
current_page: int = 1
per_page: int = 10
next_page_id: str | None = None

View File

@@ -4,6 +4,7 @@ from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from server.auth.authorization import (
Permission,
require_financial_data_access,
require_permission,
)
from server.email_validation import get_admin_user_id
@@ -22,6 +23,7 @@ from server.routes.org_models import (
OrgDatabaseError,
OrgLLMSettingsResponse,
OrgLLMSettingsUpdate,
OrgMemberFinancialPage,
OrgMemberNotFoundError,
OrgMemberPage,
OrgMemberResponse,
@@ -42,6 +44,7 @@ from server.services.org_llm_settings_service import (
OrgLLMSettingsService,
OrgLLMSettingsServiceInjector,
)
from server.services.org_member_financial_service import OrgMemberFinancialService
from server.services.org_member_service import OrgMemberService
from storage.org_service import OrgService
from storage.user_store import UserStore
@@ -68,7 +71,7 @@ async def list_user_orgs(
] = None,
limit: Annotated[
int,
Query(title='The max number of results in the page', gt=0, lte=100),
Query(title='The max number of results in the page', gt=0, le=100),
] = 100,
user_id: str = Depends(get_user_id),
) -> OrgPage:
@@ -734,7 +737,7 @@ async def get_org_members(
Query(
title='The max number of results in the page',
gt=0,
lte=100,
le=100,
),
] = 10,
email: Annotated[
@@ -883,6 +886,104 @@ async def get_org_members_count(
)
@org_router.get(
'/{org_id}/members/financial',
response_model=OrgMemberFinancialPage,
)
async def get_org_members_financial(
org_id: UUID,
page_id: Annotated[
str | None,
Query(
title='Pagination offset encoded as string',
description='Offset for pagination (e.g., "0", "10", "20")',
),
] = None,
limit: Annotated[
int,
Query(
title='Maximum items per page',
gt=0,
le=100,
),
] = 10,
email: Annotated[
str | None,
Query(
title='Filter members by email (case-insensitive partial match)',
min_length=1,
max_length=255,
),
] = None,
user_id: str = Depends(require_financial_data_access),
) -> OrgMemberFinancialPage:
"""Get paginated financial data for organization members.
Returns financial information (lifetime spend, current budget) for all members
within the specified organization. Access is restricted to:
- Organization Admins
- Organization Owners
- OpenHands members (users with @openhands.dev emails)
Args:
org_id: Organization ID (UUID)
page_id: Optional pagination offset encoded as string
limit: Maximum items per page (1-100, default 10)
email: Optional email filter (case-insensitive partial match)
user_id: Authenticated user ID (injected by require_financial_data_access)
Returns:
OrgMemberFinancialPage: Paginated response with member financial data
- items: List of members with user_id, email, lifetime_spend,
current_budget, and max_budget
- current_page: Current page number (1-indexed)
- per_page: Items per page
- next_page_id: Offset for next page, or None if no more pages
Raises:
HTTPException: 401 if user is not authenticated
HTTPException: 403 if user lacks access (not admin/owner and not @openhands.dev)
HTTPException: 400 if page_id is invalid
HTTPException: 500 if retrieval fails
"""
logger.info(
'Getting financial data for organization members',
extra={
'org_id': str(org_id),
'user_id': user_id,
'page_id': page_id,
'limit': limit,
'email_filter': email,
},
)
try:
return await OrgMemberFinancialService.get_org_members_financial_data(
org_id=org_id,
page_id=page_id,
limit=limit,
email_filter=email,
)
except ValueError as e:
logger.warning(
'Invalid page_id for financial data request',
extra={'org_id': str(org_id), 'page_id': page_id, 'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
except Exception:
logger.exception(
'Error retrieving organization member financial data',
extra={'org_id': str(org_id)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to retrieve member financial data',
)
@org_router.delete('/{org_id}/members/{user_id}')
async def remove_org_member(
org_id: UUID,

View File

@@ -5,7 +5,7 @@ This module provides endpoints for trusted internal services (e.g., automations
to perform privileged operations like creating API keys on behalf of users.
Authentication is via a shared secret (X-Service-API-Key header) configured
through the AUTOMATIONS_SERVICE_API_KEY environment variable.
through the AUTOMATIONS_SERVICE_KEY environment variable.
"""
import os
@@ -20,7 +20,7 @@ from storage.user_store import UserStore
from openhands.core.logger import openhands_logger as logger
# Environment variable for the service API key
AUTOMATIONS_SERVICE_API_KEY = os.getenv('AUTOMATIONS_SERVICE_API_KEY', '').strip()
AUTOMATIONS_SERVICE_KEY = os.getenv('AUTOMATIONS_SERVICE_KEY', '').strip()
service_router = APIRouter(prefix='/api/service', tags=['Service'])
@@ -70,9 +70,9 @@ async def validate_service_api_key(
HTTPException: 401 if key is missing or invalid
HTTPException: 503 if service auth is not configured
"""
if not AUTOMATIONS_SERVICE_API_KEY:
if not AUTOMATIONS_SERVICE_KEY:
logger.warning(
'Service authentication not configured (AUTOMATIONS_SERVICE_API_KEY not set)'
'Service authentication not configured (AUTOMATIONS_SERVICE_KEY not set)'
)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
@@ -85,7 +85,7 @@ async def validate_service_api_key(
detail='X-Service-API-Key header is required',
)
if x_service_api_key != AUTOMATIONS_SERVICE_API_KEY:
if x_service_api_key != AUTOMATIONS_SERVICE_KEY:
logger.warning('Invalid service API key attempted')
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -104,7 +104,7 @@ async def service_health() -> dict:
"""
return {
'status': 'ok',
'service_auth_configured': bool(AUTOMATIONS_SERVICE_API_KEY),
'service_auth_configured': bool(AUTOMATIONS_SERVICE_KEY),
}

View File

@@ -9,6 +9,7 @@ from utils.identity import resolve_display_name
from openhands.integrations.provider import (
PROVIDER_TOKEN_TYPE,
ProviderHandler,
)
from openhands.integrations.service_types import (
Branch,
@@ -67,6 +68,53 @@ async def saas_get_user_installations(
)
@saas_user_router.get('/git-organizations')
async def saas_get_user_git_organizations(
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
access_token: SecretStr | None = Depends(get_access_token),
user_id: str | None = Depends(get_user_id),
):
if not provider_tokens:
retval = await _check_idp(
access_token=access_token,
default_value={},
)
if retval is not None:
return retval
# _check_idp returned None (tokens refreshed on Keycloak side),
# but provider_tokens is still None for this request.
return JSONResponse(
content='Git provider token required.',
status_code=status.HTTP_401_UNAUTHORIZED,
)
client = ProviderHandler(
provider_tokens=provider_tokens,
external_auth_token=access_token,
external_auth_id=user_id,
)
# SaaS users sign in with one provider at a time
provider = next(iter(provider_tokens))
if provider == ProviderType.GITHUB:
orgs = await client.get_github_organizations()
elif provider == ProviderType.GITLAB:
orgs = await client.get_gitlab_groups()
elif provider == ProviderType.BITBUCKET:
orgs = await client.get_bitbucket_workspaces()
else:
return JSONResponse(
content=f"Provider {provider.value} doesn't support git organizations",
status_code=status.HTTP_400_BAD_REQUEST,
)
return {
'provider': provider.value,
'organizations': orgs,
}
@saas_user_router.get('/repositories', response_model=list[Repository])
async def saas_get_user_repositories(
sort: str = 'pushed',

View File

@@ -0,0 +1,171 @@
"""Service for managing organization member financial data."""
from uuid import UUID
import httpx
from server.routes.org_models import (
OrgMemberFinancialPage,
OrgMemberFinancialResponse,
)
from storage.lite_llm_manager import LiteLlmManager
from storage.org_member_store import OrgMemberStore
from openhands.core.logger import openhands_logger as logger
class OrgMemberFinancialService:
"""Service for organization member financial data operations."""
@staticmethod
async def get_org_members_financial_data(
org_id: UUID,
page_id: str | None = None,
limit: int = 10,
email_filter: str | None = None,
) -> OrgMemberFinancialPage:
"""Get paginated financial data for organization members.
Fetches member list from database and joins with financial data from LiteLLM.
Args:
org_id: Organization UUID
page_id: Offset encoded as string (e.g., "0", "10", "20")
limit: Maximum items per page (default 10)
email_filter: Optional case-insensitive partial email match
Returns:
OrgMemberFinancialPage: Paginated response with financial data
Raises:
ValueError: If page_id is invalid
"""
# Parse page_id to get offset
offset = 0
if page_id is not None:
try:
offset = int(page_id)
if offset < 0:
raise ValueError('page_id must be non-negative')
except ValueError as e:
raise ValueError(f'Invalid page_id: {page_id}') from e
# Fetch paginated members from database
members, total_count = await OrgMemberStore.get_org_members_paginated(
org_id=org_id,
offset=offset,
limit=limit,
email_filter=email_filter,
)
if not members:
return OrgMemberFinancialPage(
items=[],
current_page=(offset // limit) + 1,
per_page=limit,
next_page_id=None,
)
# Fetch financial data from LiteLLM for the entire team
# This is a single API call that returns all team members' data
try:
financial_data = await LiteLlmManager.get_team_members_financial_data(
str(org_id)
)
except httpx.HTTPStatusError as e:
# Re-raise auth errors - these indicate configuration issues that need fixing
if e.response.status_code in (401, 403):
logger.error(
'LiteLLM authentication/authorization failed',
extra={
'org_id': str(org_id),
'status_code': e.response.status_code,
'error': str(e),
},
)
raise
# For other HTTP errors (404, 500, etc.), use graceful degradation
logger.warning(
'Failed to fetch financial data from LiteLLM',
extra={
'org_id': str(org_id),
'status_code': e.response.status_code,
'error_type': type(e).__name__,
'error': str(e),
},
)
financial_data = {}
except Exception as e:
# For network errors, timeouts, etc., use graceful degradation
logger.warning(
'Failed to fetch financial data from LiteLLM',
extra={
'org_id': str(org_id),
'error_type': type(e).__name__,
'error': str(e),
},
)
financial_data = {}
# Extract team-level data for shared budget calculation
team_spend = financial_data.get('team_spend', 0) or 0
members_financial = financial_data.get('members', {})
# Build response items by joining DB members with LiteLLM financial data
items: list[OrgMemberFinancialResponse] = []
for member in members:
user = member.user
user_id_str = str(member.user_id)
# Get financial data for this user (or defaults if not found)
user_financial = members_financial.get(user_id_str, {})
individual_spend = user_financial.get('spend', 0) or 0
max_budget = user_financial.get('max_budget')
uses_shared_budget = user_financial.get('uses_shared_budget', False)
# Calculate current budget (remaining)
# For shared team budgets, use team_spend to calculate remaining budget
# This ensures all members see the same remaining budget
if max_budget is not None:
if uses_shared_budget:
# Shared budget - use team's total spend
current_budget = max(max_budget - team_spend, 0)
else:
# Individual budget - use individual spend
current_budget = max(max_budget - individual_spend, 0)
else:
# If no max_budget, current_budget is unlimited (represented as 0)
current_budget = 0
items.append(
OrgMemberFinancialResponse(
user_id=user_id_str,
email=user.email if user else None,
lifetime_spend=individual_spend,
current_budget=current_budget,
max_budget=max_budget,
)
)
# Calculate current page (1-indexed)
current_page = (offset // limit) + 1
# Calculate next_page_id
next_offset = offset + limit
next_page_id = str(next_offset) if next_offset < total_count else None
logger.debug(
'OrgMemberFinancialService:get_org_members_financial_data:success',
extra={
'org_id': str(org_id),
'items_count': len(items),
'current_page': current_page,
'total_count': total_count,
},
)
return OrgMemberFinancialPage(
items=items,
current_page=current_page,
per_page=limit,
next_page_id=next_page_id,
)

View File

@@ -0,0 +1,143 @@
"""Implementation of SharedEventService.
This implementation provides read-only access to events from shared conversations:
- Validates that the conversation is shared before returning events
- Uses existing EventService for actual event retrieval
- Uses SharedConversationInfoService for shared conversation validation
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import AsyncGenerator
from uuid import UUID
from fastapi import Request
from server.sharing.shared_conversation_info_service import (
SharedConversationInfoService,
)
from server.sharing.shared_event_service import (
SharedEventService,
SharedEventServiceInjector,
)
from server.sharing.sql_shared_conversation_info_service import (
SQLSharedConversationInfoService,
)
from openhands.agent_server.models import EventPage, EventSortOrder
from openhands.app_server.config import get_global_config
from openhands.app_server.event.event_service import EventService
from openhands.app_server.event.filesystem_event_service import FilesystemEventService
from openhands.app_server.event_callback.event_callback_models import EventKind
from openhands.app_server.services.injector import InjectorState
from openhands.sdk import Event
logger = logging.getLogger(__name__)
@dataclass
class FilesystemSharedEventService(SharedEventService):
"""Implementation of SharedEventService that validates shared access."""
shared_conversation_info_service: SharedConversationInfoService
persistence_dir: Path
async def get_event_service(self, conversation_id: UUID) -> EventService | None:
shared_conversation_info = (
await self.shared_conversation_info_service.get_shared_conversation_info(
conversation_id
)
)
if shared_conversation_info is None:
return None
return FilesystemEventService(
prefix=self.persistence_dir,
user_id=shared_conversation_info.created_by_user_id,
app_conversation_info_service=None,
app_conversation_info_load_tasks={},
)
async def get_shared_event(
self, conversation_id: UUID, event_id: UUID
) -> Event | None:
"""Given a conversation_id and event_id, retrieve an event if the conversation is shared."""
# First check if the conversation is shared
event_service = await self.get_event_service(conversation_id)
if event_service is None:
return None
# If conversation is shared, get the event
return await event_service.get_event(conversation_id, event_id)
async def search_shared_events(
self,
conversation_id: UUID,
kind__eq: EventKind | None = None,
timestamp__gte: datetime | None = None,
timestamp__lt: datetime | None = None,
sort_order: EventSortOrder = EventSortOrder.TIMESTAMP,
page_id: str | None = None,
limit: int = 100,
) -> EventPage:
"""Search events for a specific shared conversation."""
# First check if the conversation is shared
event_service = await self.get_event_service(conversation_id)
if event_service is None:
# Return empty page if conversation is not shared
return EventPage(items=[], next_page_id=None)
# If conversation is shared, search events for this conversation
return await event_service.search_events(
conversation_id=conversation_id,
kind__eq=kind__eq,
timestamp__gte=timestamp__gte,
timestamp__lt=timestamp__lt,
sort_order=sort_order,
page_id=page_id,
limit=limit,
)
async def count_shared_events(
self,
conversation_id: UUID,
kind__eq: EventKind | None = None,
timestamp__gte: datetime | None = None,
timestamp__lt: datetime | None = None,
) -> int:
"""Count events for a specific shared conversation."""
# First check if the conversation is shared
event_service = await self.get_event_service(conversation_id)
if event_service is None:
# Return empty page if conversation is not shared
return 0
# If conversation is shared, count events for this conversation
return await event_service.count_events(
conversation_id=conversation_id,
kind__eq=kind__eq,
timestamp__gte=timestamp__gte,
timestamp__lt=timestamp__lt,
)
class FilesystemSharedEventServiceInjector(SharedEventServiceInjector):
async def inject(
self, state: InjectorState, request: Request | None = None
) -> AsyncGenerator[SharedEventService, None]:
# Define inline to prevent circular lookup
from openhands.app_server.config import get_db_session
async with get_db_session(state, request) as db_session:
shared_conversation_info_service = SQLSharedConversationInfoService(
db_session=db_session
)
service = FilesystemSharedEventService(
shared_conversation_info_service=shared_conversation_info_service,
persistence_dir=get_global_config().persistence_dir,
)
yield service

View File

@@ -4,7 +4,7 @@ from datetime import datetime
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, Query
from server.sharing.shared_conversation_info_service import (
SharedConversationInfoService,
)
@@ -60,7 +60,7 @@ async def search_shared_conversations(
Query(
title='The max number of results in the page',
gt=0,
lte=100,
le=100,
),
] = 100,
include_sub_conversations: Annotated[
@@ -72,8 +72,6 @@ async def search_shared_conversations(
shared_conversation_service: SharedConversationInfoService = shared_conversation_info_service_dependency,
) -> SharedConversationPage:
"""Search / List shared conversations."""
assert limit > 0
assert limit <= 100
return await shared_conversation_service.search_shared_conversation_info(
title__contains=title__contains,
created_at__gte=created_at__gte,
@@ -127,7 +125,11 @@ async def batch_get_shared_conversations(
shared_conversation_service: SharedConversationInfoService = shared_conversation_info_service_dependency,
) -> list[SharedConversation | None]:
"""Get a batch of shared conversations given their ids. Return None for any missing or non-shared."""
assert len(ids) <= 100
if len(ids) > 100:
raise HTTPException(
status_code=400,
detail=f'Cannot request more than 100 conversations at once, got {len(ids)}',
)
uuids = [UUID(id_) for id_ in ids]
shared_conversation_info = (
await shared_conversation_service.batch_get_shared_conversation_info(uuids)

View File

@@ -4,7 +4,7 @@ from datetime import datetime
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, Query
from server.sharing.shared_event_service import (
SharedEventService,
SharedEventServiceInjector,
@@ -33,6 +33,12 @@ def get_shared_event_service_injector() -> SharedEventServiceInjector:
)
return AwsSharedEventServiceInjector()
elif provider == StorageProvider.FILESYSTEM:
from server.sharing.filesystem_shared_event_service import (
FilesystemSharedEventServiceInjector,
)
return FilesystemSharedEventServiceInjector()
else:
# GCP is the default for shared events (including filesystem fallback)
from server.sharing.google_cloud_shared_event_service import (
@@ -77,13 +83,11 @@ async def search_shared_events(
] = None,
limit: Annotated[
int,
Query(title='The max number of results in the page', gt=0, lte=100),
Query(title='The max number of results in the page', gt=0, le=100),
] = 100,
shared_event_service: SharedEventService = shared_event_service_dependency,
) -> EventPage:
"""Search / List events for a shared conversation."""
assert limit > 0
assert limit <= 100
return await shared_event_service.search_shared_events(
conversation_id=UUID(conversation_id),
kind__eq=kind__eq,
@@ -134,7 +138,11 @@ async def batch_get_shared_events(
shared_event_service: SharedEventService = shared_event_service_dependency,
) -> list[Event | None]:
"""Get a batch of events for a shared conversation given their ids, returning null for any missing event."""
assert len(id) <= 100
if len(id) > 100:
raise HTTPException(
status_code=400,
detail=f'Cannot request more than 100 events at once, got {len(id)}',
)
event_ids = [UUID(id_) for id_ in id]
events = await shared_event_service.batch_get_shared_events(
UUID(conversation_id), event_ids

View File

@@ -354,6 +354,15 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
user = result.scalar_one_or_none()
assert user
# Determine org_id: prefer API key's org_id if authenticated via API key
org_id = user.current_org_id # Default fallback
if hasattr(self.user_context, 'user_auth'):
user_auth = self.user_context.user_auth
if hasattr(user_auth, 'get_api_key_org_id'):
api_key_org_id = user_auth.get_api_key_org_id()
if api_key_org_id is not None:
org_id = api_key_org_id
# Check if SAAS metadata already exists
saas_query = select(StoredConversationMetadataSaas).where(
StoredConversationMetadataSaas.conversation_id == str(info.id)
@@ -362,16 +371,15 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
existing_saas_metadata = result.scalar_one_or_none()
assert existing_saas_metadata is None or (
existing_saas_metadata.user_id == user_id_uuid
and existing_saas_metadata.org_id == user.current_org_id
and existing_saas_metadata.org_id == org_id
)
if not existing_saas_metadata:
# Create new SAAS metadata
# Set org_id to user_id as specified in requirements
# Create new SAAS metadata with the determined org_id
saas_metadata = StoredConversationMetadataSaas(
conversation_id=str(info.id),
user_id=user_id_uuid,
org_id=user.current_org_id,
org_id=org_id,
)
self.db_session.add(saas_metadata)

View File

@@ -29,7 +29,10 @@ def get_cookie_domain() -> str | None:
def get_cookie_samesite() -> Literal['lax', 'strict']:
# for localhost and feature/staging stacks we set it to 'lax' as the cookie domain won't allow 'strict'
# Use 'strict' in production for maximum CSRF protection
# Use 'lax' for local development and staging environments
# Note: For invitation links from emails, the frontend handles acceptance via
# an authenticated POST request (same-origin), which works with 'strict' cookies
web_url = get_global_config().web_url
return (
'strict'

View File

@@ -1524,6 +1524,83 @@ class LiteLlmManager:
'LiteLlmManager:_delete_key:key_deleted',
)
@staticmethod
async def _get_team_members_financial_data(
client: httpx.AsyncClient,
team_id: str,
) -> dict:
"""
Get financial data for all members in a team.
Fetches team info from LiteLLM and extracts spending/budget data for each member.
Args:
client: HTTP client for LiteLLM API
team_id: The team/organization ID
Returns:
Dict with structure:
{
"team_max_budget": float | None, # Team's shared budget
"team_spend": float, # Team's total spend (for shared budget calc)
"members": {
user_id: {
"spend": float,
"max_budget": float | None,
"uses_shared_budget": bool # True if using team budget
},
...
}
}
Returns empty dict if team not found or LiteLLM is not configured.
"""
if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None:
logger.warning('LiteLLM API configuration not found')
return {}
team_info = await LiteLlmManager._get_team(client, team_id)
if not team_info:
logger.warning(
'LiteLlmManager:_get_team_members_financial_data:team_not_found',
extra={'team_id': team_id},
)
return {}
members: dict[str, dict] = {}
team_memberships = team_info.get('team_memberships', [])
# Get team-level budget info (shared across all members in team orgs)
team_data = team_info.get('team_info', {})
team_max_budget = team_data.get('max_budget')
team_spend = team_data.get('spend', 0) or 0
for membership in team_memberships:
user_id = membership.get('user_id')
if not user_id:
continue
# Use individual max_budget_in_team if set, otherwise fall back to team budget
member_max_budget = membership.get('max_budget_in_team')
uses_shared_budget = member_max_budget is None
if uses_shared_budget:
member_max_budget = team_max_budget
members[user_id] = {
'spend': membership.get('spend', 0) or 0,
'max_budget': member_max_budget,
'uses_shared_budget': uses_shared_budget,
}
logger.debug(
'LiteLlmManager:_get_team_members_financial_data:success',
extra={'team_id': team_id, 'member_count': len(members)},
)
return {
'team_max_budget': team_max_budget,
'team_spend': team_spend,
'members': members,
}
@staticmethod
def with_http_client(
internal_fn: Callable[..., Awaitable[Any]],
@@ -1531,7 +1608,8 @@ class LiteLlmManager:
@functools.wraps(internal_fn)
async def wrapper(*args, **kwargs):
async with httpx.AsyncClient(
headers={'x-goog-api-key': LITE_LLM_API_KEY}
headers={'x-goog-api-key': LITE_LLM_API_KEY},
timeout=httpx.Timeout(30.0),
) as client:
return await internal_fn(client, *args, **kwargs)
@@ -1558,3 +1636,6 @@ class LiteLlmManager:
get_user_keys = staticmethod(with_http_client(_get_user_keys))
delete_key_by_alias = staticmethod(with_http_client(_delete_key_by_alias))
update_user_keys = staticmethod(with_http_client(_update_user_keys))
get_team_members_financial_data = staticmethod(
with_http_client(_get_team_members_financial_data)
)

View File

@@ -3,7 +3,7 @@ SQLAlchemy model for Organization-Member relationship.
"""
from pydantic import SecretStr
from sqlalchemy import UUID, Column, ForeignKey, Integer, String
from sqlalchemy import JSON, UUID, Column, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from storage.base import Base
from storage.encrypt_utils import decrypt_value, encrypt_value
@@ -23,6 +23,7 @@ class OrgMember(Base): # type: ignore
_llm_api_key_for_byor = Column(String, nullable=True)
llm_base_url = Column(String, nullable=True)
status = Column(String, nullable=True)
mcp_config = Column(JSON, nullable=True)
# Relationships
org = relationship('Org', back_populates='org_members')

View File

@@ -59,12 +59,15 @@ class SaasSecretsStore(SecretsStore):
async with a_session_maker() as session:
# Incoming secrets are always the most updated ones
# Delete all existing records and override with incoming ones
await session.execute(
delete(StoredCustomSecrets).filter(
StoredCustomSecrets.keycloak_user_id == self.user_id
)
# Delete existing records for this user AND organization only
delete_query = delete(StoredCustomSecrets).filter(
StoredCustomSecrets.keycloak_user_id == self.user_id
)
if org_id is not None:
delete_query = delete_query.filter(StoredCustomSecrets.org_id == org_id)
else:
delete_query = delete_query.filter(StoredCustomSecrets.org_id.is_(None))
await session.execute(delete_query)
# Prepare the new secrets data
kwargs = item.model_dump(context={'expose_secrets': True})

View File

@@ -115,6 +115,9 @@ class SaasSettingsStore(SettingsStore):
kwargs['llm_api_key_for_byor'] = org_member.llm_api_key_for_byor
if org_member.llm_base_url:
kwargs['llm_base_url'] = org_member.llm_base_url
# MCP config is user-specific (stored on org_member, not org)
if org_member.mcp_config is not None:
kwargs['mcp_config'] = org_member.mcp_config
if org.v1_enabled is None:
kwargs['v1_enabled'] = True
# Apply default if sandbox_grouping_strategy is None in the database
@@ -179,7 +182,7 @@ class SaasSettingsStore(SettingsStore):
return None
# Check if we need to generate an LLM key.
if item.llm_base_url == LITE_LLM_API_URL:
if not item.llm_base_url or item.llm_base_url == LITE_LLM_API_URL:
await self._ensure_api_key(
item, str(org_id), openhands_type=is_openhands_model(item.llm_model)
)
@@ -187,6 +190,9 @@ class SaasSettingsStore(SettingsStore):
kwargs = item.model_dump(context={'expose_secrets': True})
for model in (user, org, org_member):
for key, value in kwargs.items():
# Skip mcp_config for org - it should only be stored on org_member (user-specific)
if key == 'mcp_config' and model is org:
continue
if hasattr(model, key):
setattr(model, key, value)

View File

@@ -5,6 +5,7 @@ SQLAlchemy model for User.
from uuid import uuid4
from sqlalchemy import (
JSON,
UUID,
Boolean,
Column,
@@ -34,6 +35,7 @@ class User(Base): # type: ignore
git_user_name = Column(String, nullable=True)
git_user_email = Column(String, nullable=True)
sandbox_grouping_strategy = Column(String, nullable=True)
disabled_skills = Column(JSON, nullable=True)
# Relationships
role = relationship('Role', back_populates='users')

View File

@@ -31,6 +31,7 @@ class UserSettings(Base): # type: ignore
user_version = Column(Integer, nullable=False, default=0)
accepted_tos = Column(DateTime, nullable=True)
mcp_config = Column(JSON, nullable=True)
disabled_skills = Column(JSON, nullable=True)
search_api_key = Column(String, nullable=True)
sandbox_api_key = Column(String, nullable=True)
max_budget_per_task = Column(Float, nullable=True)

View File

@@ -214,14 +214,15 @@ class UserStore:
decrypted_user_settings, user_settings.user_version
)
# avoids circular reference. This migrate method is temprorary until all users are migrated.
# Migrate stripe customer (pass session to avoid FK violation)
# avoids circular reference. This migrate method is temporary until all users are migrated.
from integrations.stripe_service import migrate_customer
logger.debug(
'user_store:migrate_user:calling_stripe_migrate_customer',
extra={'user_id': user_id},
)
await migrate_customer(user_id, org)
await migrate_customer(session, user_id, org)
logger.debug(
'user_store:migrate_user:done_stripe_migrate_customer',
extra={'user_id': user_id},

View File

@@ -13,7 +13,6 @@ Required environment variables:
- RESEND_AUDIENCE_ID: ID of the Resend audience to add users to
Optional environment variables:
- KEYCLOAK_PROVIDER_NAME: Provider name for Keycloak
- KEYCLOAK_CLIENT_ID: Client ID for Keycloak
- KEYCLOAK_CLIENT_SECRET: Client secret for Keycloak
- RESEND_FROM_EMAIL: Email address to use as the sender (default: "OpenHands Team <no-reply@welcome.openhands.dev>")
@@ -49,7 +48,6 @@ from openhands.core.logger import openhands_logger as logger
# Get Keycloak configuration from environment variables
KEYCLOAK_SERVER_URL = os.environ.get('KEYCLOAK_SERVER_URL', '')
KEYCLOAK_REALM_NAME = os.environ.get('KEYCLOAK_REALM_NAME', '')
KEYCLOAK_PROVIDER_NAME = os.environ.get('KEYCLOAK_PROVIDER_NAME', '')
KEYCLOAK_CLIENT_ID = os.environ.get('KEYCLOAK_CLIENT_ID', '')
KEYCLOAK_CLIENT_SECRET = os.environ.get('KEYCLOAK_CLIENT_SECRET', '')
KEYCLOAK_ADMIN_PASSWORD = os.environ.get('KEYCLOAK_ADMIN_PASSWORD', '')

View File

@@ -19,18 +19,14 @@ class TestValidateServiceApiKey:
@pytest.mark.asyncio
async def test_valid_service_key(self):
"""Test validation with valid service API key."""
with patch(
'server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-service-key'
):
with patch('server.routes.service.AUTOMATIONS_SERVICE_KEY', 'test-service-key'):
result = await validate_service_api_key('test-service-key')
assert result == 'automations-service'
@pytest.mark.asyncio
async def test_missing_service_key(self):
"""Test validation with missing service API key header."""
with patch(
'server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-service-key'
):
with patch('server.routes.service.AUTOMATIONS_SERVICE_KEY', 'test-service-key'):
with pytest.raises(HTTPException) as exc_info:
await validate_service_api_key(None)
assert exc_info.value.status_code == 401
@@ -39,9 +35,7 @@ class TestValidateServiceApiKey:
@pytest.mark.asyncio
async def test_invalid_service_key(self):
"""Test validation with invalid service API key."""
with patch(
'server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-service-key'
):
with patch('server.routes.service.AUTOMATIONS_SERVICE_KEY', 'test-service-key'):
with pytest.raises(HTTPException) as exc_info:
await validate_service_api_key('wrong-key')
assert exc_info.value.status_code == 401
@@ -50,7 +44,7 @@ class TestValidateServiceApiKey:
@pytest.mark.asyncio
async def test_service_auth_not_configured(self):
"""Test validation when service auth is not configured."""
with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', ''):
with patch('server.routes.service.AUTOMATIONS_SERVICE_KEY', ''):
with pytest.raises(HTTPException) as exc_info:
await validate_service_api_key('any-key')
assert exc_info.value.status_code == 503
@@ -112,7 +106,7 @@ class TestGetOrCreateApiKeyForUser:
@pytest.mark.asyncio
async def test_user_not_found(self, valid_user_id, valid_org_id, valid_request):
"""Test error when user doesn't exist."""
with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'):
with patch('server.routes.service.AUTOMATIONS_SERVICE_KEY', 'test-key'):
with patch(
'server.routes.service.UserStore.get_user_by_id', new_callable=AsyncMock
) as mock_get_user:
@@ -132,7 +126,7 @@ class TestGetOrCreateApiKeyForUser:
"""Test error when user is not a member of the org."""
mock_user = MagicMock()
with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'):
with patch('server.routes.service.AUTOMATIONS_SERVICE_KEY', 'test-key'):
with patch(
'server.routes.service.UserStore.get_user_by_id', new_callable=AsyncMock
) as mock_get_user:
@@ -164,7 +158,7 @@ class TestGetOrCreateApiKeyForUser:
return_value='sk-oh-test-key-12345678901234567890'
)
with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'):
with patch('server.routes.service.AUTOMATIONS_SERVICE_KEY', 'test-key'):
with patch(
'server.routes.service.UserStore.get_user_by_id', new_callable=AsyncMock
) as mock_get_user:
@@ -210,7 +204,7 @@ class TestGetOrCreateApiKeyForUser:
side_effect=Exception('Database error')
)
with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'):
with patch('server.routes.service.AUTOMATIONS_SERVICE_KEY', 'test-key'):
with patch(
'server.routes.service.UserStore.get_user_by_id', new_callable=AsyncMock
) as mock_get_user:
@@ -252,7 +246,7 @@ class TestDeleteUserApiKey:
mock_api_key_store.make_system_key_name.return_value = '__SYSTEM__:automation'
mock_api_key_store.delete_api_key_by_name = AsyncMock(return_value=True)
with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'):
with patch('server.routes.service.AUTOMATIONS_SERVICE_KEY', 'test-key'):
with patch(
'server.routes.service.ApiKeyStore.get_instance'
) as mock_get_store:
@@ -283,7 +277,7 @@ class TestDeleteUserApiKey:
mock_api_key_store.make_system_key_name.return_value = '__SYSTEM__:nonexistent'
mock_api_key_store.delete_api_key_by_name = AsyncMock(return_value=False)
with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'):
with patch('server.routes.service.AUTOMATIONS_SERVICE_KEY', 'test-key'):
with patch(
'server.routes.service.ApiKeyStore.get_instance'
) as mock_get_store:
@@ -303,7 +297,7 @@ class TestDeleteUserApiKey:
@pytest.mark.asyncio
async def test_delete_invalid_service_key(self, valid_org_id):
"""Test error when service API key is invalid."""
with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'):
with patch('server.routes.service.AUTOMATIONS_SERVICE_KEY', 'test-key'):
with pytest.raises(HTTPException) as exc_info:
await delete_user_api_key(
user_id='user-123',
@@ -318,7 +312,7 @@ class TestDeleteUserApiKey:
@pytest.mark.asyncio
async def test_delete_missing_service_key(self, valid_org_id):
"""Test error when service API key header is missing."""
with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'):
with patch('server.routes.service.AUTOMATIONS_SERVICE_KEY', 'test-key'):
with pytest.raises(HTTPException) as exc_info:
await delete_user_api_key(
user_id='user-123',

View File

@@ -0,0 +1,420 @@
"""Tests for OrgMemberFinancialService."""
import uuid
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from server.routes.org_models import OrgMemberFinancialPage
from server.services.org_member_financial_service import OrgMemberFinancialService
from storage.org_member import OrgMember
@pytest.fixture
def org_id():
"""Create a test organization ID."""
return uuid.uuid4()
@pytest.fixture
def mock_user():
"""Create a mock user."""
user = MagicMock()
user.email = 'test@example.com'
return user
@pytest.fixture
def mock_role():
"""Create a mock role."""
role = MagicMock()
role.id = 1
role.name = 'member'
role.rank = 1000
return role
@pytest.fixture
def mock_org_member(org_id, mock_user, mock_role):
"""Create a mock org member with user and role."""
member = MagicMock(spec=OrgMember)
member.org_id = org_id
member.user_id = uuid.uuid4()
member.role_id = mock_role.id
member.status = 'active'
member.user = mock_user
member.role = mock_role
return member
class TestOrgMemberFinancialServiceGetFinancialData:
"""Test cases for OrgMemberFinancialService.get_org_members_financial_data."""
@pytest.mark.asyncio
async def test_returns_paginated_financial_data_with_individual_budget(
self, org_id, mock_org_member
):
"""
GIVEN: Organization with members having individual budget limits
WHEN: get_org_members_financial_data is called
THEN: Returns financial data using individual spend for current_budget calc
"""
# Arrange
user_id_str = str(mock_org_member.user_id)
litellm_data = {
'team_max_budget': 1000.0,
'team_spend': 200.0,
'members': {
user_id_str: {'spend': 125.50, 'max_budget': 500.0} # Individual budget
},
}
with (
patch(
'server.services.org_member_financial_service.OrgMemberStore.get_org_members_paginated',
new_callable=AsyncMock,
) as mock_get_paginated,
patch(
'server.services.org_member_financial_service.LiteLlmManager.get_team_members_financial_data',
new_callable=AsyncMock,
) as mock_get_financial,
):
mock_get_paginated.return_value = ([mock_org_member], 1)
mock_get_financial.return_value = litellm_data
# Act
result = await OrgMemberFinancialService.get_org_members_financial_data(
org_id=org_id,
page_id=None,
limit=10,
)
# Assert
assert isinstance(result, OrgMemberFinancialPage)
assert len(result.items) == 1
assert result.items[0].user_id == user_id_str
assert result.items[0].email == 'test@example.com'
assert result.items[0].lifetime_spend == 125.50
assert result.items[0].max_budget == 500.0
# Individual budget: 500 - 125.50 = 374.50
assert result.items[0].current_budget == 374.50
assert result.current_page == 1
assert result.per_page == 10
@pytest.mark.asyncio
async def test_returns_shared_budget_using_team_spend(
self, org_id, mock_org_member
):
"""
GIVEN: Organization with shared team budget
WHEN: get_org_members_financial_data is called
THEN: Uses team_spend (not individual spend) for current_budget calculation
"""
# Arrange
user_id_str = str(mock_org_member.user_id)
litellm_data = {
'team_max_budget': 500.0,
'team_spend': 150.0, # Total team spend
'members': {
user_id_str: {
'spend': 50.0,
'max_budget': 500.0,
'uses_shared_budget': True, # Explicitly using shared budget
}
},
}
with (
patch(
'server.services.org_member_financial_service.OrgMemberStore.get_org_members_paginated',
new_callable=AsyncMock,
) as mock_get_paginated,
patch(
'server.services.org_member_financial_service.LiteLlmManager.get_team_members_financial_data',
new_callable=AsyncMock,
) as mock_get_financial,
):
mock_get_paginated.return_value = ([mock_org_member], 1)
mock_get_financial.return_value = litellm_data
# Act
result = await OrgMemberFinancialService.get_org_members_financial_data(
org_id=org_id,
)
# Assert
assert len(result.items) == 1
assert result.items[0].lifetime_spend == 50.0 # Individual spend
assert result.items[0].max_budget == 500.0
# Shared budget: 500 - 150 (team_spend) = 350
assert result.items[0].current_budget == 350.0
@pytest.mark.asyncio
async def test_returns_defaults_when_litellm_data_missing(
self, org_id, mock_org_member
):
"""
GIVEN: Organization with members but no LiteLLM data for them
WHEN: get_org_members_financial_data is called
THEN: Returns financial data with default values (spend=0, budget=None)
"""
# Arrange
with (
patch(
'server.services.org_member_financial_service.OrgMemberStore.get_org_members_paginated',
new_callable=AsyncMock,
) as mock_get_paginated,
patch(
'server.services.org_member_financial_service.LiteLlmManager.get_team_members_financial_data',
new_callable=AsyncMock,
) as mock_get_financial,
):
mock_get_paginated.return_value = ([mock_org_member], 1)
mock_get_financial.return_value = {
'team_max_budget': None,
'team_spend': 0,
'members': {},
}
# Act
result = await OrgMemberFinancialService.get_org_members_financial_data(
org_id=org_id,
)
# Assert
assert len(result.items) == 1
assert result.items[0].lifetime_spend == 0
assert result.items[0].max_budget is None
assert result.items[0].current_budget == 0
@pytest.mark.asyncio
async def test_handles_litellm_failure_gracefully(self, org_id, mock_org_member):
"""
GIVEN: LiteLLM service throws an exception
WHEN: get_org_members_financial_data is called
THEN: Returns financial data with default values (doesn't fail)
"""
# Arrange
with (
patch(
'server.services.org_member_financial_service.OrgMemberStore.get_org_members_paginated',
new_callable=AsyncMock,
) as mock_get_paginated,
patch(
'server.services.org_member_financial_service.LiteLlmManager.get_team_members_financial_data',
new_callable=AsyncMock,
) as mock_get_financial,
):
mock_get_paginated.return_value = ([mock_org_member], 1)
mock_get_financial.side_effect = Exception('LiteLLM unavailable')
# Act
result = await OrgMemberFinancialService.get_org_members_financial_data(
org_id=org_id,
)
# Assert - should not raise, returns defaults
assert len(result.items) == 1
assert result.items[0].lifetime_spend == 0
assert result.items[0].max_budget is None
@pytest.mark.asyncio
async def test_pagination_returns_next_page_id(self, org_id, mock_org_member):
"""
GIVEN: Organization with more members than limit
WHEN: get_org_members_financial_data is called
THEN: Returns next_page_id for pagination
"""
# Arrange
with (
patch(
'server.services.org_member_financial_service.OrgMemberStore.get_org_members_paginated',
new_callable=AsyncMock,
) as mock_get_paginated,
patch(
'server.services.org_member_financial_service.LiteLlmManager.get_team_members_financial_data',
new_callable=AsyncMock,
) as mock_get_financial,
):
mock_get_paginated.return_value = ([mock_org_member], 25) # 25 total
mock_get_financial.return_value = {
'team_max_budget': None,
'team_spend': 0,
'members': {},
}
# Act
result = await OrgMemberFinancialService.get_org_members_financial_data(
org_id=org_id,
page_id='0',
limit=10,
)
# Assert
assert result.current_page == 1
assert result.next_page_id == '10'
@pytest.mark.asyncio
async def test_pagination_no_next_page_on_last_page(self, org_id, mock_org_member):
"""
GIVEN: Organization on last page of results
WHEN: get_org_members_financial_data is called
THEN: Returns next_page_id as None
"""
# Arrange
with (
patch(
'server.services.org_member_financial_service.OrgMemberStore.get_org_members_paginated',
new_callable=AsyncMock,
) as mock_get_paginated,
patch(
'server.services.org_member_financial_service.LiteLlmManager.get_team_members_financial_data',
new_callable=AsyncMock,
) as mock_get_financial,
):
mock_get_paginated.return_value = ([mock_org_member], 5) # 5 total
mock_get_financial.return_value = {
'team_max_budget': None,
'team_spend': 0,
'members': {},
}
# Act
result = await OrgMemberFinancialService.get_org_members_financial_data(
org_id=org_id,
page_id='0',
limit=10,
)
# Assert
assert result.next_page_id is None
@pytest.mark.asyncio
async def test_empty_organization_returns_empty_items(self, org_id):
"""
GIVEN: Organization with no members
WHEN: get_org_members_financial_data is called
THEN: Returns empty items list
"""
# Arrange
with patch(
'server.services.org_member_financial_service.OrgMemberStore.get_org_members_paginated',
new_callable=AsyncMock,
) as mock_get_paginated:
mock_get_paginated.return_value = ([], 0)
# Act
result = await OrgMemberFinancialService.get_org_members_financial_data(
org_id=org_id,
)
# Assert
assert len(result.items) == 0
assert result.next_page_id is None
@pytest.mark.asyncio
async def test_invalid_page_id_raises_value_error(self, org_id):
"""
GIVEN: Invalid page_id format
WHEN: get_org_members_financial_data is called
THEN: Raises ValueError
"""
# Act & Assert
with pytest.raises(ValueError) as exc_info:
await OrgMemberFinancialService.get_org_members_financial_data(
org_id=org_id,
page_id='invalid',
)
assert 'Invalid page_id' in str(exc_info.value)
@pytest.mark.asyncio
async def test_negative_page_id_raises_value_error(self, org_id):
"""
GIVEN: Negative page_id
WHEN: get_org_members_financial_data is called
THEN: Raises ValueError
"""
# Act & Assert
with pytest.raises(ValueError) as exc_info:
await OrgMemberFinancialService.get_org_members_financial_data(
org_id=org_id,
page_id='-5',
)
assert 'Invalid page_id' in str(exc_info.value)
@pytest.mark.asyncio
async def test_passes_email_filter_to_store(self, org_id, mock_org_member):
"""
GIVEN: Email filter parameter
WHEN: get_org_members_financial_data is called
THEN: Passes email filter to the store
"""
# Arrange
with (
patch(
'server.services.org_member_financial_service.OrgMemberStore.get_org_members_paginated',
new_callable=AsyncMock,
) as mock_get_paginated,
patch(
'server.services.org_member_financial_service.LiteLlmManager.get_team_members_financial_data',
new_callable=AsyncMock,
) as mock_get_financial,
):
mock_get_paginated.return_value = ([mock_org_member], 1)
mock_get_financial.return_value = {
'team_max_budget': None,
'team_spend': 0,
'members': {},
}
# Act
await OrgMemberFinancialService.get_org_members_financial_data(
org_id=org_id,
email_filter='alice',
)
# Assert
mock_get_paginated.assert_called_once_with(
org_id=org_id, offset=0, limit=10, email_filter='alice'
)
@pytest.mark.asyncio
async def test_handles_missing_user_relationship(self, org_id, mock_role):
"""
GIVEN: Member with no user relationship loaded
WHEN: get_org_members_financial_data is called
THEN: Returns None for email
"""
# Arrange
member_no_user = MagicMock(spec=OrgMember)
member_no_user.org_id = org_id
member_no_user.user_id = uuid.uuid4()
member_no_user.role_id = mock_role.id
member_no_user.user = None # No user relationship
with (
patch(
'server.services.org_member_financial_service.OrgMemberStore.get_org_members_paginated',
new_callable=AsyncMock,
) as mock_get_paginated,
patch(
'server.services.org_member_financial_service.LiteLlmManager.get_team_members_financial_data',
new_callable=AsyncMock,
) as mock_get_financial,
):
mock_get_paginated.return_value = ([member_no_user], 1)
mock_get_financial.return_value = {
'team_max_budget': None,
'team_spend': 0,
'members': {},
}
# Act
result = await OrgMemberFinancialService.get_org_members_financial_data(
org_id=org_id,
)
# Assert
assert len(result.items) == 1
assert result.items[0].email is None

View File

@@ -990,3 +990,317 @@ class TestSandboxIdFilterSaas:
sandbox_id__eq=shared_sandbox_id
)
assert count == 1
class TestApiKeyOrgIdHandling:
"""Test suite for API key organization ID handling in save_app_conversation_info.
These tests verify that when a conversation is created using API key authentication,
the conversation is associated with the API key's bound organization, not the user's
currently selected organization.
"""
@pytest.mark.asyncio
async def test_api_key_org_id_used_when_available(
self,
async_session_with_users: AsyncSession,
):
"""Test that API key's org_id is used when saving conversation via API key auth.
This tests the main bug fix: when a user creates an API key in Personal Workspace,
then switches to OpenHands org in browser, and uses the API key to create a
conversation, the conversation should be saved in Personal Workspace (API key's org),
not OpenHands (user's current org).
"""
from dataclasses import dataclass
from storage.stored_conversation_metadata_saas import (
StoredConversationMetadataSaas,
)
# Create a mock UserAuth with API key org_id
@dataclass
class MockUserAuth:
user_id: str
api_key_org_id: UUID | None = None
async def get_user_id(self) -> str:
return self.user_id
def get_api_key_org_id(self) -> UUID | None:
return self.api_key_org_id
# Create a mock UserContext that wraps the MockUserAuth
@dataclass
class MockAuthUserContext:
user_auth: MockUserAuth
async def get_user_id(self) -> str | None:
return await self.user_auth.get_user_id()
# Simulate: User1's current org is ORG2, but API key is bound to ORG1
# First, update user1's current_org_id to ORG2
result = await async_session_with_users.execute(
select(User).where(User.id == USER1_ID)
)
user_to_update = result.scalars().first()
user_to_update.current_org_id = ORG2_ID # User is viewing ORG2
await async_session_with_users.commit()
async_session_with_users.expire_all()
# Create service with mock auth context where API key is bound to ORG1
mock_user_auth = MockUserAuth(
user_id=str(USER1_ID),
api_key_org_id=ORG1_ID, # API key created in ORG1
)
mock_context = MockAuthUserContext(user_auth=mock_user_auth)
service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=mock_context,
)
# Create and save a conversation
conv_id = uuid4()
conv_info = AppConversationInfo(
id=conv_id,
created_by_user_id=str(USER1_ID),
sandbox_id='sandbox_api_key_test',
title='API Key Created Conversation',
)
await service.save_app_conversation_info(conv_info)
# Verify: SAAS metadata should have ORG1 (API key's org), not ORG2 (user's current org)
saas_query = select(StoredConversationMetadataSaas).where(
StoredConversationMetadataSaas.conversation_id == str(conv_id)
)
result = await async_session_with_users.execute(saas_query)
saas_metadata = result.scalar_one_or_none()
assert saas_metadata is not None, 'SAAS metadata should be created'
assert saas_metadata.user_id == USER1_ID
assert (
saas_metadata.org_id == ORG1_ID
), 'Conversation should be in API key org (ORG1), not user current org (ORG2)'
@pytest.mark.asyncio
async def test_legacy_api_key_without_org_uses_user_current_org(
self,
async_session_with_users: AsyncSession,
):
"""Test that legacy API keys (without org_id) fall back to user's current org.
Legacy API keys created before the org_id feature was added will have
api_key_org_id = None. In this case, we should fall back to the user's
current_org_id.
"""
from dataclasses import dataclass
from storage.stored_conversation_metadata_saas import (
StoredConversationMetadataSaas,
)
# Create a mock UserAuth with API key but NO org_id (legacy key)
@dataclass
class MockUserAuth:
user_id: str
api_key_org_id: UUID | None = None
async def get_user_id(self) -> str:
return self.user_id
def get_api_key_org_id(self) -> UUID | None:
return self.api_key_org_id
@dataclass
class MockAuthUserContext:
user_auth: MockUserAuth
async def get_user_id(self) -> str | None:
return await self.user_auth.get_user_id()
# Create service with mock auth context where API key has NO org_id
mock_user_auth = MockUserAuth(
user_id=str(USER1_ID),
api_key_org_id=None, # Legacy key without org binding
)
mock_context = MockAuthUserContext(user_auth=mock_user_auth)
service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=mock_context,
)
# Create and save a conversation
conv_id = uuid4()
conv_info = AppConversationInfo(
id=conv_id,
created_by_user_id=str(USER1_ID),
sandbox_id='sandbox_legacy_key_test',
title='Legacy API Key Conversation',
)
await service.save_app_conversation_info(conv_info)
# Verify: SAAS metadata should use user's current org (ORG1) as fallback
saas_query = select(StoredConversationMetadataSaas).where(
StoredConversationMetadataSaas.conversation_id == str(conv_id)
)
result = await async_session_with_users.execute(saas_query)
saas_metadata = result.scalar_one_or_none()
assert saas_metadata is not None, 'SAAS metadata should be created'
assert saas_metadata.user_id == USER1_ID
assert (
saas_metadata.org_id == ORG1_ID
), 'Legacy key should fall back to user current org (ORG1)'
@pytest.mark.asyncio
async def test_cookie_auth_without_api_key_uses_user_current_org(
self,
async_session_with_users: AsyncSession,
):
"""Test that cookie auth (no API key) uses user's current org.
When authenticated via browser cookie (no API key), there's no
get_api_key_org_id method, so we use user's current_org_id.
This is already tested by other tests using SpecifyUserContext,
but we explicitly test the case where user_context doesn't have user_auth.
"""
from storage.stored_conversation_metadata_saas import (
StoredConversationMetadataSaas,
)
# Use SpecifyUserContext which doesn't have user_auth attribute
service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=SpecifyUserContext(user_id=str(USER1_ID)),
)
# Create and save a conversation
conv_id = uuid4()
conv_info = AppConversationInfo(
id=conv_id,
created_by_user_id=str(USER1_ID),
sandbox_id='sandbox_cookie_auth_test',
title='Cookie Auth Conversation',
)
await service.save_app_conversation_info(conv_info)
# Verify: SAAS metadata should use user's current org (ORG1)
saas_query = select(StoredConversationMetadataSaas).where(
StoredConversationMetadataSaas.conversation_id == str(conv_id)
)
result = await async_session_with_users.execute(saas_query)
saas_metadata = result.scalar_one_or_none()
assert saas_metadata is not None, 'SAAS metadata should be created'
assert saas_metadata.user_id == USER1_ID
assert (
saas_metadata.org_id == ORG1_ID
), 'Cookie auth should use user current org (ORG1)'
@pytest.mark.asyncio
async def test_api_key_org_isolation_cross_org_visibility(
self,
async_session_with_users: AsyncSession,
):
"""Test end-to-end: conversation created via API key is visible in correct org.
Simulates the full bug scenario:
1. Create conversation via API key (bound to ORG1)
2. User switches to ORG2
3. User should NOT see the conversation in ORG2
4. User switches back to ORG1
5. User should see the conversation in ORG1
"""
from dataclasses import dataclass
@dataclass
class MockUserAuth:
user_id: str
api_key_org_id: UUID | None = None
async def get_user_id(self) -> str:
return self.user_id
def get_api_key_org_id(self) -> UUID | None:
return self.api_key_org_id
@dataclass
class MockAuthUserContext:
user_auth: MockUserAuth
async def get_user_id(self) -> str | None:
return await self.user_auth.get_user_id()
# Step 1: Create conversation via API key bound to ORG1
mock_user_auth = MockUserAuth(
user_id=str(USER1_ID),
api_key_org_id=ORG1_ID,
)
mock_context = MockAuthUserContext(user_auth=mock_user_auth)
api_key_service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=mock_context,
)
conv_id = uuid4()
conv_info = AppConversationInfo(
id=conv_id,
created_by_user_id=str(USER1_ID),
sandbox_id='sandbox_e2e_api_key',
title='E2E API Key Conversation',
)
await api_key_service.save_app_conversation_info(conv_info)
# Step 2: Switch user to ORG2 in browser session
result = await async_session_with_users.execute(
select(User).where(User.id == USER1_ID)
)
user_to_update = result.scalars().first()
user_to_update.current_org_id = ORG2_ID
await async_session_with_users.commit()
async_session_with_users.expire_all()
# Step 3: User in ORG2 should NOT see the conversation
user_service_org2 = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=SpecifyUserContext(user_id=str(USER1_ID)),
)
page_org2 = await user_service_org2.search_app_conversation_info()
assert (
len(page_org2.items) == 0
), 'User in ORG2 should not see conversation created via API key in ORG1'
# Also verify get_app_conversation_info returns None
conv_from_org2 = await user_service_org2.get_app_conversation_info(conv_id)
assert (
conv_from_org2 is None
), 'User in ORG2 should not access conversation from ORG1'
# Step 4: Switch user back to ORG1
result = await async_session_with_users.execute(
select(User).where(User.id == USER1_ID)
)
user_to_update = result.scalars().first()
user_to_update.current_org_id = ORG1_ID
await async_session_with_users.commit()
async_session_with_users.expire_all()
# Step 5: User in ORG1 should see the conversation
user_service_org1 = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=SpecifyUserContext(user_id=str(USER1_ID)),
)
page_org1 = await user_service_org1.search_app_conversation_info()
assert (
len(page_org1.items) == 1
), 'User in ORG1 should see conversation created via API key in ORG1'
assert page_org1.items[0].id == conv_id
assert page_org1.items[0].title == 'E2E API Key Conversation'
# Also verify get_app_conversation_info works
conv_from_org1 = await user_service_org1.get_app_conversation_info(conv_id)
assert conv_from_org1 is not None
assert conv_from_org1.id == conv_id

View File

@@ -846,10 +846,108 @@ async def test_keycloak_callback_duplicate_email_detected(
assert exc_info.value.detail == 'duplicate_email'
# Note: test_keycloak_callback_duplicate_email_deletion_fails was removed as part of
# the user authorization refactor. The Keycloak user deletion logic for duplicate emails
# has been removed from keycloak_callback. If this behavior needs to be restored,
# it should be implemented in the DefaultUserAuthorizer or handled separately.
@pytest.mark.asyncio
async def test_keycloak_callback_duplicate_email_deletes_new_keycloak_user(
mock_request, create_keycloak_user_info
):
"""Test that new Keycloak user is deleted when duplicate email is detected.
When a user attempts to sign up with a +modifier email (e.g., joe+1@example.com)
and an account with the base email already exists, the newly created Keycloak
user should be deleted to prevent orphaned accounts from blocking future sign-ins.
"""
with (
patch('server.routes.auth.token_manager') as mock_token_manager,
patch('server.routes.auth.UserStore') as mock_user_store,
):
# Arrange
mock_token_manager.get_keycloak_tokens = AsyncMock(
return_value=('test_access_token', 'test_refresh_token')
)
mock_token_manager.get_user_info = AsyncMock(
return_value=create_keycloak_user_info(
sub='new_user_id',
preferred_username='test_user',
email='joe+1@example.com',
identity_provider='github',
)
)
mock_token_manager.delete_keycloak_user = AsyncMock(return_value=True)
# User does NOT exist in UserStore (new signup attempt)
mock_user_store.get_user_by_id = AsyncMock(return_value=None)
# Create mock authorizer that returns duplicate_email error
mock_authorizer = create_mock_user_authorizer(
success=False, error_detail='duplicate_email'
)
# Act & Assert
with pytest.raises(HTTPException) as exc_info:
await keycloak_callback(
code='test_code',
state='test_state',
request=mock_request,
user_authorizer=mock_authorizer,
)
assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED
assert exc_info.value.detail == 'duplicate_email'
# Keycloak user should be deleted since user doesn't exist in UserStore
mock_token_manager.delete_keycloak_user.assert_called_once_with('new_user_id')
@pytest.mark.asyncio
async def test_keycloak_callback_duplicate_email_preserves_existing_user(
mock_request, create_keycloak_user_info
):
"""Test that existing users are not deleted when duplicate email is detected.
When an existing user signs in and duplicate email is detected (e.g., because
another account with the same base email was created while duplicate checking
was disabled), the existing user's Keycloak account should NOT be deleted.
"""
with (
patch('server.routes.auth.token_manager') as mock_token_manager,
patch('server.routes.auth.UserStore') as mock_user_store,
):
# Arrange
mock_token_manager.get_keycloak_tokens = AsyncMock(
return_value=('test_access_token', 'test_refresh_token')
)
mock_token_manager.get_user_info = AsyncMock(
return_value=create_keycloak_user_info(
sub='existing_user_id',
preferred_username='test_user',
email='joe@example.com',
identity_provider='github',
)
)
mock_token_manager.delete_keycloak_user = AsyncMock(return_value=True)
# User EXISTS in UserStore (legitimate existing user)
mock_existing_user = MagicMock()
mock_existing_user.id = 'existing_user_id'
mock_user_store.get_user_by_id = AsyncMock(return_value=mock_existing_user)
# Create mock authorizer that returns duplicate_email error
mock_authorizer = create_mock_user_authorizer(
success=False, error_detail='duplicate_email'
)
# Act & Assert
with pytest.raises(HTTPException) as exc_info:
await keycloak_callback(
code='test_code',
state='test_state',
request=mock_request,
user_authorizer=mock_authorizer,
)
assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED
assert exc_info.value.detail == 'duplicate_email'
# Keycloak user should NOT be deleted since user exists in UserStore
mock_token_manager.delete_keycloak_user.assert_not_called()
@pytest.mark.asyncio

View File

@@ -1008,3 +1008,234 @@ class TestGetApiKeyOrgIdFromRequest:
# Assert
assert result is None
# =============================================================================
# Tests for require_financial_data_access dependency
# =============================================================================
def _create_mock_request_with_email(api_key_org_id=None, user_email='user@example.com'):
"""Helper to create a mock request with optional api_key_org_id and email."""
mock_request = MagicMock()
mock_user_auth = MagicMock()
# get_api_key_org_id is sync, not async
mock_user_auth.get_api_key_org_id.return_value = api_key_org_id
# get_user_email is async
mock_user_auth.get_user_email = AsyncMock(return_value=user_email)
mock_request.state.user_auth = mock_user_auth
return mock_request
class TestRequireFinancialDataAccess:
"""Tests for require_financial_data_access compound authorization dependency."""
@pytest.mark.asyncio
async def test_grants_access_for_openhands_email(self):
"""
GIVEN: User with @openhands.dev email
WHEN: require_financial_data_access is called
THEN: Returns user_id (access granted)
"""
from server.auth.authorization import require_financial_data_access
# Arrange
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request_with_email(user_email='admin@openhands.dev')
with patch(
'server.auth.authorization.get_user_auth',
AsyncMock(return_value=mock_request.state.user_auth),
):
# Act
result = await require_financial_data_access(
request=mock_request, org_id=org_id, user_id=user_id
)
# Assert
assert result == user_id
@pytest.mark.asyncio
async def test_grants_access_for_owner_role(self):
"""
GIVEN: User with owner role in organization (non-@openhands.dev email)
WHEN: require_financial_data_access is called
THEN: Returns user_id (access granted)
"""
from server.auth.authorization import require_financial_data_access
# Arrange
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request_with_email(user_email='user@company.com')
mock_role = MagicMock()
mock_role.name = 'owner'
with (
patch(
'server.auth.authorization.get_user_auth',
AsyncMock(return_value=mock_request.state.user_auth),
),
patch(
'server.auth.authorization.get_user_org_role',
AsyncMock(return_value=mock_role),
),
):
# Act
result = await require_financial_data_access(
request=mock_request, org_id=org_id, user_id=user_id
)
# Assert
assert result == user_id
@pytest.mark.asyncio
async def test_grants_access_for_admin_role(self):
"""
GIVEN: User with admin role in organization (non-@openhands.dev email)
WHEN: require_financial_data_access is called
THEN: Returns user_id (access granted)
"""
from server.auth.authorization import require_financial_data_access
# Arrange
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request_with_email(user_email='user@company.com')
mock_role = MagicMock()
mock_role.name = 'admin'
with (
patch(
'server.auth.authorization.get_user_auth',
AsyncMock(return_value=mock_request.state.user_auth),
),
patch(
'server.auth.authorization.get_user_org_role',
AsyncMock(return_value=mock_role),
),
):
# Act
result = await require_financial_data_access(
request=mock_request, org_id=org_id, user_id=user_id
)
# Assert
assert result == user_id
@pytest.mark.asyncio
async def test_denies_access_for_member_role_without_openhands_email(self):
"""
GIVEN: User with member role (not admin/owner) and non-@openhands.dev email
WHEN: require_financial_data_access is called
THEN: Raises 403 Forbidden
"""
from server.auth.authorization import require_financial_data_access
# Arrange
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request_with_email(user_email='user@company.com')
mock_role = MagicMock()
mock_role.name = 'member'
with (
patch(
'server.auth.authorization.get_user_auth',
AsyncMock(return_value=mock_request.state.user_auth),
),
patch(
'server.auth.authorization.get_user_org_role',
AsyncMock(return_value=mock_role),
),
):
# Act & Assert
with pytest.raises(HTTPException) as exc_info:
await require_financial_data_access(
request=mock_request, org_id=org_id, user_id=user_id
)
assert exc_info.value.status_code == 403
assert 'admins, owners, or OpenHands' in exc_info.value.detail
@pytest.mark.asyncio
async def test_denies_access_for_non_member(self):
"""
GIVEN: User who is not a member of the organization
WHEN: require_financial_data_access is called
THEN: Raises 403 Forbidden
"""
from server.auth.authorization import require_financial_data_access
# Arrange
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request_with_email(user_email='user@company.com')
with (
patch(
'server.auth.authorization.get_user_auth',
AsyncMock(return_value=mock_request.state.user_auth),
),
patch(
'server.auth.authorization.get_user_org_role',
AsyncMock(return_value=None),
),
):
# Act & Assert
with pytest.raises(HTTPException) as exc_info:
await require_financial_data_access(
request=mock_request, org_id=org_id, user_id=user_id
)
assert exc_info.value.status_code == 403
assert 'not a member' in exc_info.value.detail
@pytest.mark.asyncio
async def test_denies_access_when_not_authenticated(self):
"""
GIVEN: No user_id (not authenticated)
WHEN: require_financial_data_access is called
THEN: Raises 401 Unauthorized
"""
from server.auth.authorization import require_financial_data_access
# Arrange
org_id = uuid4()
mock_request = _create_mock_request_with_email()
# Act & Assert
with pytest.raises(HTTPException) as exc_info:
await require_financial_data_access(
request=mock_request, org_id=org_id, user_id=None
)
assert exc_info.value.status_code == 401
assert 'not authenticated' in exc_info.value.detail
@pytest.mark.asyncio
async def test_denies_access_when_api_key_org_mismatch(self):
"""
GIVEN: API key created for Org A, but user tries to access Org B
WHEN: require_financial_data_access is called
THEN: Raises 403 Forbidden with org mismatch message
"""
from server.auth.authorization import require_financial_data_access
# Arrange
user_id = str(uuid4())
api_key_org_id = uuid4() # Org A
target_org_id = uuid4() # Org B
mock_request = _create_mock_request_with_email(
api_key_org_id=api_key_org_id, user_email='admin@openhands.dev'
)
# Act & Assert
with pytest.raises(HTTPException) as exc_info:
await require_financial_data_access(
request=mock_request, org_id=target_org_id, user_id=user_id
)
assert exc_info.value.status_code == 403
assert 'API key is not authorized' in exc_info.value.detail

View File

@@ -2576,3 +2576,304 @@ class TestBudgetPayloadHandling:
'max_budget_in_team' in json_payload
), 'max_budget_in_team should be in payload when set to a value'
assert json_payload['max_budget_in_team'] == 75.0
class TestGetTeamMembersFinancialData:
"""Test cases for _get_team_members_financial_data method."""
@pytest.fixture
def mock_http_client(self):
"""Create a mock HTTP client."""
return AsyncMock(spec=httpx.AsyncClient)
@pytest.mark.asyncio
async def test_returns_financial_data_for_all_team_members(self, mock_http_client):
"""
GIVEN: Team with multiple members having financial data
WHEN: _get_team_members_financial_data is called
THEN: Returns dict with team info and member data
"""
# Arrange
mock_response = MagicMock()
mock_response.is_success = True
mock_response.status_code = 200
mock_response.json.return_value = {
'team_info': {'team_id': 'test-team', 'max_budget': 500.0, 'spend': 125.5},
'team_memberships': [
{
'user_id': 'user-1',
'spend': 50.0,
'max_budget_in_team': 200.0,
},
{
'user_id': 'user-2',
'spend': 75.5,
'max_budget_in_team': 150.0,
},
],
}
mock_response.raise_for_status = MagicMock()
mock_http_client.get.return_value = mock_response
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key'):
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
# Act
result = await LiteLlmManager._get_team_members_financial_data(
mock_http_client, 'test-team'
)
# Assert
assert result['team_max_budget'] == 500.0
assert result['team_spend'] == 125.5
assert len(result['members']) == 2
# Both users have individual budgets (max_budget_in_team is set)
assert result['members']['user-1'] == {
'spend': 50.0,
'max_budget': 200.0,
'uses_shared_budget': False,
}
assert result['members']['user-2'] == {
'spend': 75.5,
'max_budget': 150.0,
'uses_shared_budget': False,
}
@pytest.mark.asyncio
async def test_returns_empty_dict_when_litellm_not_configured(
self, mock_http_client
):
"""
GIVEN: LiteLLM API key or URL not configured
WHEN: _get_team_members_financial_data is called
THEN: Returns empty dict
"""
# Arrange - no patching, so LITE_LLM_API_KEY/URL are None
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', None):
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', None):
# Act
result = await LiteLlmManager._get_team_members_financial_data(
mock_http_client, 'test-team'
)
# Assert
assert result == {}
mock_http_client.get.assert_not_called()
@pytest.mark.asyncio
async def test_returns_empty_dict_when_team_not_found(self, mock_http_client):
"""
GIVEN: Team does not exist in LiteLLM
WHEN: _get_team_members_financial_data is called
THEN: Returns empty dict
"""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 404
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
'Not found', request=MagicMock(), response=mock_response
)
mock_http_client.get.return_value = mock_response
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key'):
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
# Act & Assert
with pytest.raises(httpx.HTTPStatusError):
await LiteLlmManager._get_team_members_financial_data(
mock_http_client, 'nonexistent-team'
)
@pytest.mark.asyncio
async def test_returns_empty_members_when_team_has_no_members(
self, mock_http_client
):
"""
GIVEN: Team exists but has no members
WHEN: _get_team_members_financial_data is called
THEN: Returns structure with empty members dict
"""
# Arrange
mock_response = MagicMock()
mock_response.is_success = True
mock_response.status_code = 200
mock_response.json.return_value = {
'team_info': {'team_id': 'empty-team', 'max_budget': 100.0, 'spend': 0},
'team_memberships': [],
}
mock_response.raise_for_status = MagicMock()
mock_http_client.get.return_value = mock_response
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key'):
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
# Act
result = await LiteLlmManager._get_team_members_financial_data(
mock_http_client, 'empty-team'
)
# Assert
assert result['team_max_budget'] == 100.0
assert result['team_spend'] == 0
assert result['members'] == {}
@pytest.mark.asyncio
async def test_falls_back_to_team_budget_when_member_budget_missing(
self, mock_http_client
):
"""
GIVEN: Team with shared budget, members without individual max_budget_in_team
WHEN: _get_team_members_financial_data is called
THEN: Falls back to team_info.max_budget for members without individual budget
"""
# Arrange
mock_response = MagicMock()
mock_response.is_success = True
mock_response.status_code = 200
mock_response.json.return_value = {
'team_info': {'team_id': 'test-team', 'max_budget': 500.0, 'spend': 150.0},
'team_memberships': [
{
'user_id': 'user-no-individual-budget',
'spend': 50.0,
# No max_budget_in_team - should fall back to team budget
},
{
'user_id': 'user-with-individual-budget',
'spend': 75.0,
'max_budget_in_team': 200.0, # Individual budget set
},
{
'user_id': 'user-null-budget',
'spend': 25.0,
'max_budget_in_team': None, # Explicit null - fall back to team
},
],
}
mock_response.raise_for_status = MagicMock()
mock_http_client.get.return_value = mock_response
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key'):
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
# Act
result = await LiteLlmManager._get_team_members_financial_data(
mock_http_client, 'test-team'
)
# Assert
assert result['team_max_budget'] == 500.0
assert result['team_spend'] == 150.0
members = result['members']
assert members['user-no-individual-budget'] == {
'spend': 50.0,
'max_budget': 500.0,
'uses_shared_budget': True,
}
assert members['user-with-individual-budget'] == {
'spend': 75.0,
'max_budget': 200.0,
'uses_shared_budget': False,
}
assert members['user-null-budget'] == {
'spend': 25.0,
'max_budget': 500.0,
'uses_shared_budget': True,
}
@pytest.mark.asyncio
async def test_uses_defaults_when_no_budget_data_available(self, mock_http_client):
"""
GIVEN: Team without budget and members without individual budgets
WHEN: _get_team_members_financial_data is called
THEN: Returns default values (spend=0, max_budget=None)
"""
# Arrange
mock_response = MagicMock()
mock_response.is_success = True
mock_response.status_code = 200
mock_response.json.return_value = {
'team_info': {'team_id': 'test-team'}, # No max_budget at team level
'team_memberships': [
{
'user_id': 'user-no-data',
# No spend or max_budget_in_team
},
{
'user_id': 'user-null-spend',
'spend': None, # Explicit null
},
],
}
mock_response.raise_for_status = MagicMock()
mock_http_client.get.return_value = mock_response
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key'):
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
# Act
result = await LiteLlmManager._get_team_members_financial_data(
mock_http_client, 'test-team'
)
# Assert
assert result['team_max_budget'] is None
assert result['team_spend'] == 0
members = result['members']
# Both users fall back to team budget (which is None)
assert members['user-no-data'] == {
'spend': 0,
'max_budget': None,
'uses_shared_budget': True,
}
assert members['user-null-spend'] == {
'spend': 0,
'max_budget': None,
'uses_shared_budget': True,
}
@pytest.mark.asyncio
async def test_skips_members_without_user_id(self, mock_http_client):
"""
GIVEN: Team with members, some missing user_id
WHEN: _get_team_members_financial_data is called
THEN: Skips members without user_id
"""
# Arrange
mock_response = MagicMock()
mock_response.is_success = True
mock_response.status_code = 200
mock_response.json.return_value = {
'team_info': {'team_id': 'test-team', 'max_budget': 300.0, 'spend': 105.0},
'team_memberships': [
{
'user_id': 'valid-user',
'spend': 25.0,
'max_budget_in_team': 100.0,
},
{
# Missing user_id
'spend': 50.0,
'max_budget_in_team': 200.0,
},
{
'user_id': None, # Explicit null
'spend': 30.0,
},
],
}
mock_response.raise_for_status = MagicMock()
mock_http_client.get.return_value = mock_response
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key'):
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
# Act
result = await LiteLlmManager._get_team_members_financial_data(
mock_http_client, 'test-team'
)
# Assert - only valid user should be included
assert result['team_max_budget'] == 300.0
assert result['team_spend'] == 105.0
assert len(result['members']) == 1
assert 'valid-user' in result['members']
assert result['members']['valid-user'] == {
'spend': 25.0,
'max_budget': 100.0,
'uses_shared_budget': False,
}

View File

@@ -5,10 +5,15 @@ from io import StringIO
from unittest.mock import patch
import pytest
from freezegun import freeze_time
from server.logger import format_stack, setup_json_logger
from openhands.core.logger import openhands_logger
FROZEN_TIMESTAMP = '2024-01-15T10:30:00+00:00'
# datetime.now().isoformat() doesn't include timezone info
FROZEN_TIMESTAMP_NO_TZ = '2024-01-15T10:30:00'
@pytest.fixture
def log_output():
@@ -21,30 +26,45 @@ def log_output():
class TestLogOutput:
@freeze_time(FROZEN_TIMESTAMP)
def test_info(self, log_output):
logger, string_io = log_output
logger.info('Test message')
output = json.loads(string_io.getvalue())
assert output == {'message': 'Test message', 'severity': 'INFO'}
assert output['message'] == 'Test message'
assert output['severity'] == 'INFO'
assert output['ts'] == FROZEN_TIMESTAMP
assert output['module'] == 'test_logger'
assert output['funcName'] == 'test_info'
assert 'lineno' in output
@freeze_time(FROZEN_TIMESTAMP)
def test_error(self, log_output):
logger, string_io = log_output
logger.error('Test message')
output = json.loads(string_io.getvalue())
assert output == {'message': 'Test message', 'severity': 'ERROR'}
assert output['message'] == 'Test message'
assert output['severity'] == 'ERROR'
assert output['ts'] == FROZEN_TIMESTAMP
assert output['module'] == 'test_logger'
assert output['funcName'] == 'test_error'
assert 'lineno' in output
@freeze_time(FROZEN_TIMESTAMP)
def test_extra_fields(self, log_output):
logger, string_io = log_output
logger.info('Test message', extra={'key': '..val..'})
output = json.loads(string_io.getvalue())
assert output == {
'key': '..val..',
'message': 'Test message',
'severity': 'INFO',
}
assert output['key'] == '..val..'
assert output['message'] == 'Test message'
assert output['severity'] == 'INFO'
assert output['ts'] == FROZEN_TIMESTAMP
assert output['module'] == 'test_logger'
assert output['funcName'] == 'test_extra_fields'
assert 'lineno' in output
def test_format_stack(self):
stack = (
@@ -257,6 +277,7 @@ class TestLogOutput:
]
assert formatted == expected
@freeze_time(FROZEN_TIMESTAMP)
def test_filtering(self):
# Ensure that secret values are still filtered
string_io = StringIO()
@@ -266,4 +287,63 @@ class TestLogOutput:
):
openhands_logger.info('The secret key was supersecretvalue')
output = json.loads(string_io.getvalue())
assert output == {'message': 'The secret key was ******', 'severity': 'INFO'}
assert output['message'] == 'The secret key was ******'
assert output['severity'] == 'INFO'
assert output['ts'] == FROZEN_TIMESTAMP
assert 'module' in output
assert 'funcName' in output
assert 'lineno' in output
@freeze_time(FROZEN_TIMESTAMP)
def test_console_serializer_uses_ts_not_timestamp(self):
"""When LOG_JSON_FOR_CONSOLE=1, use 'ts' from custom_json_serializer, not 'timestamp'."""
import server.logger as logger_module
string_io = StringIO()
logger = logging.Logger('test_console')
# Patch LOG_JSON_FOR_CONSOLE to 1 for both setup_json_logger and custom_json_serializer
with patch.object(logger_module, 'LOG_JSON_FOR_CONSOLE', 1):
setup_json_logger(logger, 'INFO', _out=string_io)
logger.info('Test console message')
# Parse output - LOG_JSON_FOR_CONSOLE pretty-prints JSON across multiple lines
output = json.loads(string_io.getvalue())
# Should have 'ts' from custom_json_serializer but NOT 'timestamp'
assert 'ts' in output
assert 'timestamp' not in output
assert output['message'] == 'Test console message'
assert output['severity'] == 'INFO'
@freeze_time(FROZEN_TIMESTAMP)
def test_ts_not_duplicated_when_both_json_modes_enabled(self):
"""When both LOG_JSON=1 and LOG_JSON_FOR_CONSOLE=1, 'ts' should appear only once."""
import server.logger as logger_module
string_io = StringIO()
logger = logging.Logger('test_both_modes')
# Patch both LOG_JSON and LOG_JSON_FOR_CONSOLE to 1
with (
patch.object(logger_module, 'LOG_JSON', True),
patch.object(logger_module, 'LOG_JSON_FOR_CONSOLE', 1),
):
setup_json_logger(logger, 'INFO', _out=string_io)
logger.info('Test both modes message')
raw_output = string_io.getvalue()
output = json.loads(raw_output)
# Should have exactly one 'ts' field (not duplicated)
assert 'ts' in output
assert 'timestamp' not in output
# Verify 'ts' appears only once in the raw output (not duplicated as key)
assert (
raw_output.count('"ts"') == 1
), f"'ts' should appear exactly once, found in: {raw_output}"
assert output['message'] == 'Test both modes message'
assert output['severity'] == 'INFO'
# When LOG_JSON_FOR_CONSOLE=1, custom_json_serializer uses datetime.now().isoformat()
# which doesn't include timezone info
assert output['ts'] == FROZEN_TIMESTAMP_NO_TZ

View File

@@ -41,191 +41,157 @@ class TestRouterPrefixes:
assert accept_router.prefix == '/api/organizations/members/invite'
class TestAcceptInvitationEndpoint:
"""Test cases for the accept invitation endpoint."""
class TestAcceptInvitationGetEndpoint:
"""Test cases for the GET accept invitation endpoint (redirect flow)."""
def test_get_accept_redirects_to_home_with_token(self, client):
"""Test that GET request always redirects to home with invitation_token.
The GET endpoint is accessed via the link in invitation emails.
It always redirects to the home page with the token, allowing the
frontend to handle acceptance via a modal with authenticated POST.
"""
response = client.get(
'/api/organizations/members/invite/accept?token=inv-test-token-123',
follow_redirects=False,
)
assert response.status_code == 302
location = response.headers.get('location', '')
assert '/?invitation_token=inv-test-token-123' in location
class TestAcceptInvitationPostEndpoint:
"""Test cases for the POST accept invitation endpoint (authenticated flow)."""
@pytest.fixture
def mock_user_auth(self):
"""Create a mock user auth."""
user_auth = MagicMock()
user_auth.get_user_id = AsyncMock(
return_value='87654321-4321-8765-4321-876543218765'
def auth_app(self):
"""Create a FastAPI app with dependency overrides for authenticated tests."""
from openhands.server.user_auth import get_user_id
app = FastAPI()
app.include_router(accept_router)
# Override the get_user_id dependency
app.dependency_overrides[get_user_id] = (
lambda: '87654321-4321-8765-4321-876543218765'
)
return user_auth
return app
@pytest.fixture
def auth_client(self, auth_app):
"""Create a test client with authentication dependency overrides."""
return TestClient(auth_app)
@pytest.mark.asyncio
async def test_accept_unauthenticated_redirects_to_login(self, client):
"""Test that unauthenticated users are redirected to login with invitation token."""
with patch(
'server.routes.org_invitations.get_user_auth',
new_callable=AsyncMock,
return_value=None,
):
response = client.get(
'/api/organizations/members/invite/accept?token=inv-test-token-123',
follow_redirects=False,
)
async def test_post_accept_success_returns_org_details(self, auth_client):
"""Test that successful POST acceptance returns organization details."""
from uuid import UUID
assert response.status_code == 302
assert '/login?invitation_token=inv-test-token-123' in response.headers.get(
'location', ''
)
@pytest.mark.asyncio
async def test_accept_authenticated_success_redirects_home(
self, client, mock_user_auth
):
"""Test that successful acceptance redirects to home page."""
mock_invitation = MagicMock()
mock_invitation.org_id = UUID('12345678-1234-5678-1234-567812345678')
mock_invitation.role_id = 3
mock_org = MagicMock()
mock_org.name = 'Test Organization'
mock_role = MagicMock()
mock_role.name = 'member'
with (
patch(
'server.routes.org_invitations.get_user_auth',
new_callable=AsyncMock,
return_value=mock_user_auth,
),
patch(
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
new_callable=AsyncMock,
return_value=mock_invitation,
),
patch(
'server.routes.org_invitations.OrgStore.get_org_by_id',
new_callable=AsyncMock,
return_value=mock_org,
),
patch(
'server.routes.org_invitations.RoleStore.get_role_by_id',
new_callable=AsyncMock,
return_value=mock_role,
),
):
response = client.get(
'/api/organizations/members/invite/accept?token=inv-test-token-123',
follow_redirects=False,
response = auth_client.post(
'/api/organizations/members/invite/accept',
json={'token': 'inv-test-token-123'},
)
assert response.status_code == 302
location = response.headers.get('location', '')
assert location.endswith('/')
assert 'invitation_expired' not in location
assert 'invitation_invalid' not in location
assert 'email_mismatch' not in location
assert response.status_code == 200
data = response.json()
assert data['success'] is True
assert data['org_id'] == '12345678-1234-5678-1234-567812345678'
assert data['org_name'] == 'Test Organization'
assert data['role'] == 'member'
@pytest.mark.asyncio
async def test_accept_expired_invitation_redirects_with_flag(
self, client, mock_user_auth
):
"""Test that expired invitation redirects with invitation_expired=true."""
with (
patch(
'server.routes.org_invitations.get_user_auth',
new_callable=AsyncMock,
return_value=mock_user_auth,
),
patch(
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
new_callable=AsyncMock,
side_effect=InvitationExpiredError(),
),
async def test_post_accept_expired_returns_400(self, auth_client):
"""Test that expired invitation returns 400 with detail."""
with patch(
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
new_callable=AsyncMock,
side_effect=InvitationExpiredError(),
):
response = client.get(
'/api/organizations/members/invite/accept?token=inv-test-token-123',
follow_redirects=False,
response = auth_client.post(
'/api/organizations/members/invite/accept',
json={'token': 'inv-test-token-123'},
)
assert response.status_code == 302
assert 'invitation_expired=true' in response.headers.get('location', '')
assert response.status_code == 400
assert response.json()['detail'] == 'invitation_expired'
@pytest.mark.asyncio
async def test_accept_invalid_invitation_redirects_with_flag(
self, client, mock_user_auth
):
"""Test that invalid invitation redirects with invitation_invalid=true."""
with (
patch(
'server.routes.org_invitations.get_user_auth',
new_callable=AsyncMock,
return_value=mock_user_auth,
),
patch(
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
new_callable=AsyncMock,
side_effect=InvitationInvalidError(),
),
async def test_post_accept_invalid_returns_400(self, auth_client):
"""Test that invalid invitation returns 400 with detail."""
with patch(
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
new_callable=AsyncMock,
side_effect=InvitationInvalidError(),
):
response = client.get(
'/api/organizations/members/invite/accept?token=inv-test-token-123',
follow_redirects=False,
response = auth_client.post(
'/api/organizations/members/invite/accept',
json={'token': 'inv-test-token-123'},
)
assert response.status_code == 302
assert 'invitation_invalid=true' in response.headers.get('location', '')
assert response.status_code == 400
assert response.json()['detail'] == 'invitation_invalid'
@pytest.mark.asyncio
async def test_accept_already_member_redirects_with_flag(
self, client, mock_user_auth
):
"""Test that already member error redirects with already_member=true."""
with (
patch(
'server.routes.org_invitations.get_user_auth',
new_callable=AsyncMock,
return_value=mock_user_auth,
),
patch(
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
new_callable=AsyncMock,
side_effect=UserAlreadyMemberError(),
),
async def test_post_accept_already_member_returns_409(self, auth_client):
"""Test that already member error returns 409 with detail."""
with patch(
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
new_callable=AsyncMock,
side_effect=UserAlreadyMemberError(),
):
response = client.get(
'/api/organizations/members/invite/accept?token=inv-test-token-123',
follow_redirects=False,
response = auth_client.post(
'/api/organizations/members/invite/accept',
json={'token': 'inv-test-token-123'},
)
assert response.status_code == 302
assert 'already_member=true' in response.headers.get('location', '')
assert response.status_code == 409
assert response.json()['detail'] == 'already_member'
@pytest.mark.asyncio
async def test_accept_email_mismatch_redirects_with_flag(
self, client, mock_user_auth
):
"""Test that email mismatch error redirects with email_mismatch=true."""
with (
patch(
'server.routes.org_invitations.get_user_auth',
new_callable=AsyncMock,
return_value=mock_user_auth,
),
patch(
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
new_callable=AsyncMock,
side_effect=EmailMismatchError(),
),
async def test_post_accept_email_mismatch_returns_403(self, auth_client):
"""Test that email mismatch error returns 403 with detail."""
with patch(
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
new_callable=AsyncMock,
side_effect=EmailMismatchError(),
):
response = client.get(
'/api/organizations/members/invite/accept?token=inv-test-token-123',
follow_redirects=False,
response = auth_client.post(
'/api/organizations/members/invite/accept',
json={'token': 'inv-test-token-123'},
)
assert response.status_code == 302
assert 'email_mismatch=true' in response.headers.get('location', '')
@pytest.mark.asyncio
async def test_accept_unexpected_error_redirects_with_flag(
self, client, mock_user_auth
):
"""Test that unexpected errors redirect with invitation_error=true."""
with (
patch(
'server.routes.org_invitations.get_user_auth',
new_callable=AsyncMock,
return_value=mock_user_auth,
),
patch(
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
new_callable=AsyncMock,
side_effect=Exception('Unexpected error'),
),
):
response = client.get(
'/api/organizations/members/invite/accept?token=inv-test-token-123',
follow_redirects=False,
)
assert response.status_code == 302
assert 'invitation_error=true' in response.headers.get('location', '')
assert response.status_code == 403
assert response.json()['detail'] == 'email_mismatch'
class TestCreateInvitationBatchEndpoint:

View File

@@ -246,3 +246,82 @@ class TestSaasSecretsStore:
assert isinstance(store, SaasSecretsStore)
assert store.user_id == 'test-user-id'
assert store.config == mock_config
@pytest.mark.asyncio
@patch(
'storage.saas_secrets_store.UserStore.get_user_by_id',
new_callable=AsyncMock,
)
async def test_secrets_isolation_between_organizations(
self, mock_get_user, secrets_store, mock_user
):
"""Test that secrets from one organization are not deleted when storing
secrets in another organization. This reproduces a bug where switching
organizations and creating a secret would delete all secrets from the
user's personal workspace."""
org1_id = UUID('a1111111-1111-1111-1111-111111111111')
org2_id = UUID('b2222222-2222-2222-2222-222222222222')
# Store secrets in org1 (personal workspace)
mock_user.current_org_id = org1_id
mock_get_user.return_value = mock_user
org1_secrets = Secrets(
custom_secrets=MappingProxyType(
{
'personal_secret': CustomSecret.from_value(
{
'secret': 'personal_secret_value',
'description': 'My personal secret',
}
),
}
)
)
await secrets_store.store(org1_secrets)
# Verify org1 secrets are stored
loaded_org1 = await secrets_store.load()
assert loaded_org1 is not None
assert 'personal_secret' in loaded_org1.custom_secrets
assert (
loaded_org1.custom_secrets['personal_secret'].secret.get_secret_value()
== 'personal_secret_value'
)
# Switch to org2 and store secrets there
mock_user.current_org_id = org2_id
mock_get_user.return_value = mock_user
org2_secrets = Secrets(
custom_secrets=MappingProxyType(
{
'org2_secret': CustomSecret.from_value(
{'secret': 'org2_secret_value', 'description': 'Org2 secret'}
),
}
)
)
await secrets_store.store(org2_secrets)
# Verify org2 secrets are stored
loaded_org2 = await secrets_store.load()
assert loaded_org2 is not None
assert 'org2_secret' in loaded_org2.custom_secrets
assert (
loaded_org2.custom_secrets['org2_secret'].secret.get_secret_value()
== 'org2_secret_value'
)
# Switch back to org1 and verify secrets are still there
mock_user.current_org_id = org1_id
mock_get_user.return_value = mock_user
loaded_org1_again = await secrets_store.load()
assert loaded_org1_again is not None
assert 'personal_secret' in loaded_org1_again.custom_secrets
assert (
loaded_org1_again.custom_secrets[
'personal_secret'
].secret.get_secret_value()
== 'personal_secret_value'
)
# Verify org2 secrets are NOT visible in org1
assert 'org2_secret' not in loaded_org1_again.custom_secrets

View File

@@ -437,3 +437,167 @@ async def test_store_updates_org_default_llm_settings(
assert org.default_llm_model == 'anthropic/claude-sonnet-4'
assert org.default_llm_base_url == 'https://api.anthropic.com/v1'
assert org.default_max_iterations == 75
@pytest.mark.asyncio
async def test_store_saves_mcp_config_to_user_org_member_only(
session_maker, async_session_maker, mock_config, org_with_multiple_members_fixture
):
"""When user saves MCP config, it should be stored ONLY on their org_member, not propagated to others.
This test verifies that MCP settings are user-specific:
1. The saving user's org_member.mcp_config is set
2. Other members' org_member.mcp_config remains unchanged (NULL)
"""
from sqlalchemy import select
from storage.org_member import OrgMember
# Arrange
fixture = org_with_multiple_members_fixture
org_id = fixture['org_id']
admin_user_id = str(fixture['admin_user_id'])
member1_user_id = fixture['member1_user_id']
member2_user_id = fixture['member2_user_id']
store = SaasSettingsStore(admin_user_id, mock_config)
user_mcp_config = {
'sse_servers': [{'url': 'https://user1-mcp-server.com', 'api_key': None}],
'stdio_servers': [],
'shttp_servers': [],
}
new_settings = DataSettings(
llm_model='test-model',
llm_base_url='http://non-litellm-url.com', # Non-LiteLLM URL to skip API key verification
llm_api_key=SecretStr('test-api-key'),
mcp_config=user_mcp_config,
)
# Act
with patch('storage.saas_settings_store.a_session_maker', async_session_maker):
await store.store(new_settings)
# Assert
with session_maker() as session:
result = session.execute(select(OrgMember).where(OrgMember.org_id == org_id))
members = {str(m.user_id): m for m in result.scalars().all()}
# Admin's mcp_config should be set
assert members[admin_user_id].mcp_config == user_mcp_config
# Other members' mcp_config should remain NULL (not propagated)
assert members[str(member1_user_id)].mcp_config is None
assert members[str(member2_user_id)].mcp_config is None
@pytest.mark.asyncio
async def test_store_does_not_update_org_mcp_config(
session_maker, async_session_maker, mock_config, org_with_multiple_members_fixture
):
"""When user saves MCP config, org.mcp_config should NOT be updated.
MCP settings are user-specific and should be stored on org_member, not org.
"""
from sqlalchemy import select
from storage.org import Org
# Arrange
fixture = org_with_multiple_members_fixture
org_id = fixture['org_id']
admin_user_id = str(fixture['admin_user_id'])
store = SaasSettingsStore(admin_user_id, mock_config)
user_mcp_config = {
'sse_servers': [{'url': 'https://private-mcp-server.com', 'api_key': None}],
'stdio_servers': [],
'shttp_servers': [],
}
new_settings = DataSettings(
llm_model='test-model',
llm_base_url='http://non-litellm-url.com', # Non-LiteLLM URL to skip API key verification
llm_api_key=SecretStr('test-api-key'),
mcp_config=user_mcp_config,
)
# Act
with patch('storage.saas_settings_store.a_session_maker', async_session_maker):
await store.store(new_settings)
# Assert - org.mcp_config should remain NULL
with session_maker() as session:
result = session.execute(select(Org).where(Org.id == org_id))
org = result.scalars().first()
assert org is not None
assert org.mcp_config is None
@pytest.mark.asyncio
async def test_load_returns_user_specific_mcp_config(
session_maker, async_session_maker, mock_config, org_with_multiple_members_fixture
):
"""When loading settings, mcp_config should come from the user's org_member, not from org or other members.
This test verifies user isolation:
1. User1 stores their MCP config
2. User2 stores a different MCP config
3. Loading as User1 returns User1's config (not User2's)
"""
# Arrange
fixture = org_with_multiple_members_fixture
admin_user_id = str(fixture['admin_user_id'])
member1_user_id = str(fixture['member1_user_id'])
user1_mcp_config = {
'sse_servers': [{'url': 'https://user1-private-server.com', 'api_key': None}],
'stdio_servers': [],
'shttp_servers': [],
}
user2_mcp_config = {
'sse_servers': [{'url': 'https://user2-private-server.com', 'api_key': None}],
'stdio_servers': [],
'shttp_servers': [],
}
# Store MCP config for user1 (admin)
store1 = SaasSettingsStore(admin_user_id, mock_config)
settings1 = DataSettings(
llm_model='test-model',
llm_base_url='http://non-litellm-url.com', # Non-LiteLLM URL to skip API key verification
llm_api_key=SecretStr('test-api-key'),
mcp_config=user1_mcp_config,
)
with patch('storage.saas_settings_store.a_session_maker', async_session_maker):
await store1.store(settings1)
# Store different MCP config for user2 (member1)
store2 = SaasSettingsStore(member1_user_id, mock_config)
settings2 = DataSettings(
llm_model='test-model',
llm_base_url='http://non-litellm-url.com', # Non-LiteLLM URL to skip API key verification
llm_api_key=SecretStr('test-api-key'),
mcp_config=user2_mcp_config,
)
with patch('storage.saas_settings_store.a_session_maker', async_session_maker):
await store2.store(settings2)
# Act - load settings as user1
# Need to patch all store modules since load() calls UserStore, OrgStore, etc.
with patch(
'storage.saas_settings_store.a_session_maker', async_session_maker
), patch('storage.user_store.a_session_maker', async_session_maker), patch(
'storage.org_store.a_session_maker', async_session_maker
):
loaded_settings = await store1.load()
# Assert - user1 should see their own MCP config, not user2's
assert loaded_settings is not None
assert loaded_settings.mcp_config is not None
assert (
loaded_settings.mcp_config.sse_servers[0].url
== 'https://user1-private-server.com'
)

View File

@@ -8,6 +8,9 @@ import os
from unittest.mock import patch
from server.sharing.aws_shared_event_service import AwsSharedEventServiceInjector
from server.sharing.filesystem_shared_event_service import (
FilesystemSharedEventServiceInjector,
)
from server.sharing.google_cloud_shared_event_service import (
GoogleCloudSharedEventServiceInjector,
)
@@ -17,8 +20,8 @@ from server.sharing.shared_event_router import get_shared_event_service_injector
class TestGetSharedEventServiceInjector:
"""Test cases for get_shared_event_service_injector function."""
def test_defaults_to_google_cloud_when_no_env_set(self):
"""Test that GoogleCloudSharedEventServiceInjector is used when no env is set."""
def test_defaults_to_filesystem_when_no_env_set(self):
"""Test that FilesystemSharedEventServiceInjector is used when no env is set."""
with patch.dict(
os.environ,
{},
@@ -29,7 +32,8 @@ class TestGetSharedEventServiceInjector:
injector = get_shared_event_service_injector()
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
# Default behavior is filesystem storage when nothing is configured
assert isinstance(injector, FilesystemSharedEventServiceInjector)
def test_uses_google_cloud_when_file_store_google_cloud(self):
"""Test that GoogleCloudSharedEventServiceInjector is used when FILE_STORE=google_cloud."""
@@ -141,8 +145,8 @@ class TestGetSharedEventServiceInjector:
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
def test_unknown_provider_defaults_to_google_cloud(self):
"""Test that unknown provider defaults to GoogleCloudSharedEventServiceInjector."""
def test_unknown_provider_defaults_to_filesystem(self):
"""Test that unknown provider defaults to FilesystemSharedEventServiceInjector."""
with patch.dict(
os.environ,
{
@@ -152,11 +156,11 @@ class TestGetSharedEventServiceInjector:
):
injector = get_shared_event_service_injector()
# Should default to GCP for unknown providers
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
# Should default to filesystem for unknown providers
assert isinstance(injector, FilesystemSharedEventServiceInjector)
def test_empty_provider_falls_back_to_file_store(self):
"""Test that empty SHARED_EVENT_STORAGE_PROVIDER falls back to FILE_STORE."""
def test_empty_provider_falls_back_to_file_store_gcp(self):
"""Test that empty SHARED_EVENT_STORAGE_PROVIDER falls back to FILE_STORE=google_cloud."""
with patch.dict(
os.environ,
{
@@ -167,5 +171,35 @@ class TestGetSharedEventServiceInjector:
):
injector = get_shared_event_service_injector()
# Should default to GCP for unknown providers
# Should use GCP when FILE_STORE=google_cloud
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
def test_empty_provider_falls_back_to_file_store_s3(self):
"""Test that empty SHARED_EVENT_STORAGE_PROVIDER falls back to FILE_STORE=s3."""
with patch.dict(
os.environ,
{
'SHARED_EVENT_STORAGE_PROVIDER': '',
'FILE_STORE': 's3',
},
clear=True,
):
injector = get_shared_event_service_injector()
# Should use AWS when FILE_STORE=s3
assert isinstance(injector, AwsSharedEventServiceInjector)
def test_empty_provider_falls_back_to_file_store_filesystem(self):
"""Test that empty SHARED_EVENT_STORAGE_PROVIDER falls back to FILE_STORE=filesystem."""
with patch.dict(
os.environ,
{
'SHARED_EVENT_STORAGE_PROVIDER': '',
'FILE_STORE': 'filesystem',
},
clear=True,
):
injector = get_shared_event_service_injector()
# Should use filesystem when FILE_STORE=filesystem
assert isinstance(injector, FilesystemSharedEventServiceInjector)

View File

@@ -0,0 +1,141 @@
"""Tests for the GET /api/user/git-organizations endpoint.
This endpoint returns git organizations for the user's active provider
in SaaS mode (single provider at a time).
"""
from types import MappingProxyType
from unittest.mock import AsyncMock, patch
import pytest
from fastapi.responses import JSONResponse
from pydantic import SecretStr
from openhands.integrations.provider import ProviderToken
from openhands.integrations.service_types import ProviderType
@pytest.fixture
def github_provider_tokens():
return MappingProxyType(
{ProviderType.GITHUB: ProviderToken(token=SecretStr('gh-token'))}
)
@pytest.fixture
def gitlab_provider_tokens():
return MappingProxyType(
{ProviderType.GITLAB: ProviderToken(token=SecretStr('gl-token'))}
)
@pytest.fixture
def bitbucket_provider_tokens():
return MappingProxyType(
{ProviderType.BITBUCKET: ProviderToken(token=SecretStr('bb-token'))}
)
@pytest.fixture
def azure_devops_provider_tokens():
return MappingProxyType(
{ProviderType.AZURE_DEVOPS: ProviderToken(token=SecretStr('az-token'))}
)
@pytest.fixture
def mock_check_idp():
with patch('server.routes.user._check_idp', new_callable=AsyncMock) as mock_fn:
yield mock_fn
@pytest.mark.asyncio
async def test_no_provider_tokens_falls_back_to_idp(mock_check_idp):
"""When no provider tokens exist, falls back to IDP check."""
from server.routes.user import saas_get_user_git_organizations
mock_check_idp.return_value = {}
result = await saas_get_user_git_organizations(
provider_tokens=None,
access_token=SecretStr('token'),
user_id='user-1',
)
assert result == {}
mock_check_idp.assert_called_once()
@pytest.mark.asyncio
async def test_unsupported_provider_returns_400(azure_devops_provider_tokens):
"""Unsupported provider returns a 400 error."""
from server.routes.user import saas_get_user_git_organizations
with patch('server.routes.user.ProviderHandler'):
result = await saas_get_user_git_organizations(
provider_tokens=azure_devops_provider_tokens,
access_token=SecretStr('token'),
user_id='user-1',
)
assert isinstance(result, JSONResponse)
assert result.status_code == 400
@pytest.mark.asyncio
@pytest.mark.parametrize(
'provider_tokens_fixture, mock_method, mock_return, expected_provider',
[
(
'github_provider_tokens',
'get_organizations_from_installations',
['All-Hands-AI', 'OpenHands'],
'github',
),
(
'gitlab_provider_tokens',
'get_user_groups',
['my-team', 'open-source'],
'gitlab',
),
(
'bitbucket_provider_tokens',
'get_installations',
['my-workspace'],
'bitbucket',
),
],
ids=['github', 'gitlab', 'bitbucket'],
)
async def test_provider_routing_with_real_handler(
provider_tokens_fixture,
mock_method,
mock_return,
expected_provider,
request,
):
"""Each provider routes to the correct service method and returns the expected JSON structure.
Uses a real ProviderHandler so the endpoint's if/elif routing and ProviderHandler's
delegation are both exercised. Only the low-level git service call is mocked.
"""
from server.routes.user import saas_get_user_git_organizations
provider_tokens = request.getfixturevalue(provider_tokens_fixture)
with patch(
'openhands.integrations.provider.ProviderHandler.get_service'
) as mock_get_service:
mock_service = mock_get_service.return_value
setattr(mock_service, mock_method, AsyncMock(return_value=mock_return))
result = await saas_get_user_git_organizations(
provider_tokens=provider_tokens,
access_token=SecretStr('token'),
user_id='user-1',
)
assert result == {
'provider': expected_provider,
'organizations': mock_return,
}

View File

@@ -0,0 +1,60 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect } from "vitest";
import { CopyableContentWrapper } from "#/components/shared/buttons/copyable-content-wrapper";
describe("CopyableContentWrapper", () => {
it("should hide the copy button by default", () => {
render(
<CopyableContentWrapper text="hello">
<p>content</p>
</CopyableContentWrapper>,
);
expect(screen.getByTestId("copy-to-clipboard")).not.toBeVisible();
});
it("should show the copy button on hover", async () => {
const user = userEvent.setup();
render(
<CopyableContentWrapper text="hello">
<p>content</p>
</CopyableContentWrapper>,
);
await user.hover(screen.getByText("content"));
expect(screen.getByTestId("copy-to-clipboard")).toBeVisible();
});
it("should copy text to clipboard on click", async () => {
const user = userEvent.setup();
render(
<CopyableContentWrapper text="copy me">
<p>content</p>
</CopyableContentWrapper>,
);
await user.click(screen.getByTestId("copy-to-clipboard"));
await waitFor(() =>
expect(navigator.clipboard.readText()).resolves.toBe("copy me"),
);
});
it("should show copied state after clicking", async () => {
const user = userEvent.setup();
render(
<CopyableContentWrapper text="hello">
<p>content</p>
</CopyableContentWrapper>,
);
await user.click(screen.getByTestId("copy-to-clipboard"));
expect(screen.getByTestId("copy-to-clipboard")).toHaveAttribute(
"aria-label",
"BUTTON$COPIED",
);
});
});

View File

@@ -1,6 +1,7 @@
import { render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { createRoutesStub } from "react-router";
import { LoginCTA } from "#/components/features/auth/login-cta";
// Mock useTracking hook
@@ -16,8 +17,23 @@ describe("LoginCTA", () => {
vi.clearAllMocks();
});
const renderWithRouter = (source?: "login_page" | "device_verify") => {
const Stub = createRoutesStub([
{
path: "/",
Component: () => <LoginCTA source={source} />,
},
{
path: "/information-request",
Component: () => <div data-testid="information-request-page" />,
},
]);
return render(<Stub initialEntries={["/"]} />);
};
it("should render enterprise CTA with title and description", () => {
render(<LoginCTA />);
renderWithRouter();
expect(screen.getByTestId("login-cta")).toBeInTheDocument();
expect(screen.getByText("CTA$ENTERPRISE")).toBeInTheDocument();
@@ -25,7 +41,7 @@ describe("LoginCTA", () => {
});
it("should render all enterprise feature list items", () => {
render(<LoginCTA />);
renderWithRouter();
expect(screen.getByText("CTA$FEATURE_ON_PREMISES")).toBeInTheDocument();
expect(screen.getByText("CTA$FEATURE_DATA_CONTROL")).toBeInTheDocument();
@@ -33,23 +49,9 @@ describe("LoginCTA", () => {
expect(screen.getByText("CTA$FEATURE_SUPPORT")).toBeInTheDocument();
});
it("should render Learn More as a link with correct href and target", () => {
render(<LoginCTA />);
const learnMoreLink = screen.getByRole("link", {
name: "CTA$LEARN_MORE",
});
expect(learnMoreLink).toHaveAttribute(
"href",
"https://openhands.dev/enterprise/",
);
expect(learnMoreLink).toHaveAttribute("target", "_blank");
expect(learnMoreLink).toHaveAttribute("rel", "noopener noreferrer");
});
it("should call trackSaasSelfhostedInquiry with location 'login_page' when Learn More is clicked", async () => {
it("should track and navigate to information request page when Learn More is clicked", async () => {
const user = userEvent.setup();
render(<LoginCTA />);
renderWithRouter();
const learnMoreLink = screen.getByRole("link", {
name: "CTA$LEARN_MORE",
@@ -59,5 +61,46 @@ describe("LoginCTA", () => {
expect(mockTrackSaasSelfhostedInquiry).toHaveBeenCalledWith({
location: "login_page",
});
expect(screen.getByTestId("information-request-page")).toBeInTheDocument();
});
it("should render Learn More as a link for Open in New Tab support", () => {
renderWithRouter();
const learnMoreLink = screen.getByRole("link", {
name: "CTA$LEARN_MORE",
});
expect(learnMoreLink).toHaveAttribute(
"href",
"/information-request",
);
});
it("should render external enterprise URL in device verify mode", () => {
renderWithRouter("device_verify");
const learnMoreLink = screen.getByRole("link", {
name: "CTA$LEARN_MORE",
});
expect(learnMoreLink).toHaveAttribute(
"href",
"https://openhands.dev/enterprise",
);
expect(learnMoreLink).toHaveAttribute("target", "_blank");
expect(learnMoreLink).toHaveAttribute("rel", "noopener noreferrer");
});
it("should track device_verify location when Learn More is clicked in device verify mode", async () => {
const user = userEvent.setup();
renderWithRouter("device_verify");
const learnMoreLink = screen.getByRole("link", {
name: "CTA$LEARN_MORE",
});
await user.click(learnMoreLink);
expect(mockTrackSaasSelfhostedInquiry).toHaveBeenCalledWith({
location: "device_verify",
});
});
});

View File

@@ -0,0 +1,53 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { MemoryRouter } from "react-router";
import { ContextMenuNavLink } from "#/components/features/context-menu/context-menu-nav-link";
import { I18nKey } from "#/i18n/declaration";
const mockNavItem = {
to: "/settings/test",
icon: <span data-testid="test-icon">Icon</span>,
text: I18nKey.SETTINGS$NAV_API_KEYS,
};
const renderContextMenuNavLink = (item = mockNavItem, onClick = vi.fn()) =>
render(
<MemoryRouter>
<ContextMenuNavLink item={item} onClick={onClick} />
</MemoryRouter>,
);
describe("ContextMenuNavLink", () => {
it("should render the link with icon and text", () => {
// Arrange & Act
renderContextMenuNavLink();
// Assert
expect(screen.getByRole("link")).toBeInTheDocument();
expect(screen.getByTestId("test-icon")).toBeInTheDocument();
expect(screen.getByText("SETTINGS$NAV_API_KEYS")).toBeInTheDocument();
});
it("should navigate to the correct route", () => {
// Arrange & Act
renderContextMenuNavLink();
// Assert
const link = screen.getByRole("link");
expect(link).toHaveAttribute("href", "/settings/test");
});
it("should call onClick when clicked", async () => {
// Arrange
const user = userEvent.setup();
const onClick = vi.fn();
renderContextMenuNavLink(mockNavItem, onClick);
// Act
await user.click(screen.getByRole("link"));
// Assert
expect(onClick).toHaveBeenCalledTimes(1);
});
});

View File

@@ -204,4 +204,84 @@ describe("HookEventItem", () => {
);
expect(screen.getByText("unknown_event")).toBeInTheDocument();
});
it("should not crash when a matcher has undefined hooks", () => {
const hookEventWithUndefinedHooks: HookEvent = {
event_type: "stop",
matchers: [
{
matcher: "*",
hooks: undefined,
},
],
};
expect(() =>
render(
<HookEventItem
{...defaultProps}
hookEvent={hookEventWithUndefinedHooks}
/>,
),
).not.toThrow();
expect(screen.getByText("0 hooks")).toBeInTheDocument();
});
it("should not crash when a matcher has undefined hooks in expanded state", () => {
const hookEventWithUndefinedHooks: HookEvent = {
event_type: "stop",
matchers: [
{
matcher: "*",
hooks: undefined,
},
],
};
expect(() =>
render(
<HookEventItem
{...defaultProps}
hookEvent={hookEventWithUndefinedHooks}
isExpanded={true}
/>,
),
).not.toThrow();
});
it("should handle a mix of matchers with and without hooks", () => {
const mixedHookEvent: HookEvent = {
event_type: "pre_tool_use",
matchers: [
{
matcher: "terminal",
hooks: [
{
type: "command",
command: "check.sh",
timeout: 10,
},
],
},
{
matcher: "browser",
hooks: undefined,
},
],
};
expect(() =>
render(
<HookEventItem
{...defaultProps}
hookEvent={mixedHookEvent}
isExpanded={true}
/>,
),
).not.toThrow();
// Should count only the valid hooks
expect(screen.getByText("1 hooks")).toBeInTheDocument();
});
});

View File

@@ -2,6 +2,7 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { ConversationTabsContextMenu } from "#/components/features/conversation/conversation-tabs/conversation-tabs-context-menu";
import { useConversationStore } from "#/stores/conversation-store";
const CONVERSATION_ID = "conv-abc123";
@@ -21,6 +22,11 @@ describe("ConversationTabsContextMenu", () => {
beforeEach(() => {
localStorage.clear();
mockHasTaskList = false;
useConversationStore.setState({
selectedTab: "editor",
isRightPanelShown: true,
hasRightPanelToggled: true,
});
});
it("should render nothing when isOpen is false", () => {
@@ -69,6 +75,33 @@ describe("ConversationTabsContextMenu", () => {
expect(storedState.unpinnedTabs).not.toContain("terminal");
});
it("should close the right panel when unpinning the currently active tab", async () => {
const user = userEvent.setup();
render(<ConversationTabsContextMenu isOpen={true} onClose={vi.fn()} />);
await user.click(screen.getByText("COMMON$CHANGES"));
const storeState = useConversationStore.getState();
expect(storeState.hasRightPanelToggled).toBe(false);
const storedState = JSON.parse(
localStorage.getItem(`conversation-state-${CONVERSATION_ID}`)!,
);
expect(storedState.rightPanelShown).toBe(false);
});
it("should not close the right panel when unpinning a non-active tab", async () => {
const user = userEvent.setup();
render(<ConversationTabsContextMenu isOpen={true} onClose={vi.fn()} />);
await user.click(screen.getByText("COMMON$TERMINAL"));
const storeState = useConversationStore.getState();
expect(storeState.hasRightPanelToggled).toBe(true);
});
describe("with tasklist", () => {
beforeEach(() => {
mockHasTaskList = true;

View File

@@ -0,0 +1,165 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { FileDiffViewer } from "#/components/features/diff-viewer/file-diff-viewer";
const MOCK_DIFF = { original: "old content", modified: "new content" };
const MOCK_MD_DIFF = {
original: "# Old Heading",
modified: "# New Heading\n\nSome **bold** text",
};
let mockDiff = MOCK_DIFF;
let mockIsSuccess = true;
let mockIsLoading = false;
vi.mock("#/hooks/query/use-unified-git-diff", () => ({
useUnifiedGitDiff: () => ({
data: mockDiff,
isLoading: mockIsLoading,
isSuccess: mockIsSuccess,
isRefetching: false,
}),
}));
vi.mock("@monaco-editor/react", () => ({
DiffEditor: (props: Record<string, unknown>) => (
<div data-testid="file-diff-viewer" data-original={props.original} data-modified={props.modified} />
),
Editor: (props: Record<string, unknown>) => (
<div data-testid="file-single-viewer" data-value={props.value} />
),
}));
vi.mock("#/components/features/markdown/markdown-renderer", () => ({
MarkdownRenderer: ({ content }: { content: string }) => (
<div data-testid="markdown-renderer">{content}</div>
),
}));
const expand = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByTestId("collapse"));
};
describe("FileDiffViewer", () => {
beforeEach(() => {
mockDiff = MOCK_DIFF;
mockIsSuccess = true;
mockIsLoading = false;
});
it("starts collapsed with no view mode buttons", () => {
render(<FileDiffViewer path="src/index.ts" type="M" />);
expect(screen.queryByTestId("view-mode-old")).not.toBeInTheDocument();
expect(screen.queryByTestId("view-mode-diff")).not.toBeInTheDocument();
expect(screen.queryByTestId("view-mode-new")).not.toBeInTheDocument();
});
it("shows view mode buttons when expanded", async () => {
const user = userEvent.setup();
render(<FileDiffViewer path="src/index.ts" type="M" />);
await expand(user);
expect(screen.getByTestId("view-mode-old")).toBeInTheDocument();
expect(screen.getByTestId("view-mode-diff")).toBeInTheDocument();
expect(screen.getByTestId("view-mode-new")).toBeInTheDocument();
});
it("shows diff editor by default when expanded", async () => {
const user = userEvent.setup();
render(<FileDiffViewer path="src/index.ts" type="M" />);
await expand(user);
expect(screen.getByTestId("file-diff-viewer")).toBeInTheDocument();
expect(screen.queryByTestId("file-single-viewer")).not.toBeInTheDocument();
});
it("switches to single editor on 'new' mode", async () => {
const user = userEvent.setup();
render(<FileDiffViewer path="src/index.ts" type="M" />);
await expand(user);
await user.click(screen.getByTestId("view-mode-new"));
expect(screen.getByTestId("file-single-viewer")).toBeInTheDocument();
expect(screen.getByTestId("file-single-viewer")).toHaveAttribute("data-value", "new content");
expect(screen.queryByTestId("file-diff-viewer")).not.toBeInTheDocument();
});
it("switches to single editor on 'old' mode", async () => {
const user = userEvent.setup();
render(<FileDiffViewer path="src/index.ts" type="M" />);
await expand(user);
await user.click(screen.getByTestId("view-mode-old"));
expect(screen.getByTestId("file-single-viewer")).toBeInTheDocument();
expect(screen.getByTestId("file-single-viewer")).toHaveAttribute("data-value", "old content");
});
it("returns to diff editor when switching back to 'diff' mode", async () => {
const user = userEvent.setup();
render(<FileDiffViewer path="src/index.ts" type="M" />);
await expand(user);
await user.click(screen.getByTestId("view-mode-new"));
await user.click(screen.getByTestId("view-mode-diff"));
expect(screen.getByTestId("file-diff-viewer")).toBeInTheDocument();
expect(screen.queryByTestId("file-single-viewer")).not.toBeInTheDocument();
});
it("renders markdown preview for .md files in 'new' mode", async () => {
mockDiff = MOCK_MD_DIFF;
const user = userEvent.setup();
render(<FileDiffViewer path="README.md" type="M" />);
await expand(user);
await user.click(screen.getByTestId("view-mode-new"));
expect(screen.getByTestId("markdown-preview")).toBeInTheDocument();
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent(/New Heading/);
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent(/bold/);
expect(screen.queryByTestId("file-single-viewer")).not.toBeInTheDocument();
});
it("renders markdown preview for .md files in 'old' mode", async () => {
mockDiff = MOCK_MD_DIFF;
const user = userEvent.setup();
render(<FileDiffViewer path="README.md" type="M" />);
await expand(user);
await user.click(screen.getByTestId("view-mode-old"));
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent(MOCK_MD_DIFF.original);
});
it("shows diff editor for .md files in 'diff' mode", async () => {
mockDiff = MOCK_MD_DIFF;
const user = userEvent.setup();
render(<FileDiffViewer path="README.md" type="M" />);
await expand(user);
expect(screen.getByTestId("file-diff-viewer")).toBeInTheDocument();
expect(screen.queryByTestId("markdown-preview")).not.toBeInTheDocument();
});
it("highlights the active view mode button", async () => {
const user = userEvent.setup();
render(<FileDiffViewer path="src/index.ts" type="M" />);
await expand(user);
expect(screen.getByTestId("view-mode-diff").className).toContain("bg-neutral-600");
expect(screen.getByTestId("view-mode-old").className).not.toContain("bg-neutral-600");
await user.click(screen.getByTestId("view-mode-old"));
expect(screen.getByTestId("view-mode-old").className).toContain("bg-neutral-600");
expect(screen.getByTestId("view-mode-diff").className).not.toContain("bg-neutral-600");
});
});

View File

@@ -3,9 +3,23 @@ import { render, screen } from "@testing-library/react";
import { createRoutesStub } from "react-router";
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import { NewConversation } from "#/components/features/home/new-conversation/new-conversation";
vi.mock("#/hooks/query/use-settings", async () => {
const actual = await vi.importActual<typeof import("#/hooks/query/use-settings")>(
"#/hooks/query/use-settings",
);
return {
...actual,
getSettingsQueryFn: vi.fn().mockResolvedValue({ v1_enabled: true }),
};
});
vi.mock("#/context/use-selected-organization", () => ({
useSelectedOrganizationId: () => ({ organizationId: null }),
}));
// Mock the translation function
vi.mock("react-i18next", async () => {
const actual = await vi.importActual("react-i18next");
@@ -50,31 +64,52 @@ const renderNewConversation = () => {
describe("NewConversation", () => {
it("should create an empty conversation and redirect when pressing the launch from scratch button", async () => {
const createConversationSpy = vi.spyOn(
ConversationService,
"createConversation",
);
const createConversationSpy = vi
.spyOn(V1ConversationService, "createConversation")
.mockResolvedValue({
id: "task-id",
created_by_user_id: null,
status: "READY",
detail: null,
app_conversation_id: "conv-123",
sandbox_id: null,
agent_server_url: "http://agent-server.local",
request: {
sandbox_id: null,
initial_message: null,
processors: [],
llm_model: null,
selected_repository: null,
selected_branch: null,
git_provider: "github",
suggested_task: null,
title: null,
trigger: null,
pr_number: [],
parent_conversation_id: null,
agent_type: "default",
},
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
});
renderNewConversation();
const launchButton = screen.getByTestId("launch-new-conversation-button");
await userEvent.click(launchButton);
expect(createConversationSpy).toHaveBeenCalledExactlyOnceWith(
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
);
expect(createConversationSpy).toHaveBeenCalledOnce();
// expect to be redirected to /conversations/:conversationId
await screen.findByTestId("conversation-screen");
});
it("should change the launch button text to 'Loading...' when creating a conversation", async () => {
// Mock V1 API to never resolve, keeping the mutation in loading state
vi.spyOn(V1ConversationService, "createConversation").mockImplementation(
() => new Promise(() => {}),
);
renderNewConversation();
const launchButton = screen.getByTestId("launch-new-conversation-button");

View File

@@ -4,7 +4,7 @@ import userEvent from "@testing-library/user-event";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { createRoutesStub, Outlet } from "react-router";
import SettingsService from "#/api/settings-service/settings-service.api";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import GitService from "#/api/git-service/git-service.api";
import OptionService from "#/api/option-service/option-service.api";
import { GitRepository } from "#/types/git";
@@ -314,23 +314,34 @@ describe("RepoConnector", () => {
});
it("should create a conversation and redirect with the selected repo when pressing the launch button", async () => {
const createConversationSpy = vi.spyOn(
ConversationService,
"createConversation",
);
createConversationSpy.mockResolvedValue({
conversation_id: "mock-conversation-id",
title: "Test Conversation",
selected_repository: "user/repo1",
selected_branch: "main",
git_provider: "github",
last_updated_at: "2023-01-01T00:00:00Z",
created_at: "2023-01-01T00:00:00Z",
status: "STARTING",
runtime_status: null,
url: null,
session_api_key: null,
});
const createConversationSpy = vi
.spyOn(V1ConversationService, "createConversation")
.mockResolvedValue({
id: "task-id",
created_by_user_id: null,
status: "READY",
detail: null,
app_conversation_id: "mock-conversation-id",
sandbox_id: null,
agent_server_url: "http://agent-server.local",
request: {
sandbox_id: null,
initial_message: null,
processors: [],
llm_model: null,
selected_repository: "rbren/polaris",
selected_branch: "main",
git_provider: "github",
suggested_task: null,
title: null,
trigger: null,
pr_number: [],
parent_conversation_id: null,
agent_type: "default",
},
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
});
const retrieveUserGitRepositoriesSpy = vi.spyOn(
GitService,
"retrieveUserGitRepositories",
@@ -390,20 +401,24 @@ describe("RepoConnector", () => {
await userEvent.click(launchButton);
expect(createConversationSpy).toHaveBeenCalledExactlyOnceWith(
expect(createConversationSpy).toHaveBeenCalledOnce();
expect(createConversationSpy).toHaveBeenCalledWith(
"rbren/polaris",
"github",
undefined,
undefined,
"main",
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
);
});
it("should change the launch button text to 'Loading...' when creating a conversation", async () => {
const createConversationSpy = vi.spyOn(
ConversationService,
V1ConversationService,
"createConversation",
);
createConversationSpy.mockImplementation(() => new Promise(() => { })); // Never resolves to keep loading state

View File

@@ -1,15 +1,28 @@
import { render, screen } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import { createRoutesStub } from "react-router";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import UserService from "#/api/user-service/user-service.api";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import GitService from "#/api/git-service/git-service.api";
import { TaskCard } from "#/components/features/home/tasks/task-card";
import { GitRepository } from "#/types/git";
import { SuggestedTask } from "#/utils/types";
vi.mock("#/hooks/query/use-settings", async () => {
const actual = await vi.importActual<typeof import("#/hooks/query/use-settings")>(
"#/hooks/query/use-settings",
);
return {
...actual,
getSettingsQueryFn: vi.fn().mockResolvedValue({ v1_enabled: true }),
};
});
vi.mock("#/context/use-selected-organization", () => ({
useSelectedOrganizationId: () => ({ organizationId: null }),
}));
const MOCK_TASK_1: SuggestedTask = {
issue_number: 123,
repo: "repo1",
@@ -56,17 +69,43 @@ describe("TaskCard", () => {
});
it("should call createConversation when clicking the launch button", async () => {
const createConversationSpy = vi.spyOn(
ConversationService,
"createConversation",
);
const createConversationSpy = vi
.spyOn(V1ConversationService, "createConversation")
.mockResolvedValue({
id: "task-id",
created_by_user_id: null,
status: "READY",
detail: null,
app_conversation_id: "conv-123",
sandbox_id: null,
agent_server_url: "http://agent-server.local",
request: {
sandbox_id: null,
initial_message: null,
processors: [],
llm_model: null,
selected_repository: null,
selected_branch: null,
git_provider: "github",
suggested_task: null,
title: null,
trigger: null,
pr_number: [],
parent_conversation_id: null,
agent_type: "default",
},
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
});
renderTaskCard();
const launchButton = screen.getByTestId("task-launch-button");
await userEvent.click(launchButton);
expect(createConversationSpy).toHaveBeenCalled();
await waitFor(() => {
expect(createConversationSpy).toHaveBeenCalled();
});
});
describe("creating suggested task conversation", () => {
@@ -82,10 +121,34 @@ describe("TaskCard", () => {
});
it("should call create conversation with suggest task trigger and selected suggested task", async () => {
const createConversationSpy = vi.spyOn(
ConversationService,
"createConversation",
);
const createConversationSpy = vi
.spyOn(V1ConversationService, "createConversation")
.mockResolvedValue({
id: "task-id",
created_by_user_id: null,
status: "READY",
detail: null,
app_conversation_id: "conv-123",
sandbox_id: null,
agent_server_url: "http://agent-server.local",
request: {
sandbox_id: null,
initial_message: null,
processors: [],
llm_model: null,
selected_repository: MOCK_RESPOSITORIES[0].full_name,
selected_branch: null,
git_provider: "github",
suggested_task: null,
title: null,
trigger: null,
pr_number: [],
parent_conversation_id: null,
agent_type: "default",
},
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
});
renderTaskCard(MOCK_TASK_1);
@@ -96,6 +159,8 @@ describe("TaskCard", () => {
MOCK_RESPOSITORIES[0].full_name,
MOCK_RESPOSITORIES[0].git_provider,
undefined,
undefined,
undefined,
{
git_provider: "github",
issue_number: 123,
@@ -106,27 +171,37 @@ describe("TaskCard", () => {
undefined,
undefined,
undefined,
undefined,
);
});
});
it("should navigate to the conversation page after creating a conversation", async () => {
const createConversationSpy = vi.spyOn(
ConversationService,
"createConversation",
);
createConversationSpy.mockResolvedValue({
conversation_id: "test-conversation-id",
title: "Test Conversation",
selected_repository: "repo1",
selected_branch: "main",
git_provider: "github",
last_updated_at: "2023-01-01T00:00:00Z",
created_at: "2023-01-01T00:00:00Z",
status: "RUNNING",
runtime_status: "STATUS$READY",
url: null,
session_api_key: null,
vi.spyOn(V1ConversationService, "createConversation").mockResolvedValue({
id: "task-id",
created_by_user_id: null,
status: "READY",
detail: null,
app_conversation_id: "test-conversation-id",
sandbox_id: null,
agent_server_url: "http://agent-server.local",
request: {
sandbox_id: null,
initial_message: null,
processors: [],
llm_model: null,
selected_repository: "repo1",
selected_branch: "main",
git_provider: "github",
suggested_task: null,
title: null,
trigger: null,
pr_number: [],
parent_conversation_id: null,
agent_type: "default",
},
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
});
renderTaskCard();

View File

@@ -0,0 +1,321 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders, createAxiosError } from "test-utils";
import { InvitationAcceptModal } from "#/components/features/invitations/invitation-accept-modal";
import { organizationService } from "#/api/organization-service/organization-service.api";
import * as toastHandlers from "#/utils/custom-toast-handlers";
// Mock the organization service
vi.mock("#/api/organization-service/organization-service.api", () => ({
organizationService: {
acceptInvitation: vi.fn(),
},
}));
// Mock toast handlers
vi.mock("#/utils/custom-toast-handlers", () => ({
displaySuccessToast: vi.fn(),
displayErrorToast: vi.fn(),
}));
describe("InvitationAcceptModal", () => {
const mockToken = "test-invitation-token-123";
const mockOnClose = vi.fn();
const mockOnSuccess = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it("should render the modal with title and description", () => {
renderWithProviders(
<InvitationAcceptModal
token={mockToken}
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>,
);
expect(screen.getByTestId("invitation-accept-modal")).toBeInTheDocument();
expect(
screen.getByText("ORG$INVITATION_ACCEPT_TITLE"),
).toBeInTheDocument();
expect(
screen.getByText("ORG$INVITATION_ACCEPT_DESCRIPTION"),
).toBeInTheDocument();
});
it("should render accept and cancel buttons", () => {
renderWithProviders(
<InvitationAcceptModal
token={mockToken}
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>,
);
expect(screen.getByTestId("accept-invitation-button")).toBeInTheDocument();
expect(screen.getByTestId("cancel-invitation-button")).toBeInTheDocument();
});
it("should call onClose when cancel button is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(
<InvitationAcceptModal
token={mockToken}
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>,
);
await user.click(screen.getByTestId("cancel-invitation-button"));
expect(mockOnClose).toHaveBeenCalledOnce();
});
it("should call acceptInvitation when accept button is clicked", async () => {
const user = userEvent.setup();
const mockResponse = {
success: true,
org_id: "org-123",
org_name: "Test Organization",
role: "member",
};
vi.mocked(organizationService.acceptInvitation).mockResolvedValueOnce(
mockResponse,
);
renderWithProviders(
<InvitationAcceptModal
token={mockToken}
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>,
);
await user.click(screen.getByTestId("accept-invitation-button"));
await waitFor(() => {
expect(organizationService.acceptInvitation).toHaveBeenCalledWith({
token: mockToken,
});
});
});
it("should call onSuccess with org_id and show success toast on successful acceptance", async () => {
const user = userEvent.setup();
const mockResponse = {
success: true,
org_id: "org-123",
org_name: "Test Organization",
role: "member",
};
vi.mocked(organizationService.acceptInvitation).mockResolvedValueOnce(
mockResponse,
);
renderWithProviders(
<InvitationAcceptModal
token={mockToken}
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>,
);
await user.click(screen.getByTestId("accept-invitation-button"));
await waitFor(() => {
expect(mockOnSuccess).toHaveBeenCalledWith({
orgId: "org-123",
orgName: "Test Organization",
isPersonal: false,
});
});
expect(toastHandlers.displaySuccessToast).toHaveBeenCalled();
});
it("should show loading spinner and disable buttons while accepting", async () => {
const user = userEvent.setup();
// Create a promise that we can control
let resolvePromise: (value: unknown) => void;
const pendingPromise = new Promise((resolve) => {
resolvePromise = resolve;
});
vi.mocked(organizationService.acceptInvitation).mockReturnValueOnce(
pendingPromise as Promise<{
success: boolean;
org_id: string;
org_name: string;
role: string;
}>,
);
renderWithProviders(
<InvitationAcceptModal
token={mockToken}
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>,
);
// Click accept to trigger loading state
await user.click(screen.getByTestId("accept-invitation-button"));
// Check loading state
await waitFor(() => {
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
});
expect(screen.getByTestId("accept-invitation-button")).toBeDisabled();
expect(screen.getByTestId("cancel-invitation-button")).toBeDisabled();
// Resolve the promise to clean up
resolvePromise!({
success: true,
org_id: "org-123",
org_name: "Test Organization",
role: "member",
});
});
describe("error handling", () => {
it("should show expired error toast and call onClose when invitation is expired", async () => {
const user = userEvent.setup();
vi.mocked(organizationService.acceptInvitation).mockRejectedValueOnce(
createAxiosError(400, "Bad Request", { detail: "invitation_expired" }),
);
renderWithProviders(
<InvitationAcceptModal
token={mockToken}
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>,
);
await user.click(screen.getByTestId("accept-invitation-button"));
await waitFor(() => {
expect(toastHandlers.displayErrorToast).toHaveBeenCalledWith(
"ORG$INVITATION_EXPIRED",
);
});
expect(mockOnClose).toHaveBeenCalledOnce();
expect(mockOnSuccess).not.toHaveBeenCalled();
});
it("should show invalid error toast and call onClose when invitation is invalid", async () => {
const user = userEvent.setup();
vi.mocked(organizationService.acceptInvitation).mockRejectedValueOnce(
createAxiosError(400, "Bad Request", { detail: "invitation_invalid" }),
);
renderWithProviders(
<InvitationAcceptModal
token={mockToken}
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>,
);
await user.click(screen.getByTestId("accept-invitation-button"));
await waitFor(() => {
expect(toastHandlers.displayErrorToast).toHaveBeenCalledWith(
"ORG$INVITATION_INVALID",
);
});
expect(mockOnClose).toHaveBeenCalledOnce();
});
it("should show already member error toast when user is already a member", async () => {
const user = userEvent.setup();
vi.mocked(organizationService.acceptInvitation).mockRejectedValueOnce(
createAxiosError(409, "Conflict", { detail: "already_member" }),
);
renderWithProviders(
<InvitationAcceptModal
token={mockToken}
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>,
);
await user.click(screen.getByTestId("accept-invitation-button"));
await waitFor(() => {
expect(toastHandlers.displayErrorToast).toHaveBeenCalledWith(
"ORG$ALREADY_MEMBER",
);
});
expect(mockOnClose).toHaveBeenCalledOnce();
});
it("should show email mismatch error toast when email does not match", async () => {
const user = userEvent.setup();
vi.mocked(organizationService.acceptInvitation).mockRejectedValueOnce(
createAxiosError(403, "Forbidden", { detail: "email_mismatch" }),
);
renderWithProviders(
<InvitationAcceptModal
token={mockToken}
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>,
);
await user.click(screen.getByTestId("accept-invitation-button"));
await waitFor(() => {
expect(toastHandlers.displayErrorToast).toHaveBeenCalledWith(
"ORG$INVITATION_EMAIL_MISMATCH",
);
});
expect(mockOnClose).toHaveBeenCalledOnce();
});
it("should show generic error toast for unknown errors", async () => {
const user = userEvent.setup();
vi.mocked(organizationService.acceptInvitation).mockRejectedValueOnce(
createAxiosError(500, "Internal Server Error", {
detail: "unexpected_error",
}),
);
renderWithProviders(
<InvitationAcceptModal
token={mockToken}
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>,
);
await user.click(screen.getByTestId("accept-invitation-button"));
await waitFor(() => {
expect(toastHandlers.displayErrorToast).toHaveBeenCalledWith(
"ORG$INVITATION_ACCEPT_ERROR",
);
});
expect(mockOnClose).toHaveBeenCalledOnce();
});
});
});

View File

@@ -0,0 +1,426 @@
import { render, screen, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { PluginLaunchModal } from "#/components/features/launch/plugin-launch-modal";
import { PluginSpec } from "#/api/conversation-service/v1-conversation-service.types";
const mockOnStartConversation = vi.fn();
const mockOnClose = vi.fn();
function renderModal(
plugins: PluginSpec[],
props: Partial<{
message: string;
isLoading: boolean;
}> = {},
) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<PluginLaunchModal
plugins={plugins}
message={props.message}
isLoading={props.isLoading ?? false}
onStartConversation={mockOnStartConversation}
onClose={mockOnClose}
/>
</QueryClientProvider>,
);
}
describe("PluginLaunchModal", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("Plugin Display Name Extraction", () => {
it("should extract plugin name from repo_path when provided", () => {
renderModal([{ source: "github:owner/repo", repo_path: "plugins/my-plugin" }]);
// Plugin name should be "my-plugin" from the path
expect(screen.getByText("my-plugin")).toBeInTheDocument();
});
it("should show repo path when no repo_path (repo IS the plugin)", () => {
renderModal([{ source: "github:owner/my-plugin" }]);
// When no repo_path, the whole repo is the plugin, show "owner/my-plugin"
const elements = screen.getAllByText("owner/my-plugin");
expect(elements.length).toBeGreaterThan(0);
});
it("should extract name from git URL", () => {
renderModal([
{ source: "https://github.com/owner/repo-name.git" },
]);
const elements = screen.getAllByText("repo-name");
expect(elements.length).toBeGreaterThan(0);
});
it("should display full source when no special format", () => {
renderModal([{ source: "local-plugin" }]);
const elements = screen.getAllByText("local-plugin");
expect(elements.length).toBeGreaterThan(0);
});
});
describe("Modal Title", () => {
it("should show plugin name in title for single plugin", () => {
renderModal([{ source: "github:owner/awesome-plugin" }]);
// Title should include the plugin name - use getAllBy since text appears in multiple places
const elements = screen.getAllByText(/owner\/awesome-plugin/);
expect(elements.length).toBeGreaterThan(0);
});
it("should show generic title for multiple plugins", () => {
renderModal([
{ source: "github:owner/plugin1" },
{ source: "github:owner/plugin2" },
]);
// The h2 title contains both LAUNCH$MODAL_TITLE and LAUNCH$MODAL_TITLE_GENERIC
const title = screen.getByRole("heading", { level: 2 });
expect(title.textContent).toContain("LAUNCH$MODAL_TITLE_GENERIC");
});
});
describe("Message Display", () => {
it("should display message when provided", () => {
renderModal([{ source: "github:owner/repo" }], {
message: "This is a custom message",
});
expect(screen.getByText("This is a custom message")).toBeInTheDocument();
});
it("should not render message element when not provided", () => {
renderModal([{ source: "github:owner/repo" }]);
// No message should be present
const modal = screen.getByTestId("plugin-launch-modal");
expect(modal.querySelector("p.text-neutral-400")).not.toBeInTheDocument();
});
});
describe("Expandable Sections", () => {
it("should expand plugin section by default when it has parameters", () => {
renderModal([
{
source: "github:owner/repo",
parameters: { apiKey: "test-key" },
},
]);
// Parameter input should be visible (section is expanded)
expect(screen.getByTestId("plugin-0-param-apiKey")).toBeInTheDocument();
});
it("should collapse/expand section when clicking header", async () => {
const user = userEvent.setup();
renderModal([
{
source: "github:owner/repo",
parameters: { apiKey: "test-key" },
},
]);
// Initially expanded - parameter visible
expect(screen.getByTestId("plugin-0-param-apiKey")).toBeInTheDocument();
// Click to collapse
await user.click(screen.getByTestId("plugin-section-0"));
// Parameter should be hidden
await waitFor(() => {
expect(
screen.queryByTestId("plugin-0-param-apiKey"),
).not.toBeInTheDocument();
});
// Click to expand again
await user.click(screen.getByTestId("plugin-section-0"));
// Parameter should be visible again
await waitFor(() => {
expect(screen.getByTestId("plugin-0-param-apiKey")).toBeInTheDocument();
});
});
});
describe("Parameter Inputs", () => {
it("should render text input for string parameters", () => {
renderModal([
{
source: "github:owner/repo",
parameters: { name: "default-name" },
},
]);
const input = screen.getByTestId("plugin-0-param-name");
expect(input).toHaveAttribute("type", "text");
expect(input).toHaveValue("default-name");
});
it("should render number input for number parameters", () => {
renderModal([
{
source: "github:owner/repo",
parameters: { count: 42 },
},
]);
const input = screen.getByTestId("plugin-0-param-count");
expect(input).toHaveAttribute("type", "number");
expect(input).toHaveValue(42);
});
it("should render checkbox for boolean parameters", () => {
renderModal([
{
source: "github:owner/repo",
parameters: { enabled: true },
},
]);
const checkbox = screen.getByTestId("plugin-0-param-enabled");
expect(checkbox).toHaveAttribute("type", "checkbox");
expect(checkbox).toBeChecked();
});
it("should update string parameter value when typing", async () => {
const user = userEvent.setup();
renderModal([
{
source: "github:owner/repo",
parameters: { apiKey: "initial" },
},
]);
const input = screen.getByTestId("plugin-0-param-apiKey");
await user.clear(input);
await user.type(input, "new-value");
expect(input).toHaveValue("new-value");
});
it("should update number parameter value when typing", async () => {
const user = userEvent.setup();
renderModal([
{
source: "github:owner/repo",
parameters: { count: 5 },
},
]);
const input = screen.getByTestId("plugin-0-param-count");
await user.clear(input);
await user.type(input, "100");
expect(input).toHaveValue(100);
});
it("should toggle boolean parameter when clicking checkbox", async () => {
const user = userEvent.setup();
renderModal([
{
source: "github:owner/repo",
parameters: { debug: false },
},
]);
const checkbox = screen.getByTestId("plugin-0-param-debug");
expect(checkbox).not.toBeChecked();
await user.click(checkbox);
expect(checkbox).toBeChecked();
});
});
describe("Ref and Path Display", () => {
it("should display ref when provided", async () => {
renderModal([
{
source: "github:owner/repo",
ref: "v1.2.3",
parameters: { key: "value" },
},
]);
// Check the expanded section contains the ref value
const modal = screen.getByTestId("plugin-launch-modal");
expect(modal.textContent).toContain("v1.2.3");
});
it("should display repo_path when provided", () => {
renderModal([
{
source: "github:owner/repo",
repo_path: "plugins/my-plugin",
parameters: { key: "value" },
},
]);
// Check the expanded section contains the path value
const modal = screen.getByTestId("plugin-launch-modal");
expect(modal.textContent).toContain("plugins/my-plugin");
});
});
describe("Plugins Without Parameters", () => {
it("should show plugins list when all plugins have no parameters", () => {
renderModal([
{ source: "github:owner/plugin1" },
{ source: "github:owner/plugin2" },
]);
expect(screen.getByText("LAUNCH$PLUGINS")).toBeInTheDocument();
// When no repo_path, the full repo path is shown (may appear multiple times)
expect(screen.getAllByText("owner/plugin1").length).toBeGreaterThan(0);
expect(screen.getAllByText("owner/plugin2").length).toBeGreaterThan(0);
});
it("should show 'Additional Plugins' when mixing plugins with and without params", () => {
renderModal([
{ source: "github:owner/with-params", parameters: { key: "val" } },
{ source: "github:owner/without-params" },
]);
expect(screen.getByText("LAUNCH$ADDITIONAL_PLUGINS")).toBeInTheDocument();
// When no repo_path, the full repo path is shown
expect(screen.getAllByText("owner/without-params").length).toBeGreaterThan(0);
});
it("should show ref in simple plugin list", () => {
renderModal([
{ source: "github:owner/plugin", ref: "main" },
]);
expect(screen.getByText("@ main")).toBeInTheDocument();
});
it("should show repo_path in simple plugin list", () => {
renderModal([
{ source: "github:owner/repo", repo_path: "plugins/city-weather" },
]);
// Should show the plugin name
expect(screen.getByText("city-weather")).toBeInTheDocument();
// Should show the source info with path
const modal = screen.getByTestId("plugin-launch-modal");
expect(modal.textContent).toContain("owner/repo");
expect(modal.textContent).toContain("plugins/city-weather");
});
});
describe("Action Buttons", () => {
it("should call onClose when close button is clicked", async () => {
const user = userEvent.setup();
renderModal([{ source: "github:owner/repo" }]);
await user.click(screen.getByTestId("close-button"));
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it("should call onStartConversation with updated plugins when start button is clicked", async () => {
const user = userEvent.setup();
renderModal([
{
source: "github:owner/repo",
ref: "main",
parameters: { apiKey: "initial" },
},
]);
// Update the parameter
const input = screen.getByTestId("plugin-0-param-apiKey");
await user.clear(input);
await user.type(input, "updated-key");
// Check the trust checkbox first
await user.click(screen.getByTestId("trust-checkbox"));
// Click start
await user.click(screen.getByTestId("start-conversation-button"));
expect(mockOnStartConversation).toHaveBeenCalledTimes(1);
const calledWithPlugins = mockOnStartConversation.mock.calls[0][0];
const calledWithMessage = mockOnStartConversation.mock.calls[0][1];
expect(calledWithPlugins[0].source).toBe("github:owner/repo");
expect(calledWithPlugins[0].ref).toBe("main");
expect(calledWithPlugins[0].parameters.apiKey).toBe("updated-key");
expect(calledWithMessage).toBeUndefined();
});
it("should call onStartConversation with message when provided", async () => {
const user = userEvent.setup();
renderModal(
[{ source: "github:owner/repo" }],
{ message: "/city-weather:now Tokyo" },
);
// Check the trust checkbox first
await user.click(screen.getByTestId("trust-checkbox"));
await user.click(screen.getByTestId("start-conversation-button"));
expect(mockOnStartConversation).toHaveBeenCalledTimes(1);
const calledWithPlugins = mockOnStartConversation.mock.calls[0][0];
const calledWithMessage = mockOnStartConversation.mock.calls[0][1];
expect(calledWithPlugins[0].source).toBe("github:owner/repo");
expect(calledWithMessage).toBe("/city-weather:now Tokyo");
});
it("should show 'Starting...' text when loading", () => {
renderModal([{ source: "github:owner/repo" }], { isLoading: true });
expect(screen.getByText("LAUNCH$STARTING")).toBeInTheDocument();
});
it("should disable start button when loading", () => {
renderModal([{ source: "github:owner/repo" }], { isLoading: true });
expect(screen.getByTestId("start-conversation-button")).toBeDisabled();
});
});
describe("Multiple Plugins with Parameters", () => {
it("should render multiple expandable sections for plugins with parameters", () => {
renderModal([
{ source: "github:owner/plugin1", parameters: { key1: "val1" } },
{ source: "github:owner/plugin2", parameters: { key2: "val2" } },
]);
expect(screen.getByTestId("plugin-section-0")).toBeInTheDocument();
expect(screen.getByTestId("plugin-section-1")).toBeInTheDocument();
});
it("should maintain separate state for each plugin's parameters", async () => {
const user = userEvent.setup();
renderModal([
{ source: "github:owner/plugin1", parameters: { key1: "val1" } },
{ source: "github:owner/plugin2", parameters: { key2: "val2" } },
]);
// Update first plugin's parameter
const input1 = screen.getByTestId("plugin-0-param-key1");
await user.clear(input1);
await user.type(input1, "new-val1");
// Second plugin's parameter should be unchanged
const input2 = screen.getByTestId("plugin-1-param-key2");
expect(input2).toHaveValue("val2");
// First plugin should have new value
expect(input1).toHaveValue("new-val1");
});
});
});

View File

@@ -0,0 +1,37 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect } from "vitest";
import { code as Code } from "#/components/features/markdown/code";
describe("code (markdown)", () => {
it("should render inline code without a copy button", () => {
render(<Code>inline snippet</Code>);
expect(screen.getByText("inline snippet")).toBeInTheDocument();
expect(screen.queryByTestId("copy-to-clipboard")).not.toBeInTheDocument();
});
it("should render a multiline code block with a copy button", () => {
render(<Code>{"line1\nline2"}</Code>);
expect(screen.getByText("line1 line2")).toBeInTheDocument();
expect(screen.getByTestId("copy-to-clipboard")).toBeInTheDocument();
});
it("should render a syntax-highlighted block with a copy button", () => {
render(<Code className="language-js">{"console.log('hi')"}</Code>);
expect(screen.getByTestId("copy-to-clipboard")).toBeInTheDocument();
});
it("should copy code block content to clipboard", async () => {
const user = userEvent.setup();
render(<Code>{"line1\nline2"}</Code>);
await user.click(screen.getByTestId("copy-to-clipboard"));
await waitFor(() =>
expect(navigator.clipboard.readText()).resolves.toBe("line1\nline2"),
);
});
});

View File

@@ -0,0 +1,83 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { MemoryRouter } from "react-router";
import { EnterpriseCard } from "#/components/features/onboarding/enterprise-card";
describe("EnterpriseCard", () => {
const defaultProps = {
icon: <svg data-testid="test-icon" />,
title: "Test Title",
description: "Test description",
features: ["Feature 1", "Feature 2"],
learnMoreLabel: "Learn More",
onLearnMore: vi.fn(),
};
const renderWithRouter = (props = defaultProps) =>
render(
<MemoryRouter>
<EnterpriseCard {...props} />
</MemoryRouter>,
);
it("should render the card with title", () => {
renderWithRouter();
expect(screen.getByText("Test Title")).toBeInTheDocument();
});
it("should render the description", () => {
renderWithRouter();
expect(screen.getByText("Test description")).toBeInTheDocument();
});
it("should render the icon", () => {
renderWithRouter();
expect(screen.getByTestId("test-icon")).toBeInTheDocument();
});
it("should render the features", () => {
renderWithRouter();
expect(screen.getByText("Feature 1")).toBeInTheDocument();
expect(screen.getByText("Feature 2")).toBeInTheDocument();
});
it("should render the learn more link with correct label", () => {
renderWithRouter();
const link = screen.getByRole("link", {
name: "Learn More Test Title",
});
expect(link).toBeInTheDocument();
});
it("should have correct href", () => {
renderWithRouter();
const link = screen.getByRole("link", { name: "Learn More Test Title" });
expect(link).toHaveAttribute("href", "/information-request");
});
it("should call onLearnMore when link is clicked", async () => {
const mockOnLearnMore = vi.fn();
const user = userEvent.setup();
renderWithRouter({ ...defaultProps, onLearnMore: mockOnLearnMore });
const link = screen.getByRole("link", { name: "Learn More Test Title" });
await user.click(link);
expect(mockOnLearnMore).toHaveBeenCalledTimes(1);
});
it("should have correct aria-label on link", () => {
renderWithRouter();
const link = screen.getByRole("link");
expect(link).toHaveAttribute("aria-label", "Learn More Test Title");
});
});

View File

@@ -0,0 +1,38 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { FeatureList } from "#/components/features/onboarding/feature-list";
describe("FeatureList", () => {
it("should render a list of features", () => {
const features = ["Feature 1", "Feature 2", "Feature 3"];
render(<FeatureList features={features} />);
expect(screen.getByText("Feature 1")).toBeInTheDocument();
expect(screen.getByText("Feature 2")).toBeInTheDocument();
expect(screen.getByText("Feature 3")).toBeInTheDocument();
});
it("should render bullet points for each feature", () => {
const features = ["Feature 1", "Feature 2"];
render(<FeatureList features={features} />);
const bullets = screen.getAllByText("•");
expect(bullets).toHaveLength(2);
});
it("should render an empty list when no features provided", () => {
render(<FeatureList features={[]} />);
const list = screen.getByRole("list");
expect(list).toBeInTheDocument();
expect(list.children).toHaveLength(0);
});
it("should render each feature as a list item", () => {
const features = ["Feature 1", "Feature 2"];
render(<FeatureList features={features} />);
const listItems = screen.getAllByRole("listitem");
expect(listItems).toHaveLength(2);
});
});

View File

@@ -0,0 +1,171 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { FormInput } from "#/components/features/onboarding/form-input";
describe("FormInput", () => {
const defaultProps = {
id: "test-input",
label: "Test Label",
value: "",
onChange: vi.fn(),
};
it("should render with correct test id", () => {
render(<FormInput {...defaultProps} />);
expect(screen.getByTestId("form-input-test-input")).toBeInTheDocument();
});
it("should render the label", () => {
render(<FormInput {...defaultProps} />);
expect(screen.getByText("Test Label")).toBeInTheDocument();
});
it("should display the provided value", () => {
render(<FormInput {...defaultProps} value="Hello World" />);
const input = screen.getByTestId("form-input-test-input");
expect(input).toHaveValue("Hello World");
});
it("should call onChange when user types", async () => {
const mockOnChange = vi.fn();
const user = userEvent.setup();
render(<FormInput {...defaultProps} onChange={mockOnChange} />);
const input = screen.getByTestId("form-input-test-input");
await user.type(input, "a");
expect(mockOnChange).toHaveBeenCalledWith("a");
});
it("should render as a text input by default", () => {
render(<FormInput {...defaultProps} />);
const input = screen.getByTestId("form-input-test-input");
expect(input).toHaveAttribute("type", "text");
});
it("should render as an email input when type is email", () => {
render(<FormInput {...defaultProps} type="email" />);
const input = screen.getByTestId("form-input-test-input");
expect(input).toHaveAttribute("type", "email");
});
it("should render a textarea when rows prop is provided", () => {
render(<FormInput {...defaultProps} rows={4} />);
const textarea = screen.getByTestId("form-input-test-input");
expect(textarea.tagName).toBe("TEXTAREA");
expect(textarea).toHaveAttribute("rows", "4");
});
it("should render placeholder text", () => {
render(<FormInput {...defaultProps} placeholder="Enter text here" />);
const input = screen.getByTestId("form-input-test-input");
expect(input).toHaveAttribute("placeholder", "Enter text here");
});
it("should have aria-required attribute when required", () => {
render(<FormInput {...defaultProps} required />);
const input = screen.getByTestId("form-input-test-input");
expect(input).toHaveAttribute("aria-required", "true");
});
it("should have aria-label attribute", () => {
render(<FormInput {...defaultProps} />);
const input = screen.getByTestId("form-input-test-input");
expect(input).toHaveAttribute("aria-label", "Test Label");
});
it("should have required attribute on input when required", () => {
render(<FormInput {...defaultProps} required />);
const input = screen.getByTestId("form-input-test-input");
expect(input).toBeRequired();
});
it("should have required attribute on textarea when required", () => {
render(<FormInput {...defaultProps} rows={4} required />);
const textarea = screen.getByTestId("form-input-test-input");
expect(textarea).toBeRequired();
});
it("should associate label with input via htmlFor", () => {
render(<FormInput {...defaultProps} />);
const label = screen.getByText("Test Label");
const input = screen.getByTestId("form-input-test-input");
expect(label).toHaveAttribute("for", "form-input-test-input");
expect(input).toHaveAttribute("id", "form-input-test-input");
});
describe("error state", () => {
it("should have aria-invalid true when showing error", () => {
render(<FormInput {...defaultProps} required showError />);
const input = screen.getByTestId("form-input-test-input");
expect(input).toHaveAttribute("aria-invalid", "true");
});
it("should have aria-invalid false when not showing error", () => {
render(<FormInput {...defaultProps} required showError={false} />);
const input = screen.getByTestId("form-input-test-input");
expect(input).toHaveAttribute("aria-invalid", "false");
});
it("should have aria-invalid false when showError is true but field has value", () => {
render(<FormInput {...defaultProps} required showError value="filled" />);
const input = screen.getByTestId("form-input-test-input");
expect(input).toHaveAttribute("aria-invalid", "false");
});
it("should have aria-invalid false when showError is true but field is not required", () => {
render(<FormInput {...defaultProps} required={false} showError />);
const input = screen.getByTestId("form-input-test-input");
expect(input).toHaveAttribute("aria-invalid", "false");
});
it("should have aria-invalid true on textarea when showError is true and empty", () => {
render(<FormInput {...defaultProps} rows={4} required showError />);
const textarea = screen.getByTestId("form-input-test-input");
expect(textarea).toHaveAttribute("aria-invalid", "true");
});
it("should have aria-invalid true for invalid email when showError is true", () => {
render(
<FormInput {...defaultProps} type="email" value="invalid" showError />,
);
const input = screen.getByTestId("form-input-test-input");
expect(input).toHaveAttribute("aria-invalid", "true");
});
it("should have aria-invalid false for valid email when showError is true", () => {
render(
<FormInput
{...defaultProps}
type="email"
value="test@example.com"
showError
/>,
);
const input = screen.getByTestId("form-input-test-input");
expect(input).toHaveAttribute("aria-invalid", "false");
});
});
});

View File

@@ -0,0 +1,367 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { createRoutesStub } from "react-router";
import { useState } from "react";
import {
InformationRequestForm,
RequestType,
} from "#/components/features/onboarding/information-request-form";
import { EnterpriseFormData } from "#/utils/local-storage";
// Mock useTracking
const mockTrackEnterpriseLeadFormSubmitted = vi.fn();
vi.mock("#/hooks/use-tracking", () => ({
useTracking: () => ({
trackEnterpriseLeadFormSubmitted: mockTrackEnterpriseLeadFormSubmitted,
}),
}));
const mockOnBack = vi.fn();
// Wrapper to manage form state (needed since component is controlled)
function StatefulForm({ requestType }: { requestType: RequestType }) {
const [formData, setFormData] = useState<EnterpriseFormData>({ name: "", company: "", email: "", message: "" });
return <InformationRequestForm requestType={requestType} formData={formData} onFormDataChange={setFormData} onBack={mockOnBack} />;
}
describe("InformationRequestForm", () => {
const defaultProps = {
requestType: "saas" as RequestType,
};
beforeEach(() => {
vi.clearAllMocks();
mockOnBack.mockClear();
});
const renderWithRouter = (props = defaultProps) => {
const Stub = createRoutesStub([
{
path: "/",
Component: () => <StatefulForm {...props} />,
},
{
path: "/login",
Component: () => <div data-testid="login-page" />,
},
{
path: "/information-request",
Component: () => <div data-testid="information-request-page" />,
},
]);
return render(<Stub initialEntries={["/"]} />);
};
it("should render the form", () => {
renderWithRouter();
expect(screen.getByTestId("information-request-form")).toBeInTheDocument();
});
it("should render the logo", () => {
renderWithRouter();
const logo = screen.getByTestId("information-request-form").querySelector("svg");
expect(logo).toBeInTheDocument();
});
it("should render all form fields", () => {
renderWithRouter();
expect(screen.getByTestId("form-input-name")).toBeInTheDocument();
expect(screen.getByTestId("form-input-company")).toBeInTheDocument();
expect(screen.getByTestId("form-input-email")).toBeInTheDocument();
expect(screen.getByTestId("form-input-message")).toBeInTheDocument();
});
it("should render SaaS-specific title when requestType is saas", () => {
renderWithRouter({ ...defaultProps, requestType: "saas" });
expect(screen.getByText("ENTERPRISE$FORM_SAAS_TITLE")).toBeInTheDocument();
});
it("should render Self-hosted-specific title when requestType is self-hosted", () => {
renderWithRouter({ ...defaultProps, requestType: "self-hosted" });
expect(screen.getByText("ENTERPRISE$FORM_SELF_HOSTED_TITLE")).toBeInTheDocument();
});
it("should render cloud icon for SaaS request type", () => {
renderWithRouter({ ...defaultProps, requestType: "saas" });
// The card should contain the cloud icon
const card = screen.getByText("ENTERPRISE$SAAS_TITLE").closest("div");
expect(card).toBeInTheDocument();
});
it("should render stacked icon for self-hosted request type", () => {
renderWithRouter({ ...defaultProps, requestType: "self-hosted" });
// The card should contain the stacked icon
const card = screen.getByText("ENTERPRISE$SELF_HOSTED_TITLE").closest("div");
expect(card).toBeInTheDocument();
});
it("should call onBack when back button is clicked", async () => {
const user = userEvent.setup();
renderWithRouter();
const backButton = screen.getByRole("button", { name: "COMMON$BACK" });
await user.click(backButton);
expect(mockOnBack).toHaveBeenCalledTimes(1);
});
it("should update form fields when user types", async () => {
const user = userEvent.setup();
renderWithRouter();
const nameInput = screen.getByTestId("form-input-name");
await user.type(nameInput, "John Doe");
expect(nameInput).toHaveValue("John Doe");
});
it("should update email field when user types", async () => {
const user = userEvent.setup();
renderWithRouter();
const emailInput = screen.getByTestId("form-input-email");
await user.type(emailInput, "john@example.com");
expect(emailInput).toHaveValue("john@example.com");
});
it("should render message as textarea", () => {
renderWithRouter();
const messageInput = screen.getByTestId("form-input-message");
expect(messageInput.tagName).toBe("TEXTAREA");
});
it("should have all fields marked as required", () => {
renderWithRouter();
expect(screen.getByTestId("form-input-name")).toBeRequired();
expect(screen.getByTestId("form-input-company")).toBeRequired();
expect(screen.getByTestId("form-input-email")).toBeRequired();
expect(screen.getByTestId("form-input-message")).toBeRequired();
});
it("should render submit button", () => {
renderWithRouter();
const submitButton = screen.getByRole("button", { name: "ENTERPRISE$FORM_SUBMIT" });
expect(submitButton).toBeInTheDocument();
expect(submitButton).toHaveAttribute("type", "submit");
});
it("should render back button", () => {
renderWithRouter();
const backButton = screen.getByRole("button", { name: "COMMON$BACK" });
expect(backButton).toBeInTheDocument();
expect(backButton).toHaveAttribute("type", "button");
});
it("should have button group with role and aria-label", () => {
renderWithRouter();
const buttonGroup = screen.getByRole("group", { name: "Form actions" });
expect(buttonGroup).toBeInTheDocument();
});
it("should display SaaS card description for saas request type", () => {
renderWithRouter({ ...defaultProps, requestType: "saas" });
expect(screen.getByText("ENTERPRISE$SAAS_DESCRIPTION")).toBeInTheDocument();
});
it("should display Self-hosted card description for self-hosted request type", () => {
renderWithRouter({ ...defaultProps, requestType: "self-hosted" });
expect(screen.getByText("ENTERPRISE$SELF_HOSTED_DESCRIPTION")).toBeInTheDocument();
});
describe("form validation", () => {
it("should not show error state before form submission", () => {
renderWithRouter();
const nameInput = screen.getByTestId("form-input-name");
const companyInput = screen.getByTestId("form-input-company");
const emailInput = screen.getByTestId("form-input-email");
const messageInput = screen.getByTestId("form-input-message");
expect(nameInput).toHaveAttribute("aria-invalid", "false");
expect(companyInput).toHaveAttribute("aria-invalid", "false");
expect(emailInput).toHaveAttribute("aria-invalid", "false");
expect(messageInput).toHaveAttribute("aria-invalid", "false");
});
it("should not navigate when form is submitted with empty fields", async () => {
const user = userEvent.setup();
renderWithRouter();
const submitButton = screen.getByRole("button", {
name: "ENTERPRISE$FORM_SUBMIT",
});
await user.click(submitButton);
// Should stay on form page, not navigate to login
expect(screen.getByTestId("information-request-form")).toBeInTheDocument();
expect(screen.queryByTestId("login-page")).not.toBeInTheDocument();
});
it("should not call tracking when form is submitted with empty fields", async () => {
const user = userEvent.setup();
renderWithRouter();
const submitButton = screen.getByRole("button", { name: "ENTERPRISE$FORM_SUBMIT" });
await user.click(submitButton);
expect(mockTrackEnterpriseLeadFormSubmitted).not.toHaveBeenCalled();
});
it("should navigate to login page when form is submitted with all fields filled", async () => {
const user = userEvent.setup();
renderWithRouter();
await user.type(screen.getByTestId("form-input-name"), "John Doe");
await user.type(screen.getByTestId("form-input-company"), "Acme Inc");
await user.type(screen.getByTestId("form-input-email"), "john@example.com");
await user.type(screen.getByTestId("form-input-message"), "Hello world");
const submitButton = screen.getByRole("button", {
name: "ENTERPRISE$FORM_SUBMIT",
});
await user.click(submitButton);
// Should navigate to login page
expect(screen.getByTestId("login-page")).toBeInTheDocument();
});
it("should call tracking with form data when form is submitted successfully", async () => {
const user = userEvent.setup();
renderWithRouter({ ...defaultProps, requestType: "saas" });
await user.type(screen.getByTestId("form-input-name"), "John Doe");
await user.type(screen.getByTestId("form-input-company"), "Acme Inc");
await user.type(screen.getByTestId("form-input-email"), "john@example.com");
await user.type(screen.getByTestId("form-input-message"), "Hello world");
const submitButton = screen.getByRole("button", { name: "ENTERPRISE$FORM_SUBMIT" });
await user.click(submitButton);
expect(mockTrackEnterpriseLeadFormSubmitted).toHaveBeenCalledTimes(1);
expect(mockTrackEnterpriseLeadFormSubmitted).toHaveBeenCalledWith({
requestType: "saas",
name: "John Doe",
company: "Acme Inc",
email: "john@example.com",
message: "Hello world",
});
});
it("should call tracking with self-hosted request type", async () => {
const user = userEvent.setup();
renderWithRouter({ ...defaultProps, requestType: "self-hosted" });
await user.type(screen.getByTestId("form-input-name"), "Jane Smith");
await user.type(screen.getByTestId("form-input-company"), "Tech Corp");
await user.type(screen.getByTestId("form-input-email"), "jane@techcorp.com");
await user.type(screen.getByTestId("form-input-message"), "Interested in self-hosted");
const submitButton = screen.getByRole("button", { name: "ENTERPRISE$FORM_SUBMIT" });
await user.click(submitButton);
expect(mockTrackEnterpriseLeadFormSubmitted).toHaveBeenCalledWith({
requestType: "self-hosted",
name: "Jane Smith",
company: "Tech Corp",
email: "jane@techcorp.com",
message: "Interested in self-hosted",
});
});
it("should trim whitespace from form fields before tracking", async () => {
const user = userEvent.setup();
renderWithRouter();
await user.type(screen.getByTestId("form-input-name"), " John Doe ");
await user.type(screen.getByTestId("form-input-company"), " Acme Inc ");
await user.type(screen.getByTestId("form-input-email"), " john@example.com ");
await user.type(screen.getByTestId("form-input-message"), " Hello world ");
const submitButton = screen.getByRole("button", { name: "ENTERPRISE$FORM_SUBMIT" });
await user.click(submitButton);
expect(mockTrackEnterpriseLeadFormSubmitted).toHaveBeenCalledWith({
requestType: "saas",
name: "John Doe",
company: "Acme Inc",
email: "john@example.com",
message: "Hello world",
});
});
it("should have valid aria-invalid state when field has value", async () => {
const user = userEvent.setup();
renderWithRouter();
const nameInput = screen.getByTestId("form-input-name");
await user.type(nameInput, "John Doe");
// Field with value should not be invalid
expect(nameInput).toHaveAttribute("aria-invalid", "false");
});
it("should not navigate when email is invalid", async () => {
const user = userEvent.setup();
renderWithRouter();
await user.type(screen.getByTestId("form-input-name"), "John Doe");
await user.type(screen.getByTestId("form-input-company"), "Acme Inc");
await user.type(screen.getByTestId("form-input-email"), "invalid-email");
await user.type(screen.getByTestId("form-input-message"), "Hello world");
const submitButton = screen.getByRole("button", {
name: "ENTERPRISE$FORM_SUBMIT",
});
await user.click(submitButton);
// Should stay on form page, not navigate to login
expect(screen.getByTestId("information-request-form")).toBeInTheDocument();
expect(screen.queryByTestId("login-page")).not.toBeInTheDocument();
expect(mockTrackEnterpriseLeadFormSubmitted).not.toHaveBeenCalled();
});
});
describe("loading state", () => {
it("should prevent double submission", async () => {
const user = userEvent.setup();
renderWithRouter();
await user.type(screen.getByTestId("form-input-name"), "John Doe");
await user.type(screen.getByTestId("form-input-company"), "Acme Inc");
await user.type(screen.getByTestId("form-input-email"), "john@example.com");
await user.type(screen.getByTestId("form-input-message"), "Hello world");
const submitButton = screen.getByRole("button", {
name: "ENTERPRISE$FORM_SUBMIT",
});
// Click multiple times rapidly
await user.click(submitButton);
await user.click(submitButton);
await user.click(submitButton);
// Should only track once
expect(mockTrackEnterpriseLeadFormSubmitted).toHaveBeenCalledTimes(1);
// Should navigate to login page
expect(screen.getByTestId("login-page")).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,113 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { RequestSubmittedModal } from "#/components/features/onboarding/request-submitted-modal";
describe("RequestSubmittedModal", () => {
const defaultProps = {
onClose: vi.fn(),
};
it("should render the modal", () => {
render(<RequestSubmittedModal {...defaultProps} />);
expect(screen.getByTestId("request-submitted-modal")).toBeInTheDocument();
});
it("should render the title", () => {
render(<RequestSubmittedModal {...defaultProps} />);
expect(
screen.getByText("ENTERPRISE$REQUEST_SUBMITTED_TITLE"),
).toBeInTheDocument();
});
it("should render the description", () => {
render(<RequestSubmittedModal {...defaultProps} />);
expect(
screen.getByText("ENTERPRISE$REQUEST_SUBMITTED_DESCRIPTION"),
).toBeInTheDocument();
});
it("should render the Done button", () => {
render(<RequestSubmittedModal {...defaultProps} />);
expect(
screen.getByRole("button", { name: "ENTERPRISE$DONE_BUTTON" }),
).toBeInTheDocument();
});
it("should render the close button", () => {
render(<RequestSubmittedModal {...defaultProps} />);
expect(
screen.getByRole("button", { name: "MODAL$CLOSE_BUTTON_LABEL" }),
).toBeInTheDocument();
});
it("should call onClose when Done button is clicked", async () => {
const mockOnClose = vi.fn();
const user = userEvent.setup();
render(<RequestSubmittedModal onClose={mockOnClose} />);
const doneButton = screen.getByRole("button", {
name: "ENTERPRISE$DONE_BUTTON",
});
await user.click(doneButton);
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it("should call onClose when close button is clicked", async () => {
const mockOnClose = vi.fn();
const user = userEvent.setup();
render(<RequestSubmittedModal onClose={mockOnClose} />);
const closeButton = screen.getByRole("button", {
name: "MODAL$CLOSE_BUTTON_LABEL",
});
await user.click(closeButton);
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it("should call onClose when Escape key is pressed", async () => {
const mockOnClose = vi.fn();
const user = userEvent.setup();
render(<RequestSubmittedModal onClose={mockOnClose} />);
await user.keyboard("{Escape}");
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it("should call onClose when backdrop is clicked", async () => {
const mockOnClose = vi.fn();
const user = userEvent.setup();
render(<RequestSubmittedModal onClose={mockOnClose} />);
// Click on the backdrop (the semi-transparent overlay)
const backdrop = screen.getByRole("dialog").querySelector(".bg-black");
if (backdrop) {
await user.click(backdrop);
}
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it("should have proper accessibility attributes", () => {
render(<RequestSubmittedModal {...defaultProps} />);
const dialog = screen.getByRole("dialog");
expect(dialog).toHaveAttribute("aria-modal", "true");
expect(dialog).toHaveAttribute(
"aria-label",
"ENTERPRISE$REQUEST_SUBMITTED_TITLE",
);
});
});

View File

@@ -1,72 +0,0 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { StepInput } from "#/components/features/onboarding/step-input";
describe("StepInput", () => {
const defaultProps = {
id: "test-input",
label: "Test Label",
value: "",
onChange: vi.fn(),
};
it("should render with correct test id", () => {
render(<StepInput {...defaultProps} />);
expect(screen.getByTestId("step-input-test-input")).toBeInTheDocument();
});
it("should render the label", () => {
render(<StepInput {...defaultProps} />);
expect(screen.getByText("Test Label")).toBeInTheDocument();
});
it("should display the provided value", () => {
render(<StepInput {...defaultProps} value="Hello World" />);
const input = screen.getByTestId("step-input-test-input");
expect(input).toHaveValue("Hello World");
});
it("should call onChange when user types", async () => {
const mockOnChange = vi.fn();
const user = userEvent.setup();
render(<StepInput {...defaultProps} onChange={mockOnChange} />);
const input = screen.getByTestId("step-input-test-input");
await user.type(input, "a");
expect(mockOnChange).toHaveBeenCalledWith("a");
});
it("should call onChange with the full input value on each keystroke", async () => {
const mockOnChange = vi.fn();
const user = userEvent.setup();
render(<StepInput {...defaultProps} onChange={mockOnChange} />);
const input = screen.getByTestId("step-input-test-input");
await user.type(input, "abc");
expect(mockOnChange).toHaveBeenCalledTimes(3);
expect(mockOnChange).toHaveBeenNthCalledWith(1, "a");
expect(mockOnChange).toHaveBeenNthCalledWith(2, "b");
expect(mockOnChange).toHaveBeenNthCalledWith(3, "c");
});
it("should use the id prop for data-testid", () => {
render(<StepInput {...defaultProps} id="org_name" />);
expect(screen.getByTestId("step-input-org_name")).toBeInTheDocument();
});
it("should render as a text input", () => {
render(<StepInput {...defaultProps} />);
const input = screen.getByTestId("step-input-test-input");
expect(input).toHaveAttribute("type", "text");
});
});

View File

@@ -0,0 +1,351 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "test-utils";
import { AddCreditsModal } from "#/components/features/org/add-credits-modal";
import BillingService from "#/api/billing-service/billing-service.api";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({
t: (key: string) => key,
i18n: {
changeLanguage: vi.fn(),
},
}),
}));
describe("AddCreditsModal", () => {
const onCloseMock = vi.fn();
const renderModal = () => {
const user = userEvent.setup();
renderWithProviders(<AddCreditsModal onClose={onCloseMock} />);
return { user };
};
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("Rendering", () => {
it("should render the form with correct elements", () => {
renderModal();
expect(screen.getByTestId("add-credits-form")).toBeInTheDocument();
expect(screen.getByTestId("amount-input")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /ORG\$NEXT/i })).toBeInTheDocument();
});
it("should display the title", () => {
renderModal();
expect(screen.getByText("ORG$ADD_CREDITS")).toBeInTheDocument();
});
});
describe("Button State Management", () => {
it("should enable submit button initially when modal opens", () => {
renderModal();
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
expect(nextButton).not.toBeDisabled();
});
it("should enable submit button when input contains invalid value", async () => {
const { user } = renderModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
await user.type(amountInput, "-50");
expect(nextButton).not.toBeDisabled();
});
it("should enable submit button when input contains valid value", async () => {
const { user } = renderModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
await user.type(amountInput, "100");
expect(nextButton).not.toBeDisabled();
});
it("should enable submit button after validation error is shown", async () => {
const { user } = renderModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
await user.type(amountInput, "9");
await user.click(nextButton);
await waitFor(() => {
expect(screen.getByTestId("amount-error")).toBeInTheDocument();
});
expect(nextButton).not.toBeDisabled();
});
});
describe("Input Attributes & Placeholder", () => {
it("should have min attribute set to 10", () => {
renderModal();
const amountInput = screen.getByTestId("amount-input");
expect(amountInput).toHaveAttribute("min", "10");
});
it("should have max attribute set to 25000", () => {
renderModal();
const amountInput = screen.getByTestId("amount-input");
expect(amountInput).toHaveAttribute("max", "25000");
});
it("should have step attribute set to 1", () => {
renderModal();
const amountInput = screen.getByTestId("amount-input");
expect(amountInput).toHaveAttribute("step", "1");
});
});
describe("Error Message Display", () => {
it("should not display error message initially when modal opens", () => {
renderModal();
const errorMessage = screen.queryByTestId("amount-error");
expect(errorMessage).not.toBeInTheDocument();
});
it("should display error message after submitting amount above maximum", async () => {
const { user } = renderModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
await user.type(amountInput, "25001");
await user.click(nextButton);
await waitFor(() => {
const errorMessage = screen.getByTestId("amount-error");
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MAXIMUM_AMOUNT");
});
});
it("should display error message after submitting decimal value", async () => {
const { user } = renderModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
await user.type(amountInput, "50.5");
await user.click(nextButton);
await waitFor(() => {
const errorMessage = screen.getByTestId("amount-error");
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MUST_BE_WHOLE_NUMBER");
});
});
it("should display error message after submitting amount below minimum", async () => {
const { user } = renderModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
await user.type(amountInput, "9");
await user.click(nextButton);
await waitFor(() => {
const errorMessage = screen.getByTestId("amount-error");
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MINIMUM_AMOUNT");
});
});
it("should display error message after submitting negative amount", async () => {
const { user } = renderModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
await user.type(amountInput, "-50");
await user.click(nextButton);
await waitFor(() => {
const errorMessage = screen.getByTestId("amount-error");
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_NEGATIVE_AMOUNT");
});
});
it("should replace error message when submitting different invalid value", async () => {
const { user } = renderModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
await user.type(amountInput, "9");
await user.click(nextButton);
await waitFor(() => {
const errorMessage = screen.getByTestId("amount-error");
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MINIMUM_AMOUNT");
});
await user.clear(amountInput);
await user.type(amountInput, "25001");
await user.click(nextButton);
await waitFor(() => {
const errorMessage = screen.getByTestId("amount-error");
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MAXIMUM_AMOUNT");
});
});
});
describe("Form Submission Behavior", () => {
it("should prevent submission when amount is invalid", async () => {
const createCheckoutSessionSpy = vi.spyOn(
BillingService,
"createCheckoutSession",
);
const { user } = renderModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
await user.type(amountInput, "9");
await user.click(nextButton);
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
await waitFor(() => {
const errorMessage = screen.getByTestId("amount-error");
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MINIMUM_AMOUNT");
});
});
it("should call createCheckoutSession with correct amount when valid", async () => {
const createCheckoutSessionSpy = vi.spyOn(
BillingService,
"createCheckoutSession",
);
const { user } = renderModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
await user.type(amountInput, "1000");
await user.click(nextButton);
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(1000);
const errorMessage = screen.queryByTestId("amount-error");
expect(errorMessage).not.toBeInTheDocument();
});
it("should not call createCheckoutSession when validation fails", async () => {
const createCheckoutSessionSpy = vi.spyOn(
BillingService,
"createCheckoutSession",
);
const { user } = renderModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
await user.type(amountInput, "-50");
await user.click(nextButton);
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
await waitFor(() => {
const errorMessage = screen.getByTestId("amount-error");
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_NEGATIVE_AMOUNT");
});
});
it("should close modal on successful submission", async () => {
vi.spyOn(BillingService, "createCheckoutSession").mockResolvedValue(
"https://checkout.stripe.com/test-session",
);
const { user } = renderModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
await user.type(amountInput, "1000");
await user.click(nextButton);
await waitFor(() => {
expect(onCloseMock).toHaveBeenCalled();
});
});
it("should allow API call when validation passes and clear any previous errors", async () => {
const createCheckoutSessionSpy = vi.spyOn(
BillingService,
"createCheckoutSession",
);
const { user } = renderModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
// First submit invalid value
await user.type(amountInput, "9");
await user.click(nextButton);
await waitFor(() => {
expect(screen.getByTestId("amount-error")).toBeInTheDocument();
});
// Then submit valid value
await user.clear(amountInput);
await user.type(amountInput, "100");
await user.click(nextButton);
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(100);
const errorMessage = screen.queryByTestId("amount-error");
expect(errorMessage).not.toBeInTheDocument();
});
});
describe("Edge Cases", () => {
it("should handle zero value correctly", async () => {
const { user } = renderModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
await user.type(amountInput, "0");
await user.click(nextButton);
await waitFor(() => {
const errorMessage = screen.getByTestId("amount-error");
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MINIMUM_AMOUNT");
});
});
it("should handle whitespace-only input correctly", async () => {
const createCheckoutSessionSpy = vi.spyOn(
BillingService,
"createCheckoutSession",
);
const { user } = renderModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
// Number inputs typically don't accept spaces, but test the behavior
await user.type(amountInput, " ");
await user.click(nextButton);
// Should not call API (empty/invalid input)
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
});
});
describe("Modal Interaction", () => {
it("should call onClose when cancel button is clicked", async () => {
const { user } = renderModal();
const cancelButton = screen.getByRole("button", { name: /close/i });
await user.click(cancelButton);
expect(onCloseMock).toHaveBeenCalledOnce();
});
});
});

View File

@@ -0,0 +1,112 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import {
ClaimButton,
getButtonState,
} from "#/components/features/org/claim-button";
import type { GitOrg } from "#/hooks/organizations/use-git-conversation-routing";
const createOrg = (overrides: Partial<GitOrg> = {}): GitOrg => ({
id: "1",
provider: "GitHub",
name: "TestOrg",
status: "unclaimed",
...overrides,
});
describe("getButtonState", () => {
it("returns 'claiming' during claiming transition regardless of hover", () => {
expect(getButtonState("claiming", false)).toBe("claiming");
expect(getButtonState("claiming", true)).toBe("claiming");
});
it("returns 'disconnecting' during disconnecting transition regardless of hover", () => {
expect(getButtonState("disconnecting", false)).toBe("disconnecting");
expect(getButtonState("disconnecting", true)).toBe("disconnecting");
});
it("returns 'disconnect' when claimed and hovered", () => {
expect(getButtonState("claimed", true)).toBe("disconnect");
});
it("returns 'claimed' when claimed and not hovered", () => {
expect(getButtonState("claimed", false)).toBe("claimed");
});
it("returns 'unclaimed' when unclaimed", () => {
expect(getButtonState("unclaimed", false)).toBe("unclaimed");
expect(getButtonState("unclaimed", true)).toBe("unclaimed");
});
});
describe("ClaimButton", () => {
it("calls onClaim when clicking an unclaimed org", async () => {
// Arrange
const onClaim = vi.fn();
const org = createOrg({ status: "unclaimed" });
renderWithProviders(
<ClaimButton org={org} onClaim={onClaim} onDisconnect={vi.fn()} />,
);
const user = userEvent.setup();
// Act
await user.click(screen.getByTestId("claim-button-1"));
// Assert
expect(onClaim).toHaveBeenCalledWith("1");
});
it("calls onDisconnect when clicking a claimed org", async () => {
// Arrange
const onDisconnect = vi.fn();
const org = createOrg({ status: "claimed" });
renderWithProviders(
<ClaimButton org={org} onClaim={vi.fn()} onDisconnect={onDisconnect} />,
);
const user = userEvent.setup();
// Act
await user.click(screen.getByTestId("claim-button-1"));
// Assert
expect(onDisconnect).toHaveBeenCalledWith("1");
});
it("does not call handlers when button is disabled during claiming", async () => {
// Arrange
const onClaim = vi.fn();
const onDisconnect = vi.fn();
const org = createOrg({ status: "claiming" });
renderWithProviders(
<ClaimButton org={org} onClaim={onClaim} onDisconnect={onDisconnect} />,
);
const user = userEvent.setup();
// Act
await user.click(screen.getByTestId("claim-button-1"));
// Assert
expect(onClaim).not.toHaveBeenCalled();
expect(onDisconnect).not.toHaveBeenCalled();
expect(screen.getByTestId("claim-button-1")).toBeDisabled();
});
it("shows 'Disconnect' label on hover when claimed", async () => {
// Arrange
const org = createOrg({ status: "claimed" });
renderWithProviders(
<ClaimButton org={org} onClaim={vi.fn()} onDisconnect={vi.fn()} />,
);
const user = userEvent.setup();
// Act
await user.hover(screen.getByTestId("claim-button-1"));
// Assert
expect(screen.getByTestId("claim-button-1")).toHaveTextContent(
"ORG$DISCONNECT",
);
});
});

View File

@@ -0,0 +1,133 @@
import { screen, act, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { GitConversationRouting } from "#/components/features/org/git-conversation-routing";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
describe("GitConversationRouting", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("should render all mock organizations", () => {
// Arrange & Act
renderWithProviders(<GitConversationRouting />);
// Assert
expect(screen.getByTestId("org-row-1")).toHaveTextContent(
"GitHub/OpenHands",
);
expect(screen.getByTestId("org-row-2")).toHaveTextContent("GitHub/AcmeCo");
expect(screen.getByTestId("org-row-3")).toHaveTextContent(
"GitHub/already-claimed",
);
expect(screen.getByTestId("org-row-4")).toHaveTextContent(
"GitLab/OpenHands",
);
});
it("should show pre-claimed org with 'Claimed' label", () => {
// Arrange & Act
renderWithProviders(<GitConversationRouting />);
// Assert
const claimedButton = screen.getByTestId("claim-button-1");
expect(claimedButton).toHaveTextContent("ORG$CLAIMED");
});
it("should show unclaimed orgs with 'Claim' label", () => {
// Arrange & Act
renderWithProviders(<GitConversationRouting />);
// Assert
expect(screen.getByTestId("claim-button-2")).toHaveTextContent("ORG$CLAIM");
});
it("should claim an organization and show success toast", async () => {
// Arrange
const successToastSpy = vi.spyOn(ToastHandlers, "displaySuccessToast");
renderWithProviders(<GitConversationRouting />);
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
// Act
await user.click(screen.getByTestId("claim-button-2"));
// Move pointer away so hover state resets after transition
await user.unhover(screen.getByTestId("claim-button-2"));
act(() => {
vi.advanceTimersByTime(1000);
});
// Assert
await waitFor(() => {
expect(screen.getByTestId("claim-button-2")).toHaveTextContent(
"ORG$CLAIMED",
);
});
expect(successToastSpy).toHaveBeenCalledWith("ORG$CLAIM_SUCCESS");
});
it("should show error toast when claiming an already-claimed org", async () => {
// Arrange
const errorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
renderWithProviders(<GitConversationRouting />);
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
// Act
await user.click(screen.getByTestId("claim-button-3"));
act(() => {
vi.advanceTimersByTime(1000);
});
// Assert
await waitFor(() => {
expect(screen.getByTestId("claim-button-3")).toHaveTextContent(
"ORG$CLAIM",
);
});
expect(errorToastSpy).toHaveBeenCalledWith("ORG$CLAIM_ERROR");
});
it("should disconnect a claimed org and show success toast", async () => {
// Arrange
const successToastSpy = vi.spyOn(ToastHandlers, "displaySuccessToast");
renderWithProviders(<GitConversationRouting />);
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
// Act — disconnect the pre-claimed org (id: 1)
await user.click(screen.getByTestId("claim-button-1"));
act(() => {
vi.advanceTimersByTime(1000);
});
// Assert
await waitFor(() => {
expect(screen.getByTestId("claim-button-1")).toHaveTextContent(
"ORG$CLAIM",
);
});
expect(successToastSpy).toHaveBeenCalledWith("ORG$DISCONNECT_SUCCESS");
});
it("should disable the button during claiming transition", async () => {
// Arrange
renderWithProviders(<GitConversationRouting />);
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
// Act
await user.click(screen.getByTestId("claim-button-2"));
// Assert — button is disabled while claiming
expect(screen.getByTestId("claim-button-2")).toBeDisabled();
// Cleanup — advance timer to complete transition
act(() => {
vi.advanceTimersByTime(1000);
});
});
});

View File

@@ -0,0 +1,45 @@
import { screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { GitOrgRow } from "#/components/features/org/git-org-row";
import type { GitOrg } from "#/hooks/organizations/use-git-conversation-routing";
const createOrg = (overrides: Partial<GitOrg> = {}): GitOrg => ({
id: "1",
provider: "GitHub",
name: "TestOrg",
status: "unclaimed",
...overrides,
});
describe("GitOrgRow", () => {
it("renders the provider and organization name", () => {
// Arrange & Act
renderWithProviders(
<GitOrgRow
org={createOrg({ provider: "GitLab", name: "MyOrg" })}
isLast={false}
onClaim={vi.fn()}
onDisconnect={vi.fn()}
/>,
);
// Assert
expect(screen.getByTestId("org-row-1")).toHaveTextContent("GitLab/MyOrg");
});
it("renders a claim button for the organization", () => {
// Arrange & Act
renderWithProviders(
<GitOrgRow
org={createOrg()}
isLast={false}
onClaim={vi.fn()}
onDisconnect={vi.fn()}
/>,
);
// Assert
expect(screen.getByTestId("claim-button-1")).toBeInTheDocument();
});
});

View File

@@ -1,9 +1,11 @@
import { screen, render, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { OrgSelector } from "#/components/features/org/org-selector";
import { organizationService } from "#/api/organization-service/organization-service.api";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
import {
MOCK_PERSONAL_ORG,
MOCK_TEAM_ORG_ACME,
@@ -32,10 +34,13 @@ vi.mock("react-i18next", async () => {
return {
...actual,
useTranslation: () => ({
t: (key: string) => {
t: (key: string, params?: Record<string, string>) => {
const translations: Record<string, string> = {
"ORG$SELECT_ORGANIZATION_PLACEHOLDER": "Please select an organization",
"ORG$PERSONAL_WORKSPACE": "Personal Workspace",
"ORG$SWITCHED_TO_ORGANIZATION": `You have switched to organization: ${params?.name ?? ""}`,
"ORG$SWITCHED_TO_PERSONAL_WORKSPACE":
"You have switched to your personal workspace.",
};
return translations[key] || key;
},
@@ -56,6 +61,9 @@ const renderOrgSelector = () =>
});
describe("OrgSelector", () => {
beforeEach(() => {
useSelectedOrganizationStore.setState({ organizationId: null });
});
it("should not render when user only has a personal workspace", async () => {
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
items: [MOCK_PERSONAL_ORG],
@@ -200,4 +208,80 @@ describe("OrgSelector", () => {
expect(screen.getByTestId("dropdown-trigger")).toBeDisabled();
});
});
it("should display toast with organization name when switching to a team organization", async () => {
// Arrange
const user = userEvent.setup();
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
items: [MOCK_PERSONAL_ORG, MOCK_TEAM_ORG_ACME],
currentOrgId: MOCK_PERSONAL_ORG.id,
});
vi.spyOn(organizationService, "switchOrganization").mockResolvedValue(
MOCK_TEAM_ORG_ACME,
);
const displaySuccessToastSpy = vi.spyOn(
ToastHandlers,
"displaySuccessToast",
);
renderOrgSelector();
await waitFor(() => {
expect(screen.getByRole("combobox")).toHaveValue("Personal Workspace");
});
// Act
const trigger = screen.getByTestId("dropdown-trigger");
await user.click(trigger);
const listbox = await screen.findByRole("listbox");
const acmeOption = within(listbox).getByText("Acme Corp");
await user.click(acmeOption);
// Assert
await waitFor(() => {
expect(displaySuccessToastSpy).toHaveBeenCalledWith(
"You have switched to organization: Acme Corp",
);
});
});
it("should display toast for personal workspace when switching to personal workspace", async () => {
// Arrange
const user = userEvent.setup();
// Pre-set the store to have team org selected
useSelectedOrganizationStore.setState({
organizationId: MOCK_TEAM_ORG_ACME.id,
});
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
items: [MOCK_TEAM_ORG_ACME, MOCK_PERSONAL_ORG],
currentOrgId: MOCK_TEAM_ORG_ACME.id,
});
vi.spyOn(organizationService, "switchOrganization").mockResolvedValue(
MOCK_PERSONAL_ORG,
);
const displaySuccessToastSpy = vi.spyOn(
ToastHandlers,
"displaySuccessToast",
);
renderOrgSelector();
await waitFor(() => {
expect(screen.getByRole("combobox")).toHaveValue("Acme Corp");
});
// Act
const trigger = screen.getByTestId("dropdown-trigger");
await user.click(trigger);
const listbox = await screen.findByRole("listbox");
const personalOption = within(listbox).getByText("Personal Workspace");
await user.click(personalOption);
// Assert
await waitFor(() => {
expect(displaySuccessToastSpy).toHaveBeenCalledWith(
"You have switched to your personal workspace.",
);
});
});
});

View File

@@ -0,0 +1,25 @@
import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { OrgWideSettingsBadge } from "#/components/features/settings/org-wide-settings-badge";
describe("OrgWideSettingsBadge", () => {
it("should render the badge with translated text", () => {
// Arrange & Act
render(<OrgWideSettingsBadge />);
// Assert
const badge = screen.getByTestId("org-wide-settings-badge");
expect(badge).toBeInTheDocument();
expect(screen.getByText("SETTINGS$ORG_WIDE_SETTING_BADGE")).toBeInTheDocument();
});
it("should render the info circle icon", () => {
// Arrange & Act
render(<OrgWideSettingsBadge />);
// Assert
const badge = screen.getByTestId("org-wide-settings-badge");
const icon = badge.querySelector("svg");
expect(icon).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,23 @@
import { render } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { SettingsNavDivider } from "#/components/features/settings/settings-nav-divider";
describe("SettingsNavDivider", () => {
it("should render the divider element", () => {
// Arrange & Act
const { container } = render(<SettingsNavDivider />);
// Assert
const divider = container.firstChild;
expect(divider).toBeInTheDocument();
});
it("should accept custom className", () => {
// Arrange & Act
const { container } = render(<SettingsNavDivider className="my-4" />);
// Assert
const divider = container.firstChild;
expect(divider).toHaveClass("my-4");
});
});

View File

@@ -0,0 +1,38 @@
import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { SettingsNavHeader } from "#/components/features/settings/settings-nav-header";
import { I18nKey } from "#/i18n/declaration";
describe("SettingsNavHeader", () => {
it("should render the translated header text", () => {
// Arrange & Act
render(<SettingsNavHeader text={I18nKey.SETTINGS$ORG_SETTINGS_HEADER} />);
// Assert
expect(screen.getByText("SETTINGS$ORG_SETTINGS_HEADER")).toBeInTheDocument();
});
it("should render different header text based on prop", () => {
// Arrange & Act
render(<SettingsNavHeader text={I18nKey.SETTINGS$PERSONAL_SETTINGS_HEADER} />);
// Assert
expect(screen.getByText("SETTINGS$PERSONAL_SETTINGS_HEADER")).toBeInTheDocument();
});
it("should accept custom className", () => {
// Arrange & Act
const { container } = render(
<SettingsNavHeader
text={I18nKey.SETTINGS$ORG_SETTINGS_HEADER}
className="px-2 pt-2 pb-1"
/>,
);
// Assert
const wrapper = container.firstChild;
expect(wrapper).toHaveClass("px-2");
expect(wrapper).toHaveClass("pt-2");
expect(wrapper).toHaveClass("pb-1");
});
});

View File

@@ -0,0 +1,73 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { MemoryRouter } from "react-router";
import { SettingsNavLink } from "#/components/features/settings/settings-nav-link";
import { I18nKey } from "#/i18n/declaration";
const mockNavItem = {
to: "/settings/test",
icon: <span data-testid="test-icon">Icon</span>,
text: I18nKey.SETTINGS$NAV_API_KEYS,
};
const renderSettingsNavLink = (
item = mockNavItem,
onClick = vi.fn(),
initialPath = "/",
) =>
render(
<MemoryRouter initialEntries={[initialPath]}>
<SettingsNavLink item={item} onClick={onClick} />
</MemoryRouter>,
);
describe("SettingsNavLink", () => {
it("should render the link with icon and text", () => {
// Arrange & Act
renderSettingsNavLink();
// Assert
expect(screen.getByRole("link")).toBeInTheDocument();
expect(screen.getByTestId("test-icon")).toBeInTheDocument();
expect(screen.getByText("SETTINGS$NAV_API_KEYS")).toBeInTheDocument();
});
it("should navigate to the correct route", () => {
// Arrange & Act
renderSettingsNavLink();
// Assert
const link = screen.getByRole("link");
expect(link).toHaveAttribute("href", "/settings/test");
});
it("should call onClick when clicked", async () => {
// Arrange
const user = userEvent.setup();
const onClick = vi.fn();
renderSettingsNavLink(mockNavItem, onClick);
// Act
await user.click(screen.getByRole("link"));
// Assert
expect(onClick).toHaveBeenCalledTimes(1);
});
it("should render different text based on item prop", () => {
// Arrange
const customItem = {
to: "/settings/secrets",
icon: <span>Icon</span>,
text: I18nKey.SETTINGS$NAV_SECRETS,
};
// Act
renderSettingsNavLink(customItem);
// Assert
expect(screen.getByText("SETTINGS$NAV_SECRETS")).toBeInTheDocument();
expect(screen.getByRole("link")).toHaveAttribute("href", "/settings/secrets");
});
});

View File

@@ -6,6 +6,7 @@ import { SettingsNavigation } from "#/components/features/settings/settings-navi
import OptionService from "#/api/option-service/option-service.api";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
import { SAAS_NAV_ITEMS, SettingsNavItem } from "#/constants/settings-nav";
import { SettingsNavRenderedItem } from "#/hooks/use-settings-nav-items";
vi.mock("react-router", async () => ({
...(await vi.importActual("react-router")),
@@ -18,13 +19,17 @@ const mockConfig = () => {
} as Awaited<ReturnType<typeof OptionService.getConfig>>);
};
// Convert SettingsNavItem[] to SettingsNavRenderedItem[]
const toRenderedItems = (items: SettingsNavItem[]): SettingsNavRenderedItem[] =>
items.map((item) => ({ type: "item", item }));
const ITEMS_WITHOUT_ORG = SAAS_NAV_ITEMS.filter(
(item) =>
item.to !== "/settings/org" && item.to !== "/settings/org-members",
);
const renderSettingsNavigation = (
items: SettingsNavItem[] = SAAS_NAV_ITEMS,
items: SettingsNavRenderedItem[] = toRenderedItems(SAAS_NAV_ITEMS),
) => {
const queryClient = new QueryClient({
defaultOptions: {
@@ -56,31 +61,31 @@ describe("SettingsNavigation", () => {
describe("renders navigation items passed via props", () => {
it("should render org routes when included in navigation items", async () => {
renderSettingsNavigation(SAAS_NAV_ITEMS);
renderSettingsNavigation(toRenderedItems(SAAS_NAV_ITEMS));
await screen.findByTestId("settings-navbar");
const orgMembersLink = await screen.findByText("Organization Members");
const orgLink = await screen.findByText("Organization");
const orgMembersLink = await screen.findByText("SETTINGS$NAV_ORG_MEMBERS");
const orgLink = await screen.findByText("SETTINGS$NAV_ORGANIZATION");
expect(orgMembersLink).toBeInTheDocument();
expect(orgLink).toBeInTheDocument();
});
it("should not render org routes when excluded from navigation items", async () => {
renderSettingsNavigation(ITEMS_WITHOUT_ORG);
renderSettingsNavigation(toRenderedItems(ITEMS_WITHOUT_ORG));
await screen.findByTestId("settings-navbar");
const orgMembersLink = screen.queryByText("Organization Members");
const orgLink = screen.queryByText("Organization");
const orgMembersLink = screen.queryByText("SETTINGS$NAV_ORG_MEMBERS");
const orgLink = screen.queryByText("SETTINGS$NAV_ORGANIZATION");
expect(orgMembersLink).not.toBeInTheDocument();
expect(orgLink).not.toBeInTheDocument();
});
it("should render all non-org SAAS items regardless of which items are passed", async () => {
renderSettingsNavigation(SAAS_NAV_ITEMS);
renderSettingsNavigation(toRenderedItems(SAAS_NAV_ITEMS));
await screen.findByTestId("settings-navbar");
@@ -99,11 +104,65 @@ describe("SettingsNavigation", () => {
await screen.findByTestId("settings-navbar");
// No nav links should be rendered
const orgMembersLink = screen.queryByText("Organization Members");
const orgLink = screen.queryByText("Organization");
const orgMembersLink = screen.queryByText("SETTINGS$NAV_ORG_MEMBERS");
const orgLink = screen.queryByText("SETTINGS$NAV_ORGANIZATION");
expect(orgMembersLink).not.toBeInTheDocument();
expect(orgLink).not.toBeInTheDocument();
});
});
describe("renders section headers and dividers", () => {
it("should render section headers when included in navigation items", async () => {
// Arrange
const itemsWithHeader: SettingsNavRenderedItem[] = [
{ type: "header", text: "SETTINGS$ORG_SETTINGS_HEADER" as any },
...toRenderedItems(SAAS_NAV_ITEMS.slice(0, 2)),
];
// Act
renderSettingsNavigation(itemsWithHeader);
await screen.findByTestId("settings-navbar");
// Assert
expect(screen.getByText("SETTINGS$ORG_SETTINGS_HEADER")).toBeInTheDocument();
});
it("should render dividers when included in navigation items", async () => {
// Arrange
const itemsWithDivider: SettingsNavRenderedItem[] = [
...toRenderedItems(SAAS_NAV_ITEMS.slice(0, 2)),
{ type: "divider" },
...toRenderedItems(SAAS_NAV_ITEMS.slice(2, 4)),
];
// Act
renderSettingsNavigation(itemsWithDivider);
await screen.findByTestId("settings-navbar");
// Assert - divider is a div with border-t class
const navbar = screen.getByTestId("settings-navbar");
const dividers = navbar.querySelectorAll(".border-t");
expect(dividers.length).toBeGreaterThan(0);
});
it("should render multiple headers and dividers in correct order", async () => {
// Arrange
const itemsWithHeadersAndDividers: SettingsNavRenderedItem[] = [
{ type: "header", text: "SETTINGS$ORG_SETTINGS_HEADER" as any },
...toRenderedItems(SAAS_NAV_ITEMS.slice(0, 1)),
{ type: "divider" },
{ type: "header", text: "SETTINGS$PERSONAL_SETTINGS_HEADER" as any },
...toRenderedItems(SAAS_NAV_ITEMS.slice(1, 2)),
];
// Act
renderSettingsNavigation(itemsWithHeadersAndDividers);
await screen.findByTestId("settings-navbar");
// Assert
expect(screen.getByText("SETTINGS$ORG_SETTINGS_HEADER")).toBeInTheDocument();
expect(screen.getByText("SETTINGS$PERSONAL_SETTINGS_HEADER")).toBeInTheDocument();
});
});
});

View File

@@ -385,22 +385,58 @@ describe("UserContextMenu", () => {
});
});
it("should render additional context items when user is an admin", () => {
it("should render additional context items when user is an admin", async () => {
// Mock SaaS mode and a team org so org management items are visible
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
createMockWebClientConfig({ app_mode: "saas" }),
);
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
items: [MOCK_TEAM_ORG_ACME],
currentOrgId: MOCK_TEAM_ORG_ACME.id,
});
useSelectedOrganizationStore.setState({
organizationId: MOCK_TEAM_ORG_ACME.id,
});
vi.spyOn(organizationService, "getMe").mockResolvedValue(
createMockUser({ role: "admin", org_id: MOCK_TEAM_ORG_ACME.id }),
);
renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn });
screen.getByTestId("org-selector");
screen.getByText("ORG$INVITE_ORG_MEMBERS");
screen.getByText("ORG$ORGANIZATION_MEMBERS");
screen.getByText("COMMON$ORGANIZATION");
// Wait for orgs to load so org management items appear
await waitFor(() => {
expect(screen.getByText("ORG$INVITE_ORG_MEMBERS")).toBeInTheDocument();
});
// Note: Organization and Org Members links may or may not appear depending on
// permission checks in useSettingsNavItems. The key test is that Invite button appears.
});
it("should render additional context items when user is an owner", () => {
it("should render additional context items when user is an owner", async () => {
// Mock SaaS mode and a team org so org management items are visible
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
createMockWebClientConfig({ app_mode: "saas" }),
);
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
items: [MOCK_TEAM_ORG_ACME],
currentOrgId: MOCK_TEAM_ORG_ACME.id,
});
useSelectedOrganizationStore.setState({
organizationId: MOCK_TEAM_ORG_ACME.id,
});
vi.spyOn(organizationService, "getMe").mockResolvedValue(
createMockUser({ role: "owner", org_id: MOCK_TEAM_ORG_ACME.id }),
);
renderUserContextMenu({ type: "owner", onClose: vi.fn, onOpenInviteModal: vi.fn });
screen.getByTestId("org-selector");
screen.getByText("ORG$INVITE_ORG_MEMBERS");
screen.getByText("ORG$ORGANIZATION_MEMBERS");
screen.getByText("COMMON$ORGANIZATION");
// Wait for orgs to load so org management items appear
await waitFor(() => {
expect(screen.getByText("ORG$INVITE_ORG_MEMBERS")).toBeInTheDocument();
});
// Note: Organization and Org Members links may or may not appear depending on
// permission checks in useSettingsNavItems. The key test is that Invite button appears.
});
it("should call the logout handler when Logout is clicked", async () => {
@@ -461,42 +497,61 @@ describe("UserContextMenu", () => {
});
});
it("should navigate to /settings/org-members when Manage Organization Members is clicked", async () => {
// Mock a team org so org management buttons are visible (not personal org)
it("should have correct link for Organization Members nav item when visible", async () => {
// Mock SaaS mode and a team org so org management items are visible
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
createMockWebClientConfig({ app_mode: "saas" }),
);
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
items: [MOCK_TEAM_ORG_ACME],
currentOrgId: MOCK_TEAM_ORG_ACME.id,
});
useSelectedOrganizationStore.setState({
organizationId: MOCK_TEAM_ORG_ACME.id,
});
vi.spyOn(organizationService, "getMe").mockResolvedValue(
createMockUser({ role: "admin", org_id: MOCK_TEAM_ORG_ACME.id }),
);
renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn });
// Wait for orgs to load so org management buttons are visible
const manageOrganizationMembersButton = await screen.findByText(
"ORG$ORGANIZATION_MEMBERS",
);
await userEvent.click(manageOrganizationMembersButton);
expect(navigateMock).toHaveBeenCalledExactlyOnceWith(
"/settings/org-members",
);
// Wait for nav items to load. The Org Members link may appear if permissions are met.
await waitFor(() => {
const orgMembersLink = screen.queryByText("SETTINGS$NAV_ORG_MEMBERS");
if (orgMembersLink) {
expect(orgMembersLink.closest("a")).toHaveAttribute(
"href",
"/settings/org-members",
);
}
});
});
it("should navigate to /settings/org when Manage Account is clicked", async () => {
// Mock a team org so org management buttons are visible (not personal org)
it("should have correct link for Organization nav item when visible", async () => {
// Mock SaaS mode and a team org so org management items are visible
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
createMockWebClientConfig({ app_mode: "saas" }),
);
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
items: [MOCK_TEAM_ORG_ACME],
currentOrgId: MOCK_TEAM_ORG_ACME.id,
});
useSelectedOrganizationStore.setState({
organizationId: MOCK_TEAM_ORG_ACME.id,
});
vi.spyOn(organizationService, "getMe").mockResolvedValue(
createMockUser({ role: "admin", org_id: MOCK_TEAM_ORG_ACME.id }),
);
renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn });
// Wait for orgs to load so org management buttons are visible
const manageAccountButton = await screen.findByText(
"COMMON$ORGANIZATION",
);
await userEvent.click(manageAccountButton);
expect(navigateMock).toHaveBeenCalledExactlyOnceWith("/settings/org");
// Wait for nav items to load. The Organization link may appear if permissions are met.
await waitFor(() => {
const orgLink = screen.queryByText("SETTINGS$NAV_ORGANIZATION");
if (orgLink) {
expect(orgLink.closest("a")).toHaveAttribute("href", "/settings/org");
}
});
});
it("should call the onClose handler when clicking outside the context menu", async () => {
@@ -519,11 +574,12 @@ describe("UserContextMenu", () => {
createMockWebClientConfig({ app_mode: "saas" }),
);
// Mock a team org so org management buttons are visible
// Mock a team org so org management items are visible
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
items: [MOCK_TEAM_ORG_ACME],
currentOrgId: MOCK_TEAM_ORG_ACME.id,
});
seedActiveUser({ role: "owner" });
const onCloseMock = vi.fn();
renderUserContextMenu({ type: "owner", onClose: onCloseMock, onOpenInviteModal: vi.fn });
@@ -533,15 +589,15 @@ describe("UserContextMenu", () => {
await userEvent.click(logoutButton);
expect(onCloseMock).toHaveBeenCalledTimes(1);
// Wait for orgs to load so org management buttons are visible
const manageOrganizationMembersButton = await screen.findByText(
"ORG$ORGANIZATION_MEMBERS",
);
await userEvent.click(manageOrganizationMembersButton);
// Wait for orgs to load so org management items are visible
// Click on Organization Members link (now it's a Link, not a button)
const orgMembersLink = await screen.findByText("SETTINGS$NAV_ORG_MEMBERS");
await userEvent.click(orgMembersLink);
expect(onCloseMock).toHaveBeenCalledTimes(2);
const manageAccountButton = screen.getByText("COMMON$ORGANIZATION");
await userEvent.click(manageAccountButton);
// Click on Organization link
const orgLink = screen.getByText("SETTINGS$NAV_ORGANIZATION");
await userEvent.click(orgLink);
expect(onCloseMock).toHaveBeenCalledTimes(3);
});
@@ -613,11 +669,17 @@ describe("UserContextMenu", () => {
});
it("should call onOpenInviteModal and onClose when Invite Organization Member is clicked", async () => {
// Mock a team org so org management buttons are visible (not personal org)
// Mock a team org so org management items are visible (not personal org)
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
items: [MOCK_TEAM_ORG_ACME],
currentOrgId: MOCK_TEAM_ORG_ACME.id,
});
useSelectedOrganizationStore.setState({
organizationId: MOCK_TEAM_ORG_ACME.id,
});
vi.spyOn(organizationService, "getMe").mockResolvedValue(
createMockUser({ role: "admin", org_id: MOCK_TEAM_ORG_ACME.id }),
);
const onCloseMock = vi.fn();
const onOpenInviteModalMock = vi.fn();
@@ -627,7 +689,7 @@ describe("UserContextMenu", () => {
onOpenInviteModal: onOpenInviteModalMock,
});
// Wait for orgs to load so org management buttons are visible
// Wait for orgs to load so org management items are visible
const inviteButton = await screen.findByText("ORG$INVITE_ORG_MEMBERS");
await userEvent.click(inviteButton);

View File

@@ -1,13 +1,16 @@
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, fireEvent, act } from "@testing-library/react";
import { describe, expect, it, vi, afterEach, beforeEach, test } from "vitest";
import userEvent from "@testing-library/user-event";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { MemoryRouter } from "react-router";
import { MemoryRouter, createRoutesStub } from "react-router";
import { ReactElement } from "react";
import { http, HttpResponse } from "msw";
import { UserActions } from "#/components/features/sidebar/user-actions";
import { organizationService } from "#/api/organization-service/organization-service.api";
import { MOCK_PERSONAL_ORG, MOCK_TEAM_ORG_ACME } from "#/mocks/org-handlers";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
import { server } from "#/mocks/node";
import { createMockWebClientConfig } from "#/mocks/settings-handlers";
import { renderWithProviders } from "../../test-utils";
vi.mock("react-router", async (importActual) => ({
@@ -59,6 +62,20 @@ const renderUserActions = (props = { hasAvatar: true }) => {
);
};
// RouterStub and render helper for menu close delay tests
const RouterStubForMenuCloseDelay = createRoutesStub([
{
path: "/",
Component: () => (
<UserActions user={{ avatar_url: "https://example.com/avatar.png" }} />
),
},
]);
const renderUserActionsForMenuCloseDelay = () => {
return renderWithProviders(<RouterStubForMenuCloseDelay initialEntries={["/"]} />);
};
// Create mocks for all the hooks we need
const useIsAuthedMock = vi
.fn()
@@ -118,36 +135,6 @@ describe("UserActions", () => {
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
});
it("should NOT show context menu when user is not authenticated and avatar is clicked", async () => {
// Set isAuthed to false for this test
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
// Keep other mocks with default values
useConfigMock.mockReturnValue({
data: { app_mode: "saas" },
isLoading: false,
});
useUserProvidersMock.mockReturnValue({
providers: [{ id: "github", name: "GitHub" }],
});
renderUserActions();
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
// Context menu should NOT appear because user is not authenticated
expect(screen.queryByTestId("user-context-menu")).not.toBeInTheDocument();
});
it("should NOT show context menu when user is undefined and avatar is hovered", async () => {
renderUserActions({ hasAvatar: false });
const userActions = screen.getByTestId("user-actions");
await user.hover(userActions);
// Context menu should NOT appear because user is undefined
expect(screen.queryByTestId("user-context-menu")).not.toBeInTheDocument();
});
it("should show context menu even when user has no avatar_url", async () => {
renderUserActions();
const userActions = screen.getByTestId("user-actions");
@@ -157,128 +144,6 @@ describe("UserActions", () => {
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
});
it("should NOT be able to access logout when user is not authenticated", async () => {
// Set isAuthed to false for this test
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
// Keep other mocks with default values
useConfigMock.mockReturnValue({
data: { app_mode: "saas" },
isLoading: false,
});
useUserProvidersMock.mockReturnValue({
providers: [{ id: "github", name: "GitHub" }],
});
renderWithRouter(<UserActions />);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
// Context menu should NOT appear because user is not authenticated
expect(screen.queryByTestId("user-context-menu")).not.toBeInTheDocument();
// Logout option should NOT be accessible when user is not authenticated
expect(
screen.queryByText("ACCOUNT_SETTINGS$LOGOUT"),
).not.toBeInTheDocument();
});
it("should handle user prop changing from undefined to defined", async () => {
// Start with no authentication
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
// Keep other mocks with default values
useConfigMock.mockReturnValue({
data: { app_mode: "saas" },
isLoading: false,
});
useUserProvidersMock.mockReturnValue({
providers: [{ id: "github", name: "GitHub" }],
});
const { unmount } = renderWithRouter(<UserActions />);
// Initially no user and not authenticated - menu should not appear
const userActions = screen.getByTestId("user-actions");
await user.hover(userActions);
expect(screen.queryByTestId("user-context-menu")).not.toBeInTheDocument();
// Unmount the first component
unmount();
// Set authentication to true for the new render
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
// Ensure config and providers are set correctly
useConfigMock.mockReturnValue({
data: { app_mode: "saas" },
isLoading: false,
});
useUserProvidersMock.mockReturnValue({
providers: [{ id: "github", name: "GitHub" }],
});
// Render a new component with user prop and authentication
renderWithRouter(
<UserActions user={{ avatar_url: "https://example.com/avatar.png" }} />,
);
// Component should render correctly
expect(screen.getByTestId("user-actions")).toBeInTheDocument();
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
// Menu should now work with user defined and authenticated
const userActionsEl = screen.getByTestId("user-actions");
await user.hover(userActionsEl);
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
});
it("should handle user prop changing from defined to undefined", async () => {
// Start with authentication and providers
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
useConfigMock.mockReturnValue({
data: { app_mode: "saas" },
isLoading: false,
});
useUserProvidersMock.mockReturnValue({
providers: [{ id: "github", name: "GitHub" }],
});
const { rerender } = renderWithRouter(
<UserActions user={{ avatar_url: "https://example.com/avatar.png" }} />,
);
// Hover to open menu
const userActions = screen.getByTestId("user-actions");
await user.hover(userActions);
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
// Set authentication to false for the rerender
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
// Keep other mocks with default values
useConfigMock.mockReturnValue({
data: { app_mode: "saas" },
isLoading: false,
});
useUserProvidersMock.mockReturnValue({
providers: [{ id: "github", name: "GitHub" }],
});
// Remove user prop - menu should disappear because user is no longer authenticated
rerender(
<MemoryRouter>
<UserActions />
</MemoryRouter>,
);
// Context menu should NOT be visible when user becomes unauthenticated
expect(screen.queryByTestId("user-context-menu")).not.toBeInTheDocument();
// Logout option should not be accessible
expect(
screen.queryByText("ACCOUNT_SETTINGS$LOGOUT"),
).not.toBeInTheDocument();
});
it("should work with loading state and user provided", async () => {
// Ensure authentication and providers are set correctly
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
@@ -347,7 +212,7 @@ describe("UserActions", () => {
expect(contextMenu).toBeVisible();
});
it("should have pointer-events-none on hover bridge pseudo-element to allow menu item clicks", async () => {
it("should use state-based visibility for hover behavior instead of CSS pseudo-element", async () => {
renderUserActions();
const userActions = screen.getByTestId("user-actions");
@@ -356,19 +221,17 @@ describe("UserActions", () => {
const contextMenu = screen.getByTestId("user-context-menu");
const hoverBridgeContainer = contextMenu.parentElement;
// The hover bridge uses a ::before pseudo-element for diagonal mouse movement
// This pseudo-element MUST have pointer-events-none to allow clicks through to menu items
// The class should include "before:pointer-events-none" to prevent the hover bridge from blocking clicks
expect(hoverBridgeContainer?.className).toContain(
"before:pointer-events-none",
);
// The component uses state-based visibility with a 500ms delay for diagonal mouse movement
// When visible, the container should have opacity-100 and pointer-events-auto
expect(hoverBridgeContainer?.className).toContain("opacity-100");
expect(hoverBridgeContainer?.className).toContain("pointer-events-auto");
});
describe("Org selector dropdown state reset when context menu hides", () => {
// These tests verify that the org selector dropdown resets its internal
// state (search text, open/closed) when the context menu hides and
// reappears. Without this, stale state persists because the context
// menu is hidden via CSS (opacity/pointer-events) rather than unmounted.
// reappears. The component uses a 500ms delay before hiding (to support
// diagonal mouse movement).
beforeEach(() => {
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
@@ -400,8 +263,22 @@ describe("UserActions", () => {
await user.type(input, "search text");
expect(input).toHaveValue("search text");
// Unhover to hide context menu, then hover again
// Unhover to trigger hide timeout, then wait for the 500ms delay to complete
await user.unhover(userActions);
// Wait for the 500ms hide delay to complete and menu to actually hide
await waitFor(
() => {
// The menu resets when it actually hides (after 500ms delay)
// After hiding, hovering again should show a fresh menu
},
{ timeout: 600 },
);
// Wait a bit more for the timeout to fire
await new Promise((resolve) => setTimeout(resolve, 550));
// Now hover again to show the menu
await user.hover(userActions);
// Org selector should be reset — showing selected org name, not search text
@@ -434,8 +311,13 @@ describe("UserActions", () => {
await user.type(input, "Acme");
expect(input).toHaveValue("Acme");
// Unhover to hide context menu, then hover again
// Unhover to trigger hide timeout
await user.unhover(userActions);
// Wait for the 500ms hide delay to complete
await new Promise((resolve) => setTimeout(resolve, 550));
// Now hover again to show the menu
await user.hover(userActions);
// Wait for fresh component with org data
@@ -454,4 +336,83 @@ describe("UserActions", () => {
expect(screen.queryAllByRole("option")).toHaveLength(0);
});
});
describe("menu close delay", () => {
beforeEach(() => {
vi.useFakeTimers();
useSelectedOrganizationStore.setState({ organizationId: "1" });
// Mock config to return SaaS mode so useShouldShowUserFeatures returns true
server.use(
http.get("/api/v1/web-client/config", () =>
HttpResponse.json(createMockWebClientConfig({ app_mode: "saas" })),
),
);
});
afterEach(() => {
vi.useRealTimers();
server.resetHandlers();
});
it("should keep menu visible when mouse leaves and re-enters within 500ms", async () => {
// Arrange - render and wait for queries to settle
renderUserActionsForMenuCloseDelay();
await act(async () => {
await vi.runAllTimersAsync();
});
const userActions = screen.getByTestId("user-actions");
// Act - open menu
await act(async () => {
fireEvent.mouseEnter(userActions);
});
// Assert - menu is visible
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
// Act - leave and re-enter within 500ms
await act(async () => {
fireEvent.mouseLeave(userActions);
await vi.advanceTimersByTimeAsync(200);
fireEvent.mouseEnter(userActions);
});
// Assert - menu should still be visible after waiting (pending close was cancelled)
await act(async () => {
await vi.advanceTimersByTimeAsync(500);
});
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
});
it("should not close menu before 500ms delay when mouse leaves", async () => {
// Arrange - render and wait for queries to settle
renderUserActionsForMenuCloseDelay();
await act(async () => {
await vi.runAllTimersAsync();
});
const userActions = screen.getByTestId("user-actions");
// Act - open menu
await act(async () => {
fireEvent.mouseEnter(userActions);
});
// Assert - menu is visible
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
// Act - leave without re-entering, but check before timeout expires
await act(async () => {
fireEvent.mouseLeave(userActions);
await vi.advanceTimersByTimeAsync(400); // Before the 500ms delay
});
// Assert - menu should still be visible (delay hasn't expired yet)
// Note: The menu is always in DOM but with opacity-0 when closed.
// This test verifies the state hasn't changed yet (delay is working).
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
});
});
});

Some files were not shown because too many files have changed in this diff Show More