Compare commits

..

68 Commits

Author SHA1 Message Date
openhands
d4f7f07d5d test: add comprehensive tests for v1-git-service query parameter changes
- Add tests verifying query parameters are used instead of path segments
- Add tests for preserving slashes in paths (main fix purpose)
- Add tests for session API key headers
- Add tests for V1 to V0 status mapping
- Add tests for getGitChangeDiff endpoint

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-02 02:59:27 +00:00
chuckbutkus
a34dc949ce Merge branch 'main' into fix/git-api-use-query-params 2026-03-01 21:39:52 -05:00
Shruti1128
d6b8d80026 Remove unused subscription-related frontend code (#12557) 2026-03-01 21:14:00 +01:00
Hiep Le
1e6a92b454 feat(backend): organizations llm settings api (org project) (#13108) 2026-03-02 00:06:37 +07:00
Hiep Le
b4a3e5db2f feat(backend): saas – organizations app settings api (#13022) 2026-03-01 23:26:39 +07:00
openhands
80e4fe1226 fix: use query parameters for V1 git API endpoints to preserve path slashes
Update V1GitService to pass path as a query parameter instead of embedding
it in the URL path segment. This fixes URL path normalization issues with
Traefik/Gateway API where encoded slashes (%2F) in path segments would be
decoded and normalized, causing leading slashes to be lost.

For example, /workspace/project was arriving as workspace/project.

Using query parameters (e.g., ?path=/workspace/project) avoids this issue
as they are passed through without path normalization.

Requires corresponding backend change in software-agent-sdk.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-01 05:09:30 +00:00
Chris Bagwell
f9d553d0bb Pass container port instead of host port to Docker (#12595)
Co-authored-by: Engel Nyst <engel.nyst@gmail.com>
2026-02-28 17:45:16 +01:00
Tim O'Farrell
f6f6c1ab25 refactor: use SQL filtering and pagination in VerifiedModelStore (#13068)
Co-authored-by: bittoby <brianwhitedev1996@gmail.com>
Co-authored-by: statxc <statxc@user.noreply.github.com>
Co-authored-by: bittoby <bittoby@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-28 07:37:11 -07:00
Hiep Le
c511a89426 feat(frontend): display Bitbucket signup disabled message on login page (#13100) 2026-02-28 19:26:16 +07:00
HeyItsChloe
1f82ff04d9 feat(frontend): SaaS NUE profile questions /Onboarding flow (#13029)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-02-28 13:27:22 +07:00
HeyItsChloe
eec17311c7 fix(frontend): bitbucket icon color (#13106)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-28 12:12:45 +07:00
Nelson Spence
c34fdf4b37 fix(security): extend action type coverage in security check (#12870)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Engel Nyst <engel.nyst@gmail.com>
2026-02-28 05:44:29 +01:00
Engel Nyst
25076ee44c chore: mark security/ and resolver/ as Legacy V0 (#13062)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-28 04:22:47 +01:00
aivong-openhands
baaec8473a Fix CVE-2024-23342: Replace python-jose with jwcrypto (#13012)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-02-27 22:15:23 +00:00
dependabot[bot]
402fa47422 chore(deps-dev): bump minimatch from 3.1.2 to 3.1.5 in /frontend in the security-all group across 1 directory (#13098)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-27 14:15:41 -06:00
dependabot[bot]
8dde385843 chore(deps): bump pypdf from 6.7.2 to 6.7.3 (#13099)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-27 13:32:02 -06:00
aivong-openhands
a905e35531 Fix CVE-2026-25990: Update pillow to 12.1.1 (#13024)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-02-27 12:37:25 -06:00
dependabot[bot]
1f185173b7 chore(deps): bump pypdf to 6.7.2 (#13072)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: aivong-openhands <ai.vong@openhands.dev>
2026-02-27 08:35:25 -06:00
dependabot[bot]
ddc7a78723 chore(deps): bump rollup from 4.57.0 to 4.59.0 in /frontend in the security-all group across 1 directory (#13078)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-27 08:29:19 -06:00
Dream
a29ed4d926 feat(frontend): display Agent Skills and Commands in slash menu (#12982)
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-02-27 20:46:38 +07:00
Hiep Le
b8ab4bb44e feat(backend): allow owners to edit owners and admins to edit admins (org project) (#13095) 2026-02-27 16:01:05 +07:00
Hiep Le
ddd544f8d6 feat(backend): saas users app settings api (#13021) 2026-02-27 13:01:03 +07:00
mamoodi
3804b66e32 Remove blank issue creation (#13090) 2026-02-26 16:56:13 -05:00
John-Mason P. Shackelford
b97adf392a docs: Add plugin launch flow design document (#13084)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <engel.nyst@gmail.com>
2026-02-26 14:03:40 -05:00
Saurya Velagapudi
dcb584913a Fix CVE-2026-26007: Update cryptography to 46.0.5 (#13059)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-02-26 09:45:54 -06:00
dependabot[bot]
d2fd54a083 chore(deps): bump the security-all group across 1 directory with 2 updates (#13069)
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-02-26 09:39:44 -06:00
Tim O'Farrell
112d863287 refactor: Remove unused Google Sheets integration from UserVerifier (#13076)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-26 08:07:38 -07:00
Hiep Le
c8680caec3 fix: db migration (#13074) 2026-02-26 21:20:08 +07:00
sp.wack
d4b9fb1d03 fix(backend): user email capture (#12902)
Co-authored-by: OpenHands Bot <contact@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-26 17:29:30 +04:00
Tim O'Farrell
409df1287d Fix api key access (#13064)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-26 06:22:23 -07:00
BitToby
a92bfe6cc0 feat: add database-backed verified models for dynamic model managemen… (#12833)
Co-authored-by: statxc <statxc@user.noreply.github.com>
Co-authored-by: bittoby <bittoby@users.noreply.github.com>
2026-02-26 06:17:18 -07:00
Hiep Le
f93e3254d3 refactor(frontend): remove feature flag (planning agent) (#12880) 2026-02-26 18:44:26 +07:00
Tim O'Farrell
0476d57451 fix: properly extract redirect URL from OAuth state in keycloak_offline_callback (#13063)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-26 03:23:40 -07:00
Hiep Le
a4cd21e155 refactor(frontend): hide microagent management ui (#13060) 2026-02-26 17:13:37 +07:00
aivong-openhands
7f3af371d1 protobuf pypdf uv lock updates (#13045) 2026-02-25 14:19:16 -06:00
aivong-openhands
1421794c1b Fix CVE-2026-26007: Update cryptography to 46.0.5 (#13009)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-02-25 14:18:16 -06:00
aivong-openhands
2fc689457c Fix CVE-2026-24486: Update python-multipart to 0.0.22 (#13015)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-02-25 10:15:58 -06:00
Tim O'Farrell
3161b365a8 Add sandbox_id field to conversation endpoints (#13044)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-25 14:29:42 +00:00
aivong-openhands
18ab56ef4e Fix CVE-2026-23490: Update pyasn1 to 0.6.2 (#13013)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-02-25 08:06:41 -06:00
Tim O'Farrell
a9c0df778c Make logs quieter. (#13042) 2026-02-25 05:11:13 -07:00
MkDev11
51b989b5f8 feat: Allow attaching/changing repository for existing conversations (#12671)
Co-authored-by: mkdev11 <MkDev11@users.noreply.github.com>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-02-25 18:09:12 +07:00
aivong-openhands
dc039d81d6 Fix CVE-2026-27199: Update werkzeug to 3.1.6 (#13028)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-02-24 18:26:28 -06:00
aivong-openhands
8e4559b14a Fix CVE-2025-61765: Update python-socketio to 5.14.0 (#13027)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-02-24 18:15:04 -06:00
aivong-openhands
b84f352b63 Fix CVE-2026-0994: Update protobuf to 5.29.6 (#13011)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-02-24 17:25:40 -06:00
aivong-openhands
a0dba6124a Fix CVE-2026-27026: Update pypdf to at least 6.7.1 (#13025)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-02-24 15:25:06 -06:00
aivong-openhands
951739f3eb Fix CVE-2025-53000: Update nbconvert to 7.17.0 (#13010)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-02-24 14:46:31 -06:00
aivong-openhands
0f1ad46a47 Fix CVE-2025-62727: Update starlette to 0.49.1 (#13016)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
Co-authored-by: Ray Myers <ray.myers@gmail.com>
2026-02-24 10:55:32 -06:00
sp.wack
5367bef43a fix: detect team/org-level budget errors in error banner (#13003) 2026-02-24 20:55:11 +04:00
Tim O'Farrell
3afeccfe7f fix: prevent token refresh deadlock with double-checked locking and timeouts (#13020)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-24 08:13:57 -07:00
Tim O'Farrell
0677c035ff Optimize get_sandbox_by_session_api_key with hash lookup (#13019)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-24 13:55:21 +00:00
Hiep Le
68165b52d9 feat(backend): add pagination and email filtering for organization members (#12999) 2026-02-24 16:02:24 +07:00
Dream
dcc8217317 feat(frontend): add mutateWithToast utility for standardized mutation toast handling (#12433)
Co-authored-by: OpenHands Bot <contact@all-hands.dev>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-02-24 15:06:44 +07:00
jpelletier1
d1410949ff Experiment - Add 'Add Team Members' button to Avatar menu in SaaS mode (#12647)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-23 23:06:57 +04:00
Tim O'Farrell
a6c0d80fe1 Fix: Logout on 401 error in useGitUser; downgrade provider error to warning (#12935)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-23 10:15:54 -07:00
Tim O'Farrell
0efb1db85d Bumped SDK to 1.11.5 (#13002) 2026-02-23 09:31:31 -07:00
Hiep Le
8e0f74c92c fix(backend): ensure members are removed from the corresponding litellm team when removed from an organization (#12996) 2026-02-23 18:45:31 +07:00
Hiep Le
6e1ba3d836 fix(backend): update current_org_id when removing a member from an organization (#12995) 2026-02-23 18:21:37 +07:00
Hiep Le
0ec97893d1 fix(backend): unable to delete an organization after inviting at least one member (#12993) 2026-02-23 18:21:10 +07:00
Tim O'Farrell
ddb809bc43 Add webhook endpoint authentication bypass and admin context unfiltered data access (#12956)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-23 09:28:49 +00:00
Alona
872f2b87f2 fix: add retry logic with exponential backoff to send_welcome_email (#12450)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2026-02-20 20:42:00 +00:00
Graham Neubig
ee86005a3a Align PR review workflow with software-agent-sdk (#12963)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-20 21:02:32 +01:00
Graham Neubig
d4aa30580b Migrate PR review workflow to use extensions action (#12917)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-20 18:44:02 +00:00
Tim O'Farrell
2f0e879129 Fix session_maker to accept kwargs for backward compatibility (#12960)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-20 10:56:44 -07:00
sp.wack
3bc2ef954e fix(backend): config values (#12944) 2026-02-20 17:53:35 +04:00
Ray Myers
32ab2a24c6 Remove enterprise-preview job and workflow (#12350)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-20 03:36:14 +01:00
Engel Nyst
a6e148d1e6 refactor: use consolidated pr-review action (#12801)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-20 02:08:42 +01:00
Manrique Vargas
3fc977eddd fix(mcp): skip conversation link when conversation_id is None (#12941)
Signed-off-by: machov <mv1742@nyu.edu>
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2026-02-19 21:41:26 +00:00
John-Mason P. Shackelford
89a6890269 Fix URL encoding in Jira OAuth authorization URLs (#12399)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2026-02-19 21:40:29 +00:00
212 changed files with 15061 additions and 1984 deletions

2
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,2 @@
# disable blank issue creation
blank_issues_enabled: false

View File

@@ -1,29 +0,0 @@
# Feature branch preview for enterprise code
name: Enterprise Preview
# Run on PRs labeled
on:
pull_request:
types: [labeled]
# Match ghcr-build.yml, but don't interrupt it.
concurrency:
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
cancel-in-progress: false
jobs:
# This must happen for the PR Docker workflow when the label is present,
# and also if it's added after the fact. Thus, it exists in both places.
enterprise-preview:
name: Enterprise preview
if: github.event.label.name == 'deploy'
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
# This should match the version in ghcr-build.yml
- name: Trigger remote job
run: |
curl --fail-with-body -sS -X POST \
-H "Authorization: Bearer ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}" \
-H "Accept: application/vnd.github+json" \
-d "{\"ref\": \"main\", \"inputs\": {\"openhandsPrNumber\": \"${{ github.event.pull_request.number }}\", \"deployEnvironment\": \"feature\", \"enterpriseImageTag\": \"pr-${{ github.event.pull_request.number }}\" }}" \
https://api.github.com/repos/OpenHands/deploy/actions/workflows/deploy.yaml/dispatches

View File

@@ -240,21 +240,6 @@ jobs:
# Add build attestations for better security
sbom: true
enterprise-preview:
name: Enterprise preview
if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'deploy')
runs-on: blacksmith-4vcpu-ubuntu-2204
needs: [ghcr_build_enterprise]
steps:
# This should match the version in enterprise-preview.yml
- name: Trigger remote job
run: |
curl --fail-with-body -sS -X POST \
-H "Authorization: Bearer ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}" \
-H "Accept: application/vnd.github+json" \
-d "{\"ref\": \"main\", \"inputs\": {\"openhandsPrNumber\": \"${{ github.event.pull_request.number }}\", \"deployEnvironment\": \"feature\", \"enterpriseImageTag\": \"pr-${{ github.event.pull_request.number }}\" }}" \
https://api.github.com/repos/OpenHands/deploy/actions/workflows/deploy.yaml/dispatches
# "All Runtime Tests Passed" is a required job for PRs to merge
# We can remove this once the config changes
runtime_tests_check_success:

View File

@@ -2,16 +2,11 @@
name: PR Review by OpenHands
on:
# Use pull_request_target to allow fork PRs to access secrets when triggered by maintainers
# Security: This workflow runs when:
# 1. A new PR is opened (non-draft), OR
# 2. A draft PR is marked as ready for review, OR
# 3. A maintainer adds the 'review-this' label, OR
# 4. A maintainer requests openhands-agent or all-hands-bot as a reviewer
# Only users with write access can add labels or request reviews, ensuring security.
# The PR code is explicitly checked out for review, but secrets are only accessible
# because the workflow runs in the base repository context
pull_request_target:
# TEMPORARY MITIGATION (Clinejection hardening)
#
# We temporarily avoid `pull_request_target` here. We'll restore it after the PR review
# workflow is fully hardened for untrusted execution.
pull_request:
types: [opened, ready_for_review, labeled, review_requested]
permissions:
@@ -21,107 +16,33 @@ permissions:
jobs:
pr-review:
# Run when one of the following conditions is met:
# 1. A new non-draft PR is opened by a trusted contributor, OR
# 2. A draft PR is converted to ready for review by a trusted contributor, OR
# 3. 'review-this' label is added, OR
# 4. openhands-agent or all-hands-bot is requested as a reviewer
# Note: FIRST_TIME_CONTRIBUTOR PRs require manual trigger via label/reviewer request
# Note: fork PRs will not have access to repository secrets under `pull_request`.
# Skip forks to avoid noisy failures until we restore a hardened `pull_request_target` flow.
if: |
(github.event.action == 'opened' && github.event.pull_request.draft == false && github.event.pull_request.author_association != 'FIRST_TIME_CONTRIBUTOR') ||
(github.event.action == 'ready_for_review' && github.event.pull_request.author_association != 'FIRST_TIME_CONTRIBUTOR') ||
github.event.label.name == 'review-this' ||
github.event.requested_reviewer.login == 'openhands-agent' ||
github.event.requested_reviewer.login == 'all-hands-bot'
github.event.pull_request.head.repo.full_name == github.repository &&
(
(github.event.action == 'opened' && github.event.pull_request.draft == false) ||
github.event.action == 'ready_for_review' ||
(github.event.action == 'labeled' && github.event.label.name == 'review-this') ||
(
github.event.action == 'review_requested' &&
(
github.event.requested_reviewer.login == 'openhands-agent' ||
github.event.requested_reviewer.login == 'all-hands-bot'
)
)
)
concurrency:
group: pr-review-${{ github.event.pull_request.number }}
cancel-in-progress: true
runs-on: blacksmith-4vcpu-ubuntu-2404
env:
LLM_MODEL: litellm_proxy/claude-sonnet-4-5-20250929
LLM_BASE_URL: https://llm-proxy.app.all-hands.dev
# PR context will be automatically provided by the agent script
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
PR_BASE_BRANCH: ${{ github.event.pull_request.base.ref }}
PR_HEAD_BRANCH: ${{ github.event.pull_request.head.ref }}
REPO_NAME: ${{ github.repository }}
runs-on: ubuntu-24.04
steps:
- name: Checkout software-agent-sdk repository
uses: actions/checkout@v5
- name: Run PR Review
uses: OpenHands/extensions/plugins/pr-review@main
with:
repository: OpenHands/software-agent-sdk
path: software-agent-sdk
- name: Checkout PR repository
uses: actions/checkout@v5
with:
# When using pull_request_target, explicitly checkout the PR branch
# This ensures we review the actual PR code (including fork PRs)
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.ref }}
fetch-depth: 0
# Security: Don't persist credentials to prevent untrusted PR code from using them
persist-credentials: false
path: pr-repo
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.13'
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Install GitHub CLI
run: |
# Install GitHub CLI for posting review comments
sudo apt-get update
sudo apt-get install -y gh
- name: Install OpenHands dependencies
run: |
# Install OpenHands SDK and tools from local checkout
uv pip install --system ./software-agent-sdk/openhands-sdk ./software-agent-sdk/openhands-tools
- name: Check required configuration
env:
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
run: |
if [ -z "$LLM_API_KEY" ]; then
echo "Error: LLM_API_KEY secret is not set."
exit 1
fi
echo "PR Number: $PR_NUMBER"
echo "PR Title: $PR_TITLE"
echo "Repository: $REPO_NAME"
echo "LLM model: $LLM_MODEL"
if [ -n "$LLM_BASE_URL" ]; then
echo "LLM base URL: $LLM_BASE_URL"
fi
- name: Run PR review
env:
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
GITHUB_TOKEN: ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}
LMNR_PROJECT_API_KEY: ${{ secrets.LMNR_SKILLS_API_KEY }}
run: |
# Change to the PR repository directory so agent can analyze the code
cd pr-repo
# Run the PR review script from the software-agent-sdk checkout
uv run python ../software-agent-sdk/examples/03_github_workflows/02_pr_review/agent_script.py
- name: Upload logs as artifact
uses: actions/upload-artifact@v5
if: always()
with:
name: openhands-pr-review-logs
path: |
*.log
output/
retention-days: 7
llm-model: litellm_proxy/claude-sonnet-4-5-20250929
llm-base-url: https://llm-proxy.app.all-hands.dev
review-style: roasted
llm-api-key: ${{ secrets.LLM_API_KEY }}
github-token: ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}
lmnr-api-key: ${{ secrets.LMNR_SKILLS_API_KEY }}

View File

@@ -0,0 +1,85 @@
---
name: PR Review Evaluation
# This workflow evaluates how well PR review comments were addressed.
# It runs when a PR is closed to assess review effectiveness.
#
# Security note: pull_request_target is safe here because:
# 1. Only triggers on PR close (not on code changes)
# 2. Does not checkout PR code - only downloads artifacts from trusted workflow runs
# 3. Runs evaluation scripts from the extensions repo, not from the PR
on:
pull_request_target:
types: [closed]
permissions:
contents: read
pull-requests: read
jobs:
evaluate:
runs-on: ubuntu-24.04
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO_NAME: ${{ github.repository }}
PR_MERGED: ${{ github.event.pull_request.merged }}
steps:
- name: Download review trace artifact
id: download-trace
uses: dawidd6/action-download-artifact@v6
continue-on-error: true
with:
workflow: pr-review-by-openhands.yml
name: pr-review-trace-${{ github.event.pull_request.number }}
path: trace-info
search_artifacts: true
if_no_artifact_found: warn
- name: Check if trace file exists
id: check-trace
run: |
if [ -f "trace-info/laminar_trace_info.json" ]; then
echo "trace_exists=true" >> $GITHUB_OUTPUT
echo "Found trace file for PR #$PR_NUMBER"
else
echo "trace_exists=false" >> $GITHUB_OUTPUT
echo "No trace file found for PR #$PR_NUMBER - skipping evaluation"
fi
# Always checkout main branch for security - cannot test script changes in PRs
- name: Checkout extensions repository
if: steps.check-trace.outputs.trace_exists == 'true'
uses: actions/checkout@v5
with:
repository: OpenHands/extensions
path: extensions
- name: Set up Python
if: steps.check-trace.outputs.trace_exists == 'true'
uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Install dependencies
if: steps.check-trace.outputs.trace_exists == 'true'
run: pip install lmnr
- name: Run evaluation
if: steps.check-trace.outputs.trace_exists == 'true'
env:
# Script expects LMNR_PROJECT_API_KEY; org secret is named LMNR_SKILLS_API_KEY
LMNR_PROJECT_API_KEY: ${{ secrets.LMNR_SKILLS_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
python extensions/plugins/pr-review/scripts/evaluate_review.py \
--trace-file trace-info/laminar_trace_info.json
- name: Upload evaluation logs
uses: actions/upload-artifact@v5
if: always() && steps.check-trace.outputs.trace_exists == 'true'
with:
name: pr-review-evaluation-${{ github.event.pull_request.number }}
path: '*.log'
retention-days: 30

View File

@@ -0,0 +1,131 @@
# Plugin Launch Flow
This document describes how plugins are launched in OpenHands Saas / Enterprise, from the plugin directory through to agent execution.
## Architecture Overview
```
Plugin Directory ──▶ Frontend /launch ──▶ App Server ──▶ Agent Server ──▶ SDK
(external) (modal) (API) (in sandbox) (plugin loading)
```
| Component | Responsibility |
|-----------|---------------|
| **Plugin Directory** | Index plugins, present to user, construct launch URLs |
| **Frontend** | Display confirmation modal, collect parameters, call API |
| **App Server** | Validate request, pass plugin specs to agent server |
| **Agent Server** | Run inside sandbox, delegate plugin loading to SDK |
| **SDK** | Fetch plugins, load contents, merge skills/hooks/MCP into agent |
## User Experience
### Plugin Directory
The plugin directory presents users with a catalog of available plugins. For each plugin, users see:
- Plugin name and description (from `plugin.json`)
- Author and version information
- A "Launch" button
When a user clicks "Launch", the plugin directory:
1. Reads the plugin's `entry_command` to know which slash command to invoke
2. Determines what parameters the plugin accepts (if any)
3. Redirects to OpenHands with this information encoded in the URL
### Parameter Collection
If a plugin requires user input (API keys, configuration values, etc.), the frontend displays a form modal before starting the conversation. Parameters are passed in the launch URL and rendered as form fields based on their type:
- **String values** → Text input
- **Number values** → Number input
- **Boolean values** → Checkbox
Only primitive types are supported. Complex types (arrays, objects) are not currently supported for parameter input.
The user fills in required values, then clicks "Start Conversation" to proceed.
## Launch Flow
1. **Plugin Directory** (external) constructs a launch URL to the OpenHands app server when user clicks "Launch":
```
/launch?plugins=BASE64_JSON&message=/city-weather:now%20Tokyo
```
The `plugins` parameter includes any parameter definitions with default values:
```json
[{
"source": "github:owner/repo",
"repo_path": "plugins/my-plugin",
"parameters": {"api_key": "", "timeout": 30, "debug": false}
}]
```
2. **OpenHands Frontend** (`/launch` route, [PR #12699](https://github.com/OpenHands/OpenHands/pull/12699)) displays modal with parameter form, collects user input
3. **OpenHands App Server** ([PR #12338](https://github.com/OpenHands/OpenHands/pull/12338)) receives the API call:
```
POST /api/v1/app-conversations
{
"plugins": [{"source": "github:owner/repo", "repo_path": "plugins/city-weather"}],
"initial_message": {"content": [{"type": "text", "text": "/city-weather:now Tokyo"}]}
}
```
Call stack:
- `AppConversationRouter` receives request with `PluginSpec` list
- `LiveStatusAppConversationService._finalize_conversation_request()` converts `PluginSpec` → `PluginSource`
- Creates `StartConversationRequest(plugins=sdk_plugins, ...)` and sends to agent server
4. **Agent Server** (inside sandbox, [SDK PR #1651](https://github.com/OpenHands/software-agent-sdk/pull/1651)) stores specs, defers loading:
Call stack:
- `ConversationService.start_conversation()` receives `StartConversationRequest`
- Creates `StoredConversation` with plugin specs
- Creates `LocalConversation(plugins=request.plugins, ...)`
- Plugin loading deferred until first `run()` or `send_message()`
5. **SDK** fetches and loads plugins on first use:
Call stack:
- `LocalConversation._ensure_plugins_loaded()` triggered by first message
- For each plugin spec:
- `Plugin.fetch(source, ref, repo_path)` → clones/caches git repo
- `Plugin.load(path)` → parses `plugin.json`, loads commands/skills/hooks
- `plugin.add_skills_to(context)` → merges skills into agent
- `plugin.add_mcp_config_to(config)` → merges MCP servers
6. **Agent** receives message, `/city-weather:now` triggers the skill
## Key Design Decisions
### Plugin Loading in Sandbox
Plugins load **inside the sandbox** because:
- Plugin hooks and scripts need isolated execution
- MCP servers run inside the sandbox
- Skills may reference sandbox filesystem
### Entry Command Handling
The `entry_command` field in `plugin.json` allows plugin authors to declare a default command:
```json
{
"name": "city-weather",
"entry_command": "now"
}
```
This flows through the system:
1. Plugin author declares `entry_command` in plugin.json
2. Plugin directory reads it when indexing
3. Plugin directory includes `/city-weather:now` in the launch URL's `message` parameter
4. Message passes through to agent as `initial_message`
The SDK exposes this field but does not auto-invoke it—callers control the initial message.
## Related
- [OpenHands PR #12338](https://github.com/OpenHands/OpenHands/pull/12338) - App server plugin support
- [OpenHands PR #12699](https://github.com/OpenHands/OpenHands/pull/12699) - Frontend `/launch` route
- [SDK PR #1651](https://github.com/OpenHands/software-agent-sdk/pull/1651) - Agent server plugin loading
- [SDK PR #1647](https://github.com/OpenHands/software-agent-sdk/pull/1647) - Plugin.fetch() for remote plugin fetching

View File

@@ -0,0 +1,41 @@
"""Add session_api_key_hash to v1_remote_sandbox table
Revision ID: 097
Revises: 096
Create Date: 2025-02-24 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '097'
down_revision: Union[str, None] = '096'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Add session_api_key_hash column to v1_remote_sandbox table."""
op.add_column(
'v1_remote_sandbox',
sa.Column('session_api_key_hash', sa.String(), nullable=True),
)
op.create_index(
op.f('ix_v1_remote_sandbox_session_api_key_hash'),
'v1_remote_sandbox',
['session_api_key_hash'],
unique=False,
)
def downgrade() -> None:
"""Remove session_api_key_hash column from v1_remote_sandbox table."""
op.drop_index(
op.f('ix_v1_remote_sandbox_session_api_key_hash'),
table_name='v1_remote_sandbox',
)
op.drop_column('v1_remote_sandbox', 'session_api_key_hash')

View File

@@ -0,0 +1,92 @@
"""Create verified_models table.
Revision ID: 098
Revises: 097
Create Date: 2026-02-26 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '098'
down_revision: Union[str, None] = '097'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create verified_models table and seed with current model list."""
op.create_table(
'verified_models',
sa.Column('id', sa.Integer, sa.Identity(), primary_key=True),
sa.Column('model_name', sa.String(255), nullable=False),
sa.Column('provider', sa.String(100), nullable=False),
sa.Column(
'is_enabled',
sa.Boolean(),
nullable=False,
server_default=sa.text('true'),
),
sa.Column(
'created_at',
sa.DateTime(),
nullable=False,
server_default=sa.text('CURRENT_TIMESTAMP'),
),
sa.Column(
'updated_at',
sa.DateTime(),
nullable=False,
server_default=sa.text('CURRENT_TIMESTAMP'),
),
sa.UniqueConstraint(
'model_name', 'provider', name='uq_verified_model_provider'
),
)
op.create_index(
'ix_verified_models_provider',
'verified_models',
['provider'],
)
op.create_index(
'ix_verified_models_is_enabled',
'verified_models',
['is_enabled'],
)
# Seed with current openhands provider models
models = [
('claude-opus-4-5-20251101', 'openhands'),
('claude-sonnet-4-5-20250929', 'openhands'),
('gpt-5.2-codex', 'openhands'),
('gpt-5.2', 'openhands'),
('minimax-m2.5', 'openhands'),
('gemini-3-pro-preview', 'openhands'),
('gemini-3-flash-preview', 'openhands'),
('deepseek-chat', 'openhands'),
('devstral-medium-2512', 'openhands'),
('kimi-k2-0711-preview', 'openhands'),
('qwen3-coder-480b', 'openhands'),
]
for model_name, provider in models:
op.execute(
sa.text(
"""
INSERT INTO verified_models (model_name, provider)
VALUES (:model_name, :provider)
"""
).bindparams(model_name=model_name, provider=provider)
)
def downgrade() -> None:
"""Drop verified_models table."""
op.drop_index('ix_verified_models_is_enabled', table_name='verified_models')
op.drop_index('ix_verified_models_provider', table_name='verified_models')
op.drop_table('verified_models')

198
enterprise/poetry.lock generated
View File

@@ -1540,66 +1540,58 @@ files = [
[[package]]
name = "cryptography"
version = "46.0.3"
version = "46.0.5"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
optional = false
python-versions = "!=3.9.0,!=3.9.1,>=3.8"
groups = ["main"]
files = [
{file = "cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a"},
{file = "cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc"},
{file = "cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d"},
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb"},
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849"},
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8"},
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec"},
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91"},
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e"},
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926"},
{file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71"},
{file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac"},
{file = "cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018"},
{file = "cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb"},
{file = "cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c"},
{file = "cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217"},
{file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5"},
{file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715"},
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54"},
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459"},
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422"},
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7"},
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044"},
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665"},
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3"},
{file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20"},
{file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de"},
{file = "cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914"},
{file = "cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db"},
{file = "cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21"},
{file = "cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936"},
{file = "cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683"},
{file = "cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d"},
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0"},
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc"},
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3"},
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971"},
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac"},
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04"},
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506"},
{file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963"},
{file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4"},
{file = "cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df"},
{file = "cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f"},
{file = "cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372"},
{file = "cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32"},
{file = "cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c"},
{file = "cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea"},
{file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b"},
{file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb"},
{file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717"},
{file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9"},
{file = "cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c"},
{file = "cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1"},
{file = "cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad"},
{file = "cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b"},
{file = "cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b"},
{file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263"},
{file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d"},
{file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed"},
{file = "cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2"},
{file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2"},
{file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0"},
{file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731"},
{file = "cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82"},
{file = "cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1"},
{file = "cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48"},
{file = "cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4"},
{file = "cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2"},
{file = "cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678"},
{file = "cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87"},
{file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee"},
{file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981"},
{file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9"},
{file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648"},
{file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4"},
{file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0"},
{file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663"},
{file = "cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826"},
{file = "cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d"},
{file = "cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a"},
{file = "cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4"},
{file = "cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31"},
{file = "cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18"},
{file = "cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235"},
{file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a"},
{file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76"},
{file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614"},
{file = "cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229"},
{file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1"},
{file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d"},
{file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c"},
{file = "cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4"},
{file = "cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9"},
{file = "cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72"},
{file = "cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595"},
{file = "cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c"},
{file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a"},
{file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356"},
{file = "cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d"},
]
[package.dependencies]
@@ -1612,7 +1604,7 @@ nox = ["nox[uv] (>=2024.4.15)"]
pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"]
sdist = ["build (>=1.0.0)"]
ssh = ["bcrypt (>=3.1.5)"]
test = ["certifi (>=2024)", "cryptography-vectors (==46.0.3)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
test = ["certifi (>=2024)", "cryptography-vectors (==46.0.5)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
test-randomorder = ["pytest-randomly"]
[[package]]
@@ -5754,14 +5746,14 @@ test = ["flaky", "ipykernel (>=6.19.3)", "ipython", "ipywidgets", "nbconvert (>=
[[package]]
name = "nbconvert"
version = "7.16.6"
description = "Converting Jupyter Notebooks (.ipynb files) to other formats. Output formats include asciidoc, html, latex, markdown, pdf, py, rst, script. nbconvert can be used both as a Python library (`import nbconvert`) or as a command line tool (invoked as `jupyter nbconvert ...`)."
version = "7.17.0"
description = "Convert Jupyter Notebooks (.ipynb files) to other formats."
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "nbconvert-7.16.6-py3-none-any.whl", hash = "sha256:1375a7b67e0c2883678c48e506dc320febb57685e5ee67faa51b18a90f3a712b"},
{file = "nbconvert-7.16.6.tar.gz", hash = "sha256:576a7e37c6480da7b8465eefa66c17844243816ce1ccc372633c6b71c3c0f582"},
{file = "nbconvert-7.17.0-py3-none-any.whl", hash = "sha256:4f99a63b337b9a23504347afdab24a11faa7d86b405e5c8f9881cd313336d518"},
{file = "nbconvert-7.17.0.tar.gz", hash = "sha256:1b2696f1b5be12309f6c7d707c24af604b87dfaf6d950794c7b07acab96dda78"},
]
[package.dependencies]
@@ -5781,8 +5773,8 @@ pygments = ">=2.4.1"
traitlets = ">=5.1"
[package.extras]
all = ["flaky", "ipykernel", "ipython", "ipywidgets (>=7.5)", "myst-parser", "nbsphinx (>=0.2.12)", "playwright", "pydata-sphinx-theme", "pyqtwebengine (>=5.15)", "pytest (>=7)", "sphinx (==5.0.2)", "sphinxcontrib-spelling", "tornado (>=6.1)"]
docs = ["ipykernel", "ipython", "myst-parser", "nbsphinx (>=0.2.12)", "pydata-sphinx-theme", "sphinx (==5.0.2)", "sphinxcontrib-spelling"]
all = ["flaky", "intersphinx-registry", "ipykernel", "ipython", "ipywidgets (>=7.5)", "myst-parser", "nbsphinx (>=0.2.12)", "playwright", "pydata-sphinx-theme", "pyqtwebengine (>=5.15)", "pytest (>=7)", "sphinx (>=5.0.2)", "sphinxcontrib-spelling", "tornado (>=6.1)"]
docs = ["intersphinx-registry", "ipykernel", "ipython", "myst-parser", "nbsphinx (>=0.2.12)", "pydata-sphinx-theme", "sphinx (>=5.0.2)", "sphinxcontrib-spelling"]
qtpdf = ["pyqtwebengine (>=5.15)"]
qtpng = ["pyqtwebengine (>=5.15)"]
serve = ["tornado (>=6.1)"]
@@ -6102,14 +6094,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.11.4"
version = "1.11.5"
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.11.4-py3-none-any.whl", hash = "sha256:739bdb774dbfcd23d6e87ee6ee32bc0999f22300037506b6dd33e9ea67fa5c2a"},
{file = "openhands_agent_server-1.11.4.tar.gz", hash = "sha256:41247f7022a046eb50ca3b552bc6d12bfa9776e1bd27d0989da91b9f7ac77ca2"},
{file = "openhands_agent_server-1.11.5-py3-none-any.whl", hash = "sha256:8bae7063f232791d58a5c31919f58b557f7cce60e6295773985c7dadc556cb9e"},
{file = "openhands_agent_server-1.11.5.tar.gz", hash = "sha256:b61366d727c61ab9b7fcd66faab53f230f8ef0928c1177a388d2c5c4be6ebbd0"},
]
[package.dependencies]
@@ -6126,7 +6118,7 @@ wsproto = ">=1.2.0"
[[package]]
name = "openhands-ai"
version = "1.3.0"
version = "1.4.0"
description = "OpenHands: Code Less, Make More"
optional = false
python-versions = "^3.12,<3.14"
@@ -6168,9 +6160,9 @@ memory-profiler = ">=0.61"
numpy = "*"
openai = "2.8"
openhands-aci = "0.3.2"
openhands-agent-server = "1.11.4"
openhands-sdk = "1.11.4"
openhands-tools = "1.11.4"
openhands-agent-server = "1.11.5"
openhands-sdk = "1.11.5"
openhands-tools = "1.11.5"
opentelemetry-api = ">=1.33.1"
opentelemetry-exporter-otlp-proto-grpc = ">=1.33.1"
pathspec = ">=0.12.1"
@@ -6194,7 +6186,7 @@ python-jose = {version = ">=3.3", extras = ["cryptography"]}
python-json-logger = ">=3.2.1"
python-multipart = "*"
python-pptx = "*"
python-socketio = "5.13"
python-socketio = "5.14"
pythonnet = "*"
pyyaml = ">=6.0.2"
qtconsole = ">=5.6.1"
@@ -6225,14 +6217,14 @@ url = ".."
[[package]]
name = "openhands-sdk"
version = "1.11.4"
version = "1.11.5"
description = "OpenHands SDK - Core functionality for building AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_sdk-1.11.4-py3-none-any.whl", hash = "sha256:9f4607c5d94b56fbcd533207026ee892779dd50e29bce79277ff82454a4f76d5"},
{file = "openhands_sdk-1.11.4.tar.gz", hash = "sha256:4088744f6b8856eeab22d3bc17e47d1736ea7ced945c2fa126bd7d48c14bb313"},
{file = "openhands_sdk-1.11.5-py3-none-any.whl", hash = "sha256:f949cd540cbecc339d90fb0cca2a5f29e1b62566b82b5aee82ef40f259d14e60"},
{file = "openhands_sdk-1.11.5.tar.gz", hash = "sha256:dd6225876b7b8dbb6c608559f2718c3d0bf44d0bb741e990b185c6cdc5150c5a"},
]
[package.dependencies]
@@ -6253,14 +6245,14 @@ boto3 = ["boto3 (>=1.35.0)"]
[[package]]
name = "openhands-tools"
version = "1.11.4"
version = "1.11.5"
description = "OpenHands Tools - Runtime tools for AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_tools-1.11.4-py3-none-any.whl", hash = "sha256:efd721b73e87a0dac69171a76931363fa59fcde98107ca86081ee7bf0253673a"},
{file = "openhands_tools-1.11.4.tar.gz", hash = "sha256:80671b1ea8c85a5247a75ea2340ae31d76363e9c723b104699a9a77e66d2043c"},
{file = "openhands_tools-1.11.5-py3-none-any.whl", hash = "sha256:1e981e1e7f3544184fe946cee8eb6bd287010cdef77d83ebac945c9f42df3baf"},
{file = "openhands_tools-1.11.5.tar.gz", hash = "sha256:d7b1163f6505a51b07147e7d8972062c129ecc46571a71f28d5470355e06650e"},
]
[package.dependencies]
@@ -7323,23 +7315,23 @@ testing = ["google-api-core (>=1.31.5)"]
[[package]]
name = "protobuf"
version = "5.29.5"
version = "5.29.6"
description = ""
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "protobuf-5.29.5-cp310-abi3-win32.whl", hash = "sha256:3f1c6468a2cfd102ff4703976138844f78ebd1fb45f49011afc5139e9e283079"},
{file = "protobuf-5.29.5-cp310-abi3-win_amd64.whl", hash = "sha256:3f76e3a3675b4a4d867b52e4a5f5b78a2ef9565549d4037e06cf7b0942b1d3fc"},
{file = "protobuf-5.29.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e38c5add5a311f2a6eb0340716ef9b039c1dfa428b28f25a7838ac329204a671"},
{file = "protobuf-5.29.5-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:fa18533a299d7ab6c55a238bf8629311439995f2e7eca5caaff08663606e9015"},
{file = "protobuf-5.29.5-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:63848923da3325e1bf7e9003d680ce6e14b07e55d0473253a690c3a8b8fd6e61"},
{file = "protobuf-5.29.5-cp38-cp38-win32.whl", hash = "sha256:ef91363ad4faba7b25d844ef1ada59ff1604184c0bcd8b39b8a6bef15e1af238"},
{file = "protobuf-5.29.5-cp38-cp38-win_amd64.whl", hash = "sha256:7318608d56b6402d2ea7704ff1e1e4597bee46d760e7e4dd42a3d45e24b87f2e"},
{file = "protobuf-5.29.5-cp39-cp39-win32.whl", hash = "sha256:6f642dc9a61782fa72b90878af134c5afe1917c89a568cd3476d758d3c3a0736"},
{file = "protobuf-5.29.5-cp39-cp39-win_amd64.whl", hash = "sha256:470f3af547ef17847a28e1f47200a1cbf0ba3ff57b7de50d22776607cd2ea353"},
{file = "protobuf-5.29.5-py3-none-any.whl", hash = "sha256:6cf42630262c59b2d8de33954443d94b746c952b01434fc58a417fdbd2e84bd5"},
{file = "protobuf-5.29.5.tar.gz", hash = "sha256:bc1463bafd4b0929216c35f437a8e28731a2b7fe3d98bb77a600efced5a15c84"},
{file = "protobuf-5.29.6-cp310-abi3-win32.whl", hash = "sha256:62e8a3114992c7c647bce37dcc93647575fc52d50e48de30c6fcb28a6a291eb1"},
{file = "protobuf-5.29.6-cp310-abi3-win_amd64.whl", hash = "sha256:7e6ad413275be172f67fdee0f43484b6de5a904cc1c3ea9804cb6fe2ff366eda"},
{file = "protobuf-5.29.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:b5a169e664b4057183a34bdc424540e86eea47560f3c123a0d64de4e137f9269"},
{file = "protobuf-5.29.6-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:a8866b2cff111f0f863c1b3b9e7572dc7eaea23a7fae27f6fc613304046483e6"},
{file = "protobuf-5.29.6-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:e3387f44798ac1106af0233c04fb8abf543772ff241169946f698b3a9a3d3ab9"},
{file = "protobuf-5.29.6-cp38-cp38-win32.whl", hash = "sha256:36ade6ff88212e91aef4e687a971a11d7d24d6948a66751abc1b3238648f5d05"},
{file = "protobuf-5.29.6-cp38-cp38-win_amd64.whl", hash = "sha256:831e2da16b6cc9d8f1654c041dd594eda43391affd3c03a91bea7f7f6da106d6"},
{file = "protobuf-5.29.6-cp39-cp39-win32.whl", hash = "sha256:cb4c86de9cd8a7f3a256b9744220d87b847371c6b2f10bde87768918ef33ba49"},
{file = "protobuf-5.29.6-cp39-cp39-win_amd64.whl", hash = "sha256:76e07e6567f8baf827137e8d5b8204b6c7b6488bbbff1bf0a72b383f77999c18"},
{file = "protobuf-5.29.6-py3-none-any.whl", hash = "sha256:6b9edb641441b2da9fa8f428760fc136a49cf97a52076010cf22a2ff73438a86"},
{file = "protobuf-5.29.6.tar.gz", hash = "sha256:da9ee6a5424b6b30fd5e45c5ea663aef540ca95f9ad99d1e887e819cdf9b8723"},
]
[[package]]
@@ -7562,14 +7554,14 @@ typing-extensions = ">=4.15.0"
[[package]]
name = "pyasn1"
version = "0.6.1"
version = "0.6.2"
description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"},
{file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"},
{file = "pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf"},
{file = "pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b"},
]
[[package]]
@@ -11578,20 +11570,20 @@ diagrams = ["jinja2", "railroad-diagrams"]
[[package]]
name = "pypdf"
version = "6.6.0"
version = "6.7.3"
description = "A pure-python PDF library capable of splitting, merging, cropping, and transforming PDF files"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "pypdf-6.6.0-py3-none-any.whl", hash = "sha256:bca9091ef6de36c7b1a81e09327c554b7ce51e88dad68f5890c2b4a4417f1fd7"},
{file = "pypdf-6.6.0.tar.gz", hash = "sha256:4c887ef2ea38d86faded61141995a3c7d068c9d6ae8477be7ae5de8a8e16592f"},
{file = "pypdf-6.7.3-py3-none-any.whl", hash = "sha256:cd25ac508f20b554a9fafd825186e3ba29591a69b78c156783c5d8a2d63a1c0a"},
{file = "pypdf-6.7.3.tar.gz", hash = "sha256:eca55c78d0ec7baa06f9288e2be5c4e8242d5cbb62c7a4b94f2716f8e50076d2"},
]
[package.extras]
crypto = ["cryptography"]
cryptodome = ["PyCryptodome"]
dev = ["black", "flit", "pip-tools", "pre-commit", "pytest-cov", "pytest-socket", "pytest-timeout", "pytest-xdist", "wheel"]
dev = ["flit", "pip-tools", "pre-commit", "pytest-cov", "pytest-socket", "pytest-timeout", "pytest-xdist", "wheel"]
docs = ["myst_parser", "sphinx", "sphinx_rtd_theme"]
full = ["Pillow (>=8.0.0)", "cryptography"]
image = ["Pillow (>=8.0.0)"]
@@ -11886,14 +11878,14 @@ requests-toolbelt = ">=0.6.0"
[[package]]
name = "python-multipart"
version = "0.0.21"
version = "0.0.22"
description = "A streaming multipart parser for Python"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "python_multipart-0.0.21-py3-none-any.whl", hash = "sha256:cf7a6713e01c87aa35387f4774e812c4361150938d20d232800f75ffcf266090"},
{file = "python_multipart-0.0.21.tar.gz", hash = "sha256:7137ebd4d3bbf70ea1622998f902b97a29434a9e8dc40eb203bbcf7c2a2cba92"},
{file = "python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155"},
{file = "python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58"},
]
[[package]]
@@ -11916,14 +11908,14 @@ XlsxWriter = ">=0.5.7"
[[package]]
name = "python-socketio"
version = "5.13.0"
version = "5.14.0"
description = "Socket.IO server and client for Python"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "python_socketio-5.13.0-py3-none-any.whl", hash = "sha256:51f68d6499f2df8524668c24bcec13ba1414117cfb3a90115c559b601ab10caf"},
{file = "python_socketio-5.13.0.tar.gz", hash = "sha256:ac4e19a0302ae812e23b712ec8b6427ca0521f7c582d6abb096e36e24a263029"},
{file = "python_socketio-5.14.0-py3-none-any.whl", hash = "sha256:7de5ad8a55efc33e17897f6cf91d20168d3d259f98c38d38e2940af83136d6f8"},
{file = "python_socketio-5.14.0.tar.gz", hash = "sha256:d057737f658b3948392ff452a5c865c5ccc969859c37cf095a73393ce755f98e"},
]
[package.dependencies]
@@ -11969,7 +11961,7 @@ description = "Python for Window Extensions"
optional = false
python-versions = "*"
groups = ["main"]
markers = "sys_platform == \"win32\" or platform_system == \"Windows\""
markers = "platform_system == \"Windows\" or sys_platform == \"win32\""
files = [
{file = "pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3"},
{file = "pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b"},

View File

@@ -47,12 +47,19 @@ from server.routes.org_invitations import ( # noqa: E402
from server.routes.orgs import org_router # noqa: E402
from server.routes.readiness import readiness_router # noqa: E402
from server.routes.user import saas_user_router # noqa: E402
from server.routes.user_app_settings import user_app_settings_router # noqa: E402
from server.sharing.shared_conversation_router import ( # noqa: E402
router as shared_conversation_router,
)
from server.sharing.shared_event_router import ( # noqa: E402
router as shared_event_router,
)
from server.verified_models.verified_model_router import ( # noqa: E402
api_router as verified_models_router,
)
from server.verified_models.verified_model_router import ( # noqa: E402
override_llm_models_dependency,
)
from openhands.server.app import app as base_app # noqa: E402
from openhands.server.listen_socket import sio # noqa: E402
@@ -76,6 +83,7 @@ base_app.include_router(api_router) # Add additional route for github auth
base_app.include_router(oauth_router) # Add additional route for oauth callback
base_app.include_router(oauth_device_router) # Add OAuth 2.0 Device Flow routes
base_app.include_router(saas_user_router) # Add additional route SAAS user calls
base_app.include_router(user_app_settings_router) # Add routes for user app settings
base_app.include_router(
billing_router
) # Add routes for credit management and Stripe payment integration
@@ -105,6 +113,14 @@ if GITLAB_APP_CLIENT_ID:
base_app.include_router(api_keys_router) # Add routes for API key management
base_app.include_router(org_router) # Add routes for organization management
base_app.include_router(
verified_models_router
) # Add routes for verified models management
# Override the default LLM models implementation with SaaS version
# This must happen after all routers are included
override_llm_models_dependency(base_app)
base_app.include_router(invitation_router) # Add routes for org invitation management
base_app.include_router(invitation_accept_router) # Add route for accepting invitations
add_github_proxy_routes(base_app)

View File

@@ -38,3 +38,9 @@ class ExpiredError(AuthError):
"""Error when a token has expired (Usually the refresh token)"""
pass
class TokenRefreshError(AuthError):
"""Error when token refresh fails due to timeout or lock contention"""
pass

View File

@@ -1,7 +1,5 @@
import os
from server.auth.sheets_client import GoogleSheetsClient
from openhands.core.logger import openhands_logger as logger
@@ -9,12 +7,9 @@ class UserVerifier:
def __init__(self) -> None:
logger.debug('Initializing UserVerifier')
self.file_users: list[str] | None = None
self.sheets_client: GoogleSheetsClient | None = None
self.spreadsheet_id: str | None = None
# Initialize from environment variables
self._init_file_users()
self._init_sheets_client()
def _init_file_users(self) -> None:
"""Load users from text file if configured."""
@@ -36,23 +31,11 @@ class UserVerifier:
except Exception:
logger.exception(f'Error reading user list file {waitlist}')
def _init_sheets_client(self) -> None:
"""Initialize Google Sheets client if configured."""
sheet_id = os.getenv('GITHUB_USERS_SHEET_ID')
if not sheet_id:
logger.debug('GITHUB_USERS_SHEET_ID not configured')
return
logger.debug('Initializing Google Sheets integration')
self.sheets_client = GoogleSheetsClient()
self.spreadsheet_id = sheet_id
def is_active(self) -> bool:
if os.getenv('DISABLE_WAITLIST', '').lower() == 'true':
logger.info('Waitlist disabled via DISABLE_WAITLIST env var')
return False
return bool(self.file_users or (self.sheets_client and self.spreadsheet_id))
return bool(self.file_users)
def is_user_allowed(self, username: str) -> bool:
"""Check if user is allowed based on file and/or sheet configuration."""
@@ -63,15 +46,6 @@ class UserVerifier:
return True
logger.debug(f'User {username} not found in text file allowlist')
if self.sheets_client and self.spreadsheet_id:
sheet_users = [
u.lower() for u in self.sheets_client.get_usernames(self.spreadsheet_id)
]
if username.lower() in sheet_users:
logger.debug(f'User {username} found in Google Sheets allowlist')
return True
logger.debug(f'User {username} not found in Google Sheets allowlist')
logger.debug(f'User {username} not found in any allowlist')
return False

View File

@@ -1,87 +1,11 @@
import os
from integrations.github.github_service import SaaSGitHubService
from pydantic import SecretStr
from server.auth.sheets_client import GoogleSheetsClient
from enterprise.server.auth.auth_utils import user_verifier
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.github.github_types import GitHubUser
class UserVerifier:
def __init__(self) -> None:
logger.debug('Initializing UserVerifier')
self.file_users: list[str] | None = None
self.sheets_client: GoogleSheetsClient | None = None
self.spreadsheet_id: str | None = None
# Initialize from environment variables
self._init_file_users()
self._init_sheets_client()
def _init_file_users(self) -> None:
"""Load users from text file if configured"""
waitlist = os.getenv('GITHUB_USER_LIST_FILE')
if not waitlist:
logger.debug('GITHUB_USER_LIST_FILE not configured')
return
if not os.path.exists(waitlist):
logger.error(f'User list file not found: {waitlist}')
raise FileNotFoundError(f'User list file not found: {waitlist}')
try:
with open(waitlist, 'r') as f:
self.file_users = [line.strip().lower() for line in f if line.strip()]
logger.info(
f'Successfully loaded {len(self.file_users)} users from {waitlist}'
)
except Exception:
logger.error(f'Error reading user list file {waitlist}', exc_info=True)
def _init_sheets_client(self) -> None:
"""Initialize Google Sheets client if configured"""
sheet_id = os.getenv('GITHUB_USERS_SHEET_ID')
if not sheet_id:
logger.debug('GITHUB_USERS_SHEET_ID not configured')
return
logger.debug('Initializing Google Sheets integration')
self.sheets_client = GoogleSheetsClient()
self.spreadsheet_id = sheet_id
def is_active(self) -> bool:
if os.getenv('DISABLE_WAITLIST', '').lower() == 'true':
logger.info('Waitlist disabled via DISABLE_WAITLIST env var')
return False
return bool(self.file_users or (self.sheets_client and self.spreadsheet_id))
def is_user_allowed(self, username: str) -> bool:
"""Check if user is allowed based on file and/or sheet configuration"""
logger.debug(f'Checking if GitHub user {username} is allowed')
if self.file_users:
if username.lower() in self.file_users:
logger.debug(f'User {username} found in text file allowlist')
return True
logger.debug(f'User {username} not found in text file allowlist')
if self.sheets_client and self.spreadsheet_id:
sheet_users = [
u.lower() for u in self.sheets_client.get_usernames(self.spreadsheet_id)
]
if username.lower() in sheet_users:
logger.debug(f'User {username} found in Google Sheets allowlist')
return True
logger.debug(f'User {username} not found in Google Sheets allowlist')
logger.debug(f'User {username} not found in any allowlist')
return False
user_verifier = UserVerifier()
def is_user_allowed(user_login: str):
if user_verifier.is_active() and not user_verifier.is_user_allowed(user_login):
logger.warning(f'GitHub user {user_login} not in allow list')

View File

@@ -49,6 +49,10 @@ from openhands.integrations.service_types import ProviderType
from openhands.server.types import SessionExpiredError
from openhands.utils.http_session import httpx_verify_option
# HTTP timeout for external IDP calls (in seconds)
# This prevents indefinite blocking if an IDP is slow or unresponsive
IDP_HTTP_TIMEOUT = 15.0
def _before_sleep_callback(retry_state: RetryCallState) -> None:
logger.info(f'Retry attempt {retry_state.attempt_number} for Keycloak operation')
@@ -202,7 +206,9 @@ class TokenManager:
access_token: str,
idp: ProviderType,
) -> dict[str, str | int]:
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
async with httpx.AsyncClient(
verify=httpx_verify_option(), timeout=IDP_HTTP_TIMEOUT
) as client:
base_url = KEYCLOAK_SERVER_URL_EXT if self.external else KEYCLOAK_SERVER_URL
url = f'{base_url}/realms/{KEYCLOAK_REALM_NAME}/broker/{idp.value}/token'
headers = {
@@ -361,7 +367,9 @@ class TokenManager:
'refresh_token': refresh_token,
'grant_type': 'refresh_token',
}
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
async with httpx.AsyncClient(
verify=httpx_verify_option(), timeout=IDP_HTTP_TIMEOUT
) as client:
response = await client.post(url, data=payload)
response.raise_for_status()
logger.info('Successfully refreshed GitHub token')
@@ -387,7 +395,9 @@ class TokenManager:
'refresh_token': refresh_token,
'grant_type': 'refresh_token',
}
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
async with httpx.AsyncClient(
verify=httpx_verify_option(), timeout=IDP_HTTP_TIMEOUT
) as client:
response = await client.post(url, data=payload)
response.raise_for_status()
logger.info('Successfully refreshed GitLab token')
@@ -415,7 +425,9 @@ class TokenManager:
'refresh_token': refresh_token,
}
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
async with httpx.AsyncClient(
verify=httpx_verify_option(), timeout=IDP_HTTP_TIMEOUT
) as client:
response = await client.post(url, data=data, headers=headers)
response.raise_for_status()
logger.info('Successfully refreshed Bitbucket token')

View File

@@ -103,11 +103,13 @@ class SetAuthCookieMiddleware:
keycloak_auth_cookie = request.cookies.get('keycloak_auth')
auth_header = request.headers.get('Authorization')
mcp_auth_header = request.headers.get('X-Session-API-Key')
api_auth_header = request.headers.get('X-Access-Token')
accepted_tos: bool | None = False
if (
keycloak_auth_cookie is None
and (auth_header is None or not auth_header.startswith('Bearer '))
and mcp_auth_header is None
and api_auth_header is None
):
raise NoCredentialsError
@@ -164,7 +166,6 @@ class SetAuthCookieMiddleware:
'/oauth/device/authorize',
'/oauth/device/token',
'/api/v1/web-client/config',
'/api/v1/webhooks/secrets',
)
if path in ignore_paths:
return False
@@ -175,6 +176,10 @@ class SetAuthCookieMiddleware:
):
return False
# Webhooks access is controlled using separate API keys
if path.startswith('/api/v1/webhooks/'):
return False
is_mcp = path.startswith('/mcp')
is_api_route = path.startswith('/api')
return is_api_route or is_mcp

View File

@@ -208,6 +208,7 @@ async def keycloak_callback(
else:
# Existing user — gradually backfill contact_name if it still has a username-style value
await UserStore.backfill_contact_name(user_id, user_info)
await UserStore.backfill_user_email(user_id, user_info)
if not user:
logger.error(f'Failed to authenticate user {user_info["preferred_username"]}')
@@ -549,7 +550,10 @@ async def keycloak_offline_callback(code: str, state: str, request: Request):
user_id=user_info['sub'], offline_token=keycloak_refresh_token
)
return RedirectResponse(state if state else request.base_url, status_code=302)
redirect_url, _, _ = _extract_oauth_state(state)
return RedirectResponse(
redirect_url if redirect_url else request.base_url, status_code=302
)
@oauth_router.get('/github/callback')

View File

@@ -23,7 +23,7 @@ from openhands.app_server.config import get_global_config
from openhands.server.user_auth import get_user_id
stripe.api_key = STRIPE_API_KEY
billing_router = APIRouter(prefix='/api/billing')
billing_router = APIRouter(prefix='/api/billing', tags=['Billing'])
async def validate_billing_enabled() -> None:

View File

@@ -8,6 +8,7 @@ from server.auth.keycloak_manager import get_keycloak_admin
from server.auth.saas_user_auth import SaasUserAuth
from server.routes.auth import set_response_cookie
from server.utils.rate_limit_utils import check_rate_limit_by_user_id
from storage.user_store import UserStore
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_id
@@ -62,6 +63,10 @@ async def update_email(
},
)
await UserStore.update_user_email(
user_id=user_id, email=email, email_verified=False
)
user_auth: SaasUserAuth = await get_user_auth(request)
await user_auth.refresh() # refresh so access token has updated email
user_auth.email = email
@@ -144,6 +149,7 @@ async def verified_email(request: Request):
user_auth: SaasUserAuth = await get_user_auth(request)
await user_auth.refresh() # refresh so access token has updated email
user_auth.email_verified = True
await UserStore.update_user_email(user_id=user_auth.user_id, email_verified=True)
scheme = 'http' if request.url.hostname == 'localhost' else 'https'
redirect_uri = f'{scheme}://{request.url.netloc}/settings/user'
response = RedirectResponse(redirect_uri, status_code=302)

View File

@@ -8,11 +8,18 @@ from storage.feedback import ConversationFeedback
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
from openhands.events.event_store import EventStore
from openhands.server.dependencies import get_dependencies
from openhands.server.shared import file_store
from openhands.server.user_auth import get_user_id
from openhands.utils.async_utils import call_sync_from_async
router = APIRouter(prefix='/feedback', tags=['feedback'])
# We use the get_dependencies method here to signal to the OpenAPI docs that this endpoint
# is protected. The actual protection is provided by SetAuthCookieMiddleware
# TODO: It may be an error by you can actually post feedback to a conversation you don't
# own right now - maybe this is useful in the context of public shared conversations?
router = APIRouter(
prefix='/feedback', tags=['feedback'], dependencies=get_dependencies()
)
async def get_event_ids(conversation_id: str, user_id: str) -> List[int]:

View File

@@ -4,7 +4,7 @@ import json
import os
import re
import uuid
from urllib.parse import urlparse
from urllib.parse import urlencode, urlparse
import requests
from fastapi import APIRouter, BackgroundTasks, Header, HTTPException, Request, status
@@ -371,9 +371,7 @@ async def create_jira_workspace(request: Request, workspace_data: JiraWorkspaceC
'prompt': 'consent',
}
auth_url = (
f"{JIRA_AUTH_URL}?{'&'.join([f'{k}={v}' for k, v in auth_params.items()])}"
)
auth_url = f'{JIRA_AUTH_URL}?{urlencode(auth_params)}'
return JSONResponse(
content={
@@ -432,9 +430,7 @@ async def create_workspace_link(request: Request, link_data: JiraLinkCreate):
'response_type': 'code',
'prompt': 'consent',
}
auth_url = (
f"{JIRA_AUTH_URL}?{'&'.join([f'{k}={v}' for k, v in auth_params.items()])}"
)
auth_url = f'{JIRA_AUTH_URL}?{urlencode(auth_params)}'
return JSONResponse(
content={

View File

@@ -2,7 +2,7 @@ import json
import os
import re
import uuid
from urllib.parse import urlparse
from urllib.parse import urlencode, urlparse
import requests
from fastapi import (
@@ -316,7 +316,7 @@ async def create_jira_dc_workspace(
'response_type': 'code',
}
auth_url = f"{JIRA_DC_AUTH_URL}?{'&'.join([f'{k}={v}' for k, v in auth_params.items()])}"
auth_url = f'{JIRA_DC_AUTH_URL}?{urlencode(auth_params)}'
return JSONResponse(
content={
@@ -436,7 +436,7 @@ async def create_workspace_link(request: Request, link_data: JiraDcLinkCreate):
'state': state,
'response_type': 'code',
}
auth_url = f"{JIRA_DC_AUTH_URL}?{'&'.join([f'{k}={v}' for k, v in auth_params.items()])}"
auth_url = f'{JIRA_DC_AUTH_URL}?{urlencode(auth_params)}'
return JSONResponse(
content={

View File

@@ -1,6 +1,13 @@
from typing import Annotated
from pydantic import BaseModel, EmailStr, Field, SecretStr, StringConstraints
from pydantic import (
BaseModel,
EmailStr,
Field,
SecretStr,
StringConstraints,
field_validator,
)
from storage.org import Org
from storage.org_member import OrgMember
from storage.role import Role
@@ -252,6 +259,115 @@ class OrgUpdate(BaseModel):
condenser_max_size: int | None = Field(default=None, ge=20)
class OrgLLMSettingsResponse(BaseModel):
"""Response model for organization LLM settings."""
default_llm_model: str | None = None
default_llm_base_url: str | None = None
search_api_key: str | None = None # Masked in response
agent: str | None = None
confirmation_mode: bool | None = None
security_analyzer: str | None = None
enable_default_condenser: bool = True
condenser_max_size: int | None = None
default_max_iterations: int | None = None
@staticmethod
def _mask_key(secret: SecretStr | None) -> str | None:
"""Mask an API key, showing only last 4 characters."""
if secret is None:
return None
raw = secret.get_secret_value()
if not raw:
return None
if len(raw) <= 4:
return '****'
return '****' + raw[-4:]
@classmethod
def from_org(cls, org: Org) -> 'OrgLLMSettingsResponse':
"""Create response from Org entity."""
return cls(
default_llm_model=org.default_llm_model,
default_llm_base_url=org.default_llm_base_url,
search_api_key=cls._mask_key(org.search_api_key),
agent=org.agent,
confirmation_mode=org.confirmation_mode,
security_analyzer=org.security_analyzer,
enable_default_condenser=org.enable_default_condenser
if org.enable_default_condenser is not None
else True,
condenser_max_size=org.condenser_max_size,
default_max_iterations=org.default_max_iterations,
)
class OrgMemberLLMSettings(BaseModel):
"""LLM settings to propagate to organization members.
Field names match OrgMember DB columns.
"""
llm_model: str | None = None
llm_base_url: str | None = None
max_iterations: int | None = None
llm_api_key: str | None = None
def has_updates(self) -> bool:
"""Check if any field is set (not None)."""
return any(getattr(self, field) is not None for field in self.model_fields)
class OrgLLMSettingsUpdate(BaseModel):
"""Request model for updating organization LLM settings.
Field names match Org DB columns exactly.
"""
default_llm_model: str | None = None
default_llm_base_url: str | None = None
search_api_key: str | None = None
agent: str | None = None
confirmation_mode: bool | None = None
security_analyzer: str | None = None
enable_default_condenser: bool | None = None
condenser_max_size: int | None = Field(default=None, ge=20)
default_max_iterations: int | None = Field(default=None, gt=0)
llm_api_key: str | None = None
def has_updates(self) -> bool:
"""Check if any field is set (not None)."""
return any(getattr(self, field) is not None for field in self.model_fields)
def apply_to_org(self, org: Org) -> None:
"""Apply non-None settings to the organization model.
Args:
org: Organization entity to update in place
"""
for field_name in self.model_fields:
value = getattr(self, field_name)
# Skip llm_api_key - it's only for member propagation, not org-level
if value is not None and field_name != 'llm_api_key':
setattr(org, field_name, value)
def get_member_updates(self) -> OrgMemberLLMSettings | None:
"""Get updates that need to be propagated to org members.
Returns:
OrgMemberLLMSettings with mapped field values, or None if no member updates needed.
Maps: default_llm_model → llm_model, default_llm_base_url → llm_base_url,
default_max_iterations → max_iterations, llm_api_key → llm_api_key
"""
member_settings = OrgMemberLLMSettings(
llm_model=self.default_llm_model,
llm_base_url=self.default_llm_base_url,
max_iterations=self.default_max_iterations,
llm_api_key=self.llm_api_key,
)
return member_settings if member_settings.has_updates() else None
class OrgMemberResponse(BaseModel):
"""Response model for a single organization member."""
@@ -267,7 +383,8 @@ class OrgMemberPage(BaseModel):
"""Paginated response for organization members."""
items: list[OrgMemberResponse]
next_page_id: str | None = None
current_page: int = 1
per_page: int = 10
class OrgMemberUpdate(BaseModel):
@@ -326,3 +443,44 @@ class MeResponse(BaseModel):
llm_base_url=member.llm_base_url,
status=member.status,
)
class OrgAppSettingsResponse(BaseModel):
"""Response model for organization app settings."""
enable_proactive_conversation_starters: bool = True
enable_solvability_analysis: bool | None = None
max_budget_per_task: float | None = None
@classmethod
def from_org(cls, org: Org) -> 'OrgAppSettingsResponse':
"""Create an OrgAppSettingsResponse from an Org entity.
Args:
org: The organization entity
Returns:
OrgAppSettingsResponse with app settings
"""
return cls(
enable_proactive_conversation_starters=org.enable_proactive_conversation_starters
if org.enable_proactive_conversation_starters is not None
else True,
enable_solvability_analysis=org.enable_solvability_analysis,
max_budget_per_task=org.max_budget_per_task,
)
class OrgAppSettingsUpdate(BaseModel):
"""Request model for updating organization app settings."""
enable_proactive_conversation_starters: bool | None = None
enable_solvability_analysis: bool | None = None
max_budget_per_task: float | None = None
@field_validator('max_budget_per_task')
@classmethod
def validate_max_budget_per_task(cls, v: float | None) -> float | None:
if v is not None and v <= 0:
raise ValueError('max_budget_per_task must be greater than 0')
return v

View File

@@ -15,9 +15,13 @@ from server.routes.org_models import (
LiteLLMIntegrationError,
MemberUpdateError,
MeResponse,
OrgAppSettingsResponse,
OrgAppSettingsUpdate,
OrgAuthorizationError,
OrgCreate,
OrgDatabaseError,
OrgLLMSettingsResponse,
OrgLLMSettingsUpdate,
OrgMemberNotFoundError,
OrgMemberPage,
OrgMemberResponse,
@@ -30,6 +34,14 @@ from server.routes.org_models import (
OrphanedUserError,
RoleNotFoundError,
)
from server.services.org_app_settings_service import (
OrgAppSettingsService,
OrgAppSettingsServiceInjector,
)
from server.services.org_llm_settings_service import (
OrgLLMSettingsService,
OrgLLMSettingsServiceInjector,
)
from server.services.org_member_service import OrgMemberService
from storage.org_service import OrgService
from storage.user_store import UserStore
@@ -38,7 +50,14 @@ from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_id
# Initialize API router
org_router = APIRouter(prefix='/api/organizations')
org_router = APIRouter(prefix='/api/organizations', tags=['Orgs'])
# Create injector instance and dependency for LLM settings
_org_llm_settings_injector = OrgLLMSettingsServiceInjector()
org_llm_settings_service_dependency = Depends(_org_llm_settings_injector.depends)
# Create injector instance and dependency at module level
_org_app_settings_injector = OrgAppSettingsServiceInjector()
org_app_settings_service_dependency = Depends(_org_app_settings_injector.depends)
@org_router.get('', response_model=OrgPage)
@@ -201,6 +220,195 @@ async def create_org(
)
@org_router.get(
'/llm',
response_model=OrgLLMSettingsResponse,
dependencies=[Depends(require_permission(Permission.VIEW_LLM_SETTINGS))],
)
async def get_org_llm_settings(
service: OrgLLMSettingsService = org_llm_settings_service_dependency,
) -> OrgLLMSettingsResponse:
"""Get LLM settings for the user's current organization.
This endpoint retrieves the LLM configuration settings for the
authenticated user's current organization. All organization members
can view these settings.
Args:
service: OrgLLMSettingsService (injected by dependency)
Returns:
OrgLLMSettingsResponse: The organization's LLM settings
Raises:
HTTPException: 401 if not authenticated
HTTPException: 403 if not a member of any organization
HTTPException: 404 if current organization not found
HTTPException: 500 if retrieval fails
"""
try:
return await service.get_org_llm_settings()
except OrgNotFoundError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e),
)
except Exception as e:
logger.exception(
'Error getting organization LLM settings',
extra={'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to retrieve LLM settings',
)
@org_router.post(
'/llm',
response_model=OrgLLMSettingsResponse,
dependencies=[Depends(require_permission(Permission.EDIT_LLM_SETTINGS))],
)
async def update_org_llm_settings(
settings: OrgLLMSettingsUpdate,
service: OrgLLMSettingsService = org_llm_settings_service_dependency,
) -> OrgLLMSettingsResponse:
"""Update LLM settings for the user's current organization.
This endpoint updates the LLM configuration settings for the
authenticated user's current organization. Only admins and owners
can update these settings.
Args:
settings: The LLM settings to update (only non-None fields are updated)
service: OrgLLMSettingsService (injected by dependency)
Returns:
OrgLLMSettingsResponse: The updated organization's LLM settings
Raises:
HTTPException: 401 if not authenticated
HTTPException: 403 if user lacks EDIT_LLM_SETTINGS permission
HTTPException: 404 if current organization not found
HTTPException: 500 if update fails
"""
try:
return await service.update_org_llm_settings(settings)
except OrgNotFoundError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e),
)
except OrgDatabaseError as e:
logger.error(
'Database error updating LLM settings',
extra={'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to update LLM settings',
)
except Exception as e:
logger.exception(
'Error updating organization LLM settings',
extra={'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to update LLM settings',
)
@org_router.get(
'/app',
response_model=OrgAppSettingsResponse,
dependencies=[Depends(require_permission(Permission.MANAGE_APPLICATION_SETTINGS))],
)
async def get_org_app_settings(
service: OrgAppSettingsService = org_app_settings_service_dependency,
) -> OrgAppSettingsResponse:
"""Get organization app settings for the user's current organization.
This endpoint retrieves application settings for the authenticated user's
current organization. Access requires the MANAGE_APPLICATION_SETTINGS permission,
which is granted to all organization members (member, admin, and owner roles).
Args:
service: OrgAppSettingsService (injected by dependency)
Returns:
OrgAppSettingsResponse: The organization app settings
Raises:
HTTPException: 401 if user is not authenticated
HTTPException: 403 if user lacks MANAGE_APPLICATION_SETTINGS permission
HTTPException: 404 if current organization not found
"""
try:
return await service.get_org_app_settings()
except OrgNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Current organization not found',
)
except Exception as e:
logger.exception(
'Unexpected error retrieving organization app settings',
extra={'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='An unexpected error occurred',
)
@org_router.post(
'/app',
response_model=OrgAppSettingsResponse,
dependencies=[Depends(require_permission(Permission.MANAGE_APPLICATION_SETTINGS))],
)
async def update_org_app_settings(
update_data: OrgAppSettingsUpdate,
service: OrgAppSettingsService = org_app_settings_service_dependency,
) -> OrgAppSettingsResponse:
"""Update organization app settings for the user's current organization.
This endpoint updates application settings for the authenticated user's
current organization. Access requires the MANAGE_APPLICATION_SETTINGS permission,
which is granted to all organization members (member, admin, and owner roles).
Args:
update_data: App settings update data
service: OrgAppSettingsService (injected by dependency)
Returns:
OrgAppSettingsResponse: The updated organization app settings
Raises:
HTTPException: 401 if user is not authenticated
HTTPException: 403 if user lacks MANAGE_APPLICATION_SETTINGS permission
HTTPException: 404 if current organization not found
HTTPException: 422 if validation errors occur (handled by FastAPI)
HTTPException: 500 if update fails
"""
try:
return await service.update_org_app_settings(update_data)
except OrgNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Current organization not found',
)
except Exception as e:
logger.exception(
'Unexpected error updating organization app settings',
extra={'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='An unexpected error occurred',
)
@org_router.get('/{org_id}', response_model=OrgResponse, status_code=status.HTTP_200_OK)
async def get_org(
org_id: UUID,
@@ -519,7 +727,7 @@ async def get_org_members(
org_id: UUID,
page_id: Annotated[
str | None,
Query(title='Optional next_page_id from the previously returned page'),
Query(title='Optional page offset for pagination'),
] = None,
limit: Annotated[
int,
@@ -528,10 +736,18 @@ async def get_org_members(
gt=0,
lte=100,
),
] = 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_permission(Permission.VIEW_ORG_SETTINGS)),
) -> OrgMemberPage:
"""Get all members of an organization with cursor-based pagination.
"""Get all members of an organization with pagination and optional email filter.
This endpoint retrieves a paginated list of organization members. Access requires
the VIEW_ORG_SETTINGS permission, which is granted to all organization members
@@ -539,12 +755,15 @@ async def get_org_members(
Args:
org_id: Organization ID (UUID)
page_id: Optional page ID (offset) for pagination
limit: Maximum number of members to return (1-100, default 100)
page_id: Optional page offset for pagination
limit: Maximum number of members to return (1-100, default 10)
email: Optional email filter (case-insensitive partial match)
user_id: Authenticated user ID (injected by require_permission dependency)
Returns:
OrgMemberPage: Paginated list of organization members
OrgMemberPage: Paginated list of organization members with
current_page and per_page metadata. Use the /count endpoint
to get the total count separately.
Raises:
HTTPException: 401 if user is not authenticated
@@ -558,6 +777,7 @@ async def get_org_members(
current_user_id=UUID(user_id),
page_id=page_id,
limit=limit,
email_filter=email,
)
if not success:
@@ -600,6 +820,64 @@ async def get_org_members(
)
@org_router.get('/{org_id}/members/count')
async def get_org_members_count(
org_id: UUID,
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_permission(Permission.VIEW_ORG_SETTINGS)),
) -> int:
"""Get count of organization members with optional email filter.
This endpoint returns the total count of organization members matching
the filter criteria. Access requires the VIEW_ORG_SETTINGS permission,
which is granted to all organization members (member, admin, and owner roles).
Args:
org_id: Organization ID (UUID)
email: Optional email filter (case-insensitive partial match)
user_id: Authenticated user ID (injected by require_permission dependency)
Returns:
int: Total count of organization members matching the filter
Raises:
HTTPException: 401 if user is not authenticated
HTTPException: 403 if user lacks VIEW_ORG_SETTINGS permission or is not a member
HTTPException: 400 if org_id format is invalid
HTTPException: 500 if retrieval fails
"""
try:
return await OrgMemberService.get_org_members_count(
org_id=org_id,
current_user_id=UUID(user_id),
email_filter=email,
)
except OrgMemberNotFoundError:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='You are not a member of this organization',
)
except ValueError:
logger.exception('Invalid UUID format')
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid organization ID format',
)
except Exception:
logger.exception('Error retrieving organization member count')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to retrieve member count',
)
@org_router.delete('/{org_id}/members/{user_id}')
async def remove_org_member(
org_id: UUID,

View File

@@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, Query, status
from fastapi.responses import JSONResponse
from pydantic import SecretStr
from server.auth.token_manager import TokenManager
from storage.user_store import UserStore
from utils.identity import resolve_display_name
from openhands.integrations.provider import (
@@ -115,13 +116,21 @@ async def saas_get_user(
content='Failed to retrieve user_info.',
status_code=status.HTTP_401_UNAUTHORIZED,
)
# Prefer email from DB; fall back to Keycloak if not yet persisted
email = user_info.get('email') if user_info else None
sub = user_info.get('sub') if user_info else ''
if sub:
db_user = await UserStore.get_user_by_id_async(sub)
if db_user and db_user.email is not None:
email = db_user.email
retval = await _check_idp(
access_token=access_token,
default_value=User(
id=(user_info.get('sub') if user_info else '') or '',
id=sub,
login=(user_info.get('preferred_username') if user_info else '') or '',
avatar_url='',
email=user_info.get('email') if user_info else None,
email=email,
name=resolve_display_name(user_info) if user_info else None,
company=user_info.get('company') if user_info else None,
),

View File

@@ -0,0 +1,115 @@
"""Routes for user app settings API.
Provides endpoints for managing user-level app preferences:
- GET /api/users/app - Retrieve current user's app settings
- POST /api/users/app - Update current user's app settings
"""
from fastapi import APIRouter, Depends, HTTPException, status
from server.routes.user_app_settings_models import (
UserAppSettingsResponse,
UserAppSettingsUpdate,
UserNotFoundError,
)
from server.services.user_app_settings_service import (
UserAppSettingsService,
UserAppSettingsServiceInjector,
)
from openhands.core.logger import openhands_logger as logger
user_app_settings_router = APIRouter(prefix='/api/users')
# Create injector instance and dependency at module level
_injector = UserAppSettingsServiceInjector()
user_app_settings_service_dependency = Depends(_injector.depends)
@user_app_settings_router.get('/app', response_model=UserAppSettingsResponse)
async def get_user_app_settings(
service: UserAppSettingsService = user_app_settings_service_dependency,
) -> UserAppSettingsResponse:
"""Get the current user's app settings.
Returns language, analytics consent, sound notifications, and git config.
Args:
service: UserAppSettingsService (injected by dependency)
Returns:
UserAppSettingsResponse: The user's app settings
Raises:
HTTPException: 401 if user is not authenticated
HTTPException: 404 if user not found
HTTPException: 500 if retrieval fails
"""
try:
return await service.get_user_app_settings()
except ValueError as e:
# User not authenticated
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(e),
)
except UserNotFoundError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e),
)
except Exception as e:
logger.exception(
'Unexpected error retrieving user app settings',
extra={'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to retrieve user app settings',
)
@user_app_settings_router.post('/app', response_model=UserAppSettingsResponse)
async def update_user_app_settings(
update_data: UserAppSettingsUpdate,
service: UserAppSettingsService = user_app_settings_service_dependency,
) -> UserAppSettingsResponse:
"""Update the current user's app settings (partial update).
Only provided fields will be updated. Pass null to clear a field.
Args:
update_data: Fields to update
service: UserAppSettingsService (injected by dependency)
Returns:
UserAppSettingsResponse: The updated user's app settings
Raises:
HTTPException: 401 if user is not authenticated
HTTPException: 404 if user not found
HTTPException: 500 if update fails
"""
try:
return await service.update_user_app_settings(update_data)
except ValueError as e:
# User not authenticated
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(e),
)
except UserNotFoundError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e),
)
except Exception as e:
logger.exception(
'Failed to update user app settings',
extra={'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to update user app settings',
)

View File

@@ -0,0 +1,57 @@
"""
Pydantic models for user app settings API.
"""
from pydantic import BaseModel, EmailStr
from storage.user import User
class UserAppSettingsError(Exception):
"""Base exception for user app settings errors."""
pass
class UserNotFoundError(UserAppSettingsError):
"""Raised when user is not found."""
def __init__(self, user_id: str):
self.user_id = user_id
super().__init__(f'User with id "{user_id}" not found')
class UserAppSettingsUpdateError(UserAppSettingsError):
"""Raised when user app settings update fails."""
pass
class UserAppSettingsResponse(BaseModel):
"""Response model for user app settings."""
language: str | None = None
user_consents_to_analytics: bool | None = None
enable_sound_notifications: bool | None = None
git_user_name: str | None = None
git_user_email: EmailStr | None = None
@classmethod
def from_user(cls, user: User) -> 'UserAppSettingsResponse':
"""Create response from User entity."""
return cls(
language=user.language,
user_consents_to_analytics=user.user_consents_to_analytics,
enable_sound_notifications=user.enable_sound_notifications,
git_user_name=user.git_user_name,
git_user_email=user.git_user_email,
)
class UserAppSettingsUpdate(BaseModel):
"""Request model for updating user app settings (partial update)."""
language: str | None = None
user_consents_to_analytics: bool | None = None
enable_sound_notifications: bool | None = None
git_user_name: str | None = None
git_user_email: EmailStr | None = None

View File

@@ -0,0 +1,130 @@
"""Service class for managing organization app settings.
Separates business logic from route handlers.
Uses dependency injection for db_session and user_context.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import AsyncGenerator
from fastapi import Request
from server.routes.org_models import (
OrgAppSettingsResponse,
OrgAppSettingsUpdate,
OrgNotFoundError,
)
from storage.org_app_settings_store import OrgAppSettingsStore
from openhands.app_server.services.injector import Injector, InjectorState
from openhands.app_server.user.user_context import UserContext
from openhands.core.logger import openhands_logger as logger
@dataclass
class OrgAppSettingsService:
"""Service for organization app settings with injected dependencies."""
store: OrgAppSettingsStore
user_context: UserContext
async def get_org_app_settings(self) -> OrgAppSettingsResponse:
"""Get organization app settings.
User ID is obtained from the injected user_context.
Returns:
OrgAppSettingsResponse: The organization's app settings
Raises:
OrgNotFoundError: If current organization is not found
"""
user_id = await self.user_context.get_user_id()
logger.info(
'Getting organization app settings',
extra={'user_id': user_id},
)
org = await self.store.get_current_org_by_user_id(user_id)
if not org:
raise OrgNotFoundError('current')
return OrgAppSettingsResponse.from_org(org)
async def update_org_app_settings(
self,
update_data: OrgAppSettingsUpdate,
) -> OrgAppSettingsResponse:
"""Update organization app settings.
Only updates fields that are explicitly provided in update_data.
User ID is obtained from the injected user_context.
Session auto-commits at request end via DbSessionInjector.
Args:
update_data: The update data from the request
Returns:
OrgAppSettingsResponse: The updated organization's app settings
Raises:
OrgNotFoundError: If current organization is not found
"""
user_id = await self.user_context.get_user_id()
logger.info(
'Updating organization app settings',
extra={'user_id': user_id},
)
# Get current org first
org = await self.store.get_current_org_by_user_id(user_id)
if not org:
raise OrgNotFoundError('current')
# Check if any fields are provided
update_dict = update_data.model_dump(exclude_unset=True)
if not update_dict:
# No fields to update, just return current settings
logger.info(
'No fields to update in app settings',
extra={'user_id': user_id, 'org_id': str(org.id)},
)
return OrgAppSettingsResponse.from_org(org)
updated_org = await self.store.update_org_app_settings(
org_id=org.id,
update_data=update_data,
)
if not updated_org:
raise OrgNotFoundError('current')
logger.info(
'Organization app settings updated successfully',
extra={'user_id': user_id, 'updated_fields': list(update_dict.keys())},
)
return OrgAppSettingsResponse.from_org(updated_org)
class OrgAppSettingsServiceInjector(Injector[OrgAppSettingsService]):
"""Injector that composes store and user_context for OrgAppSettingsService."""
async def inject(
self, state: InjectorState, request: Request | None = None
) -> AsyncGenerator[OrgAppSettingsService, None]:
# Local imports to avoid circular dependencies
from openhands.app_server.config import get_db_session, get_user_context
async with (
get_user_context(state, request) as user_context,
get_db_session(state, request) as db_session,
):
store = OrgAppSettingsStore(db_session=db_session)
yield OrgAppSettingsService(store=store, user_context=user_context)

View File

@@ -0,0 +1,130 @@
"""Service class for managing organization LLM settings.
Separates business logic from route handlers.
Uses dependency injection for db_session and user_context.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import AsyncGenerator
from fastapi import Request
from server.routes.org_models import (
OrgLLMSettingsResponse,
OrgLLMSettingsUpdate,
OrgNotFoundError,
)
from storage.org_llm_settings_store import OrgLLMSettingsStore
from openhands.app_server.services.injector import Injector, InjectorState
from openhands.app_server.user.user_context import UserContext
from openhands.core.logger import openhands_logger as logger
@dataclass
class OrgLLMSettingsService:
"""Service for org LLM settings with injected dependencies."""
store: OrgLLMSettingsStore
user_context: UserContext
async def get_org_llm_settings(self) -> OrgLLMSettingsResponse:
"""Get LLM settings for user's current organization.
User ID is obtained from the injected user_context.
Returns:
OrgLLMSettingsResponse: The organization's LLM settings
Raises:
ValueError: If user is not authenticated
OrgNotFoundError: If current organization not found
"""
user_id = await self.user_context.get_user_id()
if not user_id:
raise ValueError('User is not authenticated')
logger.info(
'Getting organization LLM settings',
extra={'user_id': user_id},
)
org = await self.store.get_current_org_by_user_id(user_id)
if not org:
raise OrgNotFoundError('No current organization')
return OrgLLMSettingsResponse.from_org(org)
async def update_org_llm_settings(
self,
update_data: OrgLLMSettingsUpdate,
) -> OrgLLMSettingsResponse:
"""Update LLM settings for user's current organization.
Only updates fields that are explicitly provided in update_data.
User ID is obtained from the injected user_context.
Session auto-commits at request end via DbSessionInjector.
Args:
update_data: The update data from the request
Returns:
OrgLLMSettingsResponse: The updated organization's LLM settings
Raises:
ValueError: If user is not authenticated
OrgNotFoundError: If current organization not found
"""
user_id = await self.user_context.get_user_id()
if not user_id:
raise ValueError('User is not authenticated')
logger.info(
'Updating organization LLM settings',
extra={'user_id': user_id},
)
# Check if any fields are provided
if not update_data.has_updates():
# No fields to update, just return current settings
return await self.get_org_llm_settings()
# Get user's current org first
org = await self.store.get_current_org_by_user_id(user_id)
if not org:
raise OrgNotFoundError('No current organization')
# Update the org LLM settings
updated_org = await self.store.update_org_llm_settings(
org_id=org.id,
update_data=update_data,
)
if not updated_org:
raise OrgNotFoundError(str(org.id))
logger.info(
'Organization LLM settings updated successfully',
extra={'user_id': user_id, 'org_id': str(org.id)},
)
return OrgLLMSettingsResponse.from_org(updated_org)
class OrgLLMSettingsServiceInjector(Injector[OrgLLMSettingsService]):
"""Injector that composes store and user_context for OrgLLMSettingsService."""
async def inject(
self, state: InjectorState, request: Request | None = None
) -> AsyncGenerator[OrgLLMSettingsService, None]:
# Local imports to avoid circular dependencies
from openhands.app_server.config import get_db_session, get_user_context
async with (
get_user_context(state, request) as user_context,
get_db_session(state, request) as db_session,
):
store = OrgLLMSettingsStore(db_session=db_session)
yield OrgLLMSettingsService(store=store, user_context=user_context)

View File

@@ -2,7 +2,7 @@
from uuid import UUID
from server.constants import ROLE_ADMIN, ROLE_MEMBER, ROLE_OWNER
from server.constants import ROLE_ADMIN, ROLE_OWNER
from server.routes.org_models import (
CannotModifySelfError,
InsufficientPermissionError,
@@ -16,10 +16,12 @@ from server.routes.org_models import (
OrgMemberUpdate,
RoleNotFoundError,
)
from storage.lite_llm_manager import LiteLlmManager
from storage.org_member_store import OrgMemberStore
from storage.role_store import RoleStore
from storage.user_store import UserStore
from openhands.core.logger import openhands_logger as logger
from openhands.utils.async_utils import call_sync_from_async
@@ -65,10 +67,18 @@ class OrgMemberService:
org_id: UUID,
current_user_id: UUID,
page_id: str | None = None,
limit: int = 100,
limit: int = 10,
email_filter: str | None = None,
) -> tuple[bool, str | None, OrgMemberPage | None]:
"""Get organization members with authorization check.
Args:
org_id: Organization UUID.
current_user_id: Requesting user's UUID.
page_id: Offset encoded as string (e.g., "0", "10", "20").
limit: Items per page (default 10).
email_filter: Optional case-insensitive partial email match.
Returns:
Tuple of (success, error_code, data). If success is True, error_code is None.
"""
@@ -88,8 +98,11 @@ class OrgMemberService:
return False, 'invalid_page_id', None
# Call store to get paginated members
members, has_more = await OrgMemberStore.get_org_members_paginated(
org_id=org_id, offset=offset, limit=limit
members, _ = await OrgMemberStore.get_org_members_paginated(
org_id=org_id,
offset=offset,
limit=limit,
email_filter=email_filter,
)
# Transform data to response format
@@ -110,12 +123,47 @@ class OrgMemberService:
)
)
# Calculate next_page_id
next_page_id = None
if has_more:
next_page_id = str(offset + limit)
# Calculate current page (1-indexed)
current_page = (offset // limit) + 1
return True, None, OrgMemberPage(items=items, next_page_id=next_page_id)
return (
True,
None,
OrgMemberPage(
items=items,
current_page=current_page,
per_page=limit,
),
)
@staticmethod
async def get_org_members_count(
org_id: UUID,
current_user_id: UUID,
email_filter: str | None = None,
) -> int:
"""Get count of organization members with authorization check.
Args:
org_id: Organization UUID.
current_user_id: Requesting user's UUID.
email_filter: Optional case-insensitive partial email match.
Returns:
int: Count of organization members matching the filter.
Raises:
OrgMemberNotFoundError: If requesting user is not a member of the organization.
"""
# Verify current user is a member of the organization
requester_membership = OrgMemberStore.get_org_member(org_id, current_user_id)
if not requester_membership:
raise OrgMemberNotFoundError(str(org_id), str(current_user_id))
return await OrgMemberStore.get_org_members_count(
org_id=org_id,
email_filter=email_filter,
)
@staticmethod
async def remove_org_member(
@@ -168,9 +216,42 @@ class OrgMemberService:
if not success:
return False, 'removal_failed'
# Update user's current_org_id if it points to the org they were removed from
user = UserStore.get_user_by_id(str(target_user_id))
if user and user.current_org_id == org_id:
# Set current_org_id to personal workspace (org.id == user.id)
UserStore.update_current_org(str(target_user_id), target_user_id)
return True, None
return await call_sync_from_async(_remove_member)
success, error = await call_sync_from_async(_remove_member)
# If database removal succeeded, also remove from LiteLLM team
if success:
try:
await LiteLlmManager.remove_user_from_team(
str(target_user_id), str(org_id)
)
logger.info(
'Successfully removed user from LiteLLM team',
extra={
'user_id': str(target_user_id),
'org_id': str(org_id),
},
)
except Exception as e:
# Log but don't fail the operation - database removal already succeeded
# LiteLLM state will be eventually consistent
logger.warning(
'Failed to remove user from LiteLLM team',
extra={
'user_id': str(target_user_id),
'org_id': str(org_id),
'error': str(e),
},
)
return success, error
@staticmethod
async def update_org_member(
@@ -182,10 +263,9 @@ class OrgMemberService:
"""Update a member's role in an organization.
Permission rules:
- Admins can change roles of users (rank > ADMIN_RANK) to Admin or User
- Admins cannot modify other Admins or Owners
- Owners can change roles of non-owners (rank > OWNER_RANK) to any role
- Owners cannot modify other Owners
- Owners can modify anyone (including other owners), can set any role
- Admins can modify other admins and users
- Admins can only set admin or user roles (not owner)
Args:
org_id: Organization ID
@@ -294,26 +374,21 @@ class OrgMemberService:
"""Check if requester can change target's role to new_role.
Permission rules:
- Owners can modify admins and users, can set any role
- Owners cannot modify other owners
- Admins can only modify users
- Owners can modify anyone (including other owners), can set any role
- Admins can modify other admins and users
- Admins can only set admin or user roles (not owner)
"""
is_requester_owner = requester_role_name == ROLE_OWNER
is_requester_admin = requester_role_name == ROLE_ADMIN
is_target_owner = target_role_name == ROLE_OWNER
is_target_admin = target_role_name == ROLE_ADMIN
is_new_role_owner = new_role_name == ROLE_OWNER
if is_requester_owner:
# Owners cannot modify other owners
if is_target_owner:
return False
# Owners can set any role (owner, admin, user)
# Owners can modify anyone (including other owners)
return True
elif is_requester_admin:
# Admins cannot modify owners or other admins
if is_target_owner or is_target_admin:
# Admins cannot modify owners
if is_target_owner:
return False
# Admins can only set admin or user roles (not owner)
return not is_new_role_owner
@@ -325,8 +400,8 @@ class OrgMemberService:
if requester_role_name == ROLE_OWNER:
return True
elif requester_role_name == ROLE_ADMIN:
# Admins can only remove members (not owners or other admins)
return target_role_name == ROLE_MEMBER
# Admins can remove admins and members (not owners)
return target_role_name != ROLE_OWNER
return False
@staticmethod

View File

@@ -0,0 +1,126 @@
"""Service class for managing user app settings.
Separates business logic from route handlers.
Uses dependency injection for db_session and user_context.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import AsyncGenerator
from fastapi import Request
from server.routes.user_app_settings_models import (
UserAppSettingsResponse,
UserAppSettingsUpdate,
UserNotFoundError,
)
from storage.user_app_settings_store import UserAppSettingsStore
from openhands.app_server.services.injector import Injector, InjectorState
from openhands.app_server.user.user_context import UserContext
from openhands.core.logger import openhands_logger as logger
@dataclass
class UserAppSettingsService:
"""Service for user app settings with injected dependencies."""
store: UserAppSettingsStore
user_context: UserContext
async def get_user_app_settings(self) -> UserAppSettingsResponse:
"""Get user app settings.
User ID is obtained from the injected user_context.
Returns:
UserAppSettingsResponse: The user's app settings
Raises:
ValueError: If user is not authenticated
UserNotFoundError: If user is not found
"""
user_id = await self.user_context.get_user_id()
if not user_id:
raise ValueError('User is not authenticated')
logger.info(
'Getting user app settings',
extra={'user_id': user_id},
)
user = await self.store.get_user_by_id(user_id)
if not user:
raise UserNotFoundError(user_id)
return UserAppSettingsResponse.from_user(user)
async def update_user_app_settings(
self,
update_data: UserAppSettingsUpdate,
) -> UserAppSettingsResponse:
"""Update user app settings.
Only updates fields that are explicitly provided in update_data.
User ID is obtained from the injected user_context.
Session auto-commits at request end via DbSessionInjector.
Args:
update_data: The update data from the request
Returns:
UserAppSettingsResponse: The updated user's app settings
Raises:
ValueError: If user is not authenticated
UserNotFoundError: If user is not found
"""
user_id = await self.user_context.get_user_id()
if not user_id:
raise ValueError('User is not authenticated')
logger.info(
'Updating user app settings',
extra={'user_id': user_id},
)
# Check if any fields are provided
update_dict = update_data.model_dump(exclude_unset=True)
if not update_dict:
# No fields to update, just return current settings
return await self.get_user_app_settings()
user = await self.store.update_user_app_settings(
user_id=user_id,
update_data=update_data,
)
if not user:
raise UserNotFoundError(user_id)
logger.info(
'User app settings updated successfully',
extra={'user_id': user_id, 'updated_fields': list(update_dict.keys())},
)
return UserAppSettingsResponse.from_user(user)
class UserAppSettingsServiceInjector(Injector[UserAppSettingsService]):
"""Injector that composes store and user_context for UserAppSettingsService."""
async def inject(
self, state: InjectorState, request: Request | None = None
) -> AsyncGenerator[UserAppSettingsService, None]:
# Local imports to avoid circular dependencies
from openhands.app_server.config import get_db_session, get_user_context
async with (
get_user_context(state, request) as user_context,
get_db_session(state, request) as db_session,
):
store = UserAppSettingsStore(db_session=db_session)
yield UserAppSettingsService(store=store, user_context=user_context)

View File

@@ -24,6 +24,7 @@ from openhands.app_server.app_conversation.sql_app_conversation_info_service imp
)
from openhands.app_server.errors import AuthError
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import ADMIN
class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
@@ -63,6 +64,12 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
Raises:
AuthError: If no user_id is available (secure default: deny access)
"""
# For internal operations such as getting a conversation by session_api_key
# we need a mode that does not have filtering. The dependency `as_admin()`
# is used to enable it
if self.user_context == ADMIN:
return query
user_id_str = await self.user_context.get_user_id()
if not user_id_str:
# Secure default: no user means no access, not "show everything"

View File

@@ -0,0 +1,33 @@
from datetime import datetime
from typing import Annotated
from pydantic import BaseModel, StringConstraints
class VerifiedModelCreate(BaseModel):
model_name: Annotated[
str,
StringConstraints(max_length=255),
]
provider: Annotated[
str,
StringConstraints(max_length=100),
]
is_enabled: bool = True
class VerifiedModel(VerifiedModelCreate):
id: int
created_at: datetime
updated_at: datetime
class VerifiedModelUpdate(BaseModel):
is_enabled: bool | None = None
class VerifiedModelPage(BaseModel):
"""Paginated response model for verified model list."""
items: list[VerifiedModel]
next_page_id: str | None = None

View File

@@ -0,0 +1,143 @@
"""API routes for managing verified LLM models (admin only)."""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from server.email_validation import get_admin_user_id
from server.verified_models.verified_model_models import (
VerifiedModel,
VerifiedModelCreate,
VerifiedModelPage,
VerifiedModelUpdate,
)
from server.verified_models.verified_model_service import (
VerifiedModelService,
verified_model_store_dependency,
)
from openhands.app_server.config import get_db_session
from openhands.server.routes import public
from openhands.utils.llm import get_supported_llm_models
api_router = APIRouter(prefix='/api/admin/verified-models', tags=['Verified Models'])
@api_router.get('')
async def search_verified_models(
provider: str | None = None,
page_id: Annotated[
str | None,
Query(title='Optional next_page_id from the previously returned page'),
] = None,
limit: Annotated[
int, Query(title='The max number of results in the page', gt=0, le=100)
] = 100,
user_id: str = Depends(get_admin_user_id),
verified_model_service: VerifiedModelService = Depends(
verified_model_store_dependency
),
) -> VerifiedModelPage:
"""List all verified models, optionally filtered by provider."""
# Use SQL-level filtering and pagination
result = await verified_model_service.search_verified_models(
provider=provider,
enabled_only=False, # Admin sees all models including disabled
page_id=page_id,
limit=limit,
)
return result
@api_router.post('', status_code=201)
async def create_verified_model(
data: VerifiedModelCreate,
user_id: str = Depends(get_admin_user_id),
verified_model_service: VerifiedModelService = Depends(
verified_model_store_dependency
),
) -> VerifiedModel:
"""Create a new verified model."""
try:
model = await verified_model_service.create_verified_model(
model_name=data.model_name,
provider=data.provider,
is_enabled=data.is_enabled,
)
return model
except ValueError as ex:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(ex),
)
@api_router.put('/{provider}/{model_name:path}')
async def update_verified_model(
provider: str,
model_name: str,
data: VerifiedModelUpdate,
user_id: str = Depends(get_admin_user_id),
verified_model_service: VerifiedModelService = Depends(
verified_model_store_dependency
),
) -> VerifiedModel:
"""Update a verified model by provider and model name."""
model = await verified_model_service.update_verified_model(
model_name=model_name,
provider=provider,
is_enabled=data.is_enabled,
)
if not model:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'Model {provider}/{model_name} not found',
)
return model
@api_router.delete('/{provider}/{model_name:path}')
async def delete_verified_model(
provider: str,
model_name: str,
user_id: str = Depends(get_admin_user_id),
verified_model_service: VerifiedModelService = Depends(
verified_model_store_dependency
),
) -> bool:
"""Delete a verified model by provider and model name."""
try:
await verified_model_service.delete_verified_model(
model_name=model_name, provider=provider
)
return True
except ValueError as ex:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(ex),
)
async def get_saas_llm_models_dependency(request: Request) -> list[str]:
"""SaaS implementation for the LLM models endpoint."""
async with get_db_session(request.state, request) as db_session:
# Prevent circular import
from openhands.server.shared import config
verified_model_service = VerifiedModelService(db_session)
page = await verified_model_service.search_verified_models(enabled_only=True)
if page.next_page_id:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Too many models defined in database',
)
verified_models = [f'{m.provider}/{m.model_name}' for m in page.items]
return get_supported_llm_models(config, verified_models)
# Override the default implementation with SaaS implementation
# This must be called after the app is created in saas_server.py
def override_llm_models_dependency(app):
"""Override the default LLM models implementation with SaaS version."""
app.dependency_overrides[public.get_llm_models_dependency] = (
get_saas_llm_models_dependency
)

View File

@@ -0,0 +1,242 @@
"""Store for managing verified LLM models in the database."""
from dataclasses import dataclass
from sqlalchemy import (
Boolean,
Column,
DateTime,
Identity,
Integer,
String,
UniqueConstraint,
and_,
func,
select,
text,
)
from sqlalchemy.ext.asyncio import AsyncSession
from storage.base import Base
from enterprise.server.verified_models.verified_model_models import (
VerifiedModel,
VerifiedModelPage,
)
from openhands.app_server.config import depends_db_session
from openhands.core.logger import openhands_logger as logger
class StoredVerifiedModel(Base): # type: ignore
"""A verified LLM model available in the model selector.
The composite unique constraint on (model_name, provider) allows the same
model name to exist under different providers (e.g. 'claude-sonnet' under
both 'openhands' and 'anthropic').
"""
__tablename__ = 'verified_models'
__table_args__ = (
UniqueConstraint('model_name', 'provider', name='uq_verified_model_provider'),
)
id = Column(Integer, Identity(), primary_key=True)
model_name = Column(String(255), nullable=False)
provider = Column(String(100), nullable=False, index=True)
is_enabled = Column(
Boolean, nullable=False, default=True, server_default=text('true')
)
created_at = Column(DateTime, nullable=False, server_default=func.now())
updated_at = Column(
DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
)
def verified_model(result: StoredVerifiedModel) -> VerifiedModel:
return VerifiedModel(
id=result.id,
model_name=result.model_name,
provider=result.provider,
is_enabled=result.is_enabled,
created_at=result.created_at,
updated_at=result.updated_at,
)
@dataclass
class VerifiedModelService:
"""Store for CRUD operations on verified models.
Follows the async pattern with db_session as an attribute.
"""
db_session: AsyncSession
async def search_verified_models(
self,
provider: str | None = None,
enabled_only: bool = True,
page_id: str | None = None,
limit: int = 100,
) -> VerifiedModelPage:
"""Search for verified models with optional filtering and pagination.
Args:
provider: Optional provider name to filter by (e.g., 'openhands', 'anthropic')
enabled_only: If True, only return enabled models (default: True)
page_id: Page id for pagination
limit: Maximum number of records to return
Returns:
SearchModelsResult containing items list and has_more flag
"""
query = select(StoredVerifiedModel)
# Build filters
filters = []
if provider:
filters.append(StoredVerifiedModel.provider == provider)
if enabled_only:
filters.append(StoredVerifiedModel.is_enabled.is_(True))
if filters:
query = query.where(and_(*filters))
# Order by provider, then model_name
query = query.order_by(
StoredVerifiedModel.provider, StoredVerifiedModel.model_name
)
# Fetch limit + 1 to check if there are more results
offset = int(page_id or '0')
query = query.offset(offset).limit(limit + 1)
result = await self.db_session.execute(query)
results = list(result.scalars().all())
has_more = len(results) > limit
next_page_id = None
# Return only the requested number of results
if has_more:
next_page_id = str(offset + limit)
results.pop()
items = [verified_model(result) for result in results]
return VerifiedModelPage(items=items, next_page_id=next_page_id)
async def get_model(self, model_name: str, provider: str) -> VerifiedModel | None:
"""Get a model by its composite key (model_name, provider).
Args:
model_name: The model identifier
provider: The provider name
"""
query = select(StoredVerifiedModel).where(
and_(
StoredVerifiedModel.model_name == model_name,
StoredVerifiedModel.provider == provider,
)
)
result = await self.db_session.execute(query)
return result.scalars().first()
async def create_verified_model(
self,
model_name: str,
provider: str,
is_enabled: bool = True,
) -> VerifiedModel:
"""Create a new verified model.
Args:
model_name: The model identifier
provider: The provider name
is_enabled: Whether the model is enabled (default True)
Raises:
ValueError: If a model with the same (model_name, provider) already exists
"""
existing_query = select(StoredVerifiedModel).where(
and_(
StoredVerifiedModel.model_name == model_name,
StoredVerifiedModel.provider == provider,
)
)
result = await self.db_session.execute(existing_query)
existing = result.scalars().first()
if existing:
raise ValueError(f'Model {provider}/{model_name} already exists')
model = StoredVerifiedModel(
model_name=model_name,
provider=provider,
is_enabled=is_enabled,
)
self.db_session.add(model)
await self.db_session.commit()
await self.db_session.refresh(model)
logger.info(f'Created verified model: {provider}/{model_name}')
return verified_model(model)
async def update_verified_model(
self,
model_name: str,
provider: str,
is_enabled: bool | None = None,
) -> VerifiedModel | None:
"""Update an existing verified model.
Args:
model_name: The model name to update
provider: The provider name
is_enabled: New enabled state (optional)
Returns:
The updated model if found, None otherwise
"""
query = select(StoredVerifiedModel).where(
and_(
StoredVerifiedModel.model_name == model_name,
StoredVerifiedModel.provider == provider,
)
)
result = await self.db_session.execute(query)
model = result.scalars().first()
if not model:
return None
if is_enabled is not None:
model.is_enabled = is_enabled
await self.db_session.commit()
await self.db_session.refresh(model)
logger.info(f'Updated verified model: {provider}/{model_name}')
return verified_model(model)
async def delete_verified_model(self, model_name: str, provider: str):
"""Delete a verified model.
Args:
model_name: The model name to delete
provider: The provider name
Returns:
True if deleted, False if not found
"""
query = select(StoredVerifiedModel).where(
and_(
StoredVerifiedModel.model_name == model_name,
StoredVerifiedModel.provider == provider,
)
)
result = await self.db_session.execute(query)
model = result.scalars().first()
if not model:
raise ValueError('Unknown model')
await self.db_session.delete(model)
await self.db_session.commit()
logger.info(f'Deleted verified model: {provider}/{model_name}')
def verified_model_store_dependency(db_session: AsyncSession = depends_db_session()):
return VerifiedModelService(db_session)

View File

@@ -4,7 +4,9 @@ import time
from dataclasses import dataclass
from typing import Awaitable, Callable, Dict
from sqlalchemy import select, update
from server.auth.auth_error import TokenRefreshError
from sqlalchemy import select, text, update
from sqlalchemy.exc import OperationalError
from sqlalchemy.orm import sessionmaker
from storage.auth_tokens import AuthTokens
from storage.database import a_session_maker
@@ -12,6 +14,14 @@ from storage.database import a_session_maker
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.service_types import ProviderType
# Time buffer (in seconds) before actual expiration to consider token expired
# This ensures tokens are refreshed before they actually expire. The
# github default is 8 hours, so 15 minutes leeway is ~3% of this.
ACCESS_TOKEN_EXPIRY_BUFFER = 900 # 15 minutes
# Database lock timeout to prevent indefinite blocking
LOCK_TIMEOUT_SECONDS = 5
@dataclass
class AuthTokenStore:
@@ -23,6 +33,31 @@ class AuthTokenStore:
def identity_provider_value(self) -> str:
return self.idp.value
def _is_token_expired(
self, access_token_expires_at: int, refresh_token_expires_at: int
) -> tuple[bool, bool]:
"""Check if access and refresh tokens are expired.
Args:
access_token_expires_at: Expiration time for access token (seconds since epoch)
refresh_token_expires_at: Expiration time for refresh token (seconds since epoch)
Returns:
Tuple of (access_expired, refresh_expired)
"""
current_time = int(time.time())
access_expired = (
False
if access_token_expires_at == 0
else access_token_expires_at < current_time + ACCESS_TOKEN_EXPIRY_BUFFER
)
refresh_expired = (
False
if refresh_token_expires_at == 0
else refresh_token_expires_at < current_time
)
return access_expired, refresh_expired
async def store_tokens(
self,
access_token: str,
@@ -73,87 +108,149 @@ class AuthTokenStore:
]
| None = None,
) -> Dict[str, str | int] | None:
"""
Load authentication tokens from the database and refresh them if necessary.
"""Load authentication tokens from the database and refresh them if necessary.
This method retrieves the current authentication tokens for the user and checks if they have expired.
It uses the provided `check_expiration_and_refresh` function to determine if the tokens need
to be refreshed and to refresh the tokens if needed.
This method uses a double-checked locking pattern to minimize lock contention:
1. First, check if the token is valid WITHOUT acquiring a lock (fast path)
2. If refresh is needed, acquire a lock with a timeout
3. Double-check if refresh is still needed (another request may have refreshed)
4. Perform the refresh if still needed
The method ensures that only one refresh operation is performed per refresh token by using a
row-level lock on the token record.
The method is designed to handle race conditions where multiple requests might attempt to refresh
the same token simultaneously, ensuring that only one refresh call occurs per refresh token.
The row-level lock ensures that only one refresh operation is performed per
refresh token, which is important because most IDPs invalidate the old refresh
token after it's used once.
Args:
check_expiration_and_refresh (Callable, optional): A function that checks if the tokens have expired
and attempts to refresh them. It should return a dictionary containing the new access_token, refresh_token,
and their respective expiration timestamps. If no refresh is needed, it should return `None`.
check_expiration_and_refresh: A function that checks if the tokens have
expired and attempts to refresh them. It should return a dictionary
containing the new access_token, refresh_token, and their respective
expiration timestamps. If no refresh is needed, it should return None.
Returns:
Dict[str, str | int] | None:
A dictionary containing the access_token, refresh_token, access_token_expires_at,
and refresh_token_expires_at. If no token record is found, returns `None`.
A dictionary containing the access_token, refresh_token,
access_token_expires_at, and refresh_token_expires_at.
If no token record is found, returns None.
Raises:
TokenRefreshError: If the lock cannot be acquired within the timeout
period. This typically means another request is holding the lock
for an extended period. Callers should handle this by returning
a 401 response to prompt the user to re-authenticate.
"""
# FAST PATH: Check without lock first to avoid unnecessary lock contention
async with self.a_session_maker() as session:
async with session.begin(): # Ensures transaction management
# Lock the row while we check if we need to refresh the tokens.
# There is a race condition where 2 or more calls can load tokens simultaneously.
# If it turns out the loaded tokens are expired, then there will be multiple
# refresh token calls with the same refresh token. Most IDPs only allow one refresh
# per refresh token. This lock ensure that only one refresh call occurs per refresh token
result = await session.execute(
select(AuthTokens)
.filter(
AuthTokens.keycloak_user_id == self.keycloak_user_id,
AuthTokens.identity_provider == self.identity_provider_value,
)
.with_for_update()
result = await session.execute(
select(AuthTokens).filter(
AuthTokens.keycloak_user_id == self.keycloak_user_id,
AuthTokens.identity_provider == self.identity_provider_value,
)
token_record = result.scalars().one_or_none()
)
token_record = result.scalars().one_or_none()
if not token_record:
return None
if not token_record:
return None
token_refresh = (
await check_expiration_and_refresh(
# Check if token needs refresh
access_expired, _ = self._is_token_expired(
token_record.access_token_expires_at,
token_record.refresh_token_expires_at,
)
# If token is still valid, return it without acquiring a lock
if not access_expired or check_expiration_and_refresh is None:
return {
'access_token': token_record.access_token,
'refresh_token': token_record.refresh_token,
'access_token_expires_at': token_record.access_token_expires_at,
'refresh_token_expires_at': token_record.refresh_token_expires_at,
}
# SLOW PATH: Token needs refresh, acquire lock
try:
async with self.a_session_maker() as session:
async with session.begin():
# Set a lock timeout to prevent indefinite blocking
# This ensures we don't hold connections forever if something goes wrong
await session.execute(
text(f"SET LOCAL lock_timeout = '{LOCK_TIMEOUT_SECONDS}s'")
)
# Acquire row-level lock to prevent concurrent refresh attempts
result = await session.execute(
select(AuthTokens)
.filter(
AuthTokens.keycloak_user_id == self.keycloak_user_id,
AuthTokens.identity_provider
== self.identity_provider_value,
)
.with_for_update()
)
token_record = result.scalars().one_or_none()
if not token_record:
return None
# Double-check: another request may have refreshed while we waited for the lock
access_expired, _ = self._is_token_expired(
token_record.access_token_expires_at,
token_record.refresh_token_expires_at,
)
if not access_expired:
# Token was refreshed by another request while we waited
logger.debug(
'Token was refreshed by another request while waiting for lock'
)
return {
'access_token': token_record.access_token,
'refresh_token': token_record.refresh_token,
'access_token_expires_at': token_record.access_token_expires_at,
'refresh_token_expires_at': token_record.refresh_token_expires_at,
}
# We're the one doing the refresh
token_refresh = await check_expiration_and_refresh(
self.idp,
token_record.refresh_token,
token_record.access_token_expires_at,
token_record.refresh_token_expires_at,
)
if check_expiration_and_refresh
else None
)
if token_refresh:
await session.execute(
update(AuthTokens)
.where(AuthTokens.id == token_record.id)
.values(
access_token=token_refresh['access_token'],
refresh_token=token_refresh['refresh_token'],
access_token_expires_at=token_refresh[
'access_token_expires_at'
],
refresh_token_expires_at=token_refresh[
'refresh_token_expires_at'
],
if token_refresh:
await session.execute(
update(AuthTokens)
.where(AuthTokens.id == token_record.id)
.values(
access_token=token_refresh['access_token'],
refresh_token=token_refresh['refresh_token'],
access_token_expires_at=token_refresh[
'access_token_expires_at'
],
refresh_token_expires_at=token_refresh[
'refresh_token_expires_at'
],
)
)
)
await session.commit()
await session.commit()
return (
token_refresh
if token_refresh
else {
'access_token': token_record.access_token,
'refresh_token': token_record.refresh_token,
'access_token_expires_at': token_record.access_token_expires_at,
'refresh_token_expires_at': token_record.refresh_token_expires_at,
}
)
return (
token_refresh
if token_refresh
else {
'access_token': token_record.access_token,
'refresh_token': token_record.refresh_token,
'access_token_expires_at': token_record.access_token_expires_at,
'refresh_token_expires_at': token_record.refresh_token_expires_at,
}
)
except OperationalError as e:
# Lock timeout - another request is holding the lock for too long
logger.warning(
f'Token refresh lock timeout for user {self.keycloak_user_id}: {e}'
)
raise TokenRefreshError(
'Unable to refresh token due to lock timeout. Please try again.'
) from e
async def is_access_token_valid(self) -> bool:
"""Check if the access token is still valid.
@@ -194,8 +291,8 @@ class AuthTokenStore:
"""Get an instance of the AuthTokenStore.
Args:
config: The application configuration
keycloak_user_id: The Keycloak user ID
idp: The identity provider type
Returns:
An instance of AuthTokenStore

View File

@@ -18,17 +18,17 @@ def _get_db_session_injector():
return _config.db_session
def session_maker():
def session_maker(**kwargs):
db_session_injector = _get_db_session_injector()
session_maker = db_session_injector.get_session_maker()
return session_maker()
factory = db_session_injector.get_session_maker()
return factory(**kwargs)
@contextlib.asynccontextmanager
async def a_session_maker():
async def a_session_maker(**kwargs):
db_session_injector = _get_db_session_injector()
a_session_maker = await db_session_injector.get_async_session_maker()
async with a_session_maker() as session:
factory = await db_session_injector.get_async_session_maker()
async with factory(**kwargs) as session:
yield session

View File

@@ -51,7 +51,9 @@ class Org(Base): # type: ignore
# Relationships
org_members = relationship('OrgMember', back_populates='org')
current_users = relationship('User', back_populates='current_org')
invitations = relationship('OrgInvitation', back_populates='org')
invitations = relationship(
'OrgInvitation', back_populates='org', passive_deletes=True
)
billing_sessions = relationship('BillingSession', back_populates='org')
stored_conversation_metadata_saas = relationship(
'StoredConversationMetadataSaas', back_populates='org'

View File

@@ -0,0 +1,105 @@
"""Store class for managing organization app settings."""
from __future__ import annotations
from dataclasses import dataclass
from uuid import UUID
from server.constants import (
LITE_LLM_API_URL,
ORG_SETTINGS_VERSION,
get_default_litellm_model,
)
from server.routes.org_models import OrgAppSettingsUpdate
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from storage.org import Org
from storage.user import User
@dataclass
class OrgAppSettingsStore:
"""Store for organization app settings with injected db_session."""
db_session: AsyncSession
async def get_current_org_by_user_id(self, user_id: str) -> Org | None:
"""Get the current organization for a user.
Args:
user_id: The user's ID (Keycloak user ID)
Returns:
Org: The organization object, or None if not found
"""
# Get user with their current_org_id
result = await self.db_session.execute(
select(User).filter(User.id == UUID(user_id))
)
user = result.scalars().first()
if not user:
return None
org_id = user.current_org_id
if not org_id:
return None
# Get the organization
result = await self.db_session.execute(select(Org).filter(Org.id == org_id))
org = result.scalars().first()
if not org:
return None
return await self._validate_org_version(org)
async def _validate_org_version(self, org: Org) -> Org:
"""Check if we need to update org version.
Args:
org: The organization to validate
Returns:
Org: The validated (and potentially updated) organization
"""
if org.org_version < ORG_SETTINGS_VERSION:
org.org_version = ORG_SETTINGS_VERSION
org.default_llm_model = get_default_litellm_model()
org.llm_base_url = LITE_LLM_API_URL
await self.db_session.flush()
await self.db_session.refresh(org)
return org
async def update_org_app_settings(
self, org_id: UUID, update_data: OrgAppSettingsUpdate
) -> Org | None:
"""Update organization app settings.
Only updates fields that are explicitly provided in update_data.
Uses flush() - commit happens at request end via DbSessionInjector.
Args:
org_id: The organization's ID
update_data: Pydantic model with fields to update
Returns:
Org: The updated organization object, or None if not found
"""
result = await self.db_session.execute(
select(Org).filter(Org.id == org_id).with_for_update()
)
org = result.scalars().first()
if not org:
return None
# Update only explicitly provided fields
for field, value in update_data.model_dump(exclude_unset=True).items():
setattr(org, field, value)
# flush instead of commit - DbSessionInjector auto-commits at request end
await self.db_session.flush()
await self.db_session.refresh(org)
return org

View File

@@ -0,0 +1,83 @@
"""Store class for managing organization LLM settings."""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from uuid import UUID
from server.routes.org_models import OrgLLMSettingsUpdate
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from storage.org import Org
from storage.org_member_store import OrgMemberStore
from storage.user import User
@dataclass
class OrgLLMSettingsStore:
"""Store for org LLM settings with injected db_session."""
db_session: AsyncSession
async def get_current_org_by_user_id(self, user_id: str) -> Org | None:
"""Get the user's current organization.
Args:
user_id: The user's ID (Keycloak user ID)
Returns:
Org: The user's current organization, or None if not found
"""
# First get the user to find their current_org_id
result = await self.db_session.execute(
select(User).filter(User.id == uuid.UUID(user_id))
)
user = result.scalars().first()
if not user or not user.current_org_id:
return None
# Then get the org
result = await self.db_session.execute(
select(Org).filter(Org.id == user.current_org_id)
)
return result.scalars().first()
async def update_org_llm_settings(
self, org_id: UUID, update_data: OrgLLMSettingsUpdate
) -> Org | None:
"""Update organization LLM settings.
Also propagates relevant settings to all org members.
Uses flush() - commit happens at request end via DbSessionInjector.
Args:
org_id: The organization's ID
update_data: Pydantic model with fields to update
Returns:
Org: The updated organization, or None if org not found
"""
result = await self.db_session.execute(
select(Org).filter(Org.id == org_id).with_for_update()
)
org = result.scalars().first()
if not org:
return None
# Apply updates to org (excludes llm_api_key which is member-only)
update_data.apply_to_org(org)
# Propagate relevant settings to all org members
member_updates = update_data.get_member_updates()
if member_updates:
await OrgMemberStore.update_all_members_llm_settings_async(
self.db_session, org_id, member_updates
)
# flush instead of commit - DbSessionInjector auto-commits at request end
await self.db_session.flush()
await self.db_session.refresh(org)
return org

View File

@@ -5,9 +5,12 @@ Store class for managing organization-member relationships.
from typing import Optional
from uuid import UUID
from sqlalchemy import select
from server.routes.org_models import OrgMemberLLMSettings
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from storage.database import a_session_maker, session_maker
from storage.encrypt_utils import encrypt_value
from storage.org_member import OrgMember
from storage.user import User
from storage.user_settings import UserSettings
@@ -183,14 +186,48 @@ class OrgMemberStore:
}
return kwargs
@staticmethod
async def get_org_members_count(
org_id: UUID,
email_filter: str | None = None,
) -> int:
"""Get total count of organization members, optionally filtered by email.
Args:
org_id: Organization UUID.
email_filter: Optional case-insensitive partial email match.
Returns:
Total count of matching members.
"""
async with a_session_maker() as session:
query = select(func.count(OrgMember.user_id)).filter(
OrgMember.org_id == org_id
)
if email_filter:
query = query.join(User, User.id == OrgMember.user_id).filter(
User.email.ilike(f'%{email_filter}%')
)
result = await session.execute(query)
return result.scalar() or 0
@staticmethod
async def get_org_members_paginated(
org_id: UUID,
offset: int = 0,
limit: int = 100,
email_filter: str | None = None,
) -> tuple[list[OrgMember], bool]:
"""Get paginated list of organization members with user and role info.
Args:
org_id: Organization UUID.
offset: Number of records to skip.
limit: Maximum number of records to return.
email_filter: Optional case-insensitive partial email match.
Returns:
Tuple of (members_list, has_more) where has_more indicates if there are more results.
"""
@@ -200,13 +237,18 @@ class OrgMemberStore:
query = (
select(OrgMember)
.options(joinedload(OrgMember.user), joinedload(OrgMember.role))
.join(User, User.id == OrgMember.user_id)
.filter(OrgMember.org_id == org_id)
.order_by(OrgMember.user_id)
.offset(offset)
.limit(limit + 1)
)
# Apply email filter if provided
if email_filter:
query = query.filter(User.email.ilike(f'%{email_filter}%'))
query = query.order_by(OrgMember.user_id).offset(offset).limit(limit + 1)
result = await session.execute(query)
members = list(result.scalars().all())
members = list(result.unique().scalars().all())
# Check if there are more results
has_more = len(members) > limit
@@ -215,3 +257,28 @@ class OrgMemberStore:
members = members[:limit]
return members, has_more
@staticmethod
async def update_all_members_llm_settings_async(
session: AsyncSession,
org_id: UUID,
member_settings: OrgMemberLLMSettings,
) -> None:
"""Update LLM settings for all members of an organization.
Args:
session: Database session (passed from caller for transaction)
org_id: Organization ID
member_settings: Typed LLM settings to apply to all members
"""
# Build update values from non-None fields
values = member_settings.model_dump(exclude_none=True)
# Handle encrypted llm_api_key field - map to _llm_api_key column with encryption
if 'llm_api_key' in values:
raw_key = values.pop('llm_api_key')
values['_llm_api_key'] = encrypt_value(raw_key)
if values:
stmt = update(OrgMember).where(OrgMember.org_id == org_id).values(**values)
await session.execute(stmt)

View File

@@ -10,10 +10,10 @@ from server.constants import (
ORG_SETTINGS_VERSION,
get_default_litellm_model,
)
from server.routes.org_models import OrphanedUserError
from sqlalchemy import text
from server.routes.org_models import OrgLLMSettingsUpdate, OrphanedUserError
from sqlalchemy import select, text
from sqlalchemy.orm import joinedload
from storage.database import session_maker
from storage.database import a_session_maker, session_maker
from storage.lite_llm_manager import LiteLlmManager
from storage.org import Org
from storage.org_member import OrgMember
@@ -386,3 +386,47 @@ class OrgStore:
extra={'org_id': str(org_id), 'error': str(e)},
)
raise
@staticmethod
async def get_org_by_id_async(org_id: UUID) -> Org | None:
"""Get organization by ID (async version)."""
async with a_session_maker() as session:
result = await session.execute(select(Org).filter(Org.id == org_id))
org = result.scalars().first()
return OrgStore._validate_org_version(org) if org else None
@staticmethod
async def update_org_llm_settings_async(
org_id: UUID,
llm_settings: OrgLLMSettingsUpdate,
) -> Org | None:
"""Update organization LLM settings and propagate to members (async version).
Args:
org_id: Organization ID
llm_settings: Typed LLM settings update model
Returns:
Updated Org or None if not found
"""
from storage.org_member_store import OrgMemberStore
async with a_session_maker() as session:
result = await session.execute(select(Org).filter(Org.id == org_id))
org = result.scalars().first()
if not org:
return None
# Apply updates to org
llm_settings.apply_to_org(org)
# Propagate relevant settings to all org members
member_updates = llm_settings.get_member_updates()
if member_updates:
await OrgMemberStore.update_all_members_llm_settings_async(
session, org_id, member_updates
)
await session.commit()
await session.refresh(org)
return org

View File

@@ -0,0 +1,64 @@
"""Store class for managing user app settings."""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from server.routes.user_app_settings_models import UserAppSettingsUpdate
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from storage.user import User
@dataclass
class UserAppSettingsStore:
"""Store for user app settings with injected db_session."""
db_session: AsyncSession
async def get_user_by_id(self, user_id: str) -> User | None:
"""Get user by ID.
Args:
user_id: The user's ID (Keycloak user ID)
Returns:
User: The user object, or None if not found
"""
result = await self.db_session.execute(
select(User).filter(User.id == uuid.UUID(user_id))
)
return result.scalars().first()
async def update_user_app_settings(
self, user_id: str, update_data: UserAppSettingsUpdate
) -> User | None:
"""Update user app settings.
Only updates fields that are explicitly provided in update_data.
Uses flush() - commit happens at request end via DbSessionInjector.
Args:
user_id: The user's ID (Keycloak user ID)
update_data: Pydantic model with fields to update
Returns:
User: The updated user object, or None if user not found
"""
result = await self.db_session.execute(
select(User).filter(User.id == uuid.UUID(user_id)).with_for_update()
)
user = result.scalars().first()
if not user:
return None
# Update only explicitly provided fields
for field, value in update_data.model_dump(exclude_unset=True).items():
setattr(user, field, value)
# flush instead of commit - DbSessionInjector auto-commits at request end
await self.db_session.flush()
await self.db_session.refresh(user)
return user

View File

@@ -869,6 +869,88 @@ class UserStore:
org.contact_name = real_name
await session.commit()
@staticmethod
async def update_user_email(
user_id: str,
email: str | None = None,
email_verified: bool | None = None,
) -> None:
"""Unconditionally update User.email and/or email_verified.
Unlike backfill_user_email(), this overwrites existing values.
No-op when both arguments are None.
Missing user is logged as a warning and ignored.
"""
if email is None and email_verified is None:
return
async with a_session_maker() as session:
result = await session.execute(
select(User).filter(User.id == uuid.UUID(user_id))
)
user = result.scalars().first()
if not user:
logger.warning(
'update_user_email:user_not_found',
extra={'user_id': user_id},
)
return
if email is not None:
user.email = email
if email_verified is not None:
user.email_verified = email_verified
logger.info(
'update_user_email:updated',
extra={
'user_id': user_id,
'email_set': email is not None,
'email_verified_set': email_verified is not None,
},
)
await session.commit()
@staticmethod
async def backfill_user_email(user_id: str, user_info: dict) -> None:
"""Set User.email and email_verified from IDP if they are still NULL.
Called during login to gradually fix existing users whose email
was never persisted on the User record. Preserves non-NULL values
(e.g. if a user manually changed their email).
"""
async with a_session_maker() as session:
result = await session.execute(
select(User).filter(User.id == uuid.UUID(user_id))
)
user = result.scalars().first()
if not user:
logger.debug(
'backfill_user_email:user_not_found',
extra={'user_id': user_id},
)
return
updated = False
if user.email is None:
user.email = user_info.get('email')
updated = True
if user.email_verified is None:
user.email_verified = user_info.get('email_verified', False)
updated = True
if updated:
logger.info(
'backfill_user_email:updated',
extra={
'user_id': user_id,
'email_set': user.email is not None,
'email_verified_set': user.email_verified is not None,
},
)
await session.commit()
# Prevent circular imports
from typing import TYPE_CHECKING

View File

@@ -96,7 +96,7 @@ class ResendAPIError(ResendSyncError):
EMAIL_REGEX = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
def is_valid_email(email: str) -> bool:
def is_valid_email(email: Optional[str]) -> bool:
"""Validate an email address format.
This uses a regex pattern that matches most valid email addresses
@@ -104,10 +104,10 @@ def is_valid_email(email: str) -> bool:
does not accept (e.g., exclamation marks).
Args:
email: The email address to validate.
email: The email address to validate, or None.
Returns:
True if the email is valid, False otherwise.
True if the email is valid, False otherwise (including for None).
"""
if not email:
return False
@@ -251,6 +251,15 @@ def add_contact_to_resend(
raise
@retry(
stop=stop_after_attempt(MAX_RETRIES),
wait=wait_exponential(
multiplier=INITIAL_BACKOFF_SECONDS,
max=MAX_BACKOFF_SECONDS,
exp_base=BACKOFF_FACTOR,
),
retry=retry_if_exception_type(ResendError),
)
def send_welcome_email(
email: str,
first_name: Optional[str] = None,
@@ -267,7 +276,7 @@ def send_welcome_email(
The API response.
Raises:
ResendError: If the API call fails.
ResendError: If the API call fails after retries.
"""
try:
# Prepare the recipient name

View File

@@ -4,6 +4,9 @@ from uuid import UUID
import pytest
from server.constants import ORG_SETTINGS_VERSION
from server.verified_models.verified_model_service import (
StoredVerifiedModel, # noqa: F401
)
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from storage.base import Base
@@ -15,6 +18,7 @@ from storage.device_code import DeviceCode # noqa: F401
from storage.feedback import Feedback
from storage.github_app_installation import GithubAppInstallation
from storage.org import Org
from storage.org_invitation import OrgInvitation # noqa: F401
from storage.org_member import OrgMember
from storage.role import Role
from storage.stored_conversation_metadata import StoredConversationMetadata

View File

@@ -6,8 +6,10 @@ from fastapi.responses import JSONResponse, RedirectResponse
from pydantic import SecretStr
from server.auth.saas_user_auth import SaasUserAuth
from server.routes.email import (
EmailUpdate,
ResendEmailVerificationRequest,
resend_email_verification,
update_email,
verified_email,
verify_email,
)
@@ -116,12 +118,15 @@ async def test_verified_email_default_redirect(mock_request, mock_user_auth):
"""Test verified_email redirects to /settings/user by default."""
# Arrange
mock_request.query_params.get.return_value = None
mock_user_auth.user_id = 'test-user-id'
# Act
with (
patch('server.routes.email.get_user_auth', return_value=mock_user_auth),
patch('server.routes.email.set_response_cookie') as mock_set_cookie,
patch('server.routes.email.UserStore') as mock_user_store,
):
mock_user_store.update_user_email = AsyncMock()
result = await verified_email(mock_request)
# Assert
@@ -140,12 +145,15 @@ async def test_verified_email_https_scheme(mock_request, mock_user_auth):
mock_request.url.hostname = 'example.com'
mock_request.url.netloc = 'example.com'
mock_request.query_params.get.return_value = None
mock_user_auth.user_id = 'test-user-id'
# Act
with (
patch('server.routes.email.get_user_auth', return_value=mock_user_auth),
patch('server.routes.email.set_response_cookie') as mock_set_cookie,
patch('server.routes.email.UserStore') as mock_user_store,
):
mock_user_store.update_user_email = AsyncMock()
result = await verified_email(mock_request)
# Assert
@@ -327,6 +335,62 @@ async def test_resend_email_verification_with_is_auth_flow_false(mock_request):
assert '/api/email/verified' in call_args.kwargs['redirect_uri']
@pytest.mark.asyncio
async def test_update_email_calls_update_user_email(mock_request, mock_user_auth):
"""POST /api/email should call UserStore.update_user_email with new email and email_verified=False."""
user_id = 'test-user-id'
new_email = 'new@example.com'
email_data = EmailUpdate(email=new_email)
mock_keycloak_admin = MagicMock()
mock_keycloak_admin.get_user.return_value = {
'enabled': True,
'username': 'testuser',
}
mock_keycloak_admin.a_update_user = AsyncMock()
mock_user_store = MagicMock()
mock_user_store.update_user_email = AsyncMock()
with (
patch(
'server.routes.email.get_keycloak_admin', return_value=mock_keycloak_admin
),
patch('server.routes.email.get_user_auth', return_value=mock_user_auth),
patch('server.routes.email.set_response_cookie'),
patch('server.routes.email.verify_email', new_callable=AsyncMock),
patch('server.routes.email.UserStore', mock_user_store),
):
result = await update_email(
email_data=email_data, request=mock_request, user_id=user_id
)
assert result.status_code == status.HTTP_200_OK
mock_user_store.update_user_email.assert_awaited_once_with(
user_id=user_id, email=new_email, email_verified=False
)
@pytest.mark.asyncio
async def test_verified_email_calls_update_user_email(mock_request, mock_user_auth):
"""GET /api/email/verified should call UserStore.update_user_email with email_verified=True."""
mock_user_auth.user_id = 'test-user-id'
mock_user_store = MagicMock()
mock_user_store.update_user_email = AsyncMock()
with (
patch('server.routes.email.get_user_auth', return_value=mock_user_auth),
patch('server.routes.email.set_response_cookie'),
patch('server.routes.email.UserStore', mock_user_store),
):
result = await verified_email(mock_request)
assert result.status_code == 302
mock_user_store.update_user_email.assert_awaited_once_with(
user_id='test-user-id', email_verified=True
)
@pytest.mark.asyncio
async def test_resend_email_verification_body_none_uses_auth(mock_request):
"""Test resend_email_verification uses auth when body is None."""

View File

@@ -1220,3 +1220,60 @@ async def test_validate_workspace_update_permissions_no_current_link(mock_manage
result = await _validate_workspace_update_permissions('user1', 'test-workspace')
assert result == mock_workspace
# Tests for OAuth URL encoding
class TestJiraDcOAuthUrlEncoding:
"""Tests to verify OAuth authorization URLs are properly URL-encoded."""
@pytest.mark.asyncio
@patch('server.routes.integration.jira_dc.get_user_auth')
@patch('server.routes.integration.jira_dc.redis_client')
@patch('server.routes.integration.jira_dc.JIRA_DC_ENABLE_OAUTH', True)
async def test_create_jira_dc_workspace_url_encoding(
self, mock_redis, mock_get_auth, mock_request, mock_user_auth
):
"""Test that create_jira_dc_workspace properly URL-encodes the authorization URL."""
mock_get_auth.return_value = mock_user_auth
mock_redis.setex.return_value = True
workspace_data = JiraDcWorkspaceCreate(
workspace_name='test-workspace',
webhook_secret='secret',
svc_acc_email='svc@test.com',
svc_acc_api_key='key',
is_active=True,
)
response = await create_jira_dc_workspace(mock_request, workspace_data)
content = json.loads(response.body)
auth_url = content['authorizationUrl']
# Verify no raw spaces in the URL (spaces should be encoded as + or %20)
assert ' ' not in auth_url
# Verify scope parameter contains encoded scopes (+ is valid URL encoding for space)
assert 'scope=read%3Ame+read%3Ajira-user+read%3Ajira-work' in auth_url
# Verify redirect_uri is properly encoded
assert 'redirect_uri=https%3A%2F%2F' in auth_url
@pytest.mark.asyncio
@patch('server.routes.integration.jira_dc.get_user_auth')
@patch('server.routes.integration.jira_dc.redis_client')
@patch('server.routes.integration.jira_dc.JIRA_DC_ENABLE_OAUTH', True)
async def test_create_workspace_link_url_encoding(
self, mock_redis, mock_get_auth, mock_request, mock_user_auth
):
"""Test that create_workspace_link properly URL-encodes the authorization URL."""
mock_get_auth.return_value = mock_user_auth
mock_redis.setex.return_value = True
link_data = JiraDcLinkCreate(workspace_name='test-workspace')
response = await create_workspace_link(mock_request, link_data)
content = json.loads(response.body)
auth_url = content['authorizationUrl']
# Verify no raw spaces in the URL (spaces should be encoded as + or %20)
assert ' ' not in auth_url
# Verify scope parameter contains encoded scopes (+ is valid URL encoding for space)
assert 'scope=read%3Ame+read%3Ajira-user+read%3Ajira-work' in auth_url
# Verify redirect_uri is properly encoded
assert 'redirect_uri=https%3A%2F%2F' in auth_url

View File

@@ -1323,3 +1323,58 @@ async def test_validate_workspace_update_permissions_no_current_link(mock_manage
result = await _validate_workspace_update_permissions('user1', 'test-workspace')
assert result == mock_workspace
# Tests for OAuth URL encoding
class TestJiraOAuthUrlEncoding:
"""Tests to verify OAuth authorization URLs are properly URL-encoded."""
@pytest.mark.asyncio
@patch('server.routes.integration.jira.get_user_auth')
@patch('server.routes.integration.jira.redis_client')
async def test_create_jira_workspace_url_encoding(
self, mock_redis, mock_get_auth, mock_request, mock_user_auth
):
"""Test that create_jira_workspace properly URL-encodes the authorization URL."""
mock_get_auth.return_value = mock_user_auth
mock_redis.setex.return_value = True
workspace_data = JiraWorkspaceCreate(
workspace_name='test-workspace',
webhook_secret='secret',
svc_acc_email='svc@test.com',
svc_acc_api_key='key',
is_active=True,
)
response = await create_jira_workspace(mock_request, workspace_data)
content = json.loads(response.body)
auth_url = content['authorizationUrl']
# Verify no raw spaces in the URL (spaces should be encoded as + or %20)
assert ' ' not in auth_url
# Verify scope parameter contains encoded scopes (+ is valid URL encoding for space)
assert 'scope=read%3Ame+read%3Ajira-user+read%3Ajira-work' in auth_url
# Verify redirect_uri is properly encoded
assert 'redirect_uri=https%3A%2F%2F' in auth_url
@pytest.mark.asyncio
@patch('server.routes.integration.jira.get_user_auth')
@patch('server.routes.integration.jira.redis_client')
async def test_create_workspace_link_url_encoding(
self, mock_redis, mock_get_auth, mock_request, mock_user_auth
):
"""Test that create_workspace_link properly URL-encodes the authorization URL."""
mock_get_auth.return_value = mock_user_auth
mock_redis.setex.return_value = True
link_data = JiraLinkCreate(workspace_name='test-workspace')
response = await create_workspace_link(mock_request, link_data)
content = json.loads(response.body)
auth_url = content['authorizationUrl']
# Verify no raw spaces in the URL (spaces should be encoded as + or %20)
assert ' ' not in auth_url
# Verify scope parameter contains encoded scopes (+ is valid URL encoding for space)
assert 'scope=read%3Ame+read%3Ajira-user+read%3Ajira-work' in auth_url
# Verify redirect_uri is properly encoded
assert 'redirect_uri=https%3A%2F%2F' in auth_url

View File

@@ -24,6 +24,8 @@ with patch('storage.database.engine', create=True), patch(
LastOwnerError,
LiteLLMIntegrationError,
MeResponse,
OrgAppSettingsResponse,
OrgAppSettingsUpdate,
OrgAuthorizationError,
OrgDatabaseError,
OrgMemberNotFoundError,
@@ -2132,7 +2134,8 @@ class TestGetOrgMembersEndpoint:
status='active',
)
],
next_page_id=None,
current_page=1,
per_page=100,
)
with patch(
@@ -2150,7 +2153,7 @@ class TestGetOrgMembersEndpoint:
# Assert
assert isinstance(result, OrgMemberPage)
assert len(result.items) == 1
assert result.next_page_id is None
assert result.current_page == 1
mock_get.assert_called_once()
@pytest.mark.asyncio
@@ -2326,7 +2329,8 @@ class TestGetOrgMembersEndpoint:
status='active',
)
],
next_page_id='200',
current_page=2,
per_page=100,
)
with patch(
@@ -2343,15 +2347,132 @@ class TestGetOrgMembersEndpoint:
# Assert
assert isinstance(result, OrgMemberPage)
assert result.next_page_id == '200'
assert result.current_page == 2
mock_get.assert_called_once_with(
org_id=uuid.UUID(org_id),
current_user_id=uuid.UUID(current_user_id),
page_id='100',
limit=100,
email_filter=None,
)
class TestGetOrgMembersCountEndpoint:
"""Test cases for GET /api/organizations/{org_id}/members/count endpoint."""
@pytest.mark.asyncio
async def test_count_succeeds_returns_int(self, org_id, current_user_id):
"""Test that successful count returns an integer."""
# Arrange
with patch(
'server.routes.orgs.OrgMemberService.get_org_members_count',
AsyncMock(return_value=42),
) as mock_get_count:
# Import here to avoid circular import issues
from server.routes.orgs import get_org_members_count
# Act
result = await get_org_members_count(
org_id=uuid.UUID(org_id),
email=None,
user_id=current_user_id,
)
# Assert
assert result == 42
mock_get_count.assert_called_once_with(
org_id=uuid.UUID(org_id),
current_user_id=uuid.UUID(current_user_id),
email_filter=None,
)
@pytest.mark.asyncio
async def test_count_with_email_filter(self, org_id, current_user_id):
"""Test that email filter is passed to service."""
# Arrange
with patch(
'server.routes.orgs.OrgMemberService.get_org_members_count',
AsyncMock(return_value=5),
) as mock_get_count:
from server.routes.orgs import get_org_members_count
# Act
result = await get_org_members_count(
org_id=uuid.UUID(org_id),
email='alice',
user_id=current_user_id,
)
# Assert
assert result == 5
mock_get_count.assert_called_once_with(
org_id=uuid.UUID(org_id),
current_user_id=uuid.UUID(current_user_id),
email_filter='alice',
)
@pytest.mark.asyncio
async def test_not_a_member_returns_403(self, org_id, current_user_id):
"""Test that OrgMemberNotFoundError returns 403 Forbidden."""
# Arrange
with patch(
'server.routes.orgs.OrgMemberService.get_org_members_count',
AsyncMock(side_effect=OrgMemberNotFoundError(org_id, current_user_id)),
):
from server.routes.orgs import get_org_members_count
# Act & Assert
with pytest.raises(HTTPException) as exc_info:
await get_org_members_count(
org_id=uuid.UUID(org_id),
email=None,
user_id=current_user_id,
)
assert exc_info.value.status_code == status.HTTP_403_FORBIDDEN
assert 'not a member of this organization' in exc_info.value.detail
@pytest.mark.asyncio
async def test_invalid_uuid_returns_400(self, org_id):
"""Test that invalid user_id UUID format returns 400 Bad Request."""
# Arrange
invalid_user_id = 'not-a-uuid'
from server.routes.orgs import get_org_members_count
# Act & Assert
with pytest.raises(HTTPException) as exc_info:
await get_org_members_count(
org_id=uuid.UUID(org_id),
email=None,
user_id=invalid_user_id,
)
assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST
assert 'Invalid organization ID format' in exc_info.value.detail
@pytest.mark.asyncio
async def test_service_exception_returns_500(self, org_id, current_user_id):
"""Test that generic exception returns 500 Internal Server Error."""
# Arrange
with patch(
'server.routes.orgs.OrgMemberService.get_org_members_count',
AsyncMock(side_effect=Exception('Database error')),
):
from server.routes.orgs import get_org_members_count
# Act & Assert
with pytest.raises(HTTPException) as exc_info:
await get_org_members_count(
org_id=uuid.UUID(org_id),
email=None,
user_id=current_user_id,
)
assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert 'Failed to retrieve member count' in exc_info.value.detail
class TestRemoveOrgMemberEndpoint:
"""Test cases for DELETE /api/organizations/{org_id}/members/{user_id} endpoint."""
@@ -3305,3 +3426,421 @@ async def test_switch_org_database_error(mock_app_with_get_user_id):
# Assert
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert 'Failed to switch organization' in response.json()['detail']
# =============================================================================
# Tests for App Settings Endpoints
# =============================================================================
@pytest.fixture
def mock_member_role():
"""Create a mock member role for authorization tests."""
mock_role = MagicMock()
mock_role.name = 'member'
return mock_role
@pytest.mark.asyncio
async def test_get_org_app_settings_success(
mock_app_with_get_user_id, mock_member_role
):
"""
GIVEN: Authenticated user with MANAGE_APPLICATION_SETTINGS permission
WHEN: GET /api/organizations/app is called
THEN: App settings are returned with 200 status
"""
# Arrange
mock_response = OrgAppSettingsResponse(
enable_proactive_conversation_starters=True,
enable_solvability_analysis=False,
max_budget_per_task=10.0,
)
with (
patch(
'server.auth.authorization.get_user_org_role_async',
AsyncMock(return_value=mock_member_role),
),
patch(
'server.routes.orgs.OrgAppSettingsService.get_org_app_settings',
AsyncMock(return_value=mock_response),
),
):
client = TestClient(mock_app_with_get_user_id)
# Act
response = client.get('/api/organizations/app')
# Assert
assert response.status_code == status.HTTP_200_OK
response_data = response.json()
assert response_data['enable_proactive_conversation_starters'] is True
assert response_data['enable_solvability_analysis'] is False
assert response_data['max_budget_per_task'] == 10.0
@pytest.mark.asyncio
async def test_get_org_app_settings_with_null_values(
mock_app_with_get_user_id, mock_member_role
):
"""
GIVEN: Organization has null app settings values
WHEN: GET /api/organizations/app is called
THEN: Default values are returned where applicable
"""
# Arrange
# OrgAppSettingsResponse.from_org() handles defaults, so we test the response model
mock_response = OrgAppSettingsResponse(
enable_proactive_conversation_starters=True, # Default when None in Org
enable_solvability_analysis=None,
max_budget_per_task=None,
)
with (
patch(
'server.auth.authorization.get_user_org_role_async',
AsyncMock(return_value=mock_member_role),
),
patch(
'server.routes.orgs.OrgAppSettingsService.get_org_app_settings',
AsyncMock(return_value=mock_response),
),
):
client = TestClient(mock_app_with_get_user_id)
# Act
response = client.get('/api/organizations/app')
# Assert
assert response.status_code == status.HTTP_200_OK
response_data = response.json()
# enable_proactive_conversation_starters defaults to True when None
assert response_data['enable_proactive_conversation_starters'] is True
assert response_data['enable_solvability_analysis'] is None
assert response_data['max_budget_per_task'] is None
@pytest.mark.asyncio
async def test_get_org_app_settings_not_found(
mock_app_with_get_user_id, mock_member_role
):
"""
GIVEN: User has no current organization
WHEN: GET /api/organizations/app is called
THEN: 404 Not Found error is returned
"""
# Arrange
with (
patch(
'server.auth.authorization.get_user_org_role_async',
AsyncMock(return_value=mock_member_role),
),
patch(
'server.routes.orgs.OrgAppSettingsService.get_org_app_settings',
AsyncMock(side_effect=OrgNotFoundError('current')),
),
):
client = TestClient(mock_app_with_get_user_id)
# Act
response = client.get('/api/organizations/app')
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
assert 'not found' in response.json()['detail'].lower()
@pytest.mark.asyncio
async def test_get_org_app_settings_user_not_member(mock_app_with_get_user_id):
"""
GIVEN: User is not a member of any organization
WHEN: GET /api/organizations/app is called
THEN: 403 Forbidden error is returned
"""
# Arrange - user has no role (not a member)
with patch(
'server.auth.authorization.get_user_org_role_async',
AsyncMock(return_value=None),
):
client = TestClient(mock_app_with_get_user_id)
# Act
response = client.get('/api/organizations/app')
# Assert
assert response.status_code == status.HTTP_403_FORBIDDEN
assert 'not a member' in response.json()['detail'].lower()
@pytest.mark.asyncio
async def test_update_org_app_settings_success(
mock_app_with_get_user_id, mock_member_role
):
"""
GIVEN: Valid update data and authenticated user
WHEN: POST /api/organizations/app is called
THEN: Updated app settings are returned with 200 status
"""
# Arrange
mock_response = OrgAppSettingsResponse(
enable_proactive_conversation_starters=False,
enable_solvability_analysis=True,
max_budget_per_task=25.0,
)
with (
patch(
'server.auth.authorization.get_user_org_role_async',
AsyncMock(return_value=mock_member_role),
),
patch(
'server.routes.orgs.OrgAppSettingsService.update_org_app_settings',
AsyncMock(return_value=mock_response),
) as mock_update,
):
client = TestClient(mock_app_with_get_user_id)
# Act
response = client.post(
'/api/organizations/app',
json={
'enable_proactive_conversation_starters': False,
'enable_solvability_analysis': True,
'max_budget_per_task': 25.0,
},
)
# Assert
assert response.status_code == status.HTTP_200_OK
response_data = response.json()
assert response_data['enable_proactive_conversation_starters'] is False
assert response_data['enable_solvability_analysis'] is True
assert response_data['max_budget_per_task'] == 25.0
mock_update.assert_called_once()
@pytest.mark.asyncio
async def test_update_org_app_settings_partial_update(
mock_app_with_get_user_id, mock_member_role
):
"""
GIVEN: Partial update data (only some fields)
WHEN: POST /api/organizations/app is called
THEN: Only specified fields are updated
"""
# Arrange
mock_response = OrgAppSettingsResponse(
enable_proactive_conversation_starters=False,
enable_solvability_analysis=True,
max_budget_per_task=10.0, # Unchanged
)
with (
patch(
'server.auth.authorization.get_user_org_role_async',
AsyncMock(return_value=mock_member_role),
),
patch(
'server.routes.orgs.OrgAppSettingsService.update_org_app_settings',
AsyncMock(return_value=mock_response),
) as mock_update,
):
client = TestClient(mock_app_with_get_user_id)
# Act - only updating one field
response = client.post(
'/api/organizations/app',
json={'enable_proactive_conversation_starters': False},
)
# Assert
assert response.status_code == status.HTTP_200_OK
mock_update.assert_called_once()
# Verify the update data only contains the specified field
call_args = mock_update.call_args
update_data = call_args[0][0] # First positional argument (update_data)
assert isinstance(update_data, OrgAppSettingsUpdate)
@pytest.mark.asyncio
async def test_update_org_app_settings_set_null(
mock_app_with_get_user_id, mock_member_role
):
"""
GIVEN: Request to set max_budget_per_task to null
WHEN: POST /api/organizations/app is called
THEN: The field is set to null successfully
"""
# Arrange
mock_response = OrgAppSettingsResponse(
enable_proactive_conversation_starters=True,
enable_solvability_analysis=True,
max_budget_per_task=None,
)
with (
patch(
'server.auth.authorization.get_user_org_role_async',
AsyncMock(return_value=mock_member_role),
),
patch(
'server.routes.orgs.OrgAppSettingsService.update_org_app_settings',
AsyncMock(return_value=mock_response),
),
):
client = TestClient(mock_app_with_get_user_id)
# Act - explicitly setting max_budget_per_task to null
response = client.post(
'/api/organizations/app',
json={'max_budget_per_task': None},
)
# Assert
assert response.status_code == status.HTTP_200_OK
response_data = response.json()
assert response_data['max_budget_per_task'] is None
@pytest.mark.asyncio
async def test_update_org_app_settings_invalid_max_budget(
mock_app_with_get_user_id, mock_member_role
):
"""
GIVEN: Invalid max_budget_per_task value (zero or negative)
WHEN: POST /api/organizations/app is called
THEN: 422 Validation error is returned
"""
# Arrange
with patch(
'server.auth.authorization.get_user_org_role_async',
AsyncMock(return_value=mock_member_role),
):
client = TestClient(mock_app_with_get_user_id)
# Act - negative value
response = client.post(
'/api/organizations/app',
json={'max_budget_per_task': -5.0},
)
# Assert
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
@pytest.mark.asyncio
async def test_update_org_app_settings_zero_max_budget(
mock_app_with_get_user_id, mock_member_role
):
"""
GIVEN: max_budget_per_task is set to zero
WHEN: POST /api/organizations/app is called
THEN: 422 Validation error is returned (must be greater than 0)
"""
# Arrange
with patch(
'server.auth.authorization.get_user_org_role_async',
AsyncMock(return_value=mock_member_role),
):
client = TestClient(mock_app_with_get_user_id)
# Act - zero value
response = client.post(
'/api/organizations/app',
json={'max_budget_per_task': 0},
)
# Assert
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
@pytest.mark.asyncio
async def test_update_org_app_settings_not_found(
mock_app_with_get_user_id, mock_member_role
):
"""
GIVEN: User has no current organization
WHEN: POST /api/organizations/app is called
THEN: 404 Not Found error is returned
"""
# Arrange
with (
patch(
'server.auth.authorization.get_user_org_role_async',
AsyncMock(return_value=mock_member_role),
),
patch(
'server.routes.orgs.OrgAppSettingsService.update_org_app_settings',
AsyncMock(side_effect=OrgNotFoundError('current')),
),
):
client = TestClient(mock_app_with_get_user_id)
# Act
response = client.post(
'/api/organizations/app',
json={'enable_proactive_conversation_starters': False},
)
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
assert 'not found' in response.json()['detail'].lower()
@pytest.mark.asyncio
async def test_update_org_app_settings_database_error(
mock_app_with_get_user_id, mock_member_role
):
"""
GIVEN: Database update fails
WHEN: POST /api/organizations/app is called
THEN: 500 Internal Server Error is returned
"""
# Arrange
with (
patch(
'server.auth.authorization.get_user_org_role_async',
AsyncMock(return_value=mock_member_role),
),
patch(
'server.routes.orgs.OrgAppSettingsService.update_org_app_settings',
AsyncMock(side_effect=Exception('Database connection failed')),
),
):
client = TestClient(mock_app_with_get_user_id)
# Act
response = client.post(
'/api/organizations/app',
json={'enable_proactive_conversation_starters': False},
)
# Assert
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert 'unexpected error' in response.json()['detail'].lower()
@pytest.mark.asyncio
async def test_update_org_app_settings_user_not_member(mock_app_with_get_user_id):
"""
GIVEN: User is not a member of any organization
WHEN: POST /api/organizations/app is called
THEN: 403 Forbidden error is returned
"""
# Arrange - user has no role (not a member)
with patch(
'server.auth.authorization.get_user_org_role_async',
AsyncMock(return_value=None),
):
client = TestClient(mock_app_with_get_user_id)
# Act
response = client.post(
'/api/organizations/app',
json={'enable_proactive_conversation_starters': False},
)
# Assert
assert response.status_code == status.HTTP_403_FORBIDDEN
assert 'not a member' in response.json()['detail'].lower()

View File

@@ -0,0 +1,207 @@
"""
Unit tests for user app settings API routes.
Tests the GET and POST /api/users/app endpoints.
"""
import uuid
from unittest.mock import AsyncMock, patch
import pytest
from fastapi import FastAPI, status
from fastapi.testclient import TestClient
from server.routes.user_app_settings import user_app_settings_router
from server.routes.user_app_settings_models import (
UserAppSettingsResponse,
UserNotFoundError,
)
from openhands.server.user_auth import get_user_id
TEST_USER_ID = str(uuid.uuid4())
@pytest.fixture
def mock_app():
"""Create a test FastAPI app with user app settings routes and mocked auth."""
app = FastAPI()
app.include_router(user_app_settings_router)
def mock_get_user_id():
return TEST_USER_ID
app.dependency_overrides[get_user_id] = mock_get_user_id
return app
@pytest.fixture
def mock_app_unauthenticated():
"""Create a test FastAPI app with no authenticated user."""
app = FastAPI()
app.include_router(user_app_settings_router)
def mock_get_user_id():
return None
app.dependency_overrides[get_user_id] = mock_get_user_id
return app
@pytest.fixture
def mock_settings_response():
"""Create a mock user app settings response."""
return UserAppSettingsResponse(
language='en',
user_consents_to_analytics=True,
enable_sound_notifications=False,
git_user_name='testuser',
git_user_email='test@example.com',
)
@pytest.mark.asyncio
async def test_get_user_app_settings_success(mock_app, mock_settings_response):
"""
GIVEN: An authenticated user with app settings
WHEN: GET /api/users/app is called
THEN: User's app settings are returned with 200 status
"""
# Arrange
with patch(
'server.routes.user_app_settings.UserAppSettingsService.get_user_app_settings',
AsyncMock(return_value=mock_settings_response),
):
client = TestClient(mock_app)
# Act
response = client.get('/api/users/app')
# Assert
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data['language'] == 'en'
assert data['user_consents_to_analytics'] is True
assert data['enable_sound_notifications'] is False
assert data['git_user_name'] == 'testuser'
assert data['git_user_email'] == 'test@example.com'
@pytest.mark.asyncio
async def test_get_user_app_settings_not_authenticated(mock_app_unauthenticated):
"""
GIVEN: An unauthenticated request
WHEN: GET /api/users/app is called
THEN: 401 Unauthorized is returned
"""
# Arrange
client = TestClient(mock_app_unauthenticated)
# Act
response = client.get('/api/users/app')
# Assert
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert 'not authenticated' in response.json()['detail'].lower()
@pytest.mark.asyncio
async def test_get_user_app_settings_user_not_found(mock_app):
"""
GIVEN: An authenticated user that doesn't exist in the database
WHEN: GET /api/users/app is called
THEN: 404 Not Found is returned
"""
# Arrange
with patch(
'server.routes.user_app_settings.UserAppSettingsService.get_user_app_settings',
AsyncMock(side_effect=UserNotFoundError(TEST_USER_ID)),
):
client = TestClient(mock_app)
# Act
response = client.get('/api/users/app')
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
assert 'not found' in response.json()['detail'].lower()
@pytest.mark.asyncio
async def test_update_user_app_settings_success(mock_app):
"""
GIVEN: An authenticated user
WHEN: POST /api/users/app is called with update data
THEN: Updated settings are returned with 200 status
"""
# Arrange
updated_response = UserAppSettingsResponse(
language='es',
user_consents_to_analytics=False,
enable_sound_notifications=True,
git_user_name='newuser',
git_user_email='new@example.com',
)
request_data = {
'language': 'es',
'user_consents_to_analytics': False,
}
with patch(
'server.routes.user_app_settings.UserAppSettingsService.update_user_app_settings',
AsyncMock(return_value=updated_response),
):
client = TestClient(mock_app)
# Act
response = client.post('/api/users/app', json=request_data)
# Assert
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data['language'] == 'es'
assert data['user_consents_to_analytics'] is False
@pytest.mark.asyncio
async def test_update_user_app_settings_not_authenticated(mock_app_unauthenticated):
"""
GIVEN: An unauthenticated request
WHEN: POST /api/users/app is called
THEN: 401 Unauthorized is returned
"""
# Arrange
request_data = {'language': 'en'}
client = TestClient(mock_app_unauthenticated)
# Act
response = client.post('/api/users/app', json=request_data)
# Assert
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert 'not authenticated' in response.json()['detail'].lower()
@pytest.mark.asyncio
async def test_update_user_app_settings_user_not_found(mock_app):
"""
GIVEN: An authenticated user that doesn't exist in the database
WHEN: POST /api/users/app is called
THEN: 404 Not Found is returned
"""
# Arrange
request_data = {'language': 'en'}
with patch(
'server.routes.user_app_settings.UserAppSettingsService.update_user_app_settings',
AsyncMock(side_effect=UserNotFoundError(TEST_USER_ID)),
):
client = TestClient(mock_app)
# Act
response = client.post('/api/users/app', json=request_data)
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
assert 'not found' in response.json()['detail'].lower()

View File

@@ -0,0 +1,173 @@
"""
Unit tests for OrgAppSettingsService.
Tests the service layer for organization app settings operations.
"""
import uuid
from unittest.mock import AsyncMock, MagicMock
import pytest
from server.routes.org_models import (
OrgAppSettingsResponse,
OrgAppSettingsUpdate,
OrgNotFoundError,
)
from server.services.org_app_settings_service import OrgAppSettingsService
from storage.org import Org
@pytest.fixture
def user_id():
"""Create a test user ID."""
return str(uuid.uuid4())
@pytest.fixture
def mock_org():
"""Create a mock organization with app settings."""
org = MagicMock(spec=Org)
org.id = uuid.uuid4()
org.enable_proactive_conversation_starters = True
org.enable_solvability_analysis = False
org.max_budget_per_task = 25.0
return org
@pytest.fixture
def mock_store():
"""Create a mock OrgAppSettingsStore."""
return MagicMock()
@pytest.fixture
def mock_user_context(user_id):
"""Create a mock UserContext that returns the user_id."""
context = MagicMock()
context.get_user_id = AsyncMock(return_value=user_id)
return context
@pytest.mark.asyncio
async def test_get_org_app_settings_success(
user_id, mock_org, mock_store, mock_user_context
):
"""
GIVEN: A user's current organization exists
WHEN: get_org_app_settings is called
THEN: OrgAppSettingsResponse is returned with correct data
"""
# Arrange
mock_store.get_current_org_by_user_id = AsyncMock(return_value=mock_org)
service = OrgAppSettingsService(store=mock_store, user_context=mock_user_context)
# Act
result = await service.get_org_app_settings()
# Assert
assert isinstance(result, OrgAppSettingsResponse)
assert result.enable_proactive_conversation_starters is True
assert result.enable_solvability_analysis is False
assert result.max_budget_per_task == 25.0
mock_store.get_current_org_by_user_id.assert_called_once_with(user_id)
@pytest.mark.asyncio
async def test_get_org_app_settings_org_not_found(
user_id, mock_store, mock_user_context
):
"""
GIVEN: A user has no current organization
WHEN: get_org_app_settings is called
THEN: OrgNotFoundError is raised
"""
# Arrange
mock_store.get_current_org_by_user_id = AsyncMock(return_value=None)
service = OrgAppSettingsService(store=mock_store, user_context=mock_user_context)
# Act & Assert
with pytest.raises(OrgNotFoundError) as exc_info:
await service.get_org_app_settings()
assert 'current' in str(exc_info.value)
@pytest.mark.asyncio
async def test_update_org_app_settings_success(
user_id, mock_org, mock_store, mock_user_context
):
"""
GIVEN: A user's current organization exists
WHEN: update_org_app_settings is called with new values
THEN: OrgAppSettingsResponse is returned with updated data
"""
# Arrange
mock_org.enable_proactive_conversation_starters = False
mock_org.max_budget_per_task = 50.0
update_data = OrgAppSettingsUpdate(
enable_proactive_conversation_starters=False,
max_budget_per_task=50.0,
)
mock_store.get_current_org_by_user_id = AsyncMock(return_value=mock_org)
mock_store.update_org_app_settings = AsyncMock(return_value=mock_org)
service = OrgAppSettingsService(store=mock_store, user_context=mock_user_context)
# Act
result = await service.update_org_app_settings(update_data)
# Assert
assert isinstance(result, OrgAppSettingsResponse)
assert result.enable_proactive_conversation_starters is False
assert result.max_budget_per_task == 50.0
mock_store.update_org_app_settings.assert_called_once_with(
org_id=mock_org.id, update_data=update_data
)
@pytest.mark.asyncio
async def test_update_org_app_settings_no_changes(
user_id, mock_org, mock_store, mock_user_context
):
"""
GIVEN: A user's current organization exists
WHEN: update_org_app_settings is called with no fields
THEN: Current settings are returned without calling update
"""
# Arrange
update_data = OrgAppSettingsUpdate() # No fields set
mock_store.get_current_org_by_user_id = AsyncMock(return_value=mock_org)
mock_store.update_org_app_settings = AsyncMock()
service = OrgAppSettingsService(store=mock_store, user_context=mock_user_context)
# Act
result = await service.update_org_app_settings(update_data)
# Assert
assert isinstance(result, OrgAppSettingsResponse)
mock_store.get_current_org_by_user_id.assert_called_once_with(user_id)
mock_store.update_org_app_settings.assert_not_called()
@pytest.mark.asyncio
async def test_update_org_app_settings_org_not_found(
user_id, mock_store, mock_user_context
):
"""
GIVEN: A user has no current organization
WHEN: update_org_app_settings is called
THEN: OrgNotFoundError is raised
"""
# Arrange
update_data = OrgAppSettingsUpdate(enable_proactive_conversation_starters=False)
mock_store.get_current_org_by_user_id = AsyncMock(return_value=None)
service = OrgAppSettingsService(store=mock_store, user_context=mock_user_context)
# Act & Assert
with pytest.raises(OrgNotFoundError) as exc_info:
await service.update_org_app_settings(update_data)
assert 'current' in str(exc_info.value)

View File

@@ -0,0 +1,215 @@
"""
Unit tests for OrgLLMSettingsService.
Tests the service layer for organization LLM settings operations.
"""
import uuid
from unittest.mock import AsyncMock, MagicMock
import pytest
from server.routes.org_models import (
OrgLLMSettingsResponse,
OrgLLMSettingsUpdate,
OrgNotFoundError,
)
from server.services.org_llm_settings_service import OrgLLMSettingsService
from storage.org import Org
@pytest.fixture
def user_id():
"""Create a test user ID."""
return str(uuid.uuid4())
@pytest.fixture
def org_id():
"""Create a test org ID."""
return uuid.uuid4()
@pytest.fixture
def mock_org(org_id):
"""Create a mock organization with LLM settings."""
org = MagicMock(spec=Org)
org.id = org_id
org.default_llm_model = 'claude-3'
org.default_llm_base_url = 'https://api.anthropic.com'
org.search_api_key = None
org.agent = 'CodeActAgent'
org.confirmation_mode = True
org.security_analyzer = None
org.enable_default_condenser = True
org.condenser_max_size = None
org.default_max_iterations = 50
return org
@pytest.fixture
def mock_store():
"""Create a mock OrgLLMSettingsStore."""
return MagicMock()
@pytest.fixture
def mock_user_context(user_id):
"""Create a mock UserContext that returns the user_id."""
context = MagicMock()
context.get_user_id = AsyncMock(return_value=user_id)
return context
@pytest.mark.asyncio
async def test_get_org_llm_settings_success(
user_id, mock_org, mock_store, mock_user_context
):
"""
GIVEN: A user with a current organization
WHEN: get_org_llm_settings is called
THEN: OrgLLMSettingsResponse is returned with correct data
"""
# Arrange
mock_store.get_current_org_by_user_id = AsyncMock(return_value=mock_org)
service = OrgLLMSettingsService(store=mock_store, user_context=mock_user_context)
# Act
result = await service.get_org_llm_settings()
# Assert
assert isinstance(result, OrgLLMSettingsResponse)
assert result.default_llm_model == 'claude-3'
assert result.agent == 'CodeActAgent'
mock_store.get_current_org_by_user_id.assert_called_once_with(user_id)
@pytest.mark.asyncio
async def test_get_org_llm_settings_user_not_authenticated(mock_store):
"""
GIVEN: A user is not authenticated
WHEN: get_org_llm_settings is called
THEN: ValueError is raised
"""
# Arrange
mock_user_context = MagicMock()
mock_user_context.get_user_id = AsyncMock(return_value=None)
service = OrgLLMSettingsService(store=mock_store, user_context=mock_user_context)
# Act & Assert
with pytest.raises(ValueError) as exc_info:
await service.get_org_llm_settings()
assert 'not authenticated' in str(exc_info.value)
@pytest.mark.asyncio
async def test_get_org_llm_settings_org_not_found(
user_id, mock_store, mock_user_context
):
"""
GIVEN: A user has no current organization
WHEN: get_org_llm_settings is called
THEN: OrgNotFoundError is raised
"""
# Arrange
mock_store.get_current_org_by_user_id = AsyncMock(return_value=None)
service = OrgLLMSettingsService(store=mock_store, user_context=mock_user_context)
# Act & Assert
with pytest.raises(OrgNotFoundError) as exc_info:
await service.get_org_llm_settings()
assert 'No current organization' in str(exc_info.value)
@pytest.mark.asyncio
async def test_update_org_llm_settings_success(
user_id, mock_org, mock_store, mock_user_context
):
"""
GIVEN: A user with a current organization
WHEN: update_org_llm_settings is called with new values
THEN: OrgLLMSettingsResponse is returned with updated data
"""
# Arrange
updated_org = MagicMock(spec=Org)
updated_org.id = mock_org.id
updated_org.default_llm_model = 'new-model'
updated_org.default_llm_base_url = None
updated_org.search_api_key = None
updated_org.agent = 'CodeActAgent'
updated_org.confirmation_mode = False
updated_org.security_analyzer = None
updated_org.enable_default_condenser = True
updated_org.condenser_max_size = None
updated_org.default_max_iterations = 100
update_data = OrgLLMSettingsUpdate(
default_llm_model='new-model',
confirmation_mode=False,
default_max_iterations=100,
)
mock_store.get_current_org_by_user_id = AsyncMock(return_value=mock_org)
mock_store.update_org_llm_settings = AsyncMock(return_value=updated_org)
service = OrgLLMSettingsService(store=mock_store, user_context=mock_user_context)
# Act
result = await service.update_org_llm_settings(update_data)
# Assert
assert isinstance(result, OrgLLMSettingsResponse)
assert result.default_llm_model == 'new-model'
assert result.confirmation_mode is False
assert result.default_max_iterations == 100
mock_store.update_org_llm_settings.assert_called_once_with(
org_id=mock_org.id,
update_data=update_data,
)
@pytest.mark.asyncio
async def test_update_org_llm_settings_no_changes(
user_id, mock_org, mock_store, mock_user_context
):
"""
GIVEN: A user with a current organization
WHEN: update_org_llm_settings is called with no fields
THEN: Current settings are returned without calling update
"""
# Arrange
update_data = OrgLLMSettingsUpdate() # No fields set
mock_store.get_current_org_by_user_id = AsyncMock(return_value=mock_org)
mock_store.update_org_llm_settings = AsyncMock()
service = OrgLLMSettingsService(store=mock_store, user_context=mock_user_context)
# Act
result = await service.update_org_llm_settings(update_data)
# Assert
assert isinstance(result, OrgLLMSettingsResponse)
assert result.default_llm_model == 'claude-3'
mock_store.update_org_llm_settings.assert_not_called()
@pytest.mark.asyncio
async def test_update_org_llm_settings_org_not_found(
user_id, mock_store, mock_user_context
):
"""
GIVEN: A user has no current organization
WHEN: update_org_llm_settings is called
THEN: OrgNotFoundError is raised
"""
# Arrange
update_data = OrgLLMSettingsUpdate(default_llm_model='new-model')
mock_store.get_current_org_by_user_id = AsyncMock(return_value=None)
service = OrgLLMSettingsService(store=mock_store, user_context=mock_user_context)
# Act & Assert
with pytest.raises(OrgNotFoundError) as exc_info:
await service.update_org_llm_settings(update_data)
assert 'No current organization' in str(exc_info.value)

View File

@@ -7,7 +7,6 @@ import pytest
from pydantic import SecretStr
from server.routes.org_models import (
CannotModifySelfError,
InsufficientPermissionError,
InvalidRoleError,
LastOwnerError,
MeResponse,
@@ -175,7 +174,8 @@ class TestOrgMemberServiceGetOrgMembers:
assert data is not None
assert isinstance(data, OrgMemberPage)
assert len(data.items) == 1
assert data.next_page_id is None
assert data.current_page == 1
assert data.per_page == 100
assert data.items[0].user_id == str(current_user_id)
assert data.items[0].email == 'test@example.com'
assert data.items[0].role_id == 1
@@ -282,9 +282,9 @@ class TestOrgMemberServiceGetOrgMembers:
# Assert
assert success is True
assert data is not None
assert data.next_page_id is None
assert data.current_page == 1
mock_get_paginated.assert_called_once_with(
org_id=org_id, offset=0, limit=100
org_id=org_id, offset=0, limit=100, email_filter=None
)
@pytest.mark.asyncio
@@ -316,9 +316,9 @@ class TestOrgMemberServiceGetOrgMembers:
# Assert
assert success is True
assert data is not None
assert data.next_page_id == '150' # offset (100) + limit (50)
assert data.current_page == 3 # offset (100) / limit (50) + 1
mock_get_paginated.assert_called_once_with(
org_id=org_id, offset=100, limit=50
org_id=org_id, offset=100, limit=50, email_filter=None
)
@pytest.mark.asyncio
@@ -350,7 +350,7 @@ class TestOrgMemberServiceGetOrgMembers:
# Assert
assert success is True
assert data is not None
assert data.next_page_id is None
assert data.current_page == 3
@pytest.mark.asyncio
async def test_empty_organization_no_members(
@@ -382,7 +382,6 @@ class TestOrgMemberServiceGetOrgMembers:
assert success is True
assert data is not None
assert len(data.items) == 0
assert data.next_page_id is None
@pytest.mark.asyncio
async def test_missing_user_relationship_handles_gracefully(
@@ -512,6 +511,156 @@ class TestOrgMemberServiceGetOrgMembers:
assert data is not None
assert len(data.items) == 2
@pytest.mark.asyncio
async def test_email_filter_passed_to_store(
self, org_id, current_user_id, mock_org_member, requester_membership_owner
):
"""Test that email filter is passed to store methods."""
# Arrange
with (
patch(
'server.services.org_member_service.OrgMemberStore.get_org_member'
) as mock_get_member,
patch(
'server.services.org_member_service.OrgMemberStore.get_org_members_paginated',
new_callable=AsyncMock,
) as mock_get_paginated,
):
mock_get_member.return_value = requester_membership_owner
mock_get_paginated.return_value = ([mock_org_member], False)
# Act
await OrgMemberService.get_org_members(
org_id=org_id,
current_user_id=current_user_id,
page_id=None,
limit=10,
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_pagination_metadata_correct_for_page_2(
self, org_id, current_user_id, mock_org_member, requester_membership_owner
):
"""Test pagination metadata is correct for page 2."""
# Arrange
with (
patch(
'server.services.org_member_service.OrgMemberStore.get_org_member'
) as mock_get_member,
patch(
'server.services.org_member_service.OrgMemberStore.get_org_members_paginated',
new_callable=AsyncMock,
) as mock_get_paginated,
):
mock_get_member.return_value = requester_membership_owner
mock_get_paginated.return_value = ([mock_org_member], True)
# Act - Request page 2 (offset 10) with limit 10
success, error_code, data = await OrgMemberService.get_org_members(
org_id=org_id,
current_user_id=current_user_id,
page_id='10',
limit=10,
)
# Assert
assert success is True
assert data is not None
assert data.current_page == 2
assert data.per_page == 10
class TestOrgMemberServiceGetOrgMembersCount:
"""Test cases for OrgMemberService.get_org_members_count."""
@pytest.fixture
def requester_membership(self, org_id, current_user_id):
"""Create a mock requester membership."""
membership = MagicMock(spec=OrgMember)
membership.org_id = org_id
membership.user_id = current_user_id
membership.role_id = 1
return membership
@pytest.mark.asyncio
async def test_count_succeeds_returns_count(
self, org_id, current_user_id, requester_membership
):
"""Test that successful count returns the member count."""
# Arrange
with (
patch(
'server.services.org_member_service.OrgMemberStore.get_org_member'
) as mock_get_member,
patch(
'server.services.org_member_service.OrgMemberStore.get_org_members_count',
new_callable=AsyncMock,
) as mock_get_count,
):
mock_get_member.return_value = requester_membership
mock_get_count.return_value = 42
# Act
count = await OrgMemberService.get_org_members_count(
org_id=org_id,
current_user_id=current_user_id,
)
# Assert
assert count == 42
mock_get_count.assert_called_once_with(org_id=org_id, email_filter=None)
@pytest.mark.asyncio
async def test_count_with_email_filter(
self, org_id, current_user_id, requester_membership
):
"""Test that email filter is passed to store method."""
# Arrange
with (
patch(
'server.services.org_member_service.OrgMemberStore.get_org_member'
) as mock_get_member,
patch(
'server.services.org_member_service.OrgMemberStore.get_org_members_count',
new_callable=AsyncMock,
) as mock_get_count,
):
mock_get_member.return_value = requester_membership
mock_get_count.return_value = 5
# Act
count = await OrgMemberService.get_org_members_count(
org_id=org_id,
current_user_id=current_user_id,
email_filter='alice',
)
# Assert
assert count == 5
mock_get_count.assert_called_once_with(org_id=org_id, email_filter='alice')
@pytest.mark.asyncio
async def test_not_a_member_raises_error(self, org_id, current_user_id):
"""Test that non-member raises OrgMemberNotFoundError."""
# Arrange
with patch(
'server.services.org_member_service.OrgMemberStore.get_org_member'
) as mock_get_member:
mock_get_member.return_value = None
# Act & Assert
with pytest.raises(OrgMemberNotFoundError):
await OrgMemberService.get_org_members_count(
org_id=org_id,
current_user_id=current_user_id,
)
@pytest.fixture
def target_membership_owner(org_id, target_user_id, owner_role):
@@ -549,6 +698,9 @@ class TestOrgMemberServiceRemoveOrgMember:
patch(
'server.services.org_member_service.OrgMemberStore.remove_user_from_org'
) as mock_remove,
patch(
'server.services.org_member_service.UserStore.get_user_by_id'
) as mock_get_user,
):
mock_get_member.side_effect = [
requester_membership_owner,
@@ -556,6 +708,7 @@ class TestOrgMemberServiceRemoveOrgMember:
]
mock_get_role.side_effect = [owner_role, member_role]
mock_remove.return_value = True
mock_get_user.return_value = None
# Act
success, error = await OrgMemberService.remove_org_member(
@@ -590,6 +743,9 @@ class TestOrgMemberServiceRemoveOrgMember:
patch(
'server.services.org_member_service.OrgMemberStore.remove_user_from_org'
) as mock_remove,
patch(
'server.services.org_member_service.UserStore.get_user_by_id'
) as mock_get_user,
):
mock_get_member.side_effect = [
requester_membership_owner,
@@ -597,6 +753,7 @@ class TestOrgMemberServiceRemoveOrgMember:
]
mock_get_role.side_effect = [owner_role, admin_role]
mock_remove.return_value = True
mock_get_user.return_value = None
# Act
success, error = await OrgMemberService.remove_org_member(
@@ -630,6 +787,9 @@ class TestOrgMemberServiceRemoveOrgMember:
patch(
'server.services.org_member_service.OrgMemberStore.remove_user_from_org'
) as mock_remove,
patch(
'server.services.org_member_service.UserStore.get_user_by_id'
) as mock_get_user,
):
mock_get_member.side_effect = [
requester_membership_admin,
@@ -637,6 +797,7 @@ class TestOrgMemberServiceRemoveOrgMember:
]
mock_get_role.side_effect = [admin_role, member_role]
mock_remove.return_value = True
mock_get_user.return_value = None
# Act
success, error = await OrgMemberService.remove_org_member(
@@ -748,7 +909,7 @@ class TestOrgMemberServiceRemoveOrgMember:
assert error == 'role_not_found'
@pytest.mark.asyncio
async def test_admin_cannot_remove_admin_returns_error(
async def test_admin_can_remove_admin_succeeds(
self,
org_id,
current_user_id,
@@ -757,7 +918,7 @@ class TestOrgMemberServiceRemoveOrgMember:
target_membership_admin,
admin_role,
):
"""Test that an admin cannot remove another admin."""
"""Test that an admin can remove another admin."""
# Arrange
with (
patch(
@@ -766,12 +927,24 @@ class TestOrgMemberServiceRemoveOrgMember:
patch(
'server.services.org_member_service.RoleStore.get_role_by_id'
) as mock_get_role,
patch(
'server.services.org_member_service.OrgMemberStore.remove_user_from_org'
) as mock_remove,
patch(
'server.services.org_member_service.UserStore.get_user_by_id'
) as mock_get_user,
patch(
'server.services.org_member_service.LiteLlmManager.remove_user_from_team'
) as mock_remove_litellm,
):
mock_get_member.side_effect = [
requester_membership_admin,
target_membership_admin,
]
mock_get_role.side_effect = [admin_role, admin_role]
mock_remove.return_value = True
mock_get_user.return_value = None
mock_remove_litellm.return_value = None
# Act
success, error = await OrgMemberService.remove_org_member(
@@ -779,8 +952,8 @@ class TestOrgMemberServiceRemoveOrgMember:
)
# Assert
assert success is False
assert error == 'insufficient_permission'
assert success is True
assert error is None
@pytest.mark.asyncio
async def test_admin_cannot_remove_owner_returns_error(
@@ -927,6 +1100,9 @@ class TestOrgMemberServiceRemoveOrgMember:
patch(
'server.services.org_member_service.OrgMemberStore.remove_user_from_org'
) as mock_remove,
patch(
'server.services.org_member_service.UserStore.get_user_by_id'
) as mock_get_user,
):
mock_get_member.side_effect = [
requester_membership_owner,
@@ -940,6 +1116,7 @@ class TestOrgMemberServiceRemoveOrgMember:
another_owner,
]
mock_remove.return_value = True
mock_get_user.return_value = None
# Act
success, error = await OrgMemberService.remove_org_member(
@@ -990,6 +1167,302 @@ class TestOrgMemberServiceRemoveOrgMember:
assert success is False
assert error == 'removal_failed'
@pytest.mark.asyncio
async def test_remove_member_updates_current_org_id_when_matching(
self,
org_id,
current_user_id,
target_user_id,
requester_membership_owner,
target_membership_user,
owner_role,
member_role,
):
"""Test that current_org_id is updated to personal workspace when it matches removed org."""
# Arrange
mock_user = MagicMock(spec=User)
mock_user.current_org_id = (
org_id # User's current org matches the org being removed
)
with (
patch(
'server.services.org_member_service.OrgMemberStore.get_org_member'
) as mock_get_member,
patch(
'server.services.org_member_service.RoleStore.get_role_by_id'
) as mock_get_role,
patch(
'server.services.org_member_service.OrgMemberStore.remove_user_from_org'
) as mock_remove,
patch(
'server.services.org_member_service.UserStore.get_user_by_id'
) as mock_get_user,
patch(
'server.services.org_member_service.UserStore.update_current_org'
) as mock_update_org,
):
mock_get_member.side_effect = [
requester_membership_owner,
target_membership_user,
]
mock_get_role.side_effect = [owner_role, member_role]
mock_remove.return_value = True
mock_get_user.return_value = mock_user
# Act
success, error = await OrgMemberService.remove_org_member(
org_id, target_user_id, current_user_id
)
# Assert
assert success is True
assert error is None
mock_update_org.assert_called_once_with(str(target_user_id), target_user_id)
@pytest.mark.asyncio
async def test_remove_member_does_not_update_current_org_id_when_not_matching(
self,
org_id,
current_user_id,
target_user_id,
requester_membership_owner,
target_membership_user,
owner_role,
member_role,
):
"""Test that current_org_id is NOT updated when it differs from removed org."""
# Arrange
different_org_id = uuid.uuid4()
mock_user = MagicMock(spec=User)
mock_user.current_org_id = different_org_id # User's current org is different
with (
patch(
'server.services.org_member_service.OrgMemberStore.get_org_member'
) as mock_get_member,
patch(
'server.services.org_member_service.RoleStore.get_role_by_id'
) as mock_get_role,
patch(
'server.services.org_member_service.OrgMemberStore.remove_user_from_org'
) as mock_remove,
patch(
'server.services.org_member_service.UserStore.get_user_by_id'
) as mock_get_user,
patch(
'server.services.org_member_service.UserStore.update_current_org'
) as mock_update_org,
):
mock_get_member.side_effect = [
requester_membership_owner,
target_membership_user,
]
mock_get_role.side_effect = [owner_role, member_role]
mock_remove.return_value = True
mock_get_user.return_value = mock_user
# Act
success, error = await OrgMemberService.remove_org_member(
org_id, target_user_id, current_user_id
)
# Assert
assert success is True
assert error is None
mock_update_org.assert_not_called()
@pytest.mark.asyncio
async def test_remove_member_succeeds_when_user_not_found_after_removal(
self,
org_id,
current_user_id,
target_user_id,
requester_membership_owner,
target_membership_user,
owner_role,
member_role,
):
"""Test that removal succeeds even if user lookup returns None after removal."""
# Arrange
with (
patch(
'server.services.org_member_service.OrgMemberStore.get_org_member'
) as mock_get_member,
patch(
'server.services.org_member_service.RoleStore.get_role_by_id'
) as mock_get_role,
patch(
'server.services.org_member_service.OrgMemberStore.remove_user_from_org'
) as mock_remove,
patch(
'server.services.org_member_service.UserStore.get_user_by_id'
) as mock_get_user,
patch(
'server.services.org_member_service.UserStore.update_current_org'
) as mock_update_org,
):
mock_get_member.side_effect = [
requester_membership_owner,
target_membership_user,
]
mock_get_role.side_effect = [owner_role, member_role]
mock_remove.return_value = True
mock_get_user.return_value = None # User not found
# Act
success, error = await OrgMemberService.remove_org_member(
org_id, target_user_id, current_user_id
)
# Assert
assert success is True
assert error is None
mock_update_org.assert_not_called()
@pytest.mark.asyncio
async def test_successful_removal_calls_litellm_remove_user_from_team(
self,
org_id,
current_user_id,
target_user_id,
requester_membership_owner,
target_membership_user,
owner_role,
member_role,
):
"""Test that LiteLLM remove_user_from_team is called after successful database removal."""
# Arrange
with (
patch(
'server.services.org_member_service.OrgMemberStore.get_org_member'
) as mock_get_member,
patch(
'server.services.org_member_service.RoleStore.get_role_by_id'
) as mock_get_role,
patch(
'server.services.org_member_service.OrgMemberStore.remove_user_from_org'
) as mock_remove,
patch(
'server.services.org_member_service.UserStore.get_user_by_id'
) as mock_get_user,
patch(
'server.services.org_member_service.LiteLlmManager.remove_user_from_team',
new_callable=AsyncMock,
) as mock_litellm_remove,
):
mock_get_member.side_effect = [
requester_membership_owner,
target_membership_user,
]
mock_get_role.side_effect = [owner_role, member_role]
mock_remove.return_value = True
mock_get_user.return_value = None
# Act
success, error = await OrgMemberService.remove_org_member(
org_id, target_user_id, current_user_id
)
# Assert
assert success is True
mock_litellm_remove.assert_called_once_with(
str(target_user_id), str(org_id)
)
@pytest.mark.asyncio
async def test_litellm_failure_does_not_fail_removal(
self,
org_id,
current_user_id,
target_user_id,
requester_membership_owner,
target_membership_user,
owner_role,
member_role,
):
"""Test that LiteLLM failure doesn't fail the overall removal operation."""
# Arrange
with (
patch(
'server.services.org_member_service.OrgMemberStore.get_org_member'
) as mock_get_member,
patch(
'server.services.org_member_service.RoleStore.get_role_by_id'
) as mock_get_role,
patch(
'server.services.org_member_service.OrgMemberStore.remove_user_from_org'
) as mock_remove,
patch(
'server.services.org_member_service.UserStore.get_user_by_id'
) as mock_get_user,
patch(
'server.services.org_member_service.LiteLlmManager.remove_user_from_team',
new_callable=AsyncMock,
) as mock_litellm_remove,
):
mock_get_member.side_effect = [
requester_membership_owner,
target_membership_user,
]
mock_get_role.side_effect = [owner_role, member_role]
mock_remove.return_value = True
mock_get_user.return_value = None
mock_litellm_remove.side_effect = Exception('LiteLLM API error')
# Act
success, error = await OrgMemberService.remove_org_member(
org_id, target_user_id, current_user_id
)
# Assert
assert success is True
assert error is None
@pytest.mark.asyncio
async def test_database_failure_skips_litellm_call(
self,
org_id,
current_user_id,
target_user_id,
requester_membership_owner,
target_membership_user,
owner_role,
member_role,
):
"""Test that LiteLLM is not called when database removal fails."""
# Arrange
with (
patch(
'server.services.org_member_service.OrgMemberStore.get_org_member'
) as mock_get_member,
patch(
'server.services.org_member_service.RoleStore.get_role_by_id'
) as mock_get_role,
patch(
'server.services.org_member_service.OrgMemberStore.remove_user_from_org'
) as mock_remove,
patch(
'server.services.org_member_service.LiteLlmManager.remove_user_from_team',
new_callable=AsyncMock,
) as mock_litellm_remove,
):
mock_get_member.side_effect = [
requester_membership_owner,
target_membership_user,
]
mock_get_role.side_effect = [owner_role, member_role]
mock_remove.return_value = False
# Act
success, error = await OrgMemberService.remove_org_member(
org_id, target_user_id, current_user_id
)
# Assert
assert success is False
mock_litellm_remove.assert_not_called()
class TestOrgMemberServiceCanRemoveMember:
"""Test cases for OrgMemberService._can_remove_member."""
@@ -1018,13 +1491,13 @@ class TestOrgMemberServiceCanRemoveMember:
# Assert
assert result is True
def test_admin_cannot_remove_admin(self):
"""Test that admin cannot remove another admin."""
def test_admin_can_remove_admin(self):
"""Test that admin can remove another admin."""
# Act
result = OrgMemberService._can_remove_member('admin', 'admin')
# Assert
assert result is False
assert result is True
def test_admin_cannot_remove_owner(self):
"""Test that admin cannot remove owner."""
@@ -1158,7 +1631,7 @@ class TestOrgMemberServiceUpdateOrgMember:
mock_update.assert_called_once_with(org_id, target_user_id, admin_role.id)
@pytest.mark.asyncio
async def test_admin_cannot_update_admin_raises_insufficient_permission(
async def test_admin_can_update_admin_to_member_succeeds(
self,
org_id,
current_user_id,
@@ -1168,8 +1641,14 @@ class TestOrgMemberServiceUpdateOrgMember:
admin_role,
member_role,
):
"""GIVEN admin and target admin WHEN admin tries to change target role THEN raises InsufficientPermissionError."""
"""GIVEN admin and target admin WHEN admin changes target role to member THEN update succeeds."""
# Arrange
updated_member = MagicMock(spec=OrgMember)
updated_member.user_id = target_user_id
updated_member.role_id = member_role.id
updated_member.status = 'active'
mock_user = MagicMock()
mock_user.email = 'target@example.com'
with (
patch(
'server.services.org_member_service.OrgMemberStore.get_org_member'
@@ -1180,6 +1659,12 @@ class TestOrgMemberServiceUpdateOrgMember:
patch(
'server.services.org_member_service.RoleStore.get_role_by_name'
) as mock_get_role_by_name,
patch(
'server.services.org_member_service.OrgMemberStore.update_user_role_in_org'
) as mock_update,
patch(
'server.services.org_member_service.UserStore.get_user_by_id'
) as mock_get_user,
):
mock_get_member.side_effect = [
requester_membership_admin,
@@ -1187,18 +1672,24 @@ class TestOrgMemberServiceUpdateOrgMember:
]
mock_get_role.side_effect = [admin_role, admin_role]
mock_get_role_by_name.return_value = member_role
mock_update.return_value = updated_member
mock_get_user.return_value = mock_user
# Act & Assert
with pytest.raises(InsufficientPermissionError):
await OrgMemberService.update_org_member(
org_id,
target_user_id,
current_user_id,
OrgMemberUpdate(role='member'),
)
# Act
data = await OrgMemberService.update_org_member(
org_id,
target_user_id,
current_user_id,
OrgMemberUpdate(role='member'),
)
# Assert
assert isinstance(data, OrgMemberResponse)
assert data.role == 'member'
mock_update.assert_called_once_with(org_id, target_user_id, member_role.id)
@pytest.mark.asyncio
async def test_owner_cannot_update_owner_raises_insufficient_permission(
async def test_owner_can_update_owner_to_admin_succeeds(
self,
org_id,
current_user_id,
@@ -1208,8 +1699,14 @@ class TestOrgMemberServiceUpdateOrgMember:
owner_role,
admin_role,
):
"""GIVEN owner and target owner WHEN owner tries to change target role THEN raises InsufficientPermissionError."""
"""GIVEN owner and target owner WHEN owner changes target role to admin THEN update succeeds."""
# Arrange
updated_member = MagicMock(spec=OrgMember)
updated_member.user_id = target_user_id
updated_member.role_id = admin_role.id
updated_member.status = 'active'
mock_user = MagicMock()
mock_user.email = 'target@example.com'
with (
patch(
'server.services.org_member_service.OrgMemberStore.get_org_member'
@@ -1220,6 +1717,13 @@ class TestOrgMemberServiceUpdateOrgMember:
patch(
'server.services.org_member_service.RoleStore.get_role_by_name'
) as mock_get_role_by_name,
patch(
'server.services.org_member_service.OrgMemberStore.update_user_role_in_org'
) as mock_update,
patch(
'server.services.org_member_service.UserStore.get_user_by_id'
) as mock_get_user,
patch.object(OrgMemberService, '_is_last_owner', return_value=False),
):
mock_get_member.side_effect = [
requester_membership_owner,
@@ -1227,15 +1731,21 @@ class TestOrgMemberServiceUpdateOrgMember:
]
mock_get_role.side_effect = [owner_role, owner_role]
mock_get_role_by_name.return_value = admin_role
mock_update.return_value = updated_member
mock_get_user.return_value = mock_user
# Act & Assert
with pytest.raises(InsufficientPermissionError):
await OrgMemberService.update_org_member(
org_id,
target_user_id,
current_user_id,
OrgMemberUpdate(role='admin'),
)
# Act
data = await OrgMemberService.update_org_member(
org_id,
target_user_id,
current_user_id,
OrgMemberUpdate(role='admin'),
)
# Assert
assert isinstance(data, OrgMemberResponse)
assert data.role == 'admin'
mock_update.assert_called_once_with(org_id, target_user_id, admin_role.id)
@pytest.mark.asyncio
async def test_requester_not_a_member_raises_error(
@@ -1450,10 +1960,10 @@ class TestOrgMemberServiceCanUpdateMemberRole:
OrgMemberService._can_update_member_role('owner', 'member', 'owner') is True
)
def test_owner_cannot_modify_owner(self):
"""Owner cannot change another owner's role."""
def test_owner_can_modify_owner(self):
"""Owner can change another owner's role."""
assert (
OrgMemberService._can_update_member_role('owner', 'owner', 'admin') is False
OrgMemberService._can_update_member_role('owner', 'owner', 'admin') is True
)
def test_admin_can_set_admin_or_member_for_member(self):
@@ -1466,12 +1976,14 @@ class TestOrgMemberServiceCanUpdateMemberRole:
is True
)
def test_admin_cannot_modify_admin_or_owner(self):
"""Admin cannot modify admin or owner targets."""
def test_admin_can_modify_admin(self):
"""Admin can modify another admin's role to member."""
assert (
OrgMemberService._can_update_member_role('admin', 'admin', 'member')
is False
OrgMemberService._can_update_member_role('admin', 'admin', 'member') is True
)
def test_admin_cannot_modify_owner(self):
"""Admin cannot modify owner targets."""
assert (
OrgMemberService._can_update_member_role('admin', 'owner', 'admin') is False
)

View File

@@ -0,0 +1,176 @@
"""
Unit tests for UserAppSettingsService.
Tests the service layer for user app settings operations.
"""
import uuid
from unittest.mock import AsyncMock, MagicMock
import pytest
from server.routes.user_app_settings_models import (
UserAppSettingsResponse,
UserAppSettingsUpdate,
UserNotFoundError,
)
from server.services.user_app_settings_service import UserAppSettingsService
from storage.user import User
@pytest.fixture
def user_id():
"""Create a test user ID."""
return str(uuid.uuid4())
@pytest.fixture
def mock_user(user_id):
"""Create a mock user with app settings."""
user = MagicMock(spec=User)
user.id = uuid.UUID(user_id)
user.language = 'en'
user.user_consents_to_analytics = True
user.enable_sound_notifications = False
user.git_user_name = 'testuser'
user.git_user_email = 'test@example.com'
return user
@pytest.fixture
def mock_store():
"""Create a mock UserAppSettingsStore."""
return MagicMock()
@pytest.fixture
def mock_user_context(user_id):
"""Create a mock UserContext that returns the user_id."""
context = MagicMock()
context.get_user_id = AsyncMock(return_value=user_id)
return context
@pytest.mark.asyncio
async def test_get_user_app_settings_success(
user_id, mock_user, mock_store, mock_user_context
):
"""
GIVEN: A user exists in the database
WHEN: get_user_app_settings is called
THEN: UserAppSettingsResponse is returned with correct data
"""
# Arrange
mock_store.get_user_by_id = AsyncMock(return_value=mock_user)
service = UserAppSettingsService(store=mock_store, user_context=mock_user_context)
# Act
result = await service.get_user_app_settings()
# Assert
assert isinstance(result, UserAppSettingsResponse)
assert result.language == 'en'
assert result.user_consents_to_analytics is True
assert result.enable_sound_notifications is False
assert result.git_user_name == 'testuser'
assert result.git_user_email == 'test@example.com'
mock_store.get_user_by_id.assert_called_once_with(user_id)
@pytest.mark.asyncio
async def test_get_user_app_settings_user_not_found(
user_id, mock_store, mock_user_context
):
"""
GIVEN: A user does not exist in the database
WHEN: get_user_app_settings is called
THEN: UserNotFoundError is raised
"""
# Arrange
mock_store.get_user_by_id = AsyncMock(return_value=None)
service = UserAppSettingsService(store=mock_store, user_context=mock_user_context)
# Act & Assert
with pytest.raises(UserNotFoundError) as exc_info:
await service.get_user_app_settings()
assert user_id in str(exc_info.value)
@pytest.mark.asyncio
async def test_update_user_app_settings_success(
user_id, mock_user, mock_store, mock_user_context
):
"""
GIVEN: A user exists in the database
WHEN: update_user_app_settings is called with new values
THEN: UserAppSettingsResponse is returned with updated data
"""
# Arrange
mock_user.language = 'es'
mock_user.user_consents_to_analytics = False
update_data = UserAppSettingsUpdate(
language='es',
user_consents_to_analytics=False,
)
mock_store.update_user_app_settings = AsyncMock(return_value=mock_user)
service = UserAppSettingsService(store=mock_store, user_context=mock_user_context)
# Act
result = await service.update_user_app_settings(update_data)
# Assert
assert isinstance(result, UserAppSettingsResponse)
assert result.language == 'es'
assert result.user_consents_to_analytics is False
mock_store.update_user_app_settings.assert_called_once_with(
user_id=user_id, update_data=update_data
)
@pytest.mark.asyncio
async def test_update_user_app_settings_no_changes(
user_id, mock_user, mock_store, mock_user_context
):
"""
GIVEN: A user exists in the database
WHEN: update_user_app_settings is called with no fields
THEN: Current settings are returned without calling update
"""
# Arrange
update_data = UserAppSettingsUpdate() # No fields set
mock_store.get_user_by_id = AsyncMock(return_value=mock_user)
mock_store.update_user_app_settings = AsyncMock()
service = UserAppSettingsService(store=mock_store, user_context=mock_user_context)
# Act
result = await service.update_user_app_settings(update_data)
# Assert
assert isinstance(result, UserAppSettingsResponse)
mock_store.get_user_by_id.assert_called_once_with(user_id)
mock_store.update_user_app_settings.assert_not_called()
@pytest.mark.asyncio
async def test_update_user_app_settings_user_not_found(
user_id, mock_store, mock_user_context
):
"""
GIVEN: A user does not exist in the database
WHEN: update_user_app_settings is called
THEN: UserNotFoundError is raised
"""
# Arrange
update_data = UserAppSettingsUpdate(language='en')
mock_store.update_user_app_settings = AsyncMock(return_value=None)
service = UserAppSettingsService(store=mock_store, user_context=mock_user_context)
# Act & Assert
with pytest.raises(UserNotFoundError) as exc_info:
await service.update_user_app_settings(update_data)
assert user_id in str(exc_info.value)

View File

@@ -0,0 +1,661 @@
"""Unit tests for AuthTokenStore."""
import time
from contextlib import asynccontextmanager
from typing import Dict
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from server.auth.auth_error import TokenRefreshError
from sqlalchemy.exc import OperationalError
from storage.auth_token_store import (
ACCESS_TOKEN_EXPIRY_BUFFER,
LOCK_TIMEOUT_SECONDS,
AuthTokenStore,
)
from openhands.integrations.service_types import ProviderType
def create_mock_session():
"""Create a mock async session with properly configured context managers."""
session = AsyncMock()
# Create async context manager for begin()
@asynccontextmanager
async def begin_context():
yield
session.begin = begin_context
return session
def create_mock_session_maker(mock_session):
"""Create a mock async session maker."""
@asynccontextmanager
async def session_context():
yield mock_session
# Return a callable that returns the context manager
return lambda: session_context()
@pytest.fixture
def mock_session():
"""Create mock async session."""
return create_mock_session()
@pytest.fixture
def mock_session_maker(mock_session):
"""Create mock async session maker."""
return create_mock_session_maker(mock_session)
@pytest.fixture
def auth_token_store(mock_session_maker):
"""Create AuthTokenStore instance with mocked session maker."""
return AuthTokenStore(
keycloak_user_id='test-user-123',
idp=ProviderType.GITHUB,
a_session_maker=mock_session_maker,
)
class TestIsTokenExpired:
"""Tests for _is_token_expired method."""
def test_both_tokens_valid(self, auth_token_store):
"""Test when both tokens are valid (not expired)."""
current_time = int(time.time())
access_expires = current_time + ACCESS_TOKEN_EXPIRY_BUFFER + 1000
refresh_expires = current_time + 1000
access_expired, refresh_expired = auth_token_store._is_token_expired(
access_expires, refresh_expires
)
assert access_expired is False
assert refresh_expired is False
def test_access_token_expired(self, auth_token_store):
"""Test when access token is expired but within buffer."""
current_time = int(time.time())
# Access token expires within buffer period
access_expires = current_time + ACCESS_TOKEN_EXPIRY_BUFFER - 100
refresh_expires = current_time + 10000
access_expired, refresh_expired = auth_token_store._is_token_expired(
access_expires, refresh_expires
)
assert access_expired is True
assert refresh_expired is False
def test_refresh_token_expired(self, auth_token_store):
"""Test when refresh token is expired."""
current_time = int(time.time())
access_expires = current_time + ACCESS_TOKEN_EXPIRY_BUFFER + 1000
refresh_expires = current_time - 100 # Already expired
access_expired, refresh_expired = auth_token_store._is_token_expired(
access_expires, refresh_expires
)
assert access_expired is False
assert refresh_expired is True
def test_both_tokens_expired(self, auth_token_store):
"""Test when both tokens are expired."""
current_time = int(time.time())
access_expires = current_time - 100
refresh_expires = current_time - 100
access_expired, refresh_expired = auth_token_store._is_token_expired(
access_expires, refresh_expires
)
assert access_expired is True
assert refresh_expired is True
def test_zero_expiration_treated_as_never_expires(self, auth_token_store):
"""Test that 0 expiration time is treated as never expires."""
access_expired, refresh_expired = auth_token_store._is_token_expired(0, 0)
assert access_expired is False
assert refresh_expired is False
class TestLoadTokensFastPath:
"""Tests for load_tokens fast path (no lock needed)."""
@pytest.mark.asyncio
async def test_fast_path_token_not_found(
self, auth_token_store, mock_session_maker, mock_session
):
"""Test fast path returns None when no token record exists."""
mock_result = MagicMock()
mock_result.scalars.return_value.one_or_none.return_value = None
mock_session.execute = AsyncMock(return_value=mock_result)
result = await auth_token_store.load_tokens()
assert result is None
@pytest.mark.asyncio
async def test_fast_path_valid_token_no_refresh_needed(
self, auth_token_store, mock_session_maker, mock_session
):
"""Test fast path returns tokens when they are still valid."""
current_time = int(time.time())
mock_token = MagicMock()
mock_token.access_token = 'valid-access-token'
mock_token.refresh_token = 'valid-refresh-token'
mock_token.access_token_expires_at = (
current_time + ACCESS_TOKEN_EXPIRY_BUFFER + 1000
)
mock_token.refresh_token_expires_at = current_time + 10000
mock_result = MagicMock()
mock_result.scalars.return_value.one_or_none.return_value = mock_token
mock_session.execute = AsyncMock(return_value=mock_result)
result = await auth_token_store.load_tokens()
assert result is not None
assert result['access_token'] == 'valid-access-token'
assert result['refresh_token'] == 'valid-refresh-token'
@pytest.mark.asyncio
async def test_fast_path_no_refresh_callback_provided(
self, auth_token_store, mock_session_maker, mock_session
):
"""Test fast path returns existing tokens when no refresh callback is provided."""
current_time = int(time.time())
mock_token = MagicMock()
mock_token.access_token = 'expired-access-token'
mock_token.refresh_token = 'valid-refresh-token'
# Expired access token
mock_token.access_token_expires_at = current_time - 100
mock_token.refresh_token_expires_at = current_time + 10000
mock_result = MagicMock()
mock_result.scalars.return_value.one_or_none.return_value = mock_token
mock_session.execute = AsyncMock(return_value=mock_result)
result = await auth_token_store.load_tokens(check_expiration_and_refresh=None)
assert result is not None
assert result['access_token'] == 'expired-access-token'
class TestLoadTokensSlowPath:
"""Tests for load_tokens slow path (lock required for refresh)."""
@pytest.mark.asyncio
async def test_slow_path_successful_refresh(self):
"""Test slow path successfully refreshes expired tokens."""
current_time = int(time.time())
mock_session = create_mock_session()
# First call (fast path) - returns expired token
# Second call (slow path) - returns same token for update
expired_token = MagicMock()
expired_token.id = 1
expired_token.access_token = 'expired-access-token'
expired_token.refresh_token = 'valid-refresh-token'
expired_token.access_token_expires_at = current_time - 100 # Expired
expired_token.refresh_token_expires_at = current_time + 10000
mock_result = MagicMock()
mock_result.scalars.return_value.one_or_none.return_value = expired_token
mock_session.execute = AsyncMock(return_value=mock_result)
mock_session.commit = AsyncMock()
mock_session_maker = create_mock_session_maker(mock_session)
auth_store = AuthTokenStore(
keycloak_user_id='test-user-123',
idp=ProviderType.GITHUB,
a_session_maker=mock_session_maker,
)
async def mock_refresh(
idp: ProviderType, refresh_token: str, access_exp: int, refresh_exp: int
) -> Dict[str, str | int]:
return {
'access_token': 'new-access-token',
'refresh_token': 'new-refresh-token',
'access_token_expires_at': current_time + 3600,
'refresh_token_expires_at': current_time + 86400,
}
result = await auth_store.load_tokens(check_expiration_and_refresh=mock_refresh)
assert result is not None
assert result['access_token'] == 'new-access-token'
assert result['refresh_token'] == 'new-refresh-token'
@pytest.mark.asyncio
async def test_slow_path_double_check_avoids_refresh(self):
"""Test double-check locking: token was refreshed by another request."""
current_time = int(time.time())
mock_session = create_mock_session()
# Simulate scenario:
# 1. Fast path sees expired token
# 2. While waiting for lock, another request refreshes
# 3. Slow path sees fresh token, skips refresh
call_count = [0]
def create_token():
call_count[0] += 1
token = MagicMock()
token.id = 1
token.access_token = 'fresh-access-token'
token.refresh_token = 'fresh-refresh-token'
if call_count[0] == 1:
# First call (fast path) - expired
token.access_token_expires_at = current_time - 100
else:
# Second call (slow path) - already refreshed
token.access_token_expires_at = (
current_time + ACCESS_TOKEN_EXPIRY_BUFFER + 1000
)
token.refresh_token_expires_at = current_time + 86400
return token
mock_result = MagicMock()
mock_result.scalars.return_value.one_or_none.side_effect = (
lambda: create_token()
)
mock_session.execute = AsyncMock(return_value=mock_result)
mock_session.commit = AsyncMock()
mock_session_maker = create_mock_session_maker(mock_session)
auth_store = AuthTokenStore(
keycloak_user_id='test-user-123',
idp=ProviderType.GITHUB,
a_session_maker=mock_session_maker,
)
refresh_called = [False]
async def mock_refresh(
idp: ProviderType, refresh_token: str, access_exp: int, refresh_exp: int
) -> Dict[str, str | int]:
refresh_called[0] = True
return {
'access_token': 'should-not-be-used',
'refresh_token': 'should-not-be-used',
'access_token_expires_at': current_time + 3600,
'refresh_token_expires_at': current_time + 86400,
}
result = await auth_store.load_tokens(check_expiration_and_refresh=mock_refresh)
# The refresh callback should not be called because double-check
# found the token was already refreshed
assert result is not None
assert result['access_token'] == 'fresh-access-token'
@pytest.mark.asyncio
async def test_slow_path_token_not_found_after_lock(self):
"""Test slow path returns None if token record disappears after lock."""
current_time = int(time.time())
mock_session = create_mock_session()
# First call (fast path) - token exists but expired
# Second call (slow path with lock) - token no longer exists
call_count = [0]
def get_token():
call_count[0] += 1
if call_count[0] == 1:
token = MagicMock()
token.access_token_expires_at = current_time - 100 # Expired
token.refresh_token_expires_at = current_time + 10000
return token
return None
mock_result = MagicMock()
mock_result.scalars.return_value.one_or_none.side_effect = get_token
mock_session.execute = AsyncMock(return_value=mock_result)
mock_session_maker = create_mock_session_maker(mock_session)
auth_store = AuthTokenStore(
keycloak_user_id='test-user-123',
idp=ProviderType.GITHUB,
a_session_maker=mock_session_maker,
)
async def mock_refresh(*args) -> Dict[str, str | int]:
return {
'access_token': 'new-token',
'refresh_token': 'new-refresh',
'access_token_expires_at': current_time + 3600,
'refresh_token_expires_at': current_time + 86400,
}
result = await auth_store.load_tokens(check_expiration_and_refresh=mock_refresh)
assert result is None
class TestLoadTokensLockTimeout:
"""Tests for lock timeout handling."""
@pytest.mark.asyncio
async def test_lock_timeout_raises_token_refresh_error(self):
"""Test that lock timeout raises TokenRefreshError."""
current_time = int(time.time())
mock_session = create_mock_session()
# First call (fast path) - returns expired token
expired_token = MagicMock()
expired_token.access_token_expires_at = current_time - 100
expired_token.refresh_token_expires_at = current_time + 10000
mock_result = MagicMock()
mock_result.scalars.return_value.one_or_none.return_value = expired_token
# First execute for fast path succeeds
# Second execute (for slow path) raises OperationalError
call_count = [0]
async def execute_side_effect(*args, **kwargs):
call_count[0] += 1
if call_count[0] <= 1:
return mock_result
# Simulate lock timeout
raise OperationalError(
'canceling statement due to lock timeout', None, None
)
mock_session.execute = execute_side_effect
mock_session_maker = create_mock_session_maker(mock_session)
auth_store = AuthTokenStore(
keycloak_user_id='test-user-123',
idp=ProviderType.GITHUB,
a_session_maker=mock_session_maker,
)
async def mock_refresh(*args) -> Dict[str, str | int]:
return {
'access_token': 'new-token',
'refresh_token': 'new-refresh',
'access_token_expires_at': current_time + 3600,
'refresh_token_expires_at': current_time + 86400,
}
with pytest.raises(TokenRefreshError) as exc_info:
await auth_store.load_tokens(check_expiration_and_refresh=mock_refresh)
assert 'lock timeout' in str(exc_info.value).lower()
@pytest.mark.asyncio
async def test_lock_timeout_preserves_original_exception(self):
"""Test that TokenRefreshError preserves the original OperationalError."""
current_time = int(time.time())
mock_session = create_mock_session()
expired_token = MagicMock()
expired_token.access_token_expires_at = current_time - 100
expired_token.refresh_token_expires_at = current_time + 10000
mock_result = MagicMock()
mock_result.scalars.return_value.one_or_none.return_value = expired_token
original_error = OperationalError(
'canceling statement due to lock timeout', None, None
)
call_count = [0]
async def execute_side_effect(*args, **kwargs):
call_count[0] += 1
if call_count[0] <= 1:
return mock_result
raise original_error
mock_session.execute = execute_side_effect
mock_session_maker = create_mock_session_maker(mock_session)
auth_store = AuthTokenStore(
keycloak_user_id='test-user-123',
idp=ProviderType.GITHUB,
a_session_maker=mock_session_maker,
)
async def mock_refresh(*args) -> Dict[str, str | int]:
return {
'access_token': 'new-token',
'refresh_token': 'new-refresh',
'access_token_expires_at': current_time + 3600,
'refresh_token_expires_at': current_time + 86400,
}
with pytest.raises(TokenRefreshError) as exc_info:
await auth_store.load_tokens(check_expiration_and_refresh=mock_refresh)
# Verify the original exception is chained
assert exc_info.value.__cause__ is original_error
class TestLoadTokensRefreshCallbackBehavior:
"""Tests for refresh callback return values."""
@pytest.mark.asyncio
async def test_refresh_callback_returns_none(self):
"""Test behavior when refresh callback returns None (no refresh performed)."""
current_time = int(time.time())
mock_session = create_mock_session()
expired_token = MagicMock()
expired_token.id = 1
expired_token.access_token = 'old-access-token'
expired_token.refresh_token = 'old-refresh-token'
expired_token.access_token_expires_at = current_time - 100 # Expired
expired_token.refresh_token_expires_at = current_time + 10000
mock_result = MagicMock()
mock_result.scalars.return_value.one_or_none.return_value = expired_token
mock_session.execute = AsyncMock(return_value=mock_result)
mock_session.commit = AsyncMock()
mock_session_maker = create_mock_session_maker(mock_session)
auth_store = AuthTokenStore(
keycloak_user_id='test-user-123',
idp=ProviderType.GITHUB,
a_session_maker=mock_session_maker,
)
async def mock_refresh_returns_none(
idp: ProviderType, refresh_token: str, access_exp: int, refresh_exp: int
) -> Dict[str, str | int] | None:
return None
result = await auth_store.load_tokens(
check_expiration_and_refresh=mock_refresh_returns_none
)
# Should return the old tokens when refresh returns None
assert result is not None
assert result['access_token'] == 'old-access-token'
assert result['refresh_token'] == 'old-refresh-token'
class TestStoreTokens:
"""Tests for store_tokens method."""
@pytest.mark.asyncio
async def test_store_tokens_creates_new_record(self):
"""Test storing tokens when no existing record."""
mock_session = create_mock_session()
mock_result = MagicMock()
mock_result.scalars.return_value.first.return_value = None
mock_session.execute = AsyncMock(return_value=mock_result)
mock_session.add = MagicMock()
mock_session.commit = AsyncMock()
mock_session_maker = create_mock_session_maker(mock_session)
auth_store = AuthTokenStore(
keycloak_user_id='test-user-123',
idp=ProviderType.GITHUB,
a_session_maker=mock_session_maker,
)
await auth_store.store_tokens(
access_token='new-access-token',
refresh_token='new-refresh-token',
access_token_expires_at=1234567890,
refresh_token_expires_at=1234657890,
)
mock_session.add.assert_called_once()
@pytest.mark.asyncio
async def test_store_tokens_updates_existing_record(self):
"""Test storing tokens updates existing record."""
mock_session = create_mock_session()
existing_token = MagicMock()
existing_token.access_token = 'old-access'
mock_result = MagicMock()
mock_result.scalars.return_value.first.return_value = existing_token
mock_session.execute = AsyncMock(return_value=mock_result)
mock_session.commit = AsyncMock()
mock_session_maker = create_mock_session_maker(mock_session)
auth_store = AuthTokenStore(
keycloak_user_id='test-user-123',
idp=ProviderType.GITHUB,
a_session_maker=mock_session_maker,
)
await auth_store.store_tokens(
access_token='new-access-token',
refresh_token='new-refresh-token',
access_token_expires_at=1234567890,
refresh_token_expires_at=1234657890,
)
assert existing_token.access_token == 'new-access-token'
assert existing_token.refresh_token == 'new-refresh-token'
class TestIsAccessTokenValid:
"""Tests for is_access_token_valid method."""
@pytest.mark.asyncio
async def test_is_access_token_valid_returns_false_when_no_tokens(
self, auth_token_store, mock_session_maker, mock_session
):
"""Test returns False when no tokens found."""
mock_result = MagicMock()
mock_result.scalars.return_value.one_or_none.return_value = None
mock_session.execute = AsyncMock(return_value=mock_result)
result = await auth_token_store.is_access_token_valid()
assert result is False
@pytest.mark.asyncio
async def test_is_access_token_valid_returns_true_for_valid_token(
self, auth_token_store, mock_session_maker, mock_session
):
"""Test returns True when token is valid."""
current_time = int(time.time())
mock_token = MagicMock()
mock_token.access_token = 'valid-access'
mock_token.refresh_token = 'valid-refresh'
mock_token.access_token_expires_at = current_time + 1000
mock_token.refresh_token_expires_at = current_time + 10000
mock_result = MagicMock()
mock_result.scalars.return_value.one_or_none.return_value = mock_token
mock_session.execute = AsyncMock(return_value=mock_result)
result = await auth_token_store.is_access_token_valid()
assert result is True
@pytest.mark.asyncio
async def test_is_access_token_valid_returns_false_for_expired_token(
self, auth_token_store, mock_session_maker, mock_session
):
"""Test returns False when token is expired."""
current_time = int(time.time())
mock_token = MagicMock()
mock_token.access_token = 'expired-access'
mock_token.refresh_token = 'valid-refresh'
mock_token.access_token_expires_at = current_time - 100 # Expired
mock_token.refresh_token_expires_at = current_time + 10000
mock_result = MagicMock()
mock_result.scalars.return_value.one_or_none.return_value = mock_token
mock_session.execute = AsyncMock(return_value=mock_result)
result = await auth_token_store.is_access_token_valid()
assert result is False
class TestGetInstance:
"""Tests for get_instance class method."""
@pytest.mark.asyncio
async def test_get_instance_creates_auth_token_store(self):
"""Test get_instance creates an AuthTokenStore with correct params."""
with patch('storage.auth_token_store.a_session_maker') as mock_a_session_maker:
store = await AuthTokenStore.get_instance(
keycloak_user_id='user-123', idp=ProviderType.GITHUB
)
assert store.keycloak_user_id == 'user-123'
assert store.idp == ProviderType.GITHUB
assert store.a_session_maker is mock_a_session_maker
class TestIdentityProviderValue:
"""Tests for identity_provider_value property."""
def test_identity_provider_value_returns_idp_value(self, auth_token_store):
"""Test that identity_provider_value returns the enum value."""
assert auth_token_store.identity_provider_value == ProviderType.GITHUB.value
def test_identity_provider_value_for_different_providers(self):
"""Test identity_provider_value for different providers."""
for provider in [
ProviderType.GITHUB,
ProviderType.GITLAB,
ProviderType.BITBUCKET,
]:
store = AuthTokenStore(
keycloak_user_id='test-user',
idp=provider,
a_session_maker=MagicMock(),
)
assert store.identity_provider_value == provider.value
class TestConstants:
"""Tests for module constants."""
def test_access_token_expiry_buffer_value(self):
"""Test ACCESS_TOKEN_EXPIRY_BUFFER is set to 15 minutes."""
assert ACCESS_TOKEN_EXPIRY_BUFFER == 900
def test_lock_timeout_seconds_value(self):
"""Test LOCK_TIMEOUT_SECONDS is set to 5 seconds."""
assert LOCK_TIMEOUT_SECONDS == 5

View File

@@ -0,0 +1,99 @@
"""Tests for the enterprise storage.database module.
These tests verify that the session_maker function properly forwards
keyword arguments to the underlying session maker for backward compatibility.
"""
from unittest.mock import MagicMock, patch
class TestSessionMaker:
"""Test cases for the session_maker function."""
@patch('enterprise.storage.database._get_db_session_injector')
def test_session_maker_without_args(self, mock_get_injector):
"""Test that session_maker works without any arguments."""
from enterprise.storage.database import session_maker
# Set up mock
mock_injector = MagicMock()
mock_inner_session_maker = MagicMock()
mock_session = MagicMock()
mock_inner_session_maker.return_value = mock_session
mock_injector.get_session_maker.return_value = mock_inner_session_maker
mock_get_injector.return_value = mock_injector
# Call session_maker without arguments
result = session_maker()
# Verify the inner session maker was called without arguments
mock_inner_session_maker.assert_called_once_with()
assert result == mock_session
@patch('enterprise.storage.database._get_db_session_injector')
def test_session_maker_with_expire_on_commit_false(self, mock_get_injector):
"""Test that session_maker accepts expire_on_commit keyword argument.
This is a critical backward compatibility test - the session_maker
must accept keyword arguments like expire_on_commit=False which is
used in slack.py and potentially other integration modules.
"""
from enterprise.storage.database import session_maker
# Set up mock
mock_injector = MagicMock()
mock_inner_session_maker = MagicMock()
mock_session = MagicMock()
mock_inner_session_maker.return_value = mock_session
mock_injector.get_session_maker.return_value = mock_inner_session_maker
mock_get_injector.return_value = mock_injector
# Call session_maker with expire_on_commit=False
# This is the exact call pattern used in slack.py line 242
result = session_maker(expire_on_commit=False)
# Verify the inner session maker was called with the keyword argument
mock_inner_session_maker.assert_called_once_with(expire_on_commit=False)
assert result == mock_session
@patch('enterprise.storage.database._get_db_session_injector')
def test_session_maker_with_multiple_kwargs(self, mock_get_injector):
"""Test that session_maker passes through multiple keyword arguments."""
from enterprise.storage.database import session_maker
# Set up mock
mock_injector = MagicMock()
mock_inner_session_maker = MagicMock()
mock_session = MagicMock()
mock_inner_session_maker.return_value = mock_session
mock_injector.get_session_maker.return_value = mock_inner_session_maker
mock_get_injector.return_value = mock_injector
# Call with multiple kwargs
result = session_maker(
expire_on_commit=False, autoflush=False, autocommit=False
)
# Verify all kwargs were passed through
mock_inner_session_maker.assert_called_once_with(
expire_on_commit=False, autoflush=False, autocommit=False
)
assert result == mock_session
@patch('enterprise.storage.database._get_db_session_injector')
def test_session_maker_returns_correct_session(self, mock_get_injector):
"""Test that session_maker returns the session from the inner session maker."""
from enterprise.storage.database import session_maker
# Set up mock
mock_injector = MagicMock()
mock_inner_session_maker = MagicMock()
mock_session = MagicMock()
mock_inner_session_maker.return_value = mock_session
mock_injector.get_session_maker.return_value = mock_inner_session_maker
mock_get_injector.return_value = mock_injector
result = session_maker()
# Verify the returned session is from the inner session maker
assert result is mock_session

View File

@@ -0,0 +1,189 @@
"""
Unit tests for OrgAppSettingsStore.
Tests the async database operations for organization app settings.
"""
import uuid
from unittest.mock import patch
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
# Mock the database module before importing
with patch('storage.database.engine', create=True), patch(
'storage.database.a_engine', create=True
):
from server.routes.org_models import OrgAppSettingsUpdate
from storage.base import Base
from storage.org import Org
from storage.org_app_settings_store import OrgAppSettingsStore
from storage.user import User
@pytest.fixture
async def async_engine():
"""Create an async SQLite engine for testing."""
engine = create_async_engine(
'sqlite+aiosqlite:///:memory:',
poolclass=StaticPool,
connect_args={'check_same_thread': False},
echo=False,
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest.fixture
async def async_session_maker(async_engine):
"""Create an async session maker for testing."""
return async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
@pytest.mark.asyncio
async def test_get_current_org_by_user_id_success(async_session_maker):
"""
GIVEN: A user exists with a current organization
WHEN: get_current_org_by_user_id is called with the user's ID
THEN: The organization is returned with correct data
"""
# Arrange
async with async_session_maker() as session:
org = Org(
name='test-org',
enable_proactive_conversation_starters=True,
enable_solvability_analysis=False,
max_budget_per_task=25.0,
)
session.add(org)
await session.flush()
user = User(
id=uuid.uuid4(),
current_org_id=org.id,
)
session.add(user)
await session.commit()
user_id = str(user.id)
# Act
store = OrgAppSettingsStore(db_session=session)
result = await store.get_current_org_by_user_id(user_id)
# Assert
assert result is not None
assert result.name == 'test-org'
assert result.enable_proactive_conversation_starters is True
assert result.enable_solvability_analysis is False
assert result.max_budget_per_task == 25.0
@pytest.mark.asyncio
async def test_get_current_org_by_user_id_user_not_found(async_session_maker):
"""
GIVEN: A user does not exist in the database
WHEN: get_current_org_by_user_id is called with a non-existent ID
THEN: None is returned
"""
# Arrange
non_existent_id = str(uuid.uuid4())
# Act
async with async_session_maker() as session:
store = OrgAppSettingsStore(db_session=session)
result = await store.get_current_org_by_user_id(non_existent_id)
# Assert
assert result is None
@pytest.mark.asyncio
async def test_update_org_app_settings_success(async_session_maker):
"""
GIVEN: An organization exists in the database
WHEN: update_org_app_settings is called with new values
THEN: The organization's settings are updated and returned
"""
# Arrange
async with async_session_maker() as session:
org = Org(
name='test-org',
enable_proactive_conversation_starters=True,
enable_solvability_analysis=False,
max_budget_per_task=10.0,
)
session.add(org)
await session.commit()
org_id = org.id
update_data = OrgAppSettingsUpdate(
enable_proactive_conversation_starters=False,
enable_solvability_analysis=True,
max_budget_per_task=50.0,
)
# Act
store = OrgAppSettingsStore(db_session=session)
result = await store.update_org_app_settings(org_id, update_data)
# Assert
assert result is not None
assert result.enable_proactive_conversation_starters is False
assert result.enable_solvability_analysis is True
assert result.max_budget_per_task == 50.0
@pytest.mark.asyncio
async def test_update_org_app_settings_partial(async_session_maker):
"""
GIVEN: An organization exists with existing settings
WHEN: update_org_app_settings is called with only some fields
THEN: Only the provided fields are updated, others remain unchanged
"""
# Arrange
async with async_session_maker() as session:
org = Org(
name='test-org',
enable_proactive_conversation_starters=True,
enable_solvability_analysis=False,
max_budget_per_task=10.0,
)
session.add(org)
await session.commit()
org_id = org.id
# Only update max_budget_per_task
update_data = OrgAppSettingsUpdate(max_budget_per_task=100.0)
# Act
store = OrgAppSettingsStore(db_session=session)
result = await store.update_org_app_settings(org_id, update_data)
# Assert
assert result is not None
assert result.max_budget_per_task == 100.0
assert result.enable_proactive_conversation_starters is True # Unchanged
assert result.enable_solvability_analysis is False # Unchanged
@pytest.mark.asyncio
async def test_update_org_app_settings_org_not_found(async_session_maker):
"""
GIVEN: An organization does not exist in the database
WHEN: update_org_app_settings is called
THEN: None is returned
"""
# Arrange
non_existent_id = uuid.uuid4()
update_data = OrgAppSettingsUpdate(enable_proactive_conversation_starters=False)
# Act
async with async_session_maker() as session:
store = OrgAppSettingsStore(db_session=session)
result = await store.update_org_app_settings(non_existent_id, update_data)
# Assert
assert result is None

View File

@@ -0,0 +1,180 @@
"""
Unit tests for OrgLLMSettingsStore.
Tests the async database operations for organization LLM settings.
"""
import uuid
from unittest.mock import AsyncMock, patch
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
# Mock the database module before importing
with patch('storage.database.engine', create=True), patch(
'storage.database.a_engine', create=True
):
from server.routes.org_models import OrgLLMSettingsUpdate
from storage.base import Base
from storage.org import Org
from storage.org_llm_settings_store import OrgLLMSettingsStore
from storage.user import User
@pytest.fixture
async def async_engine():
"""Create an async SQLite engine for testing."""
engine = create_async_engine(
'sqlite+aiosqlite:///:memory:',
poolclass=StaticPool,
connect_args={'check_same_thread': False},
echo=False,
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest.fixture
async def async_session_maker(async_engine):
"""Create an async session maker for testing."""
return async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
@pytest.mark.asyncio
async def test_get_current_org_by_user_id_success(async_session_maker):
"""
GIVEN: A user exists with a current_org_id
WHEN: get_current_org_by_user_id is called
THEN: The user's current organization is returned
"""
# Arrange
async with async_session_maker() as session:
org = Org(name='test-org', default_llm_model='claude-3')
session.add(org)
await session.flush()
user = User(id=uuid.uuid4(), current_org_id=org.id)
session.add(user)
await session.commit()
user_id = str(user.id)
# Act
store = OrgLLMSettingsStore(db_session=session)
result = await store.get_current_org_by_user_id(user_id)
# Assert
assert result is not None
assert result.name == 'test-org'
assert result.default_llm_model == 'claude-3'
@pytest.mark.asyncio
async def test_get_current_org_by_user_id_user_not_found(async_session_maker):
"""
GIVEN: A user does not exist in the database
WHEN: get_current_org_by_user_id is called
THEN: None is returned
"""
# Arrange
non_existent_id = str(uuid.uuid4())
# Act
async with async_session_maker() as session:
store = OrgLLMSettingsStore(db_session=session)
result = await store.get_current_org_by_user_id(non_existent_id)
# Assert
assert result is None
@pytest.mark.asyncio
async def test_update_org_llm_settings_success(async_session_maker):
"""
GIVEN: An organization exists in the database
WHEN: update_org_llm_settings is called with new values
THEN: The organization's LLM settings are updated and returned
"""
# Arrange
async with async_session_maker() as session:
org = Org(name='test-org', default_llm_model='old-model')
session.add(org)
await session.commit()
org_id = org.id
update_data = OrgLLMSettingsUpdate(
default_llm_model='new-model',
agent='CodeActAgent',
confirmation_mode=True,
)
# Act
store = OrgLLMSettingsStore(db_session=session)
with patch(
'storage.org_llm_settings_store.OrgMemberStore.update_all_members_llm_settings_async',
AsyncMock(),
):
result = await store.update_org_llm_settings(org_id, update_data)
# Assert
assert result is not None
assert result.default_llm_model == 'new-model'
assert result.agent == 'CodeActAgent'
assert result.confirmation_mode is True
@pytest.mark.asyncio
async def test_update_org_llm_settings_org_not_found(async_session_maker):
"""
GIVEN: An organization does not exist in the database
WHEN: update_org_llm_settings is called
THEN: None is returned
"""
# Arrange
non_existent_org_id = uuid.uuid4()
update_data = OrgLLMSettingsUpdate(default_llm_model='new-model')
# Act
async with async_session_maker() as session:
store = OrgLLMSettingsStore(db_session=session)
result = await store.update_org_llm_settings(non_existent_org_id, update_data)
# Assert
assert result is None
@pytest.mark.asyncio
async def test_update_org_llm_settings_propagates_to_members(async_session_maker):
"""
GIVEN: An organization exists with update data containing member-relevant settings
WHEN: update_org_llm_settings is called
THEN: Member settings are propagated via OrgMemberStore
"""
# Arrange
async with async_session_maker() as session:
org = Org(name='test-org', default_llm_model='old-model')
session.add(org)
await session.commit()
org_id = org.id
update_data = OrgLLMSettingsUpdate(
default_llm_model='new-model',
llm_api_key='new-api-key',
)
# Act
store = OrgLLMSettingsStore(db_session=session)
with patch(
'storage.org_llm_settings_store.OrgMemberStore.update_all_members_llm_settings_async',
AsyncMock(),
) as mock_update_members:
await store.update_org_llm_settings(org_id, update_data)
# Assert
mock_update_members.assert_called_once()
call_args = mock_update_members.call_args
member_settings = call_args[0][2]
assert member_settings.llm_model == 'new-model'
assert member_settings.llm_api_key == 'new-api-key'

View File

@@ -486,3 +486,180 @@ class TestSaasSQLAppConversationInfoService:
# Count should be 0 in org2
count_org2 = await user1_service_org2.count_app_conversation_info()
assert count_org2 == 0
class TestSaasSQLAppConversationInfoServiceAdminContext:
"""Test suite for SaasSQLAppConversationInfoService with ADMIN context."""
@pytest.mark.asyncio
async def test_admin_context_returns_unfiltered_data(
self,
async_session_with_users: AsyncSession,
):
"""Test that ADMIN context returns unfiltered data (no user/org filtering)."""
# Create conversations for different users
user1_service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=SpecifyUserContext(user_id=str(USER1_ID)),
)
# Create conversations for user1 in org1
for i in range(3):
conv = AppConversationInfo(
id=uuid4(),
created_by_user_id=str(USER1_ID),
sandbox_id=f'sandbox_user1_{i}',
title=f'User1 Conversation {i}',
)
await user1_service.save_app_conversation_info(conv)
# Now create an ADMIN service
from openhands.app_server.user.specifiy_user_context import ADMIN
admin_service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=ADMIN,
)
# ADMIN should see ALL conversations (unfiltered)
admin_page = await admin_service.search_app_conversation_info()
assert (
len(admin_page.items) == 3
), 'ADMIN context should see all conversations without filtering'
# ADMIN count should return total count (3)
admin_count = await admin_service.count_app_conversation_info()
assert (
admin_count == 3
), 'ADMIN context should count all conversations without filtering'
@pytest.mark.asyncio
async def test_admin_context_can_access_any_conversation(
self,
async_session_with_users: AsyncSession,
):
"""Test that ADMIN context can access any conversation regardless of owner."""
from openhands.app_server.user.specifiy_user_context import ADMIN
# Create a conversation as user1
user1_service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=SpecifyUserContext(user_id=str(USER1_ID)),
)
conv = AppConversationInfo(
id=uuid4(),
created_by_user_id=str(USER1_ID),
sandbox_id='sandbox_user1',
title='User1 Private Conversation',
)
await user1_service.save_app_conversation_info(conv)
# Create a service as user2 in org2 - should not see user1's conversation
user2_service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=SpecifyUserContext(user_id=str(USER2_ID)),
)
user2_page = await user2_service.search_app_conversation_info()
assert len(user2_page.items) == 0, 'User2 should not see User1 conversation'
# But ADMIN should see ALL conversations including user1's
admin_service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=ADMIN,
)
admin_page = await admin_service.search_app_conversation_info()
assert len(admin_page.items) == 1
assert admin_page.items[0].id == conv.id
# ADMIN should also be able to get specific conversation by ID
admin_get_conv = await admin_service.get_app_conversation_info(conv.id)
assert admin_get_conv is not None
assert admin_get_conv.id == conv.id
@pytest.mark.asyncio
async def test_secure_select_admin_bypasses_filtering(
self,
async_session_with_users: AsyncSession,
):
"""Test that _secure_select returns unfiltered query for ADMIN context."""
from openhands.app_server.user.specifiy_user_context import ADMIN
# Create an ADMIN service
admin_service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=ADMIN,
)
# Get the secure select query
query = await admin_service._secure_select()
# Convert query to string to verify NO filters are present
query_str = str(query.compile(compile_kwargs={'literal_binds': True}))
# For ADMIN, there should be no user_id or org_id filtering
# The query should not contain filters for user_id or org_id
assert str(USER1_ID) not in query_str.replace(
'-', ''
), 'ADMIN context should not filter by user_id'
assert str(USER2_ID) not in query_str.replace(
'-', ''
), 'ADMIN context should not filter by user_id'
@pytest.mark.asyncio
async def test_regular_user_context_filters_correctly(
self,
async_session_with_users: AsyncSession,
):
"""Test that regular user context properly filters data (control test)."""
from openhands.app_server.user.specifiy_user_context import ADMIN
# Create conversations for different users
user1_service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=SpecifyUserContext(user_id=str(USER1_ID)),
)
# Create 3 conversations for user1
for i in range(3):
conv = AppConversationInfo(
id=uuid4(),
created_by_user_id=str(USER1_ID),
sandbox_id=f'sandbox_user1_{i}',
title=f'User1 Conversation {i}',
)
await user1_service.save_app_conversation_info(conv)
# Create 2 conversations for user2
user2_service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=SpecifyUserContext(user_id=str(USER2_ID)),
)
for i in range(2):
conv = AppConversationInfo(
id=uuid4(),
created_by_user_id=str(USER2_ID),
sandbox_id=f'sandbox_user2_{i}',
title=f'User2 Conversation {i}',
)
await user2_service.save_app_conversation_info(conv)
# User1 should only see their 3 conversations
user1_page = await user1_service.search_app_conversation_info()
assert len(user1_page.items) == 3
# User2 should only see their 2 conversations
user2_page = await user2_service.search_app_conversation_info()
assert len(user2_page.items) == 2
# But ADMIN should see all 5 conversations
admin_service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=ADMIN,
)
admin_page = await admin_service.search_app_conversation_info()
assert len(admin_page.items) == 5

View File

@@ -0,0 +1,204 @@
"""
Unit tests for UserAppSettingsStore.
Tests the async database operations for user app settings.
"""
import uuid
from unittest.mock import patch
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
# Mock the database module before importing
with patch('storage.database.engine', create=True), patch(
'storage.database.a_engine', create=True
):
from server.routes.user_app_settings_models import UserAppSettingsUpdate
from storage.base import Base
from storage.org import Org
from storage.user import User
from storage.user_app_settings_store import UserAppSettingsStore
@pytest.fixture
async def async_engine():
"""Create an async SQLite engine for testing."""
engine = create_async_engine(
'sqlite+aiosqlite:///:memory:',
poolclass=StaticPool,
connect_args={'check_same_thread': False},
echo=False,
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest.fixture
async def async_session_maker(async_engine):
"""Create an async session maker for testing."""
return async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
@pytest.mark.asyncio
async def test_get_user_by_id_success(async_session_maker):
"""
GIVEN: A user exists in the database
WHEN: get_user_by_id is called with the user's ID
THEN: The user is returned with correct data
"""
# Arrange
async with async_session_maker() as session:
org = Org(name='test-org')
session.add(org)
await session.flush()
user = User(
id=uuid.uuid4(),
current_org_id=org.id,
language='en',
user_consents_to_analytics=True,
enable_sound_notifications=False,
git_user_name='testuser',
git_user_email='test@example.com',
)
session.add(user)
await session.commit()
user_id = str(user.id)
# Act - create store with the session
store = UserAppSettingsStore(db_session=session)
result = await store.get_user_by_id(user_id)
# Assert
assert result is not None
assert str(result.id) == user_id
assert result.language == 'en'
assert result.user_consents_to_analytics is True
assert result.enable_sound_notifications is False
assert result.git_user_name == 'testuser'
assert result.git_user_email == 'test@example.com'
@pytest.mark.asyncio
async def test_get_user_by_id_not_found(async_session_maker):
"""
GIVEN: A user does not exist in the database
WHEN: get_user_by_id is called with a non-existent ID
THEN: None is returned
"""
# Arrange
non_existent_id = str(uuid.uuid4())
# Act
async with async_session_maker() as session:
store = UserAppSettingsStore(db_session=session)
result = await store.get_user_by_id(non_existent_id)
# Assert
assert result is None
@pytest.mark.asyncio
async def test_update_user_app_settings_success(async_session_maker):
"""
GIVEN: A user exists in the database
WHEN: update_user_app_settings is called with new values
THEN: The user's settings are updated and returned
"""
# Arrange
async with async_session_maker() as session:
org = Org(name='test-org')
session.add(org)
await session.flush()
user = User(
id=uuid.uuid4(),
current_org_id=org.id,
language='en',
user_consents_to_analytics=False,
)
session.add(user)
await session.commit()
user_id = str(user.id)
update_data = UserAppSettingsUpdate(
language='es',
user_consents_to_analytics=True,
enable_sound_notifications=True,
git_user_name='newuser',
git_user_email='new@example.com',
)
# Act - create store with the session
store = UserAppSettingsStore(db_session=session)
result = await store.update_user_app_settings(user_id, update_data)
# Assert
assert result is not None
assert result.language == 'es'
assert result.user_consents_to_analytics is True
assert result.enable_sound_notifications is True
assert result.git_user_name == 'newuser'
assert result.git_user_email == 'new@example.com'
@pytest.mark.asyncio
async def test_update_user_app_settings_partial(async_session_maker):
"""
GIVEN: A user exists with existing settings
WHEN: update_user_app_settings is called with only some fields
THEN: Only the provided fields are updated, others remain unchanged
"""
# Arrange
async with async_session_maker() as session:
org = Org(name='test-org')
session.add(org)
await session.flush()
user = User(
id=uuid.uuid4(),
current_org_id=org.id,
language='en',
user_consents_to_analytics=True,
git_user_name='original',
)
session.add(user)
await session.commit()
user_id = str(user.id)
# Only update language
update_data = UserAppSettingsUpdate(language='fr')
# Act - create store with the session
store = UserAppSettingsStore(db_session=session)
result = await store.update_user_app_settings(user_id, update_data)
# Assert
assert result is not None
assert result.language == 'fr'
assert result.user_consents_to_analytics is True # Unchanged
assert result.git_user_name == 'original' # Unchanged
@pytest.mark.asyncio
async def test_update_user_app_settings_user_not_found(async_session_maker):
"""
GIVEN: A user does not exist in the database
WHEN: update_user_app_settings is called
THEN: None is returned
"""
# Arrange
non_existent_id = str(uuid.uuid4())
update_data = UserAppSettingsUpdate(language='en')
# Act
async with async_session_maker() as session:
store = UserAppSettingsStore(db_session=session)
result = await store.update_user_app_settings(non_existent_id, update_data)
# Assert
assert result is None

View File

@@ -1,6 +1,25 @@
"""Tests for resend_keycloak email validation."""
"""Tests for Resend Keycloak sync functionality."""
from sync.resend_keycloak import is_valid_email
import os
from unittest.mock import MagicMock, patch
import pytest
from resend.exceptions import ResendError
from tenacity import RetryError
# Set required environment variables before importing the module
# that reads them at import time
os.environ['RESEND_API_KEY'] = 'test_api_key'
os.environ['RESEND_AUDIENCE_ID'] = 'test_audience_id'
os.environ['KEYCLOAK_SERVER_URL'] = 'http://localhost:8080'
os.environ['KEYCLOAK_REALM_NAME'] = 'test_realm'
os.environ['KEYCLOAK_ADMIN_PASSWORD'] = 'test_password'
from enterprise.sync.resend_keycloak import ( # noqa: E402
add_contact_to_resend,
is_valid_email,
send_welcome_email,
)
class TestIsValidEmail:
@@ -115,3 +134,134 @@ class TestIsValidEmail:
"""Test that validation works for uppercase emails."""
assert is_valid_email('USER@EXAMPLE.COM') is True
assert is_valid_email('User@Example.Com') is True
class TestSendWelcomeEmail:
"""Tests for send_welcome_email function."""
@patch('enterprise.sync.resend_keycloak.resend.Emails.send')
def test_send_welcome_email_success(self, mock_send: MagicMock) -> None:
"""Test successful welcome email sending."""
mock_send.return_value = {'id': 'email_123'}
result = send_welcome_email(
email='test@example.com',
first_name='John',
last_name='Doe',
)
assert result == {'id': 'email_123'}
mock_send.assert_called_once()
call_args = mock_send.call_args[0][0]
assert call_args['to'] == ['test@example.com']
assert call_args['subject'] == 'Welcome to OpenHands Cloud'
assert 'Hi John Doe,' in call_args['html']
@patch('enterprise.sync.resend_keycloak.resend.Emails.send')
def test_send_welcome_email_retries_on_rate_limit(
self, mock_send: MagicMock
) -> None:
"""Test that send_welcome_email retries on rate limit errors."""
# First two calls raise rate limit error, third succeeds
mock_send.side_effect = [
ResendError(
code=429,
message='Too many requests',
error_type='rate_limit_exceeded',
suggested_action='',
),
ResendError(
code=429,
message='Too many requests',
error_type='rate_limit_exceeded',
suggested_action='',
),
{'id': 'email_123'},
]
result = send_welcome_email(
email='test@example.com',
first_name='John',
last_name='Doe',
)
assert result == {'id': 'email_123'}
assert mock_send.call_count == 3
@patch('enterprise.sync.resend_keycloak.resend.Emails.send')
def test_send_welcome_email_fails_after_max_retries(
self, mock_send: MagicMock
) -> None:
"""Test that send_welcome_email fails after max retries."""
# All calls raise rate limit error
mock_send.side_effect = ResendError(
code=429,
message='Too many requests',
error_type='rate_limit_exceeded',
suggested_action='',
)
# Tenacity wraps the final exception in RetryError
with pytest.raises(RetryError):
send_welcome_email(
email='test@example.com',
first_name='John',
last_name='Doe',
)
# Default MAX_RETRIES is 3
assert mock_send.call_count == 3
@patch('enterprise.sync.resend_keycloak.resend.Emails.send')
def test_send_welcome_email_no_name(self, mock_send: MagicMock) -> None:
"""Test welcome email with no name provided."""
mock_send.return_value = {'id': 'email_123'}
result = send_welcome_email(email='test@example.com')
assert result == {'id': 'email_123'}
call_args = mock_send.call_args[0][0]
assert 'Hi there,' in call_args['html']
class TestAddContactToResend:
"""Tests for add_contact_to_resend function."""
@patch('enterprise.sync.resend_keycloak.resend.Contacts.create')
def test_add_contact_to_resend_success(self, mock_create: MagicMock) -> None:
"""Test successful contact addition."""
mock_create.return_value = {'id': 'contact_123'}
result = add_contact_to_resend(
audience_id='test_audience',
email='test@example.com',
first_name='John',
last_name='Doe',
)
assert result == {'id': 'contact_123'}
mock_create.assert_called_once()
@patch('enterprise.sync.resend_keycloak.resend.Contacts.create')
def test_add_contact_to_resend_retries_on_rate_limit(
self, mock_create: MagicMock
) -> None:
"""Test that add_contact_to_resend retries on rate limit errors."""
# First call raises rate limit error, second succeeds
mock_create.side_effect = [
ResendError(
code=429,
message='Too many requests',
error_type='rate_limit_exceeded',
suggested_action='',
),
{'id': 'contact_123'},
]
result = add_contact_to_resend(
audience_id='test_audience',
email='test@example.com',
)
assert result == {'id': 'contact_123'}
assert mock_create.call_count == 2

View File

@@ -284,3 +284,85 @@ async def test_middleware_ignores_email_resend_path_no_tos_check(
assert result == mock_response
mock_call_next.assert_called_once_with(mock_request)
# Should not raise TosNotAcceptedError for this path
@pytest.mark.asyncio
async def test_middleware_skips_webhook_endpoints(
middleware, mock_request, mock_response
):
"""Test middleware skips webhook endpoints (/api/v1/webhooks/*) and doesn't require auth."""
# Test various webhook paths
webhook_paths = [
'/api/v1/webhooks/events',
'/api/v1/webhooks/events/123',
'/api/v1/webhooks/stats',
'/api/v1/webhooks/parent-conversation',
]
for path in webhook_paths:
mock_request.cookies = {}
mock_request.url = MagicMock()
mock_request.url.hostname = 'localhost'
mock_request.url.path = path
mock_call_next = AsyncMock(return_value=mock_response)
# Act
result = await middleware(mock_request, mock_call_next)
# Assert - middleware should skip auth check and call next
assert result == mock_response
mock_call_next.assert_called_once_with(mock_request)
@pytest.mark.asyncio
async def test_middleware_skips_webhook_secrets_endpoint(
middleware, mock_request, mock_response
):
"""Test middleware skips the old /api/v1/webhooks/secrets endpoint."""
# This was explicitly in ignore_paths but is now handled by the prefix check
mock_request.cookies = {}
mock_request.url = MagicMock()
mock_request.url.hostname = 'localhost'
mock_request.url.path = '/api/v1/webhooks/secrets'
mock_call_next = AsyncMock(return_value=mock_response)
# Act
result = await middleware(mock_request, mock_call_next)
# Assert - middleware should skip auth check and call next
assert result == mock_response
mock_call_next.assert_called_once_with(mock_request)
@pytest.mark.asyncio
async def test_middleware_does_not_skip_similar_non_webhook_paths(
middleware, mock_response
):
"""Test middleware does NOT skip paths that start with /api/v1/webhook (without 's')."""
# These paths should still be processed by the middleware (not skipped)
# They start with /api so _should_attach returns True, and since there's no auth,
# middleware should return 401 response (it catches NoCredentialsError internally)
non_webhook_paths = [
'/api/v1/webhook/events',
'/api/v1/webhook/something',
]
for path in non_webhook_paths:
# Create a fresh mock request for each test
mock_request = MagicMock(spec=Request)
mock_request.cookies = {}
mock_request.url = MagicMock()
mock_request.url.hostname = 'localhost'
mock_request.url.path = path
mock_request.headers = MagicMock()
mock_request.headers.get = MagicMock(side_effect=lambda k: None)
# Since these paths start with /api, _should_attach returns True
# Since there's no auth, middleware catches NoCredentialsError and returns 401
mock_call_next = AsyncMock()
result = await middleware(mock_request, mock_call_next)
# Should return a 401 response, not raise an exception
assert result.status_code == status.HTTP_401_UNAUTHORIZED
# Should NOT call next for non-webhook paths when auth is missing
mock_call_next.assert_not_called()

View File

@@ -154,6 +154,7 @@ async def test_keycloak_callback_user_not_allowed(mock_request):
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.migrate_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_user_store.backfill_user_email = AsyncMock()
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = False
@@ -190,6 +191,7 @@ async def test_keycloak_callback_success_with_valid_offline_token(mock_request):
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.migrate_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_user_store.backfill_user_email = AsyncMock()
mock_token_manager.get_keycloak_tokens = AsyncMock(
return_value=('test_access_token', 'test_refresh_token')
@@ -262,6 +264,7 @@ async def test_keycloak_callback_email_not_verified(mock_request):
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_user_store.backfill_user_email = AsyncMock()
# Act
result = await keycloak_callback(
@@ -310,6 +313,7 @@ async def test_keycloak_callback_email_not_verified_missing_field(mock_request):
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_user_store.backfill_user_email = AsyncMock()
# Act
result = await keycloak_callback(
@@ -352,6 +356,7 @@ async def test_keycloak_callback_success_without_offline_token(mock_request):
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.migrate_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_user_store.backfill_user_email = AsyncMock()
mock_token_manager.get_keycloak_tokens = AsyncMock(
return_value=('test_access_token', 'test_refresh_token')
@@ -587,6 +592,7 @@ async def test_keycloak_callback_blocked_email_domain(mock_request):
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_user_store.backfill_user_email = AsyncMock()
mock_domain_blocker.is_active.return_value = True
mock_domain_blocker.is_domain_blocked.return_value = True
@@ -651,6 +657,7 @@ async def test_keycloak_callback_allowed_email_domain(mock_request):
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_user_store.backfill_user_email = AsyncMock()
mock_domain_blocker.is_active.return_value = True
mock_domain_blocker.is_domain_blocked.return_value = False
@@ -715,6 +722,7 @@ async def test_keycloak_callback_domain_blocking_inactive(mock_request):
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_user_store.backfill_user_email = AsyncMock()
mock_domain_blocker.is_active.return_value = False
mock_domain_blocker.is_domain_blocked.return_value = False
@@ -777,6 +785,7 @@ async def test_keycloak_callback_missing_email(mock_request):
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_user_store.backfill_user_email = AsyncMock()
mock_domain_blocker.is_active.return_value = True
@@ -823,6 +832,7 @@ async def test_keycloak_callback_duplicate_email_detected(mock_request):
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_user_store.backfill_user_email = AsyncMock()
# Act
result = await keycloak_callback(
@@ -868,6 +878,7 @@ async def test_keycloak_callback_duplicate_email_deletion_fails(mock_request):
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_user_store.backfill_user_email = AsyncMock()
# Act
result = await keycloak_callback(
@@ -926,6 +937,7 @@ async def test_keycloak_callback_duplicate_check_exception(mock_request):
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_user_store.backfill_user_email = AsyncMock()
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
@@ -984,6 +996,7 @@ async def test_keycloak_callback_no_duplicate_email(mock_request):
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_user_store.backfill_user_email = AsyncMock()
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
@@ -1045,6 +1058,7 @@ async def test_keycloak_callback_no_email_in_user_info(mock_request):
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_user_store.backfill_user_email = AsyncMock()
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
@@ -1202,6 +1216,7 @@ class TestKeycloakCallbackRecaptcha:
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_user_store.backfill_user_email = AsyncMock()
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
@@ -1267,6 +1282,7 @@ class TestKeycloakCallbackRecaptcha:
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_user_store.backfill_user_email = AsyncMock()
mock_domain_blocker.is_domain_blocked.return_value = False
@@ -1350,6 +1366,7 @@ class TestKeycloakCallbackRecaptcha:
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_user_store.backfill_user_email = AsyncMock()
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
@@ -1438,6 +1455,7 @@ class TestKeycloakCallbackRecaptcha:
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_user_store.backfill_user_email = AsyncMock()
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
@@ -1523,6 +1541,7 @@ class TestKeycloakCallbackRecaptcha:
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_user_store.backfill_user_email = AsyncMock()
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
@@ -1607,6 +1626,7 @@ class TestKeycloakCallbackRecaptcha:
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_user_store.backfill_user_email = AsyncMock()
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
@@ -1688,6 +1708,7 @@ class TestKeycloakCallbackRecaptcha:
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_user_store.backfill_user_email = AsyncMock()
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
@@ -1755,6 +1776,7 @@ class TestKeycloakCallbackRecaptcha:
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_user_store.backfill_user_email = AsyncMock()
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
@@ -1828,6 +1850,7 @@ class TestKeycloakCallbackRecaptcha:
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_user_store.backfill_user_email = AsyncMock()
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
@@ -1899,6 +1922,7 @@ class TestKeycloakCallbackRecaptcha:
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_user_store.backfill_user_email = AsyncMock()
mock_domain_blocker.is_domain_blocked.return_value = False
@@ -1918,3 +1942,57 @@ class TestKeycloakCallbackRecaptcha:
assert call_kwargs[0][0] == 'recaptcha_blocked_at_callback'
assert call_kwargs[1]['extra']['score'] == 0.2
assert call_kwargs[1]['extra']['user_id'] == 'test_user_id'
@pytest.mark.asyncio
async def test_keycloak_callback_calls_backfill_user_email_for_existing_user(
mock_request,
):
"""When an existing user logs in, backfill_user_email should be called."""
user_info = {
'sub': 'test_user_id',
'preferred_username': 'test_user',
'identity_provider': 'github',
'email': 'test@example.com',
'email_verified': True,
}
with (
patch('server.routes.auth.token_manager') as mock_token_manager,
patch('server.routes.auth.user_verifier') as mock_verifier,
patch('server.routes.auth.set_response_cookie'),
patch('server.routes.auth.UserStore') as mock_user_store,
patch('server.routes.auth.posthog'),
):
mock_user = MagicMock()
mock_user.id = 'test_user_id'
mock_user.current_org_id = 'test_org_id'
mock_user.accepted_tos = '2025-01-01'
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_user_store.backfill_user_email = AsyncMock()
mock_token_manager.get_keycloak_tokens = AsyncMock(
return_value=('test_access_token', 'test_refresh_token')
)
mock_token_manager.get_user_info = AsyncMock(return_value=user_info)
mock_token_manager.store_idp_tokens = AsyncMock()
mock_token_manager.validate_offline_token = AsyncMock(return_value=True)
mock_token_manager.check_duplicate_base_email = AsyncMock(return_value=False)
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
result = await keycloak_callback(
code='test_code', state='test_state', request=mock_request
)
assert isinstance(result, RedirectResponse)
assert result.status_code == 302
# backfill_user_email should have been called with the user_id and user_info
mock_user_store.backfill_user_email.assert_called_once_with(
'test_user_id', user_info
)

View File

@@ -655,3 +655,506 @@ async def test_get_org_members_paginated_eager_loading(async_session_maker):
assert member.role is not None
assert member.role.name == 'owner'
assert member.role.rank == 10
@pytest.mark.asyncio
async def test_get_org_members_count_no_filter(async_session_maker):
"""Test get_org_members_count returns correct count without email filter."""
# Arrange
async with async_session_maker() as session:
org = Org(name='test-org')
session.add(org)
await session.flush()
role = Role(name='admin', rank=1)
session.add(role)
await session.flush()
users = [
User(id=uuid.uuid4(), current_org_id=org.id, email=f'user{i}@example.com')
for i in range(5)
]
session.add_all(users)
await session.flush()
org_members = [
OrgMember(
org_id=org.id,
user_id=user.id,
role_id=role.id,
llm_api_key=f'test-key-{i}',
status='active',
)
for i, user in enumerate(users)
]
session.add_all(org_members)
await session.commit()
org_id = org.id
# Act
with patch('storage.org_member_store.a_session_maker', async_session_maker):
count = await OrgMemberStore.get_org_members_count(org_id=org_id)
# Assert
assert count == 5
@pytest.mark.asyncio
async def test_get_org_members_count_with_email_filter(async_session_maker):
"""Test get_org_members_count filters by email correctly."""
# Arrange
async with async_session_maker() as session:
org = Org(name='test-org')
session.add(org)
await session.flush()
role = Role(name='admin', rank=1)
session.add(role)
await session.flush()
users = [
User(id=uuid.uuid4(), current_org_id=org.id, email='alice@example.com'),
User(id=uuid.uuid4(), current_org_id=org.id, email='bob@example.com'),
User(
id=uuid.uuid4(), current_org_id=org.id, email='alice.smith@example.com'
),
]
session.add_all(users)
await session.flush()
org_members = [
OrgMember(
org_id=org.id,
user_id=user.id,
role_id=role.id,
llm_api_key=f'test-key-{i}',
status='active',
)
for i, user in enumerate(users)
]
session.add_all(org_members)
await session.commit()
org_id = org.id
# Act
with patch('storage.org_member_store.a_session_maker', async_session_maker):
count = await OrgMemberStore.get_org_members_count(
org_id=org_id, email_filter='alice'
)
# Assert
assert count == 2
@pytest.mark.asyncio
async def test_get_org_members_paginated_with_email_filter(async_session_maker):
"""Test get_org_members_paginated filters by email correctly."""
# Arrange
async with async_session_maker() as session:
org = Org(name='test-org')
session.add(org)
await session.flush()
role = Role(name='admin', rank=1)
session.add(role)
await session.flush()
users = [
User(id=uuid.uuid4(), current_org_id=org.id, email='alice@example.com'),
User(id=uuid.uuid4(), current_org_id=org.id, email='bob@example.com'),
User(id=uuid.uuid4(), current_org_id=org.id, email='charlie@example.com'),
]
session.add_all(users)
await session.flush()
org_members = [
OrgMember(
org_id=org.id,
user_id=user.id,
role_id=role.id,
llm_api_key=f'test-key-{i}',
status='active',
)
for i, user in enumerate(users)
]
session.add_all(org_members)
await session.commit()
org_id = org.id
# Act
with patch('storage.org_member_store.a_session_maker', async_session_maker):
members, has_more = await OrgMemberStore.get_org_members_paginated(
org_id=org_id, offset=0, limit=10, email_filter='bob'
)
# Assert
assert len(members) == 1
assert members[0].user.email == 'bob@example.com'
assert has_more is False
@pytest.mark.asyncio
async def test_get_org_members_paginated_email_filter_case_insensitive(
async_session_maker,
):
"""Test email filter is case-insensitive."""
# Arrange
async with async_session_maker() as session:
org = Org(name='test-org')
session.add(org)
await session.flush()
role = Role(name='admin', rank=1)
session.add(role)
await session.flush()
user = User(id=uuid.uuid4(), current_org_id=org.id, email='Alice@Example.COM')
session.add(user)
await session.flush()
org_member = OrgMember(
org_id=org.id,
user_id=user.id,
role_id=role.id,
llm_api_key='test-key',
status='active',
)
session.add(org_member)
await session.commit()
org_id = org.id
# Act
with patch('storage.org_member_store.a_session_maker', async_session_maker):
members, has_more = await OrgMemberStore.get_org_members_paginated(
org_id=org_id, offset=0, limit=10, email_filter='alice@example'
)
# Assert
assert len(members) == 1
assert members[0].user.email == 'Alice@Example.COM'
@pytest.mark.asyncio
async def test_update_all_members_llm_settings_async_with_llm_api_key(
async_session_maker,
):
"""
GIVEN: Organization with members and llm_api_key in member settings
WHEN: update_all_members_llm_settings_async is called with llm_api_key
THEN: The llm_api_key is encrypted and stored in _llm_api_key column for all members
"""
from server.routes.org_models import OrgMemberLLMSettings
from storage.encrypt_utils import decrypt_value
# Arrange
async with async_session_maker() as session:
org = Org(name='test-org')
session.add(org)
await session.flush()
role = Role(name='member', rank=2)
session.add(role)
await session.flush()
users = [
User(id=uuid.uuid4(), current_org_id=org.id, email=f'user{i}@example.com')
for i in range(2)
]
session.add_all(users)
await session.flush()
org_members = [
OrgMember(
org_id=org.id,
user_id=user.id,
role_id=role.id,
llm_api_key='old-key',
status='active',
)
for user in users
]
session.add_all(org_members)
await session.commit()
org_id = org.id
# Act
new_api_key = 'new-test-api-key-12345'
member_settings = OrgMemberLLMSettings(llm_api_key=new_api_key)
async with async_session_maker() as session:
await OrgMemberStore.update_all_members_llm_settings_async(
session, org_id, member_settings
)
await session.commit()
# Assert
async with async_session_maker() as session:
from sqlalchemy import select
result = await session.execute(
select(OrgMember).filter(OrgMember.org_id == org_id)
)
updated_members = result.scalars().all()
assert len(updated_members) == 2
for member in updated_members:
# Verify the encrypted value can be decrypted to the original
decrypted_key = decrypt_value(member._llm_api_key)
assert decrypted_key == new_api_key
@pytest.mark.asyncio
async def test_update_all_members_llm_settings_async_with_non_encrypted_fields(
async_session_maker,
):
"""
GIVEN: Organization with members
WHEN: update_all_members_llm_settings_async is called with non-encrypted fields
THEN: The fields are updated directly without encryption
"""
from server.routes.org_models import OrgMemberLLMSettings
# Arrange
async with async_session_maker() as session:
org = Org(name='test-org')
session.add(org)
await session.flush()
role = Role(name='member', rank=2)
session.add(role)
await session.flush()
user = User(id=uuid.uuid4(), current_org_id=org.id, email='user@example.com')
session.add(user)
await session.flush()
org_member = OrgMember(
org_id=org.id,
user_id=user.id,
role_id=role.id,
llm_api_key='test-key',
llm_model='old-model',
max_iterations=10,
status='active',
)
session.add(org_member)
await session.commit()
org_id = org.id
# Act
member_settings = OrgMemberLLMSettings(
llm_model='new-model',
llm_base_url='https://new-url.com',
max_iterations=50,
)
async with async_session_maker() as session:
await OrgMemberStore.update_all_members_llm_settings_async(
session, org_id, member_settings
)
await session.commit()
# Assert
async with async_session_maker() as session:
from sqlalchemy import select
result = await session.execute(
select(OrgMember).filter(OrgMember.org_id == org_id)
)
updated_member = result.scalars().first()
assert updated_member.llm_model == 'new-model'
assert updated_member.llm_base_url == 'https://new-url.com'
assert updated_member.max_iterations == 50
@pytest.mark.asyncio
async def test_update_all_members_llm_settings_async_with_empty_settings(
async_session_maker,
):
"""
GIVEN: Organization with members and empty member settings
WHEN: update_all_members_llm_settings_async is called with no fields set
THEN: No database update is performed
"""
from server.routes.org_models import OrgMemberLLMSettings
# Arrange
async with async_session_maker() as session:
org = Org(name='test-org')
session.add(org)
await session.flush()
role = Role(name='member', rank=2)
session.add(role)
await session.flush()
user = User(id=uuid.uuid4(), current_org_id=org.id, email='user@example.com')
session.add(user)
await session.flush()
org_member = OrgMember(
org_id=org.id,
user_id=user.id,
role_id=role.id,
llm_api_key='original-key',
llm_model='original-model',
status='active',
)
session.add(org_member)
await session.commit()
org_id = org.id
# Act - Empty settings (all None)
member_settings = OrgMemberLLMSettings()
async with async_session_maker() as session:
await OrgMemberStore.update_all_members_llm_settings_async(
session, org_id, member_settings
)
await session.commit()
# Assert - Original values should be unchanged
async with async_session_maker() as session:
from sqlalchemy import select
result = await session.execute(
select(OrgMember).filter(OrgMember.org_id == org_id)
)
member = result.scalars().first()
assert member.llm_model == 'original-model'
# Original key should still be there (encrypted)
assert member._llm_api_key is not None
# =============================================================================
# OrgMemberLLMSettings and OrgLLMSettingsUpdate Model Unit Tests
# =============================================================================
def test_org_member_llm_settings_has_updates_with_llm_api_key():
"""
GIVEN: OrgMemberLLMSettings with only llm_api_key set
WHEN: has_updates() is called
THEN: Returns True
"""
from server.routes.org_models import OrgMemberLLMSettings
# Arrange
settings = OrgMemberLLMSettings(llm_api_key='test-key')
# Act
result = settings.has_updates()
# Assert
assert result is True
def test_org_member_llm_settings_has_updates_empty():
"""
GIVEN: OrgMemberLLMSettings with no fields set
WHEN: has_updates() is called
THEN: Returns False
"""
from server.routes.org_models import OrgMemberLLMSettings
# Arrange
settings = OrgMemberLLMSettings()
# Act
result = settings.has_updates()
# Assert
assert result is False
def test_org_llm_settings_update_apply_to_org_skips_llm_api_key():
"""
GIVEN: OrgLLMSettingsUpdate with llm_api_key and other fields set
WHEN: apply_to_org() is called
THEN: llm_api_key is NOT applied to org, but other fields are
"""
from unittest.mock import MagicMock
from server.routes.org_models import OrgLLMSettingsUpdate
# Arrange
settings = OrgLLMSettingsUpdate(
default_llm_model='claude-3',
llm_api_key='should-not-be-applied',
)
mock_org = MagicMock()
mock_org.default_llm_model = None
# Act
settings.apply_to_org(mock_org)
# Assert
assert mock_org.default_llm_model == 'claude-3'
# llm_api_key should NOT be set on org (it's member-only)
assert (
not hasattr(mock_org, 'llm_api_key')
or mock_org.llm_api_key != 'should-not-be-applied'
)
def test_org_llm_settings_update_get_member_updates_includes_llm_api_key():
"""
GIVEN: OrgLLMSettingsUpdate with llm_api_key set
WHEN: get_member_updates() is called
THEN: Returns OrgMemberLLMSettings with llm_api_key included
"""
from server.routes.org_models import OrgLLMSettingsUpdate
# Arrange
settings = OrgLLMSettingsUpdate(
default_llm_model='claude-3',
llm_api_key='new-member-key',
)
# Act
member_updates = settings.get_member_updates()
# Assert
assert member_updates is not None
assert member_updates.llm_api_key == 'new-member-key'
assert member_updates.llm_model == 'claude-3'
def test_org_llm_settings_update_get_member_updates_only_llm_api_key():
"""
GIVEN: OrgLLMSettingsUpdate with only llm_api_key set
WHEN: get_member_updates() is called
THEN: Returns OrgMemberLLMSettings with llm_api_key (not None)
"""
from server.routes.org_models import OrgLLMSettingsUpdate
# Arrange
settings = OrgLLMSettingsUpdate(llm_api_key='member-key-only')
# Act
member_updates = settings.get_member_updates()
# Assert
assert member_updates is not None
assert member_updates.llm_api_key == 'member-key-only'
assert member_updates.llm_model is None
def test_org_llm_settings_update_has_updates_with_llm_api_key():
"""
GIVEN: OrgLLMSettingsUpdate with only llm_api_key set
WHEN: has_updates() is called
THEN: Returns True
"""
from server.routes.org_models import OrgLLMSettingsUpdate
# Arrange
settings = OrgLLMSettingsUpdate(llm_api_key='test-key')
# Act
result = settings.has_updates()
# Assert
assert result is True

View File

@@ -1,4 +1,5 @@
import uuid
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -10,6 +11,7 @@ with patch('storage.database.engine', create=True), patch(
'storage.database.a_engine', create=True
):
from storage.org import Org
from storage.org_invitation import OrgInvitation
from storage.org_member import OrgMember
from storage.org_store import OrgStore
from storage.role import Role
@@ -806,3 +808,183 @@ def test_orphaned_user_error_contains_user_ids():
assert error.user_ids == user_ids
assert '2 user(s)' in str(error)
assert 'no remaining organization' in str(error)
def test_org_deletion_with_invitations_uses_passive_deletes(
session_maker, mock_litellm_api
):
"""
GIVEN: Organization has associated invitations with non-nullable org_id foreign key
WHEN: Organization is deleted via SQLAlchemy session.delete()
THEN: Deletion succeeds without NOT NULL constraint violation
(passive_deletes=True defers to database CASCADE instead of setting org_id to NULL)
This test verifies the fix for the bug where SQLAlchemy would try to
SET org_id=NULL on org_invitation before deleting the org, causing:
"NOT NULL constraint failed: org_invitation.org_id"
With passive_deletes=True on the relationship, SQLAlchemy defers to the
database's CASCADE constraint instead of trying to nullify the foreign key.
Note: SQLite doesn't enforce CASCADE by default, so we only verify that
the deletion succeeds. In production (PostgreSQL), CASCADE handles cleanup.
"""
from datetime import datetime, timedelta
# Arrange
org_id = uuid.uuid4()
other_org_id = uuid.uuid4()
user_id = uuid.uuid4()
with session_maker() as session:
# Create role first (required for invitation)
role = Role(id=1, name='owner', rank=1)
session.add(role)
session.flush()
# Create organization to be deleted
org = Org(id=org_id, name='test-org-with-invitations')
session.add(org)
session.flush()
# Create a second org for the user's current_org_id
# (to avoid the user.current_org_id constraint issue during deletion)
other_org = Org(id=other_org_id, name='other-org')
session.add(other_org)
session.flush()
# Create user with current_org pointing to the OTHER org (not the one being deleted)
user = User(id=user_id, current_org_id=other_org_id)
session.add(user)
session.flush()
# Create invitation associated with the organization to be deleted
invitation = OrgInvitation(
token='test-invitation-token-12345',
org_id=org_id,
email='invitee@example.com',
role_id=1,
inviter_id=user_id,
status='pending',
created_at=datetime.now(),
expires_at=datetime.now() + timedelta(days=7),
)
session.add(invitation)
session.commit()
# Verify invitation was created
invitation_count = session.query(OrgInvitation).filter_by(org_id=org_id).count()
assert invitation_count == 1
# Act - Delete organization via SQLAlchemy (this is what triggered the bug)
# Without passive_deletes=True, SQLAlchemy would try to SET org_id=NULL
# which violates the NOT NULL constraint on org_invitation.org_id
with session_maker() as session:
org = session.query(Org).filter(Org.id == org_id).first()
assert org is not None
# This should NOT raise IntegrityError with passive_deletes=True
# Previously this would fail with:
# "NOT NULL constraint failed: org_invitation.org_id"
session.delete(org)
session.commit() # Success indicates passive_deletes=True is working
# Assert - Organization should be deleted
with session_maker() as session:
deleted_org = session.query(Org).filter(Org.id == org_id).first()
assert deleted_org is None
# =============================================================================
# Tests for async LLM settings methods
# =============================================================================
@pytest.mark.asyncio
async def test_update_org_llm_settings_async_with_llm_api_key():
"""
GIVEN: Organization with members and llm_api_key in update settings
WHEN: update_org_llm_settings_async is called
THEN: Org fields are updated and llm_api_key is propagated to all members
"""
from server.routes.org_models import OrgLLMSettingsUpdate
# Arrange
org_id = uuid.uuid4()
mock_org = Org(
id=org_id,
name='Test Organization',
default_llm_model='old-model',
)
llm_settings = OrgLLMSettingsUpdate(
default_llm_model='new-model',
llm_api_key='new-member-api-key',
)
# Mock the async session and member store
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.scalars.return_value.first.return_value = mock_org
mock_session.execute.return_value = mock_result
mock_session.commit = AsyncMock()
mock_session.refresh = AsyncMock()
@asynccontextmanager
async def mock_a_session_maker():
yield mock_session
with (
patch('storage.org_store.a_session_maker', mock_a_session_maker),
patch(
'storage.org_member_store.OrgMemberStore.update_all_members_llm_settings_async',
AsyncMock(),
) as mock_member_update,
):
# Act
result = await OrgStore.update_org_llm_settings_async(org_id, llm_settings)
# Assert - Org is returned
assert result is not None
assert result.default_llm_model == 'new-model'
# Assert - Member update was called with correct settings
mock_member_update.assert_called_once()
call_args = mock_member_update.call_args
member_settings = call_args[0][2] # Third positional arg is member_settings
assert member_settings.llm_api_key == 'new-member-api-key'
assert member_settings.llm_model == 'new-model'
@pytest.mark.asyncio
async def test_update_org_llm_settings_async_org_not_found():
"""
GIVEN: Non-existent organization ID
WHEN: update_org_llm_settings_async is called
THEN: Returns None
"""
from server.routes.org_models import OrgLLMSettingsUpdate
# Arrange
non_existent_org_id = uuid.uuid4()
llm_settings = OrgLLMSettingsUpdate(default_llm_model='new-model')
# Mock the async session to return None for org
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.scalars.return_value.first.return_value = None
mock_session.execute.return_value = mock_result
@asynccontextmanager
async def mock_a_session_maker():
yield mock_session
# Act
with patch('storage.org_store.a_session_maker', mock_a_session_maker):
result = await OrgStore.update_org_llm_settings_async(
non_existent_org_id, llm_settings
)
# Assert
assert result is None

View File

@@ -5,7 +5,7 @@ the endpoint constructs a User from OIDC claims. These tests verify that name an
fields are correctly populated from Keycloak claims in this fallback path.
"""
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from pydantic import SecretStr
@@ -33,9 +33,20 @@ def mock_check_idp():
yield mock_fn
@pytest.fixture
def mock_user_store():
"""Mock UserStore.get_user_by_id_async to return None by default."""
with patch(
'server.routes.user.UserStore.get_user_by_id_async',
new_callable=AsyncMock,
return_value=None,
) as mock_fn:
yield mock_fn
@pytest.mark.asyncio
async def test_fallback_user_includes_name_from_name_claim(
mock_token_manager, mock_check_idp
mock_token_manager, mock_check_idp, mock_user_store
):
"""When Keycloak provides a 'name' claim, the fallback User should include it."""
from server.routes.user import saas_get_user
@@ -62,7 +73,7 @@ async def test_fallback_user_includes_name_from_name_claim(
@pytest.mark.asyncio
async def test_fallback_user_combines_given_and_family_name(
mock_token_manager, mock_check_idp
mock_token_manager, mock_check_idp, mock_user_store
):
"""When 'name' is absent, combine given_name + family_name."""
from server.routes.user import saas_get_user
@@ -89,7 +100,7 @@ async def test_fallback_user_combines_given_and_family_name(
@pytest.mark.asyncio
async def test_fallback_user_name_is_none_when_no_name_claims(
mock_token_manager, mock_check_idp
mock_token_manager, mock_check_idp, mock_user_store
):
"""When no name claims exist, name should be None."""
from server.routes.user import saas_get_user
@@ -113,7 +124,9 @@ async def test_fallback_user_name_is_none_when_no_name_claims(
@pytest.mark.asyncio
async def test_fallback_user_includes_company_claim(mock_token_manager, mock_check_idp):
async def test_fallback_user_includes_company_claim(
mock_token_manager, mock_check_idp, mock_user_store
):
"""When Keycloak provides a 'company' claim, include it in the User."""
from server.routes.user import saas_get_user
@@ -139,7 +152,7 @@ async def test_fallback_user_includes_company_claim(mock_token_manager, mock_che
@pytest.mark.asyncio
async def test_fallback_user_company_is_none_when_absent(
mock_token_manager, mock_check_idp
mock_token_manager, mock_check_idp, mock_user_store
):
"""When 'company' is not in Keycloak claims, company should be None."""
from server.routes.user import saas_get_user
@@ -161,3 +174,88 @@ async def test_fallback_user_company_is_none_when_absent(
assert isinstance(result, User)
assert result.company is None
@pytest.mark.asyncio
async def test_fallback_user_email_from_db_when_available(
mock_token_manager, mock_check_idp, mock_user_store
):
"""When User.email is stored in DB, use it instead of Keycloak's live email."""
from server.routes.user import saas_get_user
mock_token_manager.get_user_info = AsyncMock(
return_value={
'sub': '248289761001',
'preferred_username': 'j.doe',
'email': 'keycloak@example.com',
}
)
mock_db_user = MagicMock()
mock_db_user.email = 'db@example.com'
mock_user_store.return_value = mock_db_user
result = await saas_get_user(
provider_tokens=None,
access_token=SecretStr('test-token'),
user_id='248289761001',
)
assert isinstance(result, User)
assert result.email == 'db@example.com'
@pytest.mark.asyncio
async def test_fallback_user_email_falls_back_to_keycloak_when_db_null(
mock_token_manager, mock_check_idp, mock_user_store
):
"""When User.email is NULL in DB, fall back to Keycloak's email."""
from server.routes.user import saas_get_user
mock_token_manager.get_user_info = AsyncMock(
return_value={
'sub': '248289761001',
'preferred_username': 'j.doe',
'email': 'keycloak@example.com',
}
)
mock_db_user = MagicMock()
mock_db_user.email = None
mock_user_store.return_value = mock_db_user
result = await saas_get_user(
provider_tokens=None,
access_token=SecretStr('test-token'),
user_id='248289761001',
)
assert isinstance(result, User)
assert result.email == 'keycloak@example.com'
@pytest.mark.asyncio
async def test_fallback_user_email_falls_back_to_keycloak_when_no_db_user(
mock_token_manager, mock_check_idp, mock_user_store
):
"""When DB user doesn't exist, fall back to Keycloak's email."""
from server.routes.user import saas_get_user
mock_token_manager.get_user_info = AsyncMock(
return_value={
'sub': '248289761001',
'preferred_username': 'j.doe',
'email': 'keycloak@example.com',
}
)
# mock_user_store already returns None by default
result = await saas_get_user(
provider_tokens=None,
access_token=SecretStr('test-token'),
user_id='248289761001',
)
assert isinstance(result, User)
assert result.email == 'keycloak@example.com'

View File

@@ -639,6 +639,204 @@ async def test_backfill_contact_name_preserves_custom_value(session_maker):
assert org.contact_name == 'Custom Corp Name'
# --- Tests for backfill_user_email on login ---
# Existing users created before the email capture fix may have NULL
# email in the User table. The backfill sets User.email from the IDP
# when the user next logs in, but preserves manual changes (non-NULL).
@pytest.mark.asyncio
async def test_backfill_user_email_sets_email_when_null(session_maker):
"""When User.email is NULL, backfill_user_email should set it from user_info."""
user_id = str(uuid.uuid4())
with session_maker() as session:
org = Org(
id=uuid.UUID(user_id),
name=f'user_{user_id}_org',
contact_email='jdoe@example.com',
)
session.add(org)
user = User(
id=uuid.UUID(user_id),
current_org_id=org.id,
email=None,
email_verified=None,
)
session.add(user)
session.commit()
user_info = {
'email': 'jdoe@example.com',
'email_verified': True,
}
with patch(
'storage.user_store.a_session_maker',
_wrap_sync_as_async_session_maker(session_maker),
):
await UserStore.backfill_user_email(user_id, user_info)
with session_maker() as session:
user = session.query(User).filter(User.id == uuid.UUID(user_id)).first()
assert user.email == 'jdoe@example.com'
assert user.email_verified is True
@pytest.mark.asyncio
async def test_backfill_user_email_does_not_overwrite_existing(session_maker):
"""When User.email is already set, backfill_user_email should NOT overwrite it."""
user_id = str(uuid.uuid4())
with session_maker() as session:
org = Org(
id=uuid.UUID(user_id),
name=f'user_{user_id}_org',
contact_email='original@example.com',
)
session.add(org)
user = User(
id=uuid.UUID(user_id),
current_org_id=org.id,
email='custom@example.com',
email_verified=True,
)
session.add(user)
session.commit()
user_info = {
'email': 'different@example.com',
'email_verified': False,
}
with patch(
'storage.user_store.a_session_maker',
_wrap_sync_as_async_session_maker(session_maker),
):
await UserStore.backfill_user_email(user_id, user_info)
with session_maker() as session:
user = session.query(User).filter(User.id == uuid.UUID(user_id)).first()
assert user.email == 'custom@example.com'
assert user.email_verified is True
@pytest.mark.asyncio
async def test_backfill_user_email_sets_verified_when_null(session_maker):
"""When User.email is set but email_verified is NULL, backfill should set email_verified."""
user_id = str(uuid.uuid4())
with session_maker() as session:
org = Org(
id=uuid.UUID(user_id),
name=f'user_{user_id}_org',
contact_email='jdoe@example.com',
)
session.add(org)
user = User(
id=uuid.UUID(user_id),
current_org_id=org.id,
email='jdoe@example.com',
email_verified=None,
)
session.add(user)
session.commit()
user_info = {
'email': 'different@example.com',
'email_verified': True,
}
with patch(
'storage.user_store.a_session_maker',
_wrap_sync_as_async_session_maker(session_maker),
):
await UserStore.backfill_user_email(user_id, user_info)
with session_maker() as session:
user = session.query(User).filter(User.id == uuid.UUID(user_id)).first()
# email should NOT be overwritten since it's non-NULL
assert user.email == 'jdoe@example.com'
# email_verified should be set since it was NULL
assert user.email_verified is True
@pytest.mark.asyncio
async def test_create_user_sets_email_verified_false_from_user_info():
"""When user_info has email_verified=False, create_user() should set User.email_verified=False."""
user_id = str(uuid.uuid4())
user_info = {
'preferred_username': 'jsmith',
'email': 'jsmith@example.com',
'email_verified': False,
}
mock_session = MagicMock()
mock_sm = MagicMock()
mock_sm.return_value.__enter__ = MagicMock(return_value=mock_session)
mock_sm.return_value.__exit__ = MagicMock(return_value=False)
mock_settings = Settings(
language='en',
llm_api_key=SecretStr('test-key'),
llm_base_url='http://test.url',
)
mock_role = MagicMock()
mock_role.id = 1
with (
patch('storage.user_store.session_maker', mock_sm),
patch.object(
UserStore,
'create_default_settings',
new_callable=AsyncMock,
return_value=mock_settings,
),
patch('storage.user_store.RoleStore.get_role_by_name', return_value=mock_role),
patch(
'storage.org_member_store.OrgMemberStore.get_kwargs_from_settings',
return_value={'llm_model': None, 'llm_base_url': None},
),
):
mock_session.commit.side_effect = _StopAfterUserCreation
with pytest.raises(_StopAfterUserCreation):
await UserStore.create_user(user_id, user_info)
user = mock_session.add.call_args_list[1][0][0]
assert isinstance(user, User)
assert user.email == 'jsmith@example.com'
assert user.email_verified is False
@pytest.mark.asyncio
async def test_create_user_preserves_org_contact_email():
"""create_user() must still set Org.contact_email (no regression)."""
user_id = str(uuid.uuid4())
user_info = {
'preferred_username': 'jdoe',
'email': 'jdoe@example.com',
'email_verified': True,
}
mock_session = MagicMock()
mock_sm = MagicMock()
mock_sm.return_value.__enter__ = MagicMock(return_value=mock_session)
mock_sm.return_value.__exit__ = MagicMock(return_value=False)
with (
patch('storage.user_store.session_maker', mock_sm),
patch.object(
UserStore,
'create_default_settings',
new_callable=AsyncMock,
return_value=None,
),
):
await UserStore.create_user(user_id, user_info)
org = mock_session.add.call_args_list[0][0][0]
assert isinstance(org, Org)
assert org.contact_email == 'jdoe@example.com'
def test_update_current_org_success(session_maker):
"""
GIVEN: User exists in database
@@ -680,3 +878,100 @@ def test_update_current_org_user_not_found(session_maker):
# Assert
assert result is None
# --- Tests for update_user_email ---
# update_user_email() should unconditionally overwrite User.email and/or email_verified.
# Unlike backfill_user_email(), it does not check for NULL before writing.
@pytest.mark.asyncio
async def test_update_user_email_overwrites_existing(session_maker):
"""update_user_email() should overwrite existing email and email_verified values."""
user_id = str(uuid.uuid4())
with session_maker() as session:
org = Org(
id=uuid.UUID(user_id),
name=f'user_{user_id}_org',
contact_email='old@example.com',
)
session.add(org)
user = User(
id=uuid.UUID(user_id),
current_org_id=org.id,
email='old@example.com',
email_verified=True,
)
session.add(user)
session.commit()
with patch(
'storage.user_store.a_session_maker',
_wrap_sync_as_async_session_maker(session_maker),
):
await UserStore.update_user_email(
user_id, email='new@example.com', email_verified=False
)
with session_maker() as session:
user = session.query(User).filter(User.id == uuid.UUID(user_id)).first()
assert user.email == 'new@example.com'
assert user.email_verified is False
@pytest.mark.asyncio
async def test_update_user_email_updates_only_email_verified(session_maker):
"""update_user_email() with email=None should only update email_verified."""
user_id = str(uuid.uuid4())
with session_maker() as session:
org = Org(
id=uuid.UUID(user_id),
name=f'user_{user_id}_org',
contact_email='keep@example.com',
)
session.add(org)
user = User(
id=uuid.UUID(user_id),
current_org_id=org.id,
email='keep@example.com',
email_verified=False,
)
session.add(user)
session.commit()
with patch(
'storage.user_store.a_session_maker',
_wrap_sync_as_async_session_maker(session_maker),
):
await UserStore.update_user_email(user_id, email_verified=True)
with session_maker() as session:
user = session.query(User).filter(User.id == uuid.UUID(user_id)).first()
assert user.email == 'keep@example.com'
assert user.email_verified is True
@pytest.mark.asyncio
async def test_update_user_email_noop_when_both_none():
"""update_user_email() with both args None should not open a session."""
user_id = str(uuid.uuid4())
mock_session_maker = MagicMock()
with patch('storage.user_store.a_session_maker', mock_session_maker):
await UserStore.update_user_email(user_id, email=None, email_verified=None)
mock_session_maker.assert_not_called()
@pytest.mark.asyncio
async def test_update_user_email_missing_user_returns_without_error(session_maker):
"""update_user_email() with a non-existent user_id should return without error."""
user_id = str(uuid.uuid4())
with patch(
'storage.user_store.a_session_maker',
_wrap_sync_as_async_session_maker(session_maker),
):
await UserStore.update_user_email(
user_id, email='new@example.com', email_verified=False
)

View File

@@ -0,0 +1,225 @@
"""Unit tests for VerifiedModelService."""
import pytest
from server.verified_models.verified_model_service import (
VerifiedModelService,
)
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from storage.base import Base
@pytest.fixture
async def async_engine():
"""Create an async SQLite engine for testing."""
engine = create_async_engine(
'sqlite+aiosqlite:///:memory:',
poolclass=StaticPool,
connect_args={'check_same_thread': False},
echo=False,
)
# Create all tables
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest.fixture
async def async_session_maker(async_engine):
"""Create an async session maker for testing."""
return async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
@pytest.fixture
async def _seed_models(async_session_maker):
"""Seed the database with test models."""
async with async_session_maker() as session:
service = VerifiedModelService(session)
await service.create_verified_model(
model_name='claude-sonnet', provider='openhands'
)
await service.create_verified_model(
model_name='claude-sonnet', provider='anthropic'
)
await service.create_verified_model(
model_name='gpt-4o', provider='openhands', is_enabled=False
)
class TestCreateVerifiedModel:
async def test_create_model(self, async_session_maker):
async with async_session_maker() as session:
service = VerifiedModelService(session)
model = await service.create_verified_model(
model_name='test-model', provider='test-provider'
)
assert model.model_name == 'test-model'
assert model.provider == 'test-provider'
assert model.is_enabled is True
assert model.id is not None
async def test_create_duplicate_raises(self, async_session_maker):
async with async_session_maker() as session:
service = VerifiedModelService(session)
await service.create_verified_model(
model_name='test-model', provider='test'
)
with pytest.raises(ValueError, match='test/test-model already exists'):
await service.create_verified_model(
model_name='test-model', provider='test'
)
async def test_same_name_different_provider_allowed(self, async_session_maker):
async with async_session_maker() as session:
service = VerifiedModelService(session)
await service.create_verified_model(
model_name='claude', provider='openhands'
)
model = await service.create_verified_model(
model_name='claude', provider='anthropic'
)
assert model.provider == 'anthropic'
class TestGetModel:
async def test_get_model(self, _seed_models, async_session_maker):
async with async_session_maker() as session:
service = VerifiedModelService(session)
model = await service.get_model('claude-sonnet', 'openhands')
assert model is not None
assert model.provider == 'openhands'
async def test_get_model_not_found(self, _seed_models, async_session_maker):
async with async_session_maker() as session:
service = VerifiedModelService(session)
assert await service.get_model('nonexistent', 'openhands') is None
async def test_get_model_wrong_provider(self, _seed_models, async_session_maker):
async with async_session_maker() as session:
service = VerifiedModelService(session)
assert await service.get_model('claude-sonnet', 'openai') is None
class TestSearchVerifiedModels:
async def test_search_models_no_filters(self, _seed_models, async_session_maker):
async with async_session_maker() as session:
service = VerifiedModelService(session)
result = await service.search_verified_models()
assert len(result.items) == 2 # Only enabled models
assert result.next_page_id is None
async def test_search_models_enabled_only_true(
self, _seed_models, async_session_maker
):
async with async_session_maker() as session:
service = VerifiedModelService(session)
result = await service.search_verified_models(enabled_only=True)
assert len(result.items) == 2
names = {m.model_name for m in result.items}
assert 'gpt-4o' not in names # Disabled model not included
async def test_search_models_enabled_only_false(
self, _seed_models, async_session_maker
):
async with async_session_maker() as session:
service = VerifiedModelService(session)
result = await service.search_verified_models(enabled_only=False)
assert len(result.items) == 3 # All models including disabled
async def test_search_models_by_provider(self, _seed_models, async_session_maker):
async with async_session_maker() as session:
service = VerifiedModelService(session)
result = await service.search_verified_models(provider='openhands')
assert len(result.items) == 1
assert result.items[0].model_name == 'claude-sonnet'
async def test_search_models_pagination(self, _seed_models, async_session_maker):
async with async_session_maker() as session:
service = VerifiedModelService(session)
# Create more models for pagination testing
await service.create_verified_model(model_name='model-1', provider='test')
await service.create_verified_model(model_name='model-2', provider='test')
await service.create_verified_model(model_name='model-3', provider='test')
await service.create_verified_model(model_name='model-4', provider='test')
# Total: 7 models (3 initial + 4 new)
# First page
async with async_session_maker() as session:
service = VerifiedModelService(session)
result = await service.search_verified_models(
enabled_only=False, page_id='0', limit=3
)
assert len(result.items) == 3
assert result.next_page_id == '3' # 4 more items after position 2
# Second page (page_id 3)
async with async_session_maker() as session:
service = VerifiedModelService(session)
result = await service.search_verified_models(
enabled_only=False, page_id='3', limit=3
)
assert len(result.items) == 3
# There are 4 items total starting at offset 3 (positions 3,4,5,6), so next_page_id exists
assert result.next_page_id == '6'
# Third page (page_id 6) - last item
async with async_session_maker() as session:
service = VerifiedModelService(session)
result = await service.search_verified_models(
enabled_only=False, page_id='6', limit=3
)
assert len(result.items) == 1
assert result.next_page_id is None # No more items after position 6
class TestUpdateVerifiedModel:
async def test_update_model(self, _seed_models, async_session_maker):
async with async_session_maker() as session:
service = VerifiedModelService(session)
updated = await service.update_verified_model(
model_name='claude-sonnet', provider='openhands', is_enabled=False
)
assert updated is not None
assert updated.is_enabled is False
async def test_update_not_found(self, _seed_models, async_session_maker):
async with async_session_maker() as session:
service = VerifiedModelService(session)
assert (
await service.update_verified_model(
model_name='nonexistent', provider='openhands', is_enabled=False
)
is None
)
async def test_update_no_change(self, _seed_models, async_session_maker):
async with async_session_maker() as session:
service = VerifiedModelService(session)
updated = await service.update_verified_model(
model_name='claude-sonnet', provider='openhands'
)
assert updated is not None
assert updated.is_enabled is True
class TestDeleteVerifiedModel:
async def test_delete_model(self, _seed_models, async_session_maker):
async with async_session_maker() as session:
service = VerifiedModelService(session)
await service.delete_verified_model('claude-sonnet', 'openhands')
async with async_session_maker() as session:
service = VerifiedModelService(session)
assert await service.get_model('claude-sonnet', 'openhands') is None
# Other provider's version should still exist
assert await service.get_model('claude-sonnet', 'anthropic') is not None
async def test_delete_not_found(self, _seed_models, async_session_maker):
async with async_session_maker() as session:
service = VerifiedModelService(session)
with pytest.raises(ValueError):
assert await service.delete_verified_model('nonexistent', 'openhands')

View File

@@ -1,18 +1,175 @@
import { test, expect, vi } from "vitest";
import { describe, test, expect, vi, beforeEach } from "vitest";
import axios from "axios";
import V1GitService from "../../src/api/git-service/v1-git-service.api";
vi.mock("axios");
test("getGitChanges throws when response is not an array (dead runtime returns HTML)", async () => {
const htmlResponse = "<!DOCTYPE html><html>...</html>";
vi.mocked(axios.get).mockResolvedValue({ data: htmlResponse });
describe("V1GitService", () => {
beforeEach(() => {
vi.clearAllMocks();
});
await expect(
V1GitService.getGitChanges(
"http://localhost:3000/api/conversations/123",
"test-api-key",
"/workspace",
),
).rejects.toThrow("Invalid response from runtime");
describe("getGitChanges", () => {
test("throws when response is not an array (dead runtime returns HTML)", async () => {
const htmlResponse = "<!DOCTYPE html><html>...</html>";
vi.mocked(axios.get).mockResolvedValue({ data: htmlResponse });
await expect(
V1GitService.getGitChanges(
"http://localhost:3000/api/conversations/123",
"test-api-key",
"/workspace",
),
).rejects.toThrow("Invalid response from runtime");
});
test("uses query parameters instead of path segments for the path", async () => {
vi.mocked(axios.get).mockResolvedValue({ data: [] });
await V1GitService.getGitChanges(
"http://localhost:3000/api/conversations/123",
"test-api-key",
"/workspace/project",
);
expect(axios.get).toHaveBeenCalledTimes(1);
const [url, config] = vi.mocked(axios.get).mock.calls[0];
// URL should NOT contain the path - it should end with /api/git/changes
expect(url).toContain("/api/git/changes");
expect(url).not.toContain("/workspace/project");
expect(url).not.toContain(encodeURIComponent("/workspace/project"));
// Path should be passed as a query parameter
expect(config).toHaveProperty("params");
expect(config?.params).toEqual({ path: "/workspace/project" });
});
test("preserves slashes in path when using query parameters", async () => {
vi.mocked(axios.get).mockResolvedValue({ data: [] });
const pathWithSlashes = "/workspace/project/src/components";
await V1GitService.getGitChanges(
"http://localhost:3000/api/conversations/123",
"test-api-key",
pathWithSlashes,
);
const [, config] = vi.mocked(axios.get).mock.calls[0];
// Path should be preserved exactly as provided (slashes intact)
expect(config?.params).toEqual({ path: pathWithSlashes });
});
test("includes session API key in headers when provided", async () => {
vi.mocked(axios.get).mockResolvedValue({ data: [] });
await V1GitService.getGitChanges(
"http://localhost:3000/api/conversations/123",
"my-session-key",
"/workspace",
);
const [, config] = vi.mocked(axios.get).mock.calls[0];
expect(config?.headers).toEqual({ "X-Session-API-Key": "my-session-key" });
});
test("maps V1 git statuses to V0 format", async () => {
vi.mocked(axios.get).mockResolvedValue({
data: [
{ status: "ADDED", path: "new-file.ts" },
{ status: "DELETED", path: "removed-file.ts" },
{ status: "UPDATED", path: "changed-file.ts" },
{ status: "MOVED", path: "renamed-file.ts" },
],
});
const result = await V1GitService.getGitChanges(
"http://localhost:3000/api/conversations/123",
"test-api-key",
"/workspace",
);
expect(result).toEqual([
{ status: "A", path: "new-file.ts" },
{ status: "D", path: "removed-file.ts" },
{ status: "M", path: "changed-file.ts" },
{ status: "R", path: "renamed-file.ts" },
]);
});
});
describe("getGitChangeDiff", () => {
test("uses query parameters instead of path segments for the path", async () => {
vi.mocked(axios.get).mockResolvedValue({
data: { diff: "--- a/file.ts\n+++ b/file.ts\n..." },
});
await V1GitService.getGitChangeDiff(
"http://localhost:3000/api/conversations/123",
"test-api-key",
"/workspace/project/file.ts",
);
expect(axios.get).toHaveBeenCalledTimes(1);
const [url, config] = vi.mocked(axios.get).mock.calls[0];
// URL should NOT contain the path - it should end with /api/git/diff
expect(url).toContain("/api/git/diff");
expect(url).not.toContain("/workspace/project/file.ts");
expect(url).not.toContain(encodeURIComponent("/workspace/project/file.ts"));
// Path should be passed as a query parameter
expect(config).toHaveProperty("params");
expect(config?.params).toEqual({ path: "/workspace/project/file.ts" });
});
test("preserves slashes in file path when using query parameters", async () => {
vi.mocked(axios.get).mockResolvedValue({
data: { diff: "diff content" },
});
const filePath = "/workspace/project/src/components/Button.tsx";
await V1GitService.getGitChangeDiff(
"http://localhost:3000/api/conversations/123",
"test-api-key",
filePath,
);
const [, config] = vi.mocked(axios.get).mock.calls[0];
// Path should be preserved exactly as provided (slashes intact)
expect(config?.params).toEqual({ path: filePath });
});
test("includes session API key in headers when provided", async () => {
vi.mocked(axios.get).mockResolvedValue({
data: { diff: "diff content" },
});
await V1GitService.getGitChangeDiff(
"http://localhost:3000/api/conversations/123",
"my-session-key",
"/workspace/file.ts",
);
const [, config] = vi.mocked(axios.get).mock.calls[0];
expect(config?.headers).toEqual({ "X-Session-API-Key": "my-session-key" });
});
test("returns the diff data from the response", async () => {
const expectedDiff = {
diff: "--- a/file.ts\n+++ b/file.ts\n@@ -1,3 +1,4 @@\n+new line",
};
vi.mocked(axios.get).mockResolvedValue({ data: expectedDiff });
const result = await V1GitService.getGitChangeDiff(
"http://localhost:3000/api/conversations/123",
"test-api-key",
"/workspace/file.ts",
);
expect(result).toEqual(expectedDiff);
});
});
});

View File

@@ -1,9 +1,27 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, test, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest";
import { AccountSettingsContextMenu } from "#/components/features/context-menu/account-settings-context-menu";
import { MemoryRouter } from "react-router";
import { renderWithProviders } from "../../../test-utils";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createMockWebClientConfig } from "../../helpers/mock-config";
const mockTrackAddTeamMembersButtonClick = vi.fn();
vi.mock("#/hooks/use-tracking", () => ({
useTracking: () => ({
trackAddTeamMembersButtonClick: mockTrackAddTeamMembersButtonClick,
}),
}));
// Mock posthog feature flag
vi.mock("posthog-js/react", () => ({
useFeatureFlagEnabled: vi.fn(),
}));
// Import the mocked module to get access to the mock
import * as posthog from "posthog-js/react";
describe("AccountSettingsContextMenu", () => {
const user = userEvent.setup();
@@ -11,15 +29,45 @@ describe("AccountSettingsContextMenu", () => {
const onLogoutMock = vi.fn();
const onCloseMock = vi.fn();
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
// Set default feature flag to false
vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(false);
});
// Create a wrapper with MemoryRouter and renderWithProviders
const renderWithRouter = (ui: React.ReactElement) => {
return renderWithProviders(<MemoryRouter>{ui}</MemoryRouter>);
};
const renderWithSaasConfig = (ui: React.ReactElement) => {
queryClient.setQueryData(["web-client-config"], createMockWebClientConfig({ app_mode: "saas" }));
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>
);
};
const renderWithOssConfig = (ui: React.ReactElement) => {
queryClient.setQueryData(["web-client-config"], createMockWebClientConfig({ app_mode: "oss" }));
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>
);
};
afterEach(() => {
onClickAccountSettingsMock.mockClear();
onLogoutMock.mockClear();
onCloseMock.mockClear();
mockTrackAddTeamMembersButtonClick.mockClear();
vi.mocked(posthog.useFeatureFlagEnabled).mockClear();
});
it("should always render the right options", () => {
@@ -93,4 +141,59 @@ describe("AccountSettingsContextMenu", () => {
expect(onCloseMock).toHaveBeenCalledOnce();
});
it("should show Add Team Members button in SaaS mode when feature flag is enabled", () => {
vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(true);
renderWithSaasConfig(
<AccountSettingsContextMenu
onLogout={onLogoutMock}
onClose={onCloseMock}
/>,
);
expect(screen.getByTestId("add-team-members-button")).toBeInTheDocument();
expect(screen.getByText("SETTINGS$NAV_ADD_TEAM_MEMBERS")).toBeInTheDocument();
});
it("should not show Add Team Members button in SaaS mode when feature flag is disabled", () => {
vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(false);
renderWithSaasConfig(
<AccountSettingsContextMenu
onLogout={onLogoutMock}
onClose={onCloseMock}
/>,
);
expect(screen.queryByTestId("add-team-members-button")).not.toBeInTheDocument();
expect(screen.queryByText("SETTINGS$NAV_ADD_TEAM_MEMBERS")).not.toBeInTheDocument();
});
it("should not show Add Team Members button in OSS mode even when feature flag is enabled", () => {
vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(true);
renderWithOssConfig(
<AccountSettingsContextMenu
onLogout={onLogoutMock}
onClose={onCloseMock}
/>,
);
expect(screen.queryByTestId("add-team-members-button")).not.toBeInTheDocument();
expect(screen.queryByText("SETTINGS$NAV_ADD_TEAM_MEMBERS")).not.toBeInTheDocument();
});
it("should call tracking function and onClose when Add Team Members button is clicked", async () => {
vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(true);
renderWithSaasConfig(
<AccountSettingsContextMenu
onLogout={onLogoutMock}
onClose={onCloseMock}
/>,
);
const addTeamMembersButton = screen.getByTestId("add-team-members-button");
await user.click(addTeamMembersButton);
expect(mockTrackAddTeamMembersButtonClick).toHaveBeenCalledOnce();
expect(onCloseMock).toHaveBeenCalledOnce();
});
});

View File

@@ -235,6 +235,38 @@ describe("LoginContent", () => {
).not.toBeInTheDocument();
});
it("should display Bitbucket signup disabled message when Bitbucket is configured", () => {
render(
<MemoryRouter>
<LoginContent
githubAuthUrl="https://github.com/oauth/authorize"
appMode="saas"
providersConfigured={["github", "bitbucket"]}
/>
</MemoryRouter>,
);
expect(
screen.getByText("AUTH$BITBUCKET_SIGNUP_DISABLED"),
).toBeInTheDocument();
});
it("should not display Bitbucket signup disabled message when Bitbucket is not configured", () => {
render(
<MemoryRouter>
<LoginContent
githubAuthUrl="https://github.com/oauth/authorize"
appMode="saas"
providersConfigured={["github"]}
/>
</MemoryRouter>,
);
expect(
screen.queryByText("AUTH$BITBUCKET_SIGNUP_DISABLED"),
).not.toBeInTheDocument();
});
it("should call buildOAuthStateData when clicking auth button", async () => {
const user = userEvent.setup();
const mockBuildOAuthStateData = vi.fn((baseState) => ({

View File

@@ -5,11 +5,6 @@ import { ChangeAgentButton } from "#/components/features/chat/change-agent-butto
import { renderWithProviders } from "../../../../test-utils";
import { useConversationStore } from "#/stores/conversation-store";
// Mock feature flag to enable planning agent
vi.mock("#/utils/feature-flags", () => ({
USE_PLANNING_AGENT: () => true,
}));
// Mock WebSocket status
vi.mock("#/hooks/use-unified-websocket-status", () => ({
useUnifiedWebSocketStatus: () => "CONNECTED",

View File

@@ -0,0 +1,195 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { GitControlBarRepoButton } from "#/components/features/chat/git-control-bar-repo-button";
// Mock react-i18next
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
// Mock GitProviderIcon
vi.mock("#/components/shared/git-provider-icon", () => ({
GitProviderIcon: ({ gitProvider }: { gitProvider: string }) => (
<span data-testid="git-provider-icon">{gitProvider}</span>
),
}));
// Mock GitExternalLinkIcon
vi.mock(
"#/components/features/chat/git-external-link-icon",
() => ({
GitExternalLinkIcon: () => (
<span data-testid="git-external-link-icon">external</span>
),
}),
);
// Mock RepoForkedIcon
vi.mock("#/icons/repo-forked.svg?react", () => ({
default: () => <span data-testid="repo-forked-icon">forked</span>,
}));
// Mock constructRepositoryUrl
vi.mock("#/utils/utils", async (importOriginal) => {
const actual = await importOriginal<typeof import("#/utils/utils")>();
return {
...actual,
constructRepositoryUrl: (provider: string, repo: string) =>
`https://${provider}.com/${repo}`,
};
});
describe("GitControlBarRepoButton", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("when repository is connected", () => {
it("should render as a link with repository name", () => {
render(
<GitControlBarRepoButton
selectedRepository="owner/repo"
gitProvider="github"
/>,
);
const link = screen.getByRole("link");
expect(link).toHaveAttribute("href", "https://github.com/owner/repo");
expect(link).toHaveAttribute("target", "_blank");
expect(screen.getByText("owner/repo")).toBeInTheDocument();
});
it("should show git provider icon and external link icon", () => {
render(
<GitControlBarRepoButton
selectedRepository="owner/repo"
gitProvider="github"
/>,
);
expect(screen.getByTestId("git-provider-icon")).toBeInTheDocument();
expect(screen.getByTestId("git-external-link-icon")).toBeInTheDocument();
});
it("should not show repo forked icon", () => {
render(
<GitControlBarRepoButton
selectedRepository="owner/repo"
gitProvider="github"
/>,
);
expect(
screen.queryByTestId("repo-forked-icon"),
).not.toBeInTheDocument();
});
});
describe("when no repository is connected", () => {
it("should render as a button with 'No Repo Connected' text", () => {
render(
<GitControlBarRepoButton
selectedRepository={null}
gitProvider={null}
/>,
);
const button = screen.getByRole("button");
expect(button).toBeInTheDocument();
expect(
screen.getByText("COMMON$NO_REPO_CONNECTED"),
).toBeInTheDocument();
});
it("should show repo forked icon instead of provider icon", () => {
render(
<GitControlBarRepoButton
selectedRepository={null}
gitProvider={null}
/>,
);
expect(screen.getByTestId("repo-forked-icon")).toBeInTheDocument();
expect(
screen.queryByTestId("git-provider-icon"),
).not.toBeInTheDocument();
});
it("should not show external link icon", () => {
render(
<GitControlBarRepoButton
selectedRepository={null}
gitProvider={null}
/>,
);
expect(
screen.queryByTestId("git-external-link-icon"),
).not.toBeInTheDocument();
});
it("should call onClick when clicked", async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(
<GitControlBarRepoButton
selectedRepository={null}
gitProvider={null}
onClick={handleClick}
/>,
);
await user.click(screen.getByRole("button"));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it("should be disabled when disabled prop is true", () => {
render(
<GitControlBarRepoButton
selectedRepository={null}
gitProvider={null}
disabled={true}
/>,
);
const button = screen.getByRole("button");
expect(button).toBeDisabled();
expect(button).toHaveClass("cursor-not-allowed");
});
it("should be clickable when disabled prop is false", () => {
render(
<GitControlBarRepoButton
selectedRepository={null}
gitProvider={null}
disabled={false}
/>,
);
const button = screen.getByRole("button");
expect(button).not.toBeDisabled();
expect(button).toHaveClass("cursor-pointer");
});
it("should not call onClick when disabled", async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(
<GitControlBarRepoButton
selectedRepository={null}
gitProvider={null}
onClick={handleClick}
disabled={true}
/>,
);
await user.click(screen.getByRole("button"));
expect(handleClick).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,45 @@
import { describe, it, expect } from "vitest";
describe("GitControlBar clone prompt format", () => {
// Helper function that mirrors the logic in git-control-bar.tsx
const generateClonePrompt = (
fullName: string,
gitProvider: string,
branchName: string,
) => {
const providerName =
gitProvider.charAt(0).toUpperCase() + gitProvider.slice(1);
return `Clone ${fullName} from ${providerName} and checkout branch ${branchName}.`;
};
it("should include GitHub in clone prompt for github provider", () => {
const prompt = generateClonePrompt("user/repo", "github", "main");
expect(prompt).toBe("Clone user/repo from Github and checkout branch main.");
});
it("should include GitLab in clone prompt for gitlab provider", () => {
const prompt = generateClonePrompt("group/project", "gitlab", "develop");
expect(prompt).toBe(
"Clone group/project from Gitlab and checkout branch develop.",
);
});
it("should handle different branch names", () => {
const prompt = generateClonePrompt(
"hieptl.developer-group/hieptl.developer-project",
"gitlab",
"add-batman-microagent",
);
expect(prompt).toBe(
"Clone hieptl.developer-group/hieptl.developer-project from Gitlab and checkout branch add-batman-microagent.",
);
});
it("should capitalize first letter of provider name", () => {
const githubPrompt = generateClonePrompt("a/b", "github", "main");
const gitlabPrompt = generateClonePrompt("a/b", "gitlab", "main");
expect(githubPrompt).toContain("from Github");
expect(gitlabPrompt).toContain("from Gitlab");
});
});

View File

@@ -0,0 +1,381 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { OpenRepositoryModal } from "#/components/features/chat/open-repository-modal";
// Mock react-i18next
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
// Mock useUserProviders - default to single provider (no dropdown shown)
const mockProviders = vi.hoisted(() => ({
current: ["github"] as string[],
}));
vi.mock("#/hooks/use-user-providers", () => ({
useUserProviders: () => ({
providers: mockProviders.current,
isLoadingSettings: false,
}),
}));
// Mock GitProviderDropdown
vi.mock(
"#/components/features/home/git-provider-dropdown/git-provider-dropdown",
() => ({
GitProviderDropdown: ({
providers,
onChange,
}: {
providers: string[];
onChange: (provider: string | null) => void;
}) => (
<div data-testid="git-provider-dropdown">
{providers.map((p: string) => (
<button
key={p}
data-testid={`provider-${p}`}
onClick={() => onChange(p)}
>
{p}
</button>
))}
</div>
),
}),
);
// Mock GitRepoDropdown
vi.mock(
"#/components/features/home/git-repo-dropdown/git-repo-dropdown",
() => ({
GitRepoDropdown: ({
onChange,
}: {
onChange: (repo?: {
id: number;
full_name: string;
git_provider: string;
main_branch: string;
}) => void;
}) => (
<button
data-testid="git-repo-dropdown"
onClick={() =>
onChange({
id: 1,
full_name: "owner/repo",
git_provider: "github",
main_branch: "main",
})
}
>
Mock Repo Dropdown
</button>
),
}),
);
// Mock GitBranchDropdown
vi.mock(
"#/components/features/home/git-branch-dropdown/git-branch-dropdown",
() => ({
GitBranchDropdown: ({
onBranchSelect,
disabled,
}: {
onBranchSelect: (branch: { name: string } | null) => void;
disabled: boolean;
}) => (
<button
data-testid="git-branch-dropdown"
disabled={disabled}
onClick={() => onBranchSelect({ name: "main" })}
>
Mock Branch Dropdown
</button>
),
}),
);
// Mock RepoForkedIcon
vi.mock("#/icons/repo-forked.svg?react", () => ({
default: () => <div data-testid="repo-forked-icon" />,
}));
describe("OpenRepositoryModal", () => {
const mockOnClose = vi.fn();
const mockOnLaunch = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
mockProviders.current = ["github"];
});
it("should not render when isOpen is false", () => {
render(
<OpenRepositoryModal
isOpen={false}
onClose={mockOnClose}
onLaunch={mockOnLaunch}
/>,
);
expect(
screen.queryByText("CONVERSATION$OPEN_REPOSITORY"),
).not.toBeInTheDocument();
});
it("should render modal with title and description when open", () => {
render(
<OpenRepositoryModal
isOpen={true}
onClose={mockOnClose}
onLaunch={mockOnLaunch}
/>,
);
expect(
screen.getByText("CONVERSATION$OPEN_REPOSITORY"),
).toBeInTheDocument();
expect(
screen.getByText("CONVERSATION$SELECT_OR_INSERT_LINK"),
).toBeInTheDocument();
expect(screen.getByTestId("repo-forked-icon")).toBeInTheDocument();
});
it("should render Launch and Cancel buttons", () => {
render(
<OpenRepositoryModal
isOpen={true}
onClose={mockOnClose}
onLaunch={mockOnLaunch}
/>,
);
expect(screen.getByText("BUTTON$LAUNCH")).toBeInTheDocument();
expect(screen.getByText("BUTTON$CANCEL")).toBeInTheDocument();
});
it("should disable Launch button when no repository or branch is selected", () => {
render(
<OpenRepositoryModal
isOpen={true}
onClose={mockOnClose}
onLaunch={mockOnLaunch}
/>,
);
const launchButton = screen.getByText("BUTTON$LAUNCH").closest("button");
expect(launchButton).toBeDisabled();
});
it("should call onClose and reset state when Cancel is clicked", async () => {
const user = userEvent.setup();
render(
<OpenRepositoryModal
isOpen={true}
onClose={mockOnClose}
onLaunch={mockOnLaunch}
/>,
);
await user.click(screen.getByText("BUTTON$CANCEL"));
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it("should enable Launch button after selecting repository and branch", async () => {
const user = userEvent.setup();
render(
<OpenRepositoryModal
isOpen={true}
onClose={mockOnClose}
onLaunch={mockOnLaunch}
/>,
);
// Select a repository
await user.click(screen.getByTestId("git-repo-dropdown"));
// Select a branch
await user.click(screen.getByTestId("git-branch-dropdown"));
const launchButton = screen.getByText("BUTTON$LAUNCH").closest("button");
expect(launchButton).not.toBeDisabled();
});
it("should call onLaunch with selected repository and branch, then close", async () => {
const user = userEvent.setup();
render(
<OpenRepositoryModal
isOpen={true}
onClose={mockOnClose}
onLaunch={mockOnLaunch}
/>,
);
// Select repository and branch
await user.click(screen.getByTestId("git-repo-dropdown"));
await user.click(screen.getByTestId("git-branch-dropdown"));
// Click Launch
await user.click(screen.getByText("BUTTON$LAUNCH"));
expect(mockOnLaunch).toHaveBeenCalledWith(
{
id: 1,
full_name: "owner/repo",
git_provider: "github",
main_branch: "main",
},
{ name: "main" },
);
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it("should not call onLaunch when Launch is clicked without selections", async () => {
const user = userEvent.setup();
render(
<OpenRepositoryModal
isOpen={true}
onClose={mockOnClose}
onLaunch={mockOnLaunch}
/>,
);
// Force click the launch button even though it's disabled
const launchButton = screen.getByText("BUTTON$LAUNCH").closest("button")!;
await user.click(launchButton);
expect(mockOnLaunch).not.toHaveBeenCalled();
});
it("should reset branch selection when repository changes", async () => {
const user = userEvent.setup();
render(
<OpenRepositoryModal
isOpen={true}
onClose={mockOnClose}
onLaunch={mockOnLaunch}
/>,
);
// Select repository and branch
await user.click(screen.getByTestId("git-repo-dropdown"));
await user.click(screen.getByTestId("git-branch-dropdown"));
// Launch button should be enabled
let launchButton = screen.getByText("BUTTON$LAUNCH").closest("button");
expect(launchButton).not.toBeDisabled();
// Select a new repository (resets branch)
await user.click(screen.getByTestId("git-repo-dropdown"));
// Launch button should be disabled again (branch was reset)
launchButton = screen.getByText("BUTTON$LAUNCH").closest("button");
expect(launchButton).toBeDisabled();
});
it("should use small modal width", () => {
render(
<OpenRepositoryModal
isOpen={true}
onClose={mockOnClose}
onLaunch={mockOnLaunch}
/>,
);
// ModalBody with width="small" renders w-[384px]
const modalBody = screen
.getByText("CONVERSATION$OPEN_REPOSITORY")
.closest(".bg-base-secondary");
expect(modalBody).toHaveClass("w-[384px]");
});
it("should override default gap with !gap-4 for tighter spacing", () => {
render(
<OpenRepositoryModal
isOpen={true}
onClose={mockOnClose}
onLaunch={mockOnLaunch}
/>,
);
const modalBody = screen
.getByText("CONVERSATION$OPEN_REPOSITORY")
.closest(".bg-base-secondary");
expect(modalBody).toHaveClass("!gap-4");
});
describe("provider switching", () => {
it("should not show provider dropdown when only one provider exists", () => {
mockProviders.current = ["github"];
render(
<OpenRepositoryModal
isOpen={true}
onClose={mockOnClose}
onLaunch={mockOnLaunch}
/>,
);
expect(
screen.queryByTestId("git-provider-dropdown"),
).not.toBeInTheDocument();
});
it("should show provider dropdown when multiple providers exist", () => {
mockProviders.current = ["github", "gitlab"];
render(
<OpenRepositoryModal
isOpen={true}
onClose={mockOnClose}
onLaunch={mockOnLaunch}
/>,
);
expect(
screen.getByTestId("git-provider-dropdown"),
).toBeInTheDocument();
expect(screen.getByTestId("provider-github")).toBeInTheDocument();
expect(screen.getByTestId("provider-gitlab")).toBeInTheDocument();
});
it("should reset repository and branch when provider changes", async () => {
mockProviders.current = ["github", "gitlab"];
const user = userEvent.setup();
render(
<OpenRepositoryModal
isOpen={true}
onClose={mockOnClose}
onLaunch={mockOnLaunch}
/>,
);
// Select repo and branch
await user.click(screen.getByTestId("git-repo-dropdown"));
await user.click(screen.getByTestId("git-branch-dropdown"));
// Launch should be enabled
let launchButton = screen.getByText("BUTTON$LAUNCH").closest("button");
expect(launchButton).not.toBeDisabled();
// Switch provider — should reset selections
await user.click(screen.getByTestId("provider-gitlab"));
// Launch should be disabled (repo and branch reset)
launchButton = screen.getByText("BUTTON$LAUNCH").closest("button");
expect(launchButton).toBeDisabled();
});
});
});

View File

@@ -27,11 +27,6 @@ function renderPlanPreview(ui: React.ReactElement) {
);
}
// Mock the feature flag to always return true (not testing feature flag behavior)
vi.mock("#/utils/feature-flags", () => ({
USE_PLANNING_AGENT: vi.fn(() => true),
}));
// Mock i18n - need to preserve initReactI18next and I18nextProvider for test-utils
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();

View File

@@ -0,0 +1,226 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeAll } from "vitest";
import { renderWithProviders } from "test-utils";
import {
SlashCommandMenu,
getSkillDescription,
stripMarkdown,
} from "#/components/features/chat/components/slash-command-menu";
import { SlashCommandItem } from "#/hooks/chat/use-slash-command";
// jsdom does not implement scrollIntoView
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn();
});
const makeItem = (
name: string,
command: string,
content: string = "",
): SlashCommandItem => ({
skill: {
name,
type: "agentskills" as const,
content,
triggers: [command],
},
command,
});
const defaultItems: SlashCommandItem[] = [
makeItem("code-search", "/code-search", "Search code semantically."),
makeItem("random-number", "/random-number", "Generate a random number."),
makeItem(
"init",
"/init",
"---\nname: init\ndescription: Initialize a project\n---\n## Usage\nRun /init to start.",
),
];
describe("SlashCommandMenu", () => {
it("renders nothing when items is empty", () => {
const { container } = renderWithProviders(
<SlashCommandMenu items={[]} selectedIndex={0} onSelect={vi.fn()} />,
);
expect(container.innerHTML).toBe("");
});
it("renders all items with slash commands as primary text", () => {
renderWithProviders(
<SlashCommandMenu
items={defaultItems}
selectedIndex={0}
onSelect={vi.fn()}
/>,
);
expect(screen.getByText("/code-search")).toBeInTheDocument();
expect(screen.getByText("/random-number")).toBeInTheDocument();
expect(screen.getByText("/init")).toBeInTheDocument();
});
it("marks the selected item with aria-selected", () => {
renderWithProviders(
<SlashCommandMenu
items={defaultItems}
selectedIndex={1}
onSelect={vi.fn()}
/>,
);
const options = screen.getAllByRole("option");
expect(options[0]).toHaveAttribute("aria-selected", "false");
expect(options[1]).toHaveAttribute("aria-selected", "true");
expect(options[2]).toHaveAttribute("aria-selected", "false");
});
it("calls onSelect on mouseDown", async () => {
const onSelect = vi.fn();
const user = userEvent.setup();
renderWithProviders(
<SlashCommandMenu
items={defaultItems}
selectedIndex={0}
onSelect={onSelect}
/>,
);
const options = screen.getAllByRole("option");
await user.click(options[1]);
expect(onSelect).toHaveBeenCalledWith(defaultItems[1]);
});
it("displays skill descriptions", () => {
renderWithProviders(
<SlashCommandMenu
items={defaultItems}
selectedIndex={0}
onSelect={vi.fn()}
/>,
);
// First item: first-sentence extraction
expect(screen.getByText("Search code semantically.")).toBeInTheDocument();
// Third item: frontmatter description extraction
expect(screen.getByText("Initialize a project")).toBeInTheDocument();
});
it("has an accessible listbox role and translated aria-label", () => {
renderWithProviders(
<SlashCommandMenu
items={defaultItems}
selectedIndex={0}
onSelect={vi.fn()}
/>,
);
const listbox = screen.getByRole("listbox");
expect(listbox).toBeInTheDocument();
// In test env, translation key is returned as-is
expect(listbox).toHaveAttribute("aria-label", "CHAT_INTERFACE$COMMANDS");
});
});
describe("getSkillDescription", () => {
it("extracts description from YAML frontmatter", () => {
const content =
"---\nname: test\ndescription: A test skill\n---\n## Usage\nDetails here.";
expect(getSkillDescription(content)).toBe("A test skill");
});
it("strips double quotes from frontmatter description", () => {
const content = '---\ndescription: "Quoted description"\n---\nBody.';
expect(getSkillDescription(content)).toBe("Quoted description");
});
it("strips single quotes from frontmatter description", () => {
const content = "---\ndescription: 'Single quoted'\n---\nBody.";
expect(getSkillDescription(content)).toBe("Single quoted");
});
it("falls back to first meaningful line when no frontmatter", () => {
const content = "# Title\n\nThis is a description.";
expect(getSkillDescription(content)).toBe("This is a description.");
});
it("falls back to first sentence from body when frontmatter has no description", () => {
const content =
"---\nname: test\ntriggers: ['/test']\n---\nA helpful tool. It does things.";
expect(getSkillDescription(content)).toBe("A helpful tool.");
});
it("skips headers and empty lines", () => {
const content = "\n\n# Header\n## Subheader\n\nActual content here";
expect(getSkillDescription(content)).toBe("Actual content here");
});
it("returns null for empty content", () => {
expect(getSkillDescription("")).toBeNull();
});
it("returns null for content with only headers", () => {
expect(getSkillDescription("# Title\n## Subtitle")).toBeNull();
});
it("returns the whole line when there is no sentence-ending punctuation", () => {
const content = "A description without punctuation";
expect(getSkillDescription(content)).toBe(
"A description without punctuation",
);
});
it("strips markdown from frontmatter description", () => {
const content =
'---\ndescription: "A **bold** and *italic* description"\n---\nBody.';
expect(getSkillDescription(content)).toBe(
"A bold and italic description",
);
});
it("strips markdown from body fallback", () => {
const content = "# Title\n\nUse `code` and [links](http://example.com).";
expect(getSkillDescription(content)).toBe("Use code and links.");
});
});
describe("stripMarkdown", () => {
it("strips bold syntax", () => {
expect(stripMarkdown("a **bold** word")).toBe("a bold word");
});
it("strips italic syntax", () => {
expect(stripMarkdown("an *italic* word")).toBe("an italic word");
});
it("strips bold-italic syntax", () => {
expect(stripMarkdown("***both***")).toBe("both");
});
it("strips inline code", () => {
expect(stripMarkdown("run `npm test` now")).toBe("run npm test now");
});
it("strips links", () => {
expect(stripMarkdown("see [docs](http://example.com)")).toBe("see docs");
});
it("strips images", () => {
expect(stripMarkdown("![alt text](image.png)")).toBe("alt text");
});
it("strips strikethrough", () => {
expect(stripMarkdown("~~removed~~")).toBe("removed");
});
it("strips underscore emphasis", () => {
expect(stripMarkdown("__bold__ and _italic_")).toBe("bold and italic");
});
it("returns plain text unchanged", () => {
expect(stripMarkdown("plain text")).toBe("plain text");
});
});

View File

@@ -10,10 +10,6 @@ import { useConversationStore } from "#/stores/conversation-store";
const TASK_CONVERSATION_ID = "task-ec03fb2ab8604517b24af632b058c2fd";
const REAL_CONVERSATION_ID = "conv-abc123";
vi.mock("#/utils/feature-flags", () => ({
USE_PLANNING_AGENT: () => false,
}));
let mockConversationId = TASK_CONVERSATION_ID;
vi.mock("#/hooks/use-conversation-id", () => ({
@@ -120,9 +116,7 @@ describe("ConversationTabs localStorage behavior", () => {
// Verify localStorage was updated
const storedState = JSON.parse(
localStorage.getItem(
`conversation-state-${REAL_CONVERSATION_ID}`,
)!,
localStorage.getItem(`conversation-state-${REAL_CONVERSATION_ID}`)!,
);
expect(storedState.selectedTab).toBe("terminal");
expect(storedState.rightPanelShown).toBe(true);
@@ -152,9 +146,7 @@ describe("ConversationTabs localStorage behavior", () => {
// Verify localStorage was updated
const storedState = JSON.parse(
localStorage.getItem(
`conversation-state-${REAL_CONVERSATION_ID}`,
)!,
localStorage.getItem(`conversation-state-${REAL_CONVERSATION_ID}`)!,
);
expect(storedState.rightPanelShown).toBe(false);
});
@@ -184,9 +176,7 @@ describe("ConversationTabs localStorage behavior", () => {
// Verify localStorage was updated
const storedState = JSON.parse(
localStorage.getItem(
`conversation-state-${REAL_CONVERSATION_ID}`,
)!,
localStorage.getItem(`conversation-state-${REAL_CONVERSATION_ID}`)!,
);
expect(storedState.selectedTab).toBe("browser");
});

View File

@@ -0,0 +1,224 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../../../test-utils";
import OnboardingForm from "#/routes/onboarding-form";
const mockMutate = vi.fn();
const mockNavigate = vi.fn();
vi.mock("react-router", async (importOriginal) => {
const original = await importOriginal<typeof import("react-router")>();
return {
...original,
useNavigate: () => mockNavigate,
};
});
vi.mock("#/hooks/mutation/use-submit-onboarding", () => ({
useSubmitOnboarding: () => ({
mutate: mockMutate,
}),
}));
const renderOnboardingForm = () => {
return renderWithProviders(
<MemoryRouter>
<OnboardingForm />
</MemoryRouter>,
);
};
describe("OnboardingForm", () => {
beforeEach(() => {
mockMutate.mockClear();
mockNavigate.mockClear();
});
it("should render with the correct test id", () => {
renderOnboardingForm();
expect(screen.getByTestId("onboarding-form")).toBeInTheDocument();
});
it("should render the first step initially", () => {
renderOnboardingForm();
expect(screen.getByTestId("step-header")).toBeInTheDocument();
expect(screen.getByTestId("step-content")).toBeInTheDocument();
expect(screen.getByTestId("step-actions")).toBeInTheDocument();
});
it("should display step progress indicator with 3 bars", () => {
renderOnboardingForm();
const stepHeader = screen.getByTestId("step-header");
const progressBars = stepHeader.querySelectorAll(".rounded-full");
expect(progressBars).toHaveLength(3);
});
it("should have the Next button disabled when no option is selected", () => {
renderOnboardingForm();
const nextButton = screen.getByRole("button", { name: /next/i });
expect(nextButton).toBeDisabled();
});
it("should enable the Next button when an option is selected", async () => {
const user = userEvent.setup();
renderOnboardingForm();
await user.click(screen.getByTestId("step-option-software_engineer"));
const nextButton = screen.getByRole("button", { name: /next/i });
expect(nextButton).not.toBeDisabled();
});
it("should advance to the next step when Next is clicked", async () => {
const user = userEvent.setup();
renderOnboardingForm();
// On step 1, first progress bar should be filled (bg-white)
const stepHeader = screen.getByTestId("step-header");
let progressBars = stepHeader.querySelectorAll(".bg-white");
expect(progressBars).toHaveLength(1);
await user.click(screen.getByTestId("step-option-software_engineer"));
await user.click(screen.getByRole("button", { name: /next/i }));
// On step 2, first two progress bars should be filled
progressBars = stepHeader.querySelectorAll(".bg-white");
expect(progressBars).toHaveLength(2);
});
it("should disable Next button again on new step until option is selected", async () => {
const user = userEvent.setup();
renderOnboardingForm();
await user.click(screen.getByTestId("step-option-software_engineer"));
await user.click(screen.getByRole("button", { name: /next/i }));
const nextButton = screen.getByRole("button", { name: /next/i });
expect(nextButton).toBeDisabled();
});
it("should call submitOnboarding with selections when finishing the last step", async () => {
const user = userEvent.setup();
renderOnboardingForm();
// Step 1 - select role
await user.click(screen.getByTestId("step-option-software_engineer"));
await user.click(screen.getByRole("button", { name: /next/i }));
// Step 2 - select org size
await user.click(screen.getByTestId("step-option-org_2_10"));
await user.click(screen.getByRole("button", { name: /next/i }));
// Step 3 - select use case
await user.click(screen.getByTestId("step-option-new_features"));
await user.click(screen.getByRole("button", { name: /finish/i }));
expect(mockMutate).toHaveBeenCalledTimes(1);
expect(mockMutate).toHaveBeenCalledWith({
selections: {
step1: "software_engineer",
step2: "org_2_10",
step3: "new_features",
},
});
});
it("should render 6 options on step 1", () => {
renderOnboardingForm();
const options = screen
.getAllByRole("button")
.filter((btn) =>
btn.getAttribute("data-testid")?.startsWith("step-option-"),
);
expect(options).toHaveLength(6);
});
it("should preserve selections when navigating through steps", async () => {
const user = userEvent.setup();
renderOnboardingForm();
// Select role on step 1
await user.click(screen.getByTestId("step-option-cto_founder"));
await user.click(screen.getByRole("button", { name: /next/i }));
// Select org size on step 2
await user.click(screen.getByTestId("step-option-solo"));
await user.click(screen.getByRole("button", { name: /next/i }));
// Select use case on step 3
await user.click(screen.getByTestId("step-option-fixing_bugs"));
await user.click(screen.getByRole("button", { name: /finish/i }));
// Verify all selections were preserved
expect(mockMutate).toHaveBeenCalledWith({
selections: {
step1: "cto_founder",
step2: "solo",
step3: "fixing_bugs",
},
});
});
it("should show all progress bars filled on the last step", async () => {
const user = userEvent.setup();
renderOnboardingForm();
// Navigate to step 3
await user.click(screen.getByTestId("step-option-software_engineer"));
await user.click(screen.getByRole("button", { name: /next/i }));
await user.click(screen.getByTestId("step-option-solo"));
await user.click(screen.getByRole("button", { name: /next/i }));
// On step 3, all three progress bars should be filled
const stepHeader = screen.getByTestId("step-header");
const progressBars = stepHeader.querySelectorAll(".bg-white");
expect(progressBars).toHaveLength(3);
});
it("should not render the Back button on the first step", () => {
renderOnboardingForm();
const backButton = screen.queryByRole("button", { name: /back/i });
expect(backButton).not.toBeInTheDocument();
});
it("should render the Back button on step 2", async () => {
const user = userEvent.setup();
renderOnboardingForm();
await user.click(screen.getByTestId("step-option-software_engineer"));
await user.click(screen.getByRole("button", { name: /next/i }));
const backButton = screen.getByRole("button", { name: /back/i });
expect(backButton).toBeInTheDocument();
});
it("should go back to the previous step when Back is clicked", async () => {
const user = userEvent.setup();
renderOnboardingForm();
// Navigate to step 2
await user.click(screen.getByTestId("step-option-software_engineer"));
await user.click(screen.getByRole("button", { name: /next/i }));
// Verify we're on step 2 (2 progress bars filled)
const stepHeader = screen.getByTestId("step-header");
let progressBars = stepHeader.querySelectorAll(".bg-white");
expect(progressBars).toHaveLength(2);
// Click Back
await user.click(screen.getByRole("button", { name: /back/i }));
// Verify we're back on step 1 (1 progress bar filled)
progressBars = stepHeader.querySelectorAll(".bg-white");
expect(progressBars).toHaveLength(1);
});
});

View File

@@ -0,0 +1,86 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { StepContent } from "#/components/features/onboarding/step-content";
describe("StepContent", () => {
const mockOptions = [
{ id: "option1", label: "Option 1" },
{ id: "option2", label: "Option 2" },
{ id: "option3", label: "Option 3" },
];
const defaultProps = {
options: mockOptions,
selectedOptionId: null,
onSelectOption: vi.fn(),
};
it("should render with the correct test id", () => {
render(<StepContent {...defaultProps} />);
expect(screen.getByTestId("step-content")).toBeInTheDocument();
});
it("should render all options", () => {
render(<StepContent {...defaultProps} />);
expect(screen.getByText("Option 1")).toBeInTheDocument();
expect(screen.getByText("Option 2")).toBeInTheDocument();
expect(screen.getByText("Option 3")).toBeInTheDocument();
});
it("should call onSelectOption with correct id when option is clicked", async () => {
const onSelectOptionMock = vi.fn();
const user = userEvent.setup();
render(
<StepContent {...defaultProps} onSelectOption={onSelectOptionMock} />,
);
await user.click(screen.getByTestId("step-option-option2"));
expect(onSelectOptionMock).toHaveBeenCalledWith("option2");
});
it("should mark the selected option as selected", () => {
render(<StepContent {...defaultProps} selectedOptionId="option1" />);
const selectedOption = screen.getByTestId("step-option-option1");
const unselectedOption = screen.getByTestId("step-option-option2");
expect(selectedOption).toHaveClass("border-white");
expect(unselectedOption).toHaveClass("border-[#3a3a3a]");
});
it("should render no options when options array is empty", () => {
render(<StepContent {...defaultProps} options={[]} />);
expect(screen.getByTestId("step-content")).toBeInTheDocument();
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("should render correct number of options", () => {
render(<StepContent {...defaultProps} />);
const options = screen.getAllByRole("button");
expect(options).toHaveLength(3);
});
it("should allow selecting different options", async () => {
const onSelectOptionMock = vi.fn();
const user = userEvent.setup();
render(
<StepContent {...defaultProps} onSelectOption={onSelectOptionMock} />,
);
await user.click(screen.getByTestId("step-option-option1"));
expect(onSelectOptionMock).toHaveBeenCalledWith("option1");
await user.click(screen.getByTestId("step-option-option3"));
expect(onSelectOptionMock).toHaveBeenCalledWith("option3");
expect(onSelectOptionMock).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,75 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import StepHeader from "#/components/features/onboarding/step-header";
describe("StepHeader", () => {
const defaultProps = {
title: "Test Title",
currentStep: 1,
totalSteps: 3,
};
it("should render with the correct test id", () => {
render(<StepHeader {...defaultProps} />);
expect(screen.getByTestId("step-header")).toBeInTheDocument();
});
it("should display the title", () => {
render(<StepHeader {...defaultProps} />);
expect(screen.getByText("Test Title")).toBeInTheDocument();
});
it("should render correct number of progress dots based on totalSteps", () => {
render(<StepHeader {...defaultProps} totalSteps={5} />);
const stepHeader = screen.getByTestId("step-header");
const progressDots = stepHeader.querySelectorAll(".rounded-full");
expect(progressDots).toHaveLength(5);
});
it("should fill progress dots up to currentStep", () => {
render(<StepHeader {...defaultProps} currentStep={2} totalSteps={4} />);
const stepHeader = screen.getByTestId("step-header");
const filledDots = stepHeader.querySelectorAll(".bg-white");
const unfilledDots = stepHeader.querySelectorAll(".bg-neutral-600");
expect(filledDots).toHaveLength(2);
expect(unfilledDots).toHaveLength(2);
});
it("should show all dots filled when on last step", () => {
render(<StepHeader {...defaultProps} currentStep={3} totalSteps={3} />);
const stepHeader = screen.getByTestId("step-header");
const filledDots = stepHeader.querySelectorAll(".bg-white");
const unfilledDots = stepHeader.querySelectorAll(".bg-neutral-600");
expect(filledDots).toHaveLength(3);
expect(unfilledDots).toHaveLength(0);
});
it("should show no dots filled when currentStep is 0", () => {
render(<StepHeader {...defaultProps} currentStep={0} totalSteps={3} />);
const stepHeader = screen.getByTestId("step-header");
const filledDots = stepHeader.querySelectorAll(".bg-white");
const unfilledDots = stepHeader.querySelectorAll(".bg-neutral-600");
expect(filledDots).toHaveLength(0);
expect(unfilledDots).toHaveLength(3);
});
it("should handle single step progress", () => {
render(<StepHeader {...defaultProps} currentStep={1} totalSteps={1} />);
const stepHeader = screen.getByTestId("step-header");
const progressDots = stepHeader.querySelectorAll(".rounded-full");
const filledDots = stepHeader.querySelectorAll(".bg-white");
expect(progressDots).toHaveLength(1);
expect(filledDots).toHaveLength(1);
});
});

View File

@@ -0,0 +1,89 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { StepOption } from "#/components/features/onboarding/step-option";
describe("StepOption", () => {
const defaultProps = {
id: "test-option",
label: "Test Label",
selected: false,
onClick: vi.fn(),
};
it("should render with the correct test id", () => {
render(<StepOption {...defaultProps} />);
expect(screen.getByTestId("step-option-test-option")).toBeInTheDocument();
});
it("should display the label", () => {
render(<StepOption {...defaultProps} />);
expect(screen.getByText("Test Label")).toBeInTheDocument();
});
it("should call onClick when clicked", async () => {
const onClickMock = vi.fn();
const user = userEvent.setup();
render(<StepOption {...defaultProps} onClick={onClickMock} />);
await user.click(screen.getByTestId("step-option-test-option"));
expect(onClickMock).toHaveBeenCalledTimes(1);
});
it("should call onClick when Enter key is pressed", async () => {
const onClickMock = vi.fn();
const user = userEvent.setup();
render(<StepOption {...defaultProps} onClick={onClickMock} />);
const option = screen.getByTestId("step-option-test-option");
option.focus();
await user.keyboard("{Enter}");
expect(onClickMock).toHaveBeenCalledTimes(1);
});
it("should call onClick when Space key is pressed", async () => {
const onClickMock = vi.fn();
const user = userEvent.setup();
render(<StepOption {...defaultProps} onClick={onClickMock} />);
const option = screen.getByTestId("step-option-test-option");
option.focus();
await user.keyboard(" ");
expect(onClickMock).toHaveBeenCalledTimes(1);
});
it("should have role='button' for accessibility", () => {
render(<StepOption {...defaultProps} />);
expect(screen.getByRole("button")).toBeInTheDocument();
});
it("should be focusable with tabIndex=0", () => {
render(<StepOption {...defaultProps} />);
const option = screen.getByTestId("step-option-test-option");
expect(option).toHaveAttribute("tabIndex", "0");
});
it("should have selected styling when selected is true", () => {
render(<StepOption {...defaultProps} selected />);
const option = screen.getByTestId("step-option-test-option");
expect(option).toHaveClass("border-white");
});
it("should have unselected styling when selected is false", () => {
render(<StepOption {...defaultProps} selected={false} />);
const option = screen.getByTestId("step-option-test-option");
expect(option).toHaveClass("border-[#3a3a3a]");
});
});

View File

@@ -9,11 +9,6 @@ import {
createPlanningObservationEvent,
} from "test-utils";
// Mock the feature flag
vi.mock("#/utils/feature-flags", () => ({
USE_PLANNING_AGENT: vi.fn(() => true),
}));
// Mock useConfig
vi.mock("#/hooks/query/use-config", () => ({
useConfig: () => ({

View File

@@ -358,6 +358,30 @@ describe("Conversation WebSocket Handler", () => {
});
});
it("should show friendly i18n message for budget ConversationErrorEvent", async () => {
const mockBudgetConversationError = createMockConversationErrorEvent({
detail:
"Budget has been exceeded! Current cost: 18.51, Max budget: 18.24",
});
mswServer.use(
wsLink.addEventListener("connection", ({ client, server }) => {
server.connect();
client.send(JSON.stringify(mockBudgetConversationError));
}),
);
renderWithWebSocketContext(<ErrorMessageStoreComponent />);
expect(screen.getByTestId("error-message")).toHaveTextContent("none");
await waitFor(() => {
expect(screen.getByTestId("error-message")).toHaveTextContent(
"STATUS$ERROR_LLM_OUT_OF_CREDITS",
);
});
});
it("should set error message store on WebSocket connection errors", async () => {
// Simulate a connect-then-fail sequence (the MSW server auto-connects by default).
// This should surface an error message because the app has previously connected.

View File

@@ -0,0 +1,139 @@
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { useUpdateConversationRepository } from "#/hooks/mutation/use-update-conversation-repository";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
// Mock the V1ConversationService
vi.mock("#/api/conversation-service/v1-conversation-service.api", () => ({
default: {
updateConversationRepository: vi.fn(),
},
}));
// Mock react-i18next
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
// Mock toast handlers
vi.mock("#/utils/custom-toast-handlers", () => ({
displaySuccessToast: vi.fn(),
displayErrorToast: vi.fn(),
}));
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe("useUpdateConversationRepository", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should call updateConversationRepository with correct parameters", async () => {
const mockResponse = {
id: "test-conversation-id",
selected_repository: "owner/repo",
selected_branch: "main",
git_provider: "github",
};
vi.mocked(V1ConversationService.updateConversationRepository).mockResolvedValue(
mockResponse as any,
);
const { result } = renderHook(() => useUpdateConversationRepository(), {
wrapper: createWrapper(),
});
result.current.mutate({
conversationId: "test-conversation-id",
repository: "owner/repo",
branch: "main",
gitProvider: "github",
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(V1ConversationService.updateConversationRepository).toHaveBeenCalledWith(
"test-conversation-id",
"owner/repo",
"main",
"github",
);
});
it("should handle repository removal (null values)", async () => {
const mockResponse = {
id: "test-conversation-id",
selected_repository: null,
selected_branch: null,
git_provider: null,
};
vi.mocked(V1ConversationService.updateConversationRepository).mockResolvedValue(
mockResponse as any,
);
const { result } = renderHook(() => useUpdateConversationRepository(), {
wrapper: createWrapper(),
});
result.current.mutate({
conversationId: "test-conversation-id",
repository: null,
branch: null,
gitProvider: null,
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(V1ConversationService.updateConversationRepository).toHaveBeenCalledWith(
"test-conversation-id",
null,
null,
null,
);
});
it("should handle errors gracefully", async () => {
vi.mocked(V1ConversationService.updateConversationRepository).mockRejectedValue(
new Error("Failed to update repository"),
);
const { result } = renderHook(() => useUpdateConversationRepository(), {
wrapper: createWrapper(),
});
result.current.mutate({
conversationId: "test-conversation-id",
repository: "owner/repo",
branch: "main",
gitProvider: "github",
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
});
});

View File

@@ -0,0 +1,113 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react";
import { useGitUser } from "#/hooks/query/use-git-user";
import { useLogout } from "#/hooks/mutation/use-logout";
import UserService from "#/api/user-service/user-service.api";
import * as useShouldShowUserFeaturesModule from "#/hooks/use-should-show-user-features";
import * as useConfigModule from "#/hooks/query/use-config";
import { AxiosError } from "axios";
vi.mock("#/hooks/use-should-show-user-features");
vi.mock("#/hooks/query/use-config");
vi.mock("#/hooks/mutation/use-logout");
vi.mock("#/api/user-service/user-service.api");
vi.mock("posthog-js/react", () => ({
usePostHog: vi.fn(() => ({
identify: vi.fn(),
})),
}));
describe("useGitUser", () => {
let mockLogout: ReturnType<typeof useLogout>;
beforeEach(() => {
vi.clearAllMocks();
mockLogout = {
mutate: vi.fn(),
mutateAsync: vi.fn(),
data: undefined,
error: null,
isPending: false,
isSuccess: false,
isError: false,
isIdle: true,
reset: vi.fn(),
status: "idle",
} as unknown as ReturnType<typeof useLogout>;
vi.mocked(useShouldShowUserFeaturesModule.useShouldShowUserFeatures).mockReturnValue(true);
vi.mocked(useConfigModule.useConfig).mockReturnValue({
data: { app_mode: "saas" },
isLoading: false,
error: null,
} as any);
vi.mocked(useLogout).mockReturnValue(mockLogout);
});
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
};
it("should call logout when receiving a 401 error", async () => {
// Mock the user service to throw a 401 error
const mockError = new AxiosError("Unauthorized", "401", undefined, undefined, {
status: 401,
data: { message: "Unauthorized" },
} as any);
vi.mocked(UserService.getUser).mockRejectedValue(mockError);
const { result } = renderHook(() => useGitUser(), {
wrapper: createWrapper(),
});
// Wait for the query to fail (status becomes 'error')
await waitFor(() => {
expect(result.current.status).toBe("error");
});
// Wait for the useEffect to trigger logout
await waitFor(() => {
expect(mockLogout.mutate).toHaveBeenCalled();
});
});
it("should not call logout for non-401 errors", async () => {
// Mock the user service to throw a 500 error
const mockError = new AxiosError("Server Error", "500", undefined, undefined, {
status: 500,
data: { message: "Internal Server Error" },
} as any);
vi.mocked(UserService.getUser).mockRejectedValue(mockError);
const { result } = renderHook(() => useGitUser(), {
wrapper: createWrapper(),
});
// Wait for the query to fail (status becomes 'error')
await waitFor(() => {
expect(result.current.status).toBe("error");
});
// Wait a bit to ensure logout is not called
await waitFor(() => {
expect(mockLogout.mutate).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,64 @@
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
// Unmock the hook so we can test the real implementation
vi.unmock("#/hooks/use-is-on-intermediate-page");
const useLocationMock = vi.fn();
vi.mock("react-router", async () => {
const actual = await vi.importActual("react-router");
return {
...actual,
useLocation: useLocationMock,
};
});
// Import after mock setup
const { useIsOnIntermediatePage } = await import(
"#/hooks/use-is-on-intermediate-page"
);
describe("useIsOnIntermediatePage", () => {
describe("returns true for intermediate pages", () => {
it("should return true when on /accept-tos page", () => {
useLocationMock.mockReturnValue({ pathname: "/accept-tos" });
const { result } = renderHook(() => useIsOnIntermediatePage());
expect(result.current).toBe(true);
});
it("should return true when on /onboarding page", () => {
useLocationMock.mockReturnValue({ pathname: "/onboarding" });
const { result } = renderHook(() => useIsOnIntermediatePage());
expect(result.current).toBe(true);
});
});
describe("returns false for non-intermediate pages", () => {
it("should return false when on root page", () => {
useLocationMock.mockReturnValue({ pathname: "/" });
const { result } = renderHook(() => useIsOnIntermediatePage());
expect(result.current).toBe(false);
});
it("should return false when on /settings page", () => {
useLocationMock.mockReturnValue({ pathname: "/settings" });
const { result } = renderHook(() => useIsOnIntermediatePage());
expect(result.current).toBe(false);
});
});
describe("handles edge cases", () => {
it("should return false for paths containing intermediate page names", () => {
useLocationMock.mockReturnValue({ pathname: "/accept-tos-extra" });
const { result } = renderHook(() => useIsOnIntermediatePage());
expect(result.current).toBe(false);
});
it("should return false for paths with intermediate page names as subpaths", () => {
useLocationMock.mockReturnValue({ pathname: "/settings/accept-tos" });
const { result } = renderHook(() => useIsOnIntermediatePage());
expect(result.current).toBe(false);
});
});
});

View File

@@ -9,7 +9,10 @@ import {
} from "vitest";
import { screen, waitFor, render, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createMockAgentErrorEvent } from "#/mocks/mock-ws-helpers";
import {
createMockAgentErrorEvent,
createMockConversationErrorEvent,
} from "#/mocks/mock-ws-helpers";
import { ConversationWebSocketProvider } from "#/contexts/conversation-websocket-context";
import { conversationWebSocketTestSetup } from "./helpers/msw-websocket-setup";
import { ConnectionStatusComponent } from "./helpers/websocket-test-components";
@@ -229,5 +232,35 @@ describe("PostHog Analytics Tracking", () => {
}),
);
});
it("should track credit_limit_reached when ConversationErrorEvent contains budget error", async () => {
const mockBudgetConversationError = createMockConversationErrorEvent({
detail:
"Budget has been exceeded! Current cost: 18.51, Max budget: 18.24",
});
mswServer.use(
wsLink.addEventListener("connection", ({ client, server }) => {
server.connect();
client.send(JSON.stringify(mockBudgetConversationError));
}),
);
renderWithProviders(<ConnectionStatusComponent />);
await waitFor(() => {
expect(screen.getByTestId("connection-state")).toHaveTextContent(
"OPEN",
);
});
await waitFor(() => {
expect(mockTrackCreditLimitReached).toHaveBeenCalledWith(
expect.objectContaining({
conversationId: "test-conversation-123",
}),
);
});
});
});
});

View File

@@ -18,7 +18,7 @@
"@uidotdev/usehooks": "^2.4.1",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"axios": "^1.13.2",
"axios": "^1.13.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"downshift": "^9.0.13",
@@ -1263,11 +1263,10 @@
}
},
"node_modules/@eslint/eslintrc/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -2888,11 +2887,10 @@
}
},
"node_modules/@humanwhocodes/config-array/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -5179,325 +5177,300 @@
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.0.tgz",
"integrity": "sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.0.tgz",
"integrity": "sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.0.tgz",
"integrity": "sha512-/NNIj9A7yLjKdmkx5dC2XQ9DmjIECpGpwHoGmA5E1AhU0fuICSqSWScPhN1yLCkEdkCwJIDu2xIeLPs60MNIVg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.0.tgz",
"integrity": "sha512-xoh8abqgPrPYPr7pTYipqnUi1V3em56JzE/HgDgitTqZBZ3yKCWI+7KUkceM6tNweyUKYru1UMi7FC060RyKwA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.0.tgz",
"integrity": "sha512-PCkMh7fNahWSbA0OTUQ2OpYHpjZZr0hPr8lId8twD7a7SeWrvT3xJVyza+dQwXSSq4yEQTMoXgNOfMCsn8584g==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.0.tgz",
"integrity": "sha512-1j3stGx+qbhXql4OCDZhnK7b01s6rBKNybfsX+TNrEe9JNq4DLi1yGiR1xW+nL+FNVvI4D02PUnl6gJ/2y6WJA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.0.tgz",
"integrity": "sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.0.tgz",
"integrity": "sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.0.tgz",
"integrity": "sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.0.tgz",
"integrity": "sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.0.tgz",
"integrity": "sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
"cpu": [
"loong64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.0.tgz",
"integrity": "sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
"cpu": [
"loong64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.0.tgz",
"integrity": "sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.0.tgz",
"integrity": "sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.0.tgz",
"integrity": "sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.0.tgz",
"integrity": "sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.0.tgz",
"integrity": "sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
"cpu": [
"s390x"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.0.tgz",
"integrity": "sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.0.tgz",
"integrity": "sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.0.tgz",
"integrity": "sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.0.tgz",
"integrity": "sha512-v5xwKDWcu7qhAEcsUubiav7r+48Uk/ENWdr82MBZZRIm7zThSxCIVDfb3ZeRRq9yqk+oIzMdDo6fCcA5DHfMyA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openharmony"
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.0.tgz",
"integrity": "sha512-XnaaaSMGSI6Wk8F4KK3QP7GfuuhjGchElsVerCplUuxRIzdvZ7hRBpLR0omCmw+kI2RFJB80nenhOoGXlJ5TfQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.0.tgz",
"integrity": "sha512-3K1lP+3BXY4t4VihLw5MEg6IZD3ojSYzqzBG571W3kNQe4G4CcFpSUQVgurYgib5d+YaCjeFow8QivWp8vuSvA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.0.tgz",
"integrity": "sha512-MDk610P/vJGc5L5ImE4k5s+GZT3en0KoK1MKPXCRgzmksAMk79j4h3k1IerxTNqwDLxsGxStEZVBqG0gIqZqoA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.0.tgz",
"integrity": "sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
@@ -5981,6 +5954,60 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.7.1",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.7.1",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.0",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
@@ -7335,13 +7362,12 @@
}
},
"node_modules/axios": {
"version": "1.13.3",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.3.tgz",
"integrity": "sha512-ERT8kdX7DZjtUm7IitEyV7InTHAF42iJuMArIiDIV5YtPanJkgw4hw5Dyg9fh0mihdWNn1GKaeIWErfe56UQ1g==",
"license": "MIT",
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
}
},
@@ -9200,11 +9226,10 @@
}
},
"node_modules/eslint-plugin-import/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -9274,11 +9299,10 @@
}
},
"node_modules/eslint-plugin-jsx-a11y/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -9388,11 +9412,10 @@
}
},
"node_modules/eslint-plugin-react/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -9486,11 +9509,10 @@
}
},
"node_modules/eslint/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -10212,11 +10234,10 @@
}
},
"node_modules/glob/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -13082,13 +13103,12 @@
}
},
"node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -14173,10 +14193,9 @@
}
},
"node_modules/qs": {
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"license": "BSD-3-Clause",
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
"dependencies": {
"side-channel": "^1.1.0"
},
@@ -14763,10 +14782,9 @@
}
},
"node_modules/rollup": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz",
"integrity": "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA==",
"license": "MIT",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -14778,31 +14796,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.57.0",
"@rollup/rollup-android-arm64": "4.57.0",
"@rollup/rollup-darwin-arm64": "4.57.0",
"@rollup/rollup-darwin-x64": "4.57.0",
"@rollup/rollup-freebsd-arm64": "4.57.0",
"@rollup/rollup-freebsd-x64": "4.57.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.57.0",
"@rollup/rollup-linux-arm-musleabihf": "4.57.0",
"@rollup/rollup-linux-arm64-gnu": "4.57.0",
"@rollup/rollup-linux-arm64-musl": "4.57.0",
"@rollup/rollup-linux-loong64-gnu": "4.57.0",
"@rollup/rollup-linux-loong64-musl": "4.57.0",
"@rollup/rollup-linux-ppc64-gnu": "4.57.0",
"@rollup/rollup-linux-ppc64-musl": "4.57.0",
"@rollup/rollup-linux-riscv64-gnu": "4.57.0",
"@rollup/rollup-linux-riscv64-musl": "4.57.0",
"@rollup/rollup-linux-s390x-gnu": "4.57.0",
"@rollup/rollup-linux-x64-gnu": "4.57.0",
"@rollup/rollup-linux-x64-musl": "4.57.0",
"@rollup/rollup-openbsd-x64": "4.57.0",
"@rollup/rollup-openharmony-arm64": "4.57.0",
"@rollup/rollup-win32-arm64-msvc": "4.57.0",
"@rollup/rollup-win32-ia32-msvc": "4.57.0",
"@rollup/rollup-win32-x64-gnu": "4.57.0",
"@rollup/rollup-win32-x64-msvc": "4.57.0",
"@rollup/rollup-android-arm-eabi": "4.59.0",
"@rollup/rollup-android-arm64": "4.59.0",
"@rollup/rollup-darwin-arm64": "4.59.0",
"@rollup/rollup-darwin-x64": "4.59.0",
"@rollup/rollup-freebsd-arm64": "4.59.0",
"@rollup/rollup-freebsd-x64": "4.59.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
"@rollup/rollup-linux-arm64-musl": "4.59.0",
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
"@rollup/rollup-linux-loong64-musl": "4.59.0",
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
"@rollup/rollup-linux-x64-gnu": "4.59.0",
"@rollup/rollup-linux-x64-musl": "4.59.0",
"@rollup/rollup-openbsd-x64": "4.59.0",
"@rollup/rollup-openharmony-arm64": "4.59.0",
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
"@rollup/rollup-win32-x64-gnu": "4.59.0",
"@rollup/rollup-win32-x64-msvc": "4.59.0",
"fsevents": "~2.3.2"
}
},

View File

@@ -17,7 +17,7 @@
"@uidotdev/usehooks": "^2.4.1",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"axios": "^1.13.2",
"axios": "^1.13.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"downshift": "^9.0.13",

View File

@@ -1,8 +1,4 @@
import { openHands } from "../open-hands-axios";
import {
CancelSubscriptionResponse,
SubscriptionAccess,
} from "./billing.types";
/**
* Billing Service API - Handles all billing-related API endpoints
@@ -44,41 +40,6 @@ class BillingService {
);
return data.credits;
}
/**
* Get the user's subscription access information
* @returns The user's subscription access details or null if not available
*/
static async getSubscriptionAccess(): Promise<SubscriptionAccess | null> {
const { data } = await openHands.get<SubscriptionAccess | null>(
"/api/billing/subscription-access",
);
return data;
}
/**
* Create a subscription checkout session for subscribing to a plan
* @returns The redirect URL for the subscription checkout session
*/
static async createSubscriptionCheckoutSession(): Promise<{
redirect_url?: string;
}> {
const { data } = await openHands.post(
"/api/billing/subscription-checkout-session",
);
return data;
}
/**
* Cancel the user's subscription
* @returns The response indicating the result of the cancellation request
*/
static async cancelSubscription(): Promise<CancelSubscriptionResponse> {
const { data } = await openHands.post<CancelSubscriptionResponse>(
"/api/billing/cancel-subscription",
);
return data;
}
}
export default BillingService;

View File

@@ -1,12 +0,0 @@
export type SubscriptionAccess = {
start_at: string;
end_at: string;
created_at: string;
cancelled_at?: string | null;
stripe_subscription_id?: string | null;
};
export interface CancelSubscriptionResponse {
status: string;
message: string;
}

View File

@@ -319,6 +319,39 @@ class V1ConversationService {
return data;
}
/**
* Update a V1 conversation's repository settings
* @param conversationId The conversation ID
* @param repository The repository to attach (e.g., "owner/repo") or null to remove
* @param branch The branch to use (optional)
* @param gitProvider The git provider (e.g., "github", "gitlab")
* @returns Updated conversation info
*/
static async updateConversationRepository(
conversationId: string,
repository: string | null,
branch?: string | null,
gitProvider?: string | null,
): Promise<V1AppConversation> {
const payload: Record<string, string | null | undefined> = {};
if (repository !== undefined) {
payload.selected_repository = repository;
}
if (branch !== undefined) {
payload.selected_branch = branch;
}
if (gitProvider !== undefined) {
payload.git_provider = gitProvider;
}
const { data } = await openHands.patch<V1AppConversation>(
`/api/v1/app-conversations/${conversationId}`,
payload,
);
return data;
}
/**
* Read a file from a specific conversation's sandbox workspace
* @param conversationId The conversation ID

View File

@@ -30,7 +30,7 @@ class V1GitService {
/**
* Get git changes for a V1 conversation
* Uses the agent server endpoint: GET /api/git/changes/{path}
* Uses the agent server endpoint: GET /api/git/changes?path={path}
* Maps V1 status types (ADDED, DELETED, etc.) to V0 format (A, D, etc.)
*
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
@@ -43,15 +43,14 @@ class V1GitService {
sessionApiKey: string | null | undefined,
path: string,
): Promise<GitChange[]> {
const encodedPath = encodeURIComponent(path);
const url = this.buildRuntimeUrl(
conversationUrl,
`/api/git/changes/${encodedPath}`,
);
const url = this.buildRuntimeUrl(conversationUrl, `/api/git/changes`);
const headers = buildSessionHeaders(sessionApiKey);
// V1 API returns V1GitChangeStatus types, we need to map them to V0 format
const { data } = await axios.get<V1GitChange[]>(url, { headers });
const { data } = await axios.get<V1GitChange[]>(url, {
headers,
params: { path },
});
// Validate response is an array (could be HTML error page if runtime is dead)
if (!Array.isArray(data)) {
@@ -69,7 +68,7 @@ class V1GitService {
/**
* Get git change diff for a specific file in a V1 conversation
* Uses the agent server endpoint: GET /api/git/diff/{path}
* Uses the agent server endpoint: GET /api/git/diff?path={path}
*
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
* @param sessionApiKey Session API key for authentication (required for V1)
@@ -81,14 +80,13 @@ class V1GitService {
sessionApiKey: string | null | undefined,
path: string,
): Promise<GitChangeDiff> {
const encodedPath = encodeURIComponent(path);
const url = this.buildRuntimeUrl(
conversationUrl,
`/api/git/diff/${encodedPath}`,
);
const url = this.buildRuntimeUrl(conversationUrl, `/api/git/diff`);
const headers = buildSessionHeaders(sessionApiKey);
const { data } = await axios.get<GitChangeDiff>(url, { headers });
const { data } = await axios.get<GitChangeDiff>(url, {
headers,
params: { path },
});
return data;
}
}

View File

@@ -79,6 +79,7 @@ export interface Conversation {
conversation_version?: "V0" | "V1";
sub_conversation_ids?: string[];
public?: boolean;
sandbox_id?: string | null;
}
export interface ResultSet<T> {

View File

@@ -1 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Bitbucket</title><path d="M.778 1.213a.768.768 0 00-.768.892l3.263 19.81c.084.5.515.868 1.022.873H19.95a.772.772 0 00.77-.646l3.27-20.03a.768.768 0 00-.768-.891zM14.52 15.53H9.522L8.17 8.466h7.561z"/></svg>
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Bitbucket</title><path fill="currentColor" d="M.778 1.213a.768.768 0 00-.768.892l3.263 19.81c.084.5.515.868 1.022.873H19.95a.772.772 0 00.77-.646l3.27-20.03a.768.768 0 00-.768-.891zM14.52 15.53H9.522L8.17 8.466h7.561z"/></svg>

Before

Width:  |  Height:  |  Size: 285 B

After

Width:  |  Height:  |  Size: 305 B

View File

@@ -136,7 +136,11 @@ export function LoginContent({
const buttonLabelClasses = "text-sm font-medium leading-5 px-1";
const shouldShownHelperText =
emailVerified || hasDuplicatedEmail || recaptchaBlocked || hasInvitation;
emailVerified ||
hasDuplicatedEmail ||
recaptchaBlocked ||
hasInvitation ||
showBitbucket;
return (
<div
@@ -173,6 +177,11 @@ export function LoginContent({
{t(I18nKey.AUTH$INVITATION_PENDING)}
</p>
)}
{showBitbucket && (
<p className="text-sm text-white text-center max-w-125">
{t(I18nKey.AUTH$BITBUCKET_SIGNUP_DISABLED)}
</p>
)}
</div>
)}

View File

@@ -9,7 +9,6 @@ import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
import { useConversationStore } from "#/stores/conversation-store";
import { ChangeAgentContextMenu } from "./change-agent-context-menu";
import { cn } from "#/utils/utils";
import { USE_PLANNING_AGENT } from "#/utils/feature-flags";
import { useAgentState } from "#/hooks/use-agent-state";
import { AgentState } from "#/types/agent-state";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
@@ -27,8 +26,6 @@ export function ChangeAgentButton() {
const isWebSocketConnected = webSocketStatus === "CONNECTED";
const shouldUsePlanningAgent = USE_PLANNING_AGENT();
const { curAgentState } = useAgentState();
const { t } = useTranslation();
@@ -83,10 +80,7 @@ export function ChangeAgentButton() {
}, [isAgentRunning, contextMenuOpen, isWebSocketConnected]);
const isButtonDisabled =
isAgentRunning ||
isCreatingConversation ||
!isWebSocketConnected ||
!shouldUsePlanningAgent;
isAgentRunning || isCreatingConversation || !isWebSocketConnected;
// Handle Shift + Tab keyboard shortcut to cycle through modes
useEffect(() => {
@@ -151,10 +145,6 @@ export function ChangeAgentButton() {
return <LessonPlanIcon width={18} height={18} color="#ffffff" />;
}, [isExecutionAgent]);
if (!shouldUsePlanningAgent) {
return null;
}
return (
<div className="relative">
<button

View File

@@ -19,7 +19,6 @@ import { useInitialQueryStore } from "#/stores/initial-query-store";
import { useSendMessage } from "#/hooks/use-send-message";
import { useAgentState } from "#/hooks/use-agent-state";
import { useHandleBuildPlanClick } from "#/hooks/use-handle-build-plan-click";
import { USE_PLANNING_AGENT } from "#/utils/feature-flags";
import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
@@ -84,7 +83,6 @@ export function ChatInterface() {
const { curAgentState } = useAgentState();
const { handleBuildPlanClick } = useHandleBuildPlanClick();
const shouldUsePlanningAgent = USE_PLANNING_AGENT();
// Disable Build button while agent is running (streaming)
const isAgentRunning =
@@ -95,7 +93,7 @@ export function ChatInterface() {
// This is placed here instead of PlanPreview to avoid duplicate listeners
// when multiple PlanPreview components exist in the chat
React.useEffect(() => {
if (!shouldUsePlanningAgent || isAgentRunning) {
if (isAgentRunning) {
return undefined;
}
@@ -114,12 +112,7 @@ export function ChatInterface() {
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [
shouldUsePlanningAgent,
isAgentRunning,
handleBuildPlanClick,
scrollDomToBottom,
]);
}, [isAgentRunning, handleBuildPlanClick, scrollDomToBottom]);
const [feedbackPolarity, setFeedbackPolarity] = React.useState<
"positive" | "negative"

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