mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
70 Commits
ci/pr-amd6
...
test-8core
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
290eb2b2ae | ||
|
|
0efa2f8243 | ||
|
|
0731e8c68a | ||
|
|
0a9570eea2 | ||
|
|
c00f90bf86 | ||
|
|
1bbf699498 | ||
|
|
f76517732d | ||
|
|
7bb567734d | ||
|
|
45f0c77f36 | ||
|
|
fe3d33f222 | ||
|
|
2b53d44c2a | ||
|
|
0541cb58b2 | ||
|
|
5d593ca6e4 | ||
|
|
2158e30e87 | ||
|
|
7b4ae66e5a | ||
|
|
3e1e8f00f7 | ||
|
|
74a69b2dcc | ||
|
|
fc36913518 | ||
|
|
c788674b41 | ||
|
|
849548a132 | ||
|
|
c73e22d7cd | ||
|
|
6304f9f4c5 | ||
|
|
93be4d9d0b | ||
|
|
ec66250e74 | ||
|
|
dbd199e77c | ||
|
|
f0c454caf1 | ||
|
|
df3360005c | ||
|
|
df4fea6aca | ||
|
|
2b3868ddc3 | ||
|
|
e3c9fa9d05 | ||
|
|
2fec71320a | ||
|
|
9c0f5d785e | ||
|
|
73ba66faea | ||
|
|
a198599d91 | ||
|
|
7e20bd51f9 | ||
|
|
b75c83d92a | ||
|
|
5528b01c18 | ||
|
|
ed5ab11fcc | ||
|
|
e1afc95b6c | ||
|
|
6dd9046ba2 | ||
|
|
9ad47bf43f | ||
|
|
b0d8244ad5 | ||
|
|
c210d5294f | ||
|
|
c7190ddb30 | ||
|
|
df64ce9668 | ||
|
|
f72a9622f6 | ||
|
|
193eb34dc7 | ||
|
|
87f582db6a | ||
|
|
4b69370c73 | ||
|
|
74ac6e06a1 | ||
|
|
a91dceacfb | ||
|
|
98c61e1ee4 | ||
|
|
3268c29945 | ||
|
|
239e40da75 | ||
|
|
d190d8ee50 | ||
|
|
5f064fa88b | ||
|
|
8f87ef59c7 | ||
|
|
fdc6ba82c9 | ||
|
|
a75038bee0 | ||
|
|
fbe6eb30cb | ||
|
|
aeda0ea762 | ||
|
|
30b7af31b9 | ||
|
|
05a3916c98 | ||
|
|
eba1f60c1d | ||
|
|
024f4d3326 | ||
|
|
3e38f13d12 | ||
|
|
8a61fc824b | ||
|
|
6794603963 | ||
|
|
9be60bc286 | ||
|
|
f7b53283b5 |
2
.github/workflows/check-package-versions.yml
vendored
2
.github/workflows/check-package-versions.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
|
||||
2
.github/workflows/e2e-tests.yml
vendored
2
.github/workflows/e2e-tests.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install poetry via pipx
|
||||
uses: abatilo/actions-poetry@v4
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout PR branch
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Find Comment
|
||||
uses: peter-evans/find-comment@v3
|
||||
uses: peter-evans/find-comment@v4
|
||||
id: find-comment
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
|
||||
2
.github/workflows/fe-e2e-tests.yml
vendored
2
.github/workflows/fe-e2e-tests.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
fail-fast: true
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Set up Node.js
|
||||
uses: useblacksmith/setup-node@v5
|
||||
with:
|
||||
|
||||
2
.github/workflows/fe-unit-tests.yml
vendored
2
.github/workflows/fe-unit-tests.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
fail-fast: true
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Set up Node.js
|
||||
uses: useblacksmith/setup-node@v5
|
||||
with:
|
||||
|
||||
28
.github/workflows/ghcr-build.yml
vendored
28
.github/workflows/ghcr-build.yml
vendored
@@ -33,34 +33,39 @@ jobs:
|
||||
runs-on: blacksmith
|
||||
outputs:
|
||||
base_image: ${{ steps.define-base-images.outputs.base_image }}
|
||||
platforms: ${{ steps.define-base-images.outputs.platforms }}
|
||||
steps:
|
||||
- name: Define base images
|
||||
shell: bash
|
||||
id: define-base-images
|
||||
run: |
|
||||
if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
|
||||
json=$(jq -n -c '[
|
||||
{ image: "nikolaik/python-nodejs:python3.12-nodejs22-slim", tag: "nikolaik" }
|
||||
platforms="linux/amd64"
|
||||
json=$(jq -n -c --arg platforms "$platforms" '[
|
||||
{ image: "nikolaik/python-nodejs:python3.12-nodejs22-slim", tag: "nikolaik", platforms: $platforms }
|
||||
]')
|
||||
else
|
||||
json=$(jq -n -c '[
|
||||
{ image: "nikolaik/python-nodejs:python3.12-nodejs22-slim", tag: "nikolaik" },
|
||||
{ image: "ubuntu:24.04", tag: "ubuntu" }
|
||||
platforms="linux/amd64,linux/arm64"
|
||||
json=$(jq -n -c --arg platforms "$platforms" '[
|
||||
{ image: "nikolaik/python-nodejs:python3.12-nodejs22-slim", tag: "nikolaik", platforms: $platforms },
|
||||
{ image: "ubuntu:24.04", tag: "ubuntu", platforms: $platforms }
|
||||
]')
|
||||
fi
|
||||
echo "base_image=$json" >> "$GITHUB_OUTPUT"
|
||||
echo "platforms=$platforms" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Builds the OpenHands Docker images
|
||||
ghcr_build_app:
|
||||
name: Build App Image
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
|
||||
needs: define-matrix
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Set up QEMU
|
||||
@@ -82,7 +87,7 @@ jobs:
|
||||
- name: Build and push app image
|
||||
if: "!github.event.pull_request.head.repo.fork"
|
||||
run: |
|
||||
./containers/build.sh -i openhands -o ${{ env.REPO_OWNER }} --push
|
||||
./containers/build.sh -i openhands -o ${{ env.REPO_OWNER }} --push -p ${{ needs.define-matrix.outputs.platforms }}
|
||||
|
||||
# Builds the runtime Docker images
|
||||
ghcr_build_runtime:
|
||||
@@ -98,7 +103,7 @@ jobs:
|
||||
base_image: ${{ fromJson(needs.define-matrix.outputs.base_image) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Set up QEMU
|
||||
@@ -136,7 +141,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
./containers/build.sh -i runtime -o ${{ env.REPO_OWNER }} -t ${{ matrix.base_image.tag }} --dry
|
||||
./containers/build.sh -i runtime -o ${{ env.REPO_OWNER }} -t ${{ matrix.base_image.tag }} --dry -p ${{ matrix.base_image.platforms }}
|
||||
|
||||
DOCKER_BUILD_JSON=$(jq -c . < docker-build-dry.json)
|
||||
echo "DOCKER_TAGS=$(echo "$DOCKER_BUILD_JSON" | jq -r '.tags | join(",")')" >> $GITHUB_ENV
|
||||
@@ -180,7 +185,7 @@ jobs:
|
||||
if: github.event.pull_request.head.repo.fork != true
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
@@ -210,6 +215,7 @@ jobs:
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=match,pattern=cloud-\d+\.\d+\.\d+
|
||||
flavor: |
|
||||
latest=auto
|
||||
prefix=
|
||||
@@ -254,7 +260,7 @@ jobs:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Get short SHA
|
||||
id: short_sha
|
||||
|
||||
4
.github/workflows/lint-fix.yml
vendored
4
.github/workflows/lint-fix.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
6
.github/workflows/lint.yml
vendored
6
.github/workflows/lint.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
name: Lint frontend
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Install Node.js 22
|
||||
uses: useblacksmith/setup-node@v5
|
||||
with:
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
name: Lint python
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up python
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
name: Lint enterprise python
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up python
|
||||
|
||||
4
.github/workflows/npm-publish-ui.yml
vendored
4
.github/workflows/npm-publish-ui.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
current-version: ${{ steps.version-check.outputs.current-version }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 2 # Need previous commit to compare
|
||||
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
2
.github/workflows/openhands-resolver.yml
vendored
2
.github/workflows/openhands-resolver.yml
vendored
@@ -86,7 +86,7 @@ jobs:
|
||||
runs-on: "${{ inputs.runner || 'ubuntu-latest' }}"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
|
||||
152
.github/workflows/pr-artifacts.yml
vendored
152
.github/workflows/pr-artifacts.yml
vendored
@@ -1,136 +1,30 @@
|
||||
---
|
||||
name: PR Artifacts
|
||||
|
||||
run-name: PR Artifacts Smoke Test
|
||||
|
||||
on:
|
||||
workflow_dispatch: # Manual trigger for testing
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
branches: [main]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
# Auto-remove .pr/ directory when a reviewer approves
|
||||
cleanup-on-approval:
|
||||
concurrency:
|
||||
group: cleanup-pr-artifacts-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: false
|
||||
if: github.event_name == 'pull_request_review' && github.event.review.state == 'approved'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check if fork PR
|
||||
id: check-fork
|
||||
run: |
|
||||
if [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.event.pull_request.base.repo.full_name }}" ]; then
|
||||
echo "is_fork=true" >> $GITHUB_OUTPUT
|
||||
echo "::notice::Fork PR detected - skipping auto-cleanup (manual removal required)"
|
||||
else
|
||||
echo "is_fork=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
smoke-test:
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest-8core
|
||||
|
||||
- uses: actions/checkout@v5
|
||||
if: steps.check-fork.outputs.is_fork == 'false'
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
token: ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}
|
||||
steps:
|
||||
- name: Show runner details
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
echo "runner_name=${RUNNER_NAME}"
|
||||
echo "runner_os=${RUNNER_OS}"
|
||||
echo "runner_arch=${RUNNER_ARCH}"
|
||||
uname -a
|
||||
nproc
|
||||
free -h || true
|
||||
df -h
|
||||
|
||||
- name: Remove .pr/ directory
|
||||
id: remove
|
||||
if: steps.check-fork.outputs.is_fork == 'false'
|
||||
run: |
|
||||
if [ -d ".pr" ]; then
|
||||
git config user.name "allhands-bot"
|
||||
git config user.email "allhands-bot@users.noreply.github.com"
|
||||
git rm -rf .pr/
|
||||
git commit -m "chore: Remove PR-only artifacts [automated]"
|
||||
git push || {
|
||||
echo "::error::Failed to push cleanup commit. Check branch protection rules."
|
||||
exit 1
|
||||
}
|
||||
echo "removed=true" >> $GITHUB_OUTPUT
|
||||
echo "::notice::Removed .pr/ directory"
|
||||
else
|
||||
echo "removed=false" >> $GITHUB_OUTPUT
|
||||
echo "::notice::No .pr/ directory to remove"
|
||||
fi
|
||||
|
||||
- name: Update PR comment after cleanup
|
||||
if: steps.check-fork.outputs.is_fork == 'false' && steps.remove.outputs.removed == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- pr-artifacts-notice -->';
|
||||
const body = `${marker}
|
||||
✅ **PR Artifacts Cleaned Up**
|
||||
|
||||
The \`.pr/\` directory has been automatically removed.
|
||||
`;
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body: body,
|
||||
});
|
||||
}
|
||||
|
||||
# Warn if .pr/ directory exists (will be auto-removed on approval)
|
||||
check-pr-artifacts:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Check for .pr/ directory
|
||||
id: check
|
||||
run: |
|
||||
if [ -d ".pr" ]; then
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
echo "::warning::.pr/ directory exists and will be automatically removed when the PR is approved. For fork PRs, manual removal is required before merging."
|
||||
else
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Post or update PR comment
|
||||
if: steps.check.outputs.exists == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- pr-artifacts-notice -->';
|
||||
const body = `${marker}
|
||||
📁 **PR Artifacts Notice**
|
||||
|
||||
This PR contains a \`.pr/\` directory with PR-specific documents. This directory will be **automatically removed** when the PR is approved.
|
||||
|
||||
> For fork PRs: Manual removal is required before merging.
|
||||
`;
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (!existing) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: body,
|
||||
});
|
||||
}
|
||||
- name: Simple shell check
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
echo "smoke test start"
|
||||
sleep 10
|
||||
echo "smoke test done"
|
||||
|
||||
2
.github/workflows/pr-review-evaluation.yml
vendored
2
.github/workflows/pr-review-evaluation.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
steps:
|
||||
- name: Download review trace artifact
|
||||
id: download-trace
|
||||
uses: dawidd6/action-download-artifact@v6
|
||||
uses: dawidd6/action-download-artifact@v15
|
||||
continue-on-error: true
|
||||
with:
|
||||
workflow: pr-review-by-openhands.yml
|
||||
|
||||
8
.github/workflows/py-tests.yml
vendored
8
.github/workflows/py-tests.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
pull-requests: write
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
matrix:
|
||||
python-version: ["3.12"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
- name: Set up Python
|
||||
@@ -111,9 +111,9 @@ jobs:
|
||||
pull-requests: write
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/download-artifact@v6
|
||||
- uses: actions/download-artifact@v7
|
||||
id: download
|
||||
with:
|
||||
pattern: coverage-*
|
||||
|
||||
6
.github/workflows/pypi-release.yml
vendored
6
.github/workflows/pypi-release.yml
vendored
@@ -18,12 +18,12 @@ on:
|
||||
jobs:
|
||||
release:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
# Run when manually dispatched for "app server" OR for tag pushes that don't contain '-cli'
|
||||
# Run when manually dispatched for "app server" OR for tag pushes that don't contain '-cli' and don't start with 'cloud-'
|
||||
if: |
|
||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.reason == 'app server')
|
||||
|| (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-cli'))
|
||||
|| (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-cli') && !startsWith(github.ref, 'refs/tags/cloud-'))
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: 3.12
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
if: github.repository == 'OpenHands/OpenHands'
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
stale-issue-message: 'This issue is stale because it has been open for 40 days with no activity. Remove the stale label or leave a comment, otherwise it will be closed in 10 days.'
|
||||
stale-pr-message: 'This PR is stale because it has been open for 40 days with no activity. Remove the stale label or leave a comment, otherwise it will be closed in 10 days.'
|
||||
|
||||
2
.github/workflows/ui-build.yml
vendored
2
.github/workflows/ui-build.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version-file: "openhands-ui/.bun-version"
|
||||
|
||||
36
AGENTS.md
36
AGENTS.md
@@ -36,6 +36,42 @@ then re-run the command to ensure it passes. Common issues include:
|
||||
- Be especially careful with `git reset --hard` after staging files, as it will remove accidentally staged files
|
||||
- When remote has new changes, use `git fetch upstream && git rebase upstream/<branch>` on the same branch
|
||||
|
||||
## Lockfile Regeneration (Preserve Original Tool Versions)
|
||||
|
||||
When regenerating lockfiles (poetry.lock, uv.lock, etc.), you MUST use the same tool version that originally generated the lockfile to avoid unnecessary diff noise. Each lockfile contains a version header indicating which tool version was used.
|
||||
|
||||
### Poetry (poetry.lock)
|
||||
|
||||
1. Extract the version from the lockfile header:
|
||||
```bash
|
||||
POETRY_VERSION=$(grep -m1 "^# This file is automatically @generated by Poetry" poetry.lock | sed 's/.*Poetry \([0-9.]*\).*/\1/')
|
||||
```
|
||||
2. If a version is found, install that specific version:
|
||||
```bash
|
||||
pipx install poetry==$POETRY_VERSION --force
|
||||
```
|
||||
3. Then regenerate the lockfile:
|
||||
```bash
|
||||
poetry lock --no-update
|
||||
```
|
||||
|
||||
### uv (uv.lock)
|
||||
|
||||
1. Extract the version from the lockfile header:
|
||||
```bash
|
||||
UV_VERSION=$(grep -m1 "^# This file was autogenerated by uv" uv.lock | sed 's/.*uv version \([0-9.]*\).*/\1/')
|
||||
```
|
||||
2. If a version is found, install that specific version:
|
||||
```bash
|
||||
pipx install uv==$UV_VERSION --force
|
||||
```
|
||||
3. Then regenerate the lockfile:
|
||||
```bash
|
||||
uv lock
|
||||
```
|
||||
|
||||
This ensures that lockfile updates only contain actual dependency changes, not tool version migration artifacts.
|
||||
|
||||
## PR-Specific Artifacts (`.pr/` directory)
|
||||
|
||||
When working on a PR that requires design documents, scripts meant for development-only, or other temporary artifacts that should NOT be merged to main, store them in a `.pr/` directory at the repository root.
|
||||
|
||||
56
README.md
56
README.md
@@ -23,7 +23,6 @@
|
||||
<a href="https://www.readme-i18n.com/OpenHands/OpenHands?lang=pt">Português</a> |
|
||||
<a href="https://www.readme-i18n.com/OpenHands/OpenHands?lang=ru">Русский</a> |
|
||||
<a href="https://www.readme-i18n.com/OpenHands/OpenHands?lang=zh">中文</a>
|
||||
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
@@ -84,3 +83,58 @@ All our work is available under the MIT license, except for the `enterprise/` di
|
||||
The core `openhands` and `agent-server` Docker images are fully MIT-licensed as well.
|
||||
|
||||
If you need help with anything, or just want to chat, [come find us on Slack](https://dub.sh/openhands).
|
||||
|
||||
<hr>
|
||||
|
||||
<div align="center">
|
||||
<strong>Trusted by engineers at</strong>
|
||||
<br/><br/>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/tiktok.svg">
|
||||
<img src="https://assets.openhands.dev/logos/external/black/tiktok.svg" alt="TikTok" height="17" hspace="5">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/vmware.svg">
|
||||
<img src="https://assets.openhands.dev/logos/external/black/vmware.svg" alt="VMware" height="17" hspace="5">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/roche.svg">
|
||||
<img src="https://assets.openhands.dev/logos/external/black/roche.svg" alt="Roche" height="17" hspace="5">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/amazon.svg">
|
||||
<img src="https://assets.openhands.dev/logos/external/black/amazon.svg" alt="Amazon" height="17" hspace="5">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/c3-ai.svg">
|
||||
<img src="https://assets.openhands.dev/logos/external/black/c3-ai.svg" alt="C3 AI" height="17" hspace="5">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/netflix.svg">
|
||||
<img src="https://assets.openhands.dev/logos/external/black/netflix.svg" alt="Netflix" height="17" hspace="5">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/mastercard.svg">
|
||||
<img src="https://assets.openhands.dev/logos/external/black/mastercard.svg" alt="Mastercard" height="17" hspace="5">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/red-hat.svg">
|
||||
<img src="https://assets.openhands.dev/logos/external/black/red-hat.svg" alt="Red Hat" height="17" hspace="5">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/mongodb.svg">
|
||||
<img src="https://assets.openhands.dev/logos/external/black/mongodb.svg" alt="MongoDB" height="17" hspace="5">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/apple.svg">
|
||||
<img src="https://assets.openhands.dev/logos/external/black/apple.svg" alt="Apple" height="17" hspace="5">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/nvidia.svg">
|
||||
<img src="https://assets.openhands.dev/logos/external/black/nvidia.svg" alt="NVIDIA" height="17" hspace="5">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/google.svg">
|
||||
<img src="https://assets.openhands.dev/logos/external/black/google.svg" alt="Google" height="17" hspace="5">
|
||||
</picture>
|
||||
</div>
|
||||
|
||||
@@ -50,7 +50,7 @@ RUN mkdir -p $FILE_STORE_PATH
|
||||
RUN mkdir -p $WORKSPACE_BASE
|
||||
|
||||
RUN apt-get update -y \
|
||||
&& apt-get install -y curl ssh sudo \
|
||||
&& apt-get install -y curl git ssh sudo \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Default is 1000, but OSX is often 501
|
||||
@@ -73,6 +73,17 @@ ENV VIRTUAL_ENV=/app/.venv \
|
||||
|
||||
COPY --chown=openhands:openhands --chmod=770 --from=backend-builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
|
||||
|
||||
# Pin pip to a known-good version (reproducible builds) and fix CVE-2025-8869
|
||||
# Pin both venv pip and system pip (Trivy scans both)
|
||||
# - `python -m pip` uses the venv because `PATH` is prefixed with `${VIRTUAL_ENV}/bin`
|
||||
# - `/usr/local/bin/python3 -m pip` uses the system interpreter regardless of `PATH`
|
||||
ARG PIP_VERSION=26.0.1
|
||||
RUN python -m pip install --no-cache-dir "pip==${PIP_VERSION}"
|
||||
|
||||
USER root
|
||||
RUN /usr/local/bin/python3 -m pip install --no-cache-dir "pip==${PIP_VERSION}" --break-system-packages
|
||||
USER openhands
|
||||
|
||||
COPY --chown=openhands:openhands --chmod=770 ./skills ./skills
|
||||
COPY --chown=openhands:openhands --chmod=770 ./openhands ./openhands
|
||||
COPY --chown=openhands:openhands --chmod=777 ./openhands/runtime/plugins ./openhands/runtime/plugins
|
||||
|
||||
@@ -8,15 +8,17 @@ push=0
|
||||
load=0
|
||||
tag_suffix=""
|
||||
dry_run=0
|
||||
platform_override=""
|
||||
|
||||
# Function to display usage information
|
||||
usage() {
|
||||
echo "Usage: $0 -i <image_name> [-o <org_name>] [--push] [--load] [-t <tag_suffix>] [--dry]"
|
||||
echo "Usage: $0 -i <image_name> [-o <org_name>] [--push] [--load] [-t <tag_suffix>] [-p <platform>] [--dry]"
|
||||
echo " -i: Image name (required)"
|
||||
echo " -o: Organization name"
|
||||
echo " --push: Push the image"
|
||||
echo " --load: Load the image"
|
||||
echo " -t: Tag suffix"
|
||||
echo " -p: Platform(s) to build for (e.g. linux/amd64 or linux/amd64,linux/arm64)"
|
||||
echo " --dry: Don't build, only create build-args.json"
|
||||
exit 1
|
||||
}
|
||||
@@ -29,6 +31,7 @@ while [[ $# -gt 0 ]]; do
|
||||
--push) push=1; shift ;;
|
||||
--load) load=1; shift ;;
|
||||
-t) tag_suffix="$2"; shift 2 ;;
|
||||
-p) platform_override="$2"; shift 2 ;;
|
||||
--dry) dry_run=1; shift ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
@@ -134,8 +137,10 @@ fi
|
||||
|
||||
echo "Args: $args"
|
||||
|
||||
# Modify the platform selection based on --load flag
|
||||
if [[ $load -eq 1 ]]; then
|
||||
# Determine the platform(s) to build for
|
||||
if [[ -n "$platform_override" ]]; then
|
||||
platform="$platform_override"
|
||||
elif [[ $load -eq 1 ]]; then
|
||||
# When loading, build only for the current platform
|
||||
platform=$(docker version -f '{{.Server.Os}}/{{.Server.Arch}}')
|
||||
else
|
||||
|
||||
@@ -13,7 +13,7 @@ services:
|
||||
- DOCKER_HOST_ADDR=host.docker.internal
|
||||
#
|
||||
- AGENT_SERVER_IMAGE_REPOSITORY=${AGENT_SERVER_IMAGE_REPOSITORY:-ghcr.io/openhands/agent-server}
|
||||
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-1.12.0-python}
|
||||
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-1.15.0-python}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -8,7 +8,7 @@ services:
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- AGENT_SERVER_IMAGE_REPOSITORY=${AGENT_SERVER_IMAGE_REPOSITORY:-ghcr.io/openhands/agent-server}
|
||||
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-1.12.0-python}
|
||||
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-1.15.0-python}
|
||||
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -723,11 +723,13 @@
|
||||
"https://$WEB_HOST/slack/keycloak-callback",
|
||||
"https://$WEB_HOST/oauth/device/keycloak-callback",
|
||||
"https://$WEB_HOST/api/email/verified",
|
||||
"/realms/$KEYCLOAK_REALM_NAME/$KEYCLOAK_CLIENT_ID/*"
|
||||
"/realms/$KEYCLOAK_REALM_NAME/$KEYCLOAK_CLIENT_ID/*",
|
||||
"https://laminar.$WEB_HOST/api/auth/callback/keycloak"
|
||||
],
|
||||
"webOrigins": [
|
||||
"https://$WEB_HOST",
|
||||
"https://$AUTH_WEB_HOST"
|
||||
"https://$AUTH_WEB_HOST",
|
||||
"https://laminar.$WEB_HOST"
|
||||
],
|
||||
"notBefore": 0,
|
||||
"bearerOnly": false,
|
||||
|
||||
@@ -8,7 +8,7 @@ logging.getLogger('alembic.runtime.plugins').setLevel(logging.WARNING)
|
||||
|
||||
from alembic import context # noqa: E402
|
||||
from google.cloud.sql.connector import Connector # noqa: E402
|
||||
from sqlalchemy import create_engine # noqa: E402
|
||||
from sqlalchemy import create_engine, text # noqa: E402
|
||||
from storage.base import Base # noqa: E402
|
||||
|
||||
target_metadata = Base.metadata
|
||||
@@ -109,6 +109,10 @@ def run_migrations_online() -> None:
|
||||
version_table_schema=target_metadata.schema,
|
||||
)
|
||||
|
||||
# Lock number must be unique — md5 hash of 'openhands_enterprise_migrations'
|
||||
# Lock is released when the connection context manager exits
|
||||
connection.execute(text('SELECT pg_advisory_lock(3617572382373537863)'))
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Add mcp_config to org_member for user-specific MCP settings.
|
||||
|
||||
Revision ID: 103
|
||||
Revises: 102
|
||||
Create Date: 2026-03-26
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '103'
|
||||
down_revision: Union[str, None] = '102'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('org_member', sa.Column('mcp_config', sa.JSON(), nullable=True))
|
||||
|
||||
# Migrate existing org-level MCP configs to all members in each org.
|
||||
# This preserves existing configurations while transitioning to user-specific settings.
|
||||
conn = op.get_bind()
|
||||
orgs_with_config = conn.execute(
|
||||
sa.text('SELECT id, mcp_config FROM org WHERE mcp_config IS NOT NULL')
|
||||
).fetchall()
|
||||
|
||||
for org_id, mcp_config in orgs_with_config:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
'UPDATE org_member SET mcp_config = :config WHERE org_id = :org_id'
|
||||
),
|
||||
{'config': json.dumps(mcp_config), 'org_id': str(org_id)},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('org_member', 'mcp_config')
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Add disabled_skills column to user table.
|
||||
|
||||
Migration 102 added disabled_skills to the legacy user_settings table,
|
||||
but the active SaaS flow (SaasSettingsStore) reads from/writes to the
|
||||
user table. This migration adds the column where it is actually needed.
|
||||
|
||||
Revision ID: 104
|
||||
Revises: 103
|
||||
Create Date: 2026-03-31
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '104'
|
||||
down_revision: Union[str, None] = '103'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('user', sa.Column('disabled_skills', sa.JSON(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('user', 'disabled_skills')
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Create org_git_claim table for tracking Git organization claims.
|
||||
|
||||
Revision ID: 105
|
||||
Revises: 104
|
||||
Create Date: 2026-04-01
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '105'
|
||||
down_revision: Union[str, None] = '104'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'org_git_claim',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('org_id', sa.UUID(), nullable=False),
|
||||
sa.Column('provider', sa.String(), nullable=False),
|
||||
sa.Column('git_organization', sa.String(), nullable=False),
|
||||
sa.Column('claimed_by', sa.UUID(), nullable=False),
|
||||
sa.Column('claimed_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['org_id'], ['org.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['claimed_by'], ['user.id']),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('provider', 'git_organization', name='uq_provider_git_org'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('org_git_claim')
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Add tags column to conversation_metadata table.
|
||||
|
||||
Tags store key-value pairs for automation context (trigger type, automation_id),
|
||||
skills used, and other metadata. This enables querying conversations by
|
||||
automation source and associating SDK-provided context with conversations.
|
||||
|
||||
Revision ID: 106
|
||||
Revises: 105
|
||||
Create Date: 2026-03-31
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '106'
|
||||
down_revision: Union[str, None] = '105'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'conversation_metadata',
|
||||
sa.Column('tags', sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('conversation_metadata', 'tags')
|
||||
4801
enterprise/poetry.lock
generated
4801
enterprise/poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -41,7 +41,7 @@ from storage.role import Role
|
||||
from storage.role_store import RoleStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth import get_user_id
|
||||
from openhands.server.user_auth import get_user_auth, get_user_id
|
||||
|
||||
|
||||
class Permission(str, Enum):
|
||||
@@ -84,6 +84,9 @@ class Permission(str, Enum):
|
||||
# Temporary permissions until we finish the API updates.
|
||||
EDIT_ORG_SETTINGS = 'edit_org_settings'
|
||||
|
||||
# Git organization claims
|
||||
MANAGE_ORG_CLAIMS = 'manage_org_claims'
|
||||
|
||||
|
||||
class RoleName(str, Enum):
|
||||
"""Role names used in the system."""
|
||||
@@ -118,6 +121,8 @@ ROLE_PERMISSIONS: dict[RoleName, frozenset[Permission]] = {
|
||||
# Organization Management (Owner only)
|
||||
Permission.CHANGE_ORGANIZATION_NAME,
|
||||
Permission.DELETE_ORGANIZATION,
|
||||
# Git organization claims
|
||||
Permission.MANAGE_ORG_CLAIMS,
|
||||
]
|
||||
),
|
||||
RoleName.ADMIN: frozenset(
|
||||
@@ -139,6 +144,8 @@ ROLE_PERMISSIONS: dict[RoleName, frozenset[Permission]] = {
|
||||
# Organization Management
|
||||
Permission.VIEW_ORG_SETTINGS,
|
||||
Permission.EDIT_ORG_SETTINGS,
|
||||
# Git organization claims
|
||||
Permission.MANAGE_ORG_CLAIMS,
|
||||
]
|
||||
),
|
||||
RoleName.MEMBER: frozenset(
|
||||
@@ -311,3 +318,96 @@ def require_permission(permission: Permission):
|
||||
return user_id
|
||||
|
||||
return permission_checker
|
||||
|
||||
|
||||
async def require_financial_data_access(
|
||||
request: Request,
|
||||
org_id: UUID,
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
) -> str:
|
||||
"""
|
||||
Authorization dependency for accessing organization financial data.
|
||||
|
||||
Allows access if ANY of these conditions are met:
|
||||
1. User has Admin or Owner role in the organization
|
||||
2. User has @openhands.dev email domain
|
||||
|
||||
This is used for the organization members financial data endpoint.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object
|
||||
org_id: Organization UUID from path parameter
|
||||
user_id: User ID from authentication
|
||||
|
||||
Returns:
|
||||
str: User ID if authorized
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if not authenticated, 403 if not authorized
|
||||
"""
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='User not authenticated',
|
||||
)
|
||||
|
||||
# Validate API key organization binding
|
||||
api_key_org_id = await get_api_key_org_id_from_request(request)
|
||||
if api_key_org_id is not None:
|
||||
if api_key_org_id != org_id:
|
||||
logger.warning(
|
||||
'API key organization mismatch for financial data access',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'api_key_org_id': str(api_key_org_id),
|
||||
'target_org_id': str(org_id),
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='API key is not authorized for this organization',
|
||||
)
|
||||
|
||||
# Check if user has @openhands.dev email
|
||||
user_auth = await get_user_auth(request)
|
||||
user_email = await user_auth.get_user_email()
|
||||
|
||||
if user_email and user_email.endswith('@openhands.dev'):
|
||||
logger.debug(
|
||||
'Financial data access granted via @openhands.dev email',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id)},
|
||||
)
|
||||
return user_id
|
||||
|
||||
# Check if user has Admin or Owner role in the organization
|
||||
user_role = await get_user_org_role(user_id, org_id)
|
||||
|
||||
if not user_role:
|
||||
logger.warning(
|
||||
'Financial data access denied - user not a member of organization',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='User is not a member of this organization',
|
||||
)
|
||||
|
||||
if user_role.name not in (RoleName.OWNER.value, RoleName.ADMIN.value):
|
||||
logger.warning(
|
||||
'Financial data access denied - insufficient role',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
'user_role': user_role.name,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Access restricted to organization admins, owners, or OpenHands members',
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
'Financial data access granted via admin/owner role',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id), 'role': user_role.name},
|
||||
)
|
||||
return user_id
|
||||
|
||||
@@ -6,7 +6,6 @@ GITHUB_APP_WEBHOOK_SECRET = os.getenv('GITHUB_APP_WEBHOOK_SECRET', '')
|
||||
GITHUB_APP_PRIVATE_KEY = os.getenv('GITHUB_APP_PRIVATE_KEY', '').replace('\\n', '\n')
|
||||
KEYCLOAK_SERVER_URL = os.getenv('KEYCLOAK_SERVER_URL', '').rstrip('/')
|
||||
KEYCLOAK_REALM_NAME = os.getenv('KEYCLOAK_REALM_NAME', '')
|
||||
KEYCLOAK_PROVIDER_NAME = os.getenv('KEYCLOAK_PROVIDER_NAME', '')
|
||||
KEYCLOAK_CLIENT_ID = os.getenv('KEYCLOAK_CLIENT_ID', '')
|
||||
KEYCLOAK_CLIENT_SECRET = os.getenv('KEYCLOAK_CLIENT_SECRET', '')
|
||||
KEYCLOAK_SERVER_URL_EXT = os.getenv(
|
||||
|
||||
@@ -4,7 +4,6 @@ from server.auth.constants import (
|
||||
KEYCLOAK_ADMIN_PASSWORD,
|
||||
KEYCLOAK_CLIENT_ID,
|
||||
KEYCLOAK_CLIENT_SECRET,
|
||||
KEYCLOAK_PROVIDER_NAME,
|
||||
KEYCLOAK_REALM_NAME,
|
||||
KEYCLOAK_SERVER_URL,
|
||||
KEYCLOAK_SERVER_URL_EXT,
|
||||
@@ -12,7 +11,7 @@ from server.auth.constants import (
|
||||
from server.logger import logger
|
||||
|
||||
logger.debug(
|
||||
f'KEYCLOAK_SERVER_URL:{KEYCLOAK_SERVER_URL}, KEYCLOAK_SERVER_URL_EXT:{KEYCLOAK_SERVER_URL_EXT}, KEYCLOAK_PROVIDER_NAME:{KEYCLOAK_PROVIDER_NAME}, KEYCLOAK_CLIENT_ID:{KEYCLOAK_CLIENT_ID}'
|
||||
f'KEYCLOAK_SERVER_URL:{KEYCLOAK_SERVER_URL}, KEYCLOAK_SERVER_URL_EXT:{KEYCLOAK_SERVER_URL_EXT}, KEYCLOAK_CLIENT_ID:{KEYCLOAK_CLIENT_ID}'
|
||||
)
|
||||
|
||||
_keycloak_instances = {}
|
||||
|
||||
@@ -287,7 +287,28 @@ def get_api_key_from_header(request: Request):
|
||||
return session_api_key
|
||||
|
||||
# Fallback to X-Access-Token header as an additional option
|
||||
return request.headers.get('X-Access-Token')
|
||||
access_token = request.headers.get('X-Access-Token')
|
||||
if access_token:
|
||||
return access_token
|
||||
|
||||
# DEPRECATED: Accept api_key from query parameters for backward compatibility.
|
||||
# Passing API keys as query parameters is a security risk because they are
|
||||
# logged in proxy access logs (e.g. Traefik) and application logs.
|
||||
# Callers should migrate to using the Authorization header instead:
|
||||
# Authorization: Bearer sk-oh-...
|
||||
# See: https://github.com/OpenHands/evaluation/issues/391
|
||||
query_api_key = request.query_params.get('api_key')
|
||||
if query_api_key:
|
||||
logger.warning(
|
||||
'DEPRECATED: api_key passed as URL query parameter. '
|
||||
'This is a security risk as tokens are logged in proxy/access logs. '
|
||||
'Use the Authorization header instead: Authorization: Bearer <token>. '
|
||||
'Query parameter support will be removed in a future release.',
|
||||
extra={'path': request.url.path},
|
||||
)
|
||||
return query_api_key
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def saas_user_auth_from_bearer(request: Request) -> SaasUserAuth | None:
|
||||
|
||||
@@ -80,8 +80,7 @@ def setup_json_logger(
|
||||
handler.setLevel(level)
|
||||
|
||||
formatter = JsonFormatter(
|
||||
'{message}{levelname}',
|
||||
style='{',
|
||||
'%(message)s%(levelname)s%(module)s%(funcName)s%(lineno)d',
|
||||
rename_fields={'levelname': 'severity'},
|
||||
json_serializer=custom_json_serializer,
|
||||
# Use 'ts' for consistency with LOG_JSON_FOR_CONSOLE mode (skip when console mode to avoid duplicates)
|
||||
|
||||
@@ -106,12 +106,17 @@ class SetAuthCookieMiddleware:
|
||||
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')
|
||||
# DEPRECATED: Also check for api_key in query params for backward
|
||||
# compatibility. The actual deprecation warning is logged in
|
||||
# get_api_key_from_header() when the key is extracted.
|
||||
query_api_key = request.query_params.get('api_key')
|
||||
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
|
||||
and query_api_key is None
|
||||
):
|
||||
raise NoCredentialsError
|
||||
|
||||
|
||||
@@ -120,3 +120,18 @@ class BatchInvitationResponse(BaseModel):
|
||||
|
||||
successful: list[InvitationResponse]
|
||||
failed: list[InvitationFailure]
|
||||
|
||||
|
||||
class AcceptInvitationRequest(BaseModel):
|
||||
"""Request model for accepting an invitation via POST."""
|
||||
|
||||
token: str
|
||||
|
||||
|
||||
class AcceptInvitationResponse(BaseModel):
|
||||
"""Response model for successful invitation acceptance."""
|
||||
|
||||
success: bool
|
||||
org_id: str
|
||||
org_name: str
|
||||
role: str
|
||||
|
||||
@@ -5,6 +5,8 @@ from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from server.routes.org_invitation_models import (
|
||||
AcceptInvitationRequest,
|
||||
AcceptInvitationResponse,
|
||||
BatchInvitationResponse,
|
||||
EmailMismatchError,
|
||||
InsufficientPermissionError,
|
||||
@@ -17,10 +19,11 @@ from server.routes.org_invitation_models import (
|
||||
)
|
||||
from server.services.org_invitation_service import OrgInvitationService
|
||||
from server.utils.rate_limit_utils import check_rate_limit_by_user_id
|
||||
from storage.org_store import OrgStore
|
||||
from storage.role_store import RoleStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth import get_user_id
|
||||
from openhands.server.user_auth.user_auth import get_user_auth
|
||||
|
||||
# Router for invitation operations on an organization (requires org_id)
|
||||
invitation_router = APIRouter(prefix='/api/organizations/{org_id}/members')
|
||||
@@ -123,70 +126,93 @@ async def create_invitation(
|
||||
|
||||
|
||||
@accept_router.get('/accept')
|
||||
async def accept_invitation(
|
||||
async def accept_invitation_redirect(
|
||||
token: str,
|
||||
request: Request,
|
||||
):
|
||||
"""Accept an organization invitation via token.
|
||||
"""Redirect invitation acceptance to frontend.
|
||||
|
||||
This endpoint is accessed via the link in the invitation email.
|
||||
It always redirects to the home page with the invitation token,
|
||||
allowing the frontend to handle the acceptance flow via a modal.
|
||||
|
||||
Flow:
|
||||
1. If user is authenticated: Accept invitation directly and redirect to home
|
||||
2. If user is not authenticated: Redirect to login page with invitation token
|
||||
- Frontend stores token and includes it in OAuth state during login
|
||||
- After authentication, keycloak_callback processes the invitation
|
||||
This approach works with SameSite='strict' cookies because:
|
||||
- Cross-site navigation (clicking email link) doesn't send cookies
|
||||
- But same-origin POST requests (from frontend) DO send cookies
|
||||
|
||||
Args:
|
||||
token: The invitation token from the email link
|
||||
request: FastAPI request
|
||||
|
||||
Returns:
|
||||
RedirectResponse: Redirect to home page on success, or login page if not authenticated,
|
||||
or home page with error query params on failure
|
||||
RedirectResponse: Redirect to home page with invitation_token query param
|
||||
"""
|
||||
base_url = str(request.base_url).rstrip('/')
|
||||
|
||||
# Try to get user_id from auth (may not be authenticated)
|
||||
user_id = None
|
||||
try:
|
||||
user_auth = await get_user_auth(request)
|
||||
if user_auth:
|
||||
user_id = await user_auth.get_user_id()
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(
|
||||
'Invitation accept: redirecting to frontend for acceptance',
|
||||
extra={'token_prefix': token[:10] + '...'},
|
||||
)
|
||||
|
||||
if not user_id:
|
||||
# User not authenticated - redirect to login page with invitation token
|
||||
# Frontend will store the token and include it in OAuth state during login
|
||||
logger.info(
|
||||
'Invitation accept: redirecting unauthenticated user to login',
|
||||
extra={'token_prefix': token[:10] + '...'},
|
||||
)
|
||||
login_url = f'{base_url}/login?invitation_token={token}'
|
||||
return RedirectResponse(login_url, status_code=302)
|
||||
return RedirectResponse(f'{base_url}/?invitation_token={token}', status_code=302)
|
||||
|
||||
|
||||
@accept_router.post('/accept', response_model=AcceptInvitationResponse)
|
||||
async def accept_invitation(
|
||||
request_data: AcceptInvitationRequest,
|
||||
user_id: str = Depends(get_user_id),
|
||||
):
|
||||
"""Accept an organization invitation via authenticated POST request.
|
||||
|
||||
This endpoint is called by the frontend after displaying the acceptance modal.
|
||||
Requires authentication - cookies are sent because this is a same-origin request.
|
||||
|
||||
Args:
|
||||
request_data: Contains the invitation token
|
||||
user_id: Authenticated user ID (from dependency)
|
||||
|
||||
Returns:
|
||||
AcceptInvitationResponse: Success response with organization details
|
||||
|
||||
Raises:
|
||||
HTTPException 400: Invalid or expired token
|
||||
HTTPException 403: Email mismatch
|
||||
HTTPException 409: User already a member
|
||||
"""
|
||||
token = request_data.token
|
||||
|
||||
# User is authenticated - process the invitation directly
|
||||
try:
|
||||
await OrgInvitationService.accept_invitation(token, UUID(user_id))
|
||||
invitation = await OrgInvitationService.accept_invitation(token, UUID(user_id))
|
||||
|
||||
# Get organization and role details for response
|
||||
org = await OrgStore.get_org_by_id(invitation.org_id)
|
||||
role = await RoleStore.get_role_by_id(invitation.role_id)
|
||||
|
||||
logger.info(
|
||||
'Invitation accepted successfully',
|
||||
'Invitation accepted via API',
|
||||
extra={
|
||||
'token_prefix': token[:10] + '...',
|
||||
'user_id': user_id,
|
||||
'org_id': str(invitation.org_id),
|
||||
},
|
||||
)
|
||||
|
||||
# Redirect to home page on success
|
||||
return RedirectResponse(f'{base_url}/', status_code=302)
|
||||
return AcceptInvitationResponse(
|
||||
success=True,
|
||||
org_id=str(invitation.org_id),
|
||||
org_name=org.name if org else '',
|
||||
role=role.name if role else '',
|
||||
)
|
||||
|
||||
except InvitationExpiredError:
|
||||
logger.warning(
|
||||
'Invitation accept failed: expired',
|
||||
extra={'token_prefix': token[:10] + '...', 'user_id': user_id},
|
||||
)
|
||||
return RedirectResponse(f'{base_url}/?invitation_expired=true', status_code=302)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='invitation_expired',
|
||||
)
|
||||
|
||||
except InvitationInvalidError as e:
|
||||
logger.warning(
|
||||
@@ -197,14 +223,20 @@ async def accept_invitation(
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
return RedirectResponse(f'{base_url}/?invitation_invalid=true', status_code=302)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='invitation_invalid',
|
||||
)
|
||||
|
||||
except UserAlreadyMemberError:
|
||||
logger.info(
|
||||
'Invitation accept: user already member',
|
||||
extra={'token_prefix': token[:10] + '...', 'user_id': user_id},
|
||||
)
|
||||
return RedirectResponse(f'{base_url}/?already_member=true', status_code=302)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='already_member',
|
||||
)
|
||||
|
||||
except EmailMismatchError as e:
|
||||
logger.warning(
|
||||
@@ -215,15 +247,21 @@ async def accept_invitation(
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
return RedirectResponse(f'{base_url}/?email_mismatch=true', status_code=302)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='email_mismatch',
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Unexpected error accepting invitation',
|
||||
'Unexpected error accepting invitation via API',
|
||||
extra={
|
||||
'token_prefix': token[:10] + '...',
|
||||
'user_id': user_id,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
return RedirectResponse(f'{base_url}/?invitation_error=true', status_code=302)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='An unexpected error occurred',
|
||||
)
|
||||
|
||||
@@ -241,7 +241,6 @@ class OrgUpdate(BaseModel):
|
||||
enable_proactive_conversation_starters: bool | None = None
|
||||
sandbox_base_container_image: str | None = None
|
||||
sandbox_runtime_container_image: str | None = None
|
||||
mcp_config: dict | None = None
|
||||
sandbox_api_key: str | None = None
|
||||
max_budget_per_task: float | None = Field(default=None, gt=0)
|
||||
enable_solvability_analysis: bool | None = None
|
||||
@@ -484,3 +483,72 @@ class OrgAppSettingsUpdate(BaseModel):
|
||||
if v is not None and v <= 0:
|
||||
raise ValueError('max_budget_per_task must be greater than 0')
|
||||
return v
|
||||
|
||||
|
||||
VALID_GIT_PROVIDERS = {'github', 'gitlab', 'bitbucket'}
|
||||
|
||||
|
||||
class GitOrgClaimRequest(BaseModel):
|
||||
"""Request model for claiming a Git organization."""
|
||||
|
||||
provider: str
|
||||
git_organization: str
|
||||
|
||||
@field_validator('provider')
|
||||
@classmethod
|
||||
def validate_provider(cls, v: str) -> str:
|
||||
v = v.lower().strip()
|
||||
if v not in VALID_GIT_PROVIDERS:
|
||||
raise ValueError(
|
||||
f'Invalid provider: "{v}". Must be one of: {", ".join(sorted(VALID_GIT_PROVIDERS))}'
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator('git_organization')
|
||||
@classmethod
|
||||
def validate_git_organization(cls, v: str) -> str:
|
||||
v = v.strip().lower()
|
||||
if not v:
|
||||
raise ValueError('git_organization must not be empty')
|
||||
return v
|
||||
|
||||
|
||||
class GitOrgClaimResponse(BaseModel):
|
||||
"""Response model for a Git organization claim."""
|
||||
|
||||
id: str
|
||||
org_id: str
|
||||
provider: str
|
||||
git_organization: str
|
||||
claimed_by: str
|
||||
claimed_at: str
|
||||
|
||||
|
||||
class GitOrgAlreadyClaimedError(Exception):
|
||||
"""Raised when a Git organization is already claimed by another OpenHands org."""
|
||||
|
||||
def __init__(self, provider: str, git_organization: str):
|
||||
self.provider = provider
|
||||
self.git_organization = git_organization
|
||||
super().__init__(
|
||||
f'Git organization "{git_organization}" on {provider} is already claimed by another organization'
|
||||
)
|
||||
|
||||
|
||||
class OrgMemberFinancialResponse(BaseModel):
|
||||
"""Financial data for a single organization member."""
|
||||
|
||||
user_id: str
|
||||
email: str | None
|
||||
lifetime_spend: float # Total amount spent (from LiteLLM)
|
||||
current_budget: float # Remaining budget (max_budget - spend)
|
||||
max_budget: float | None # Total allocated budget (None = unlimited)
|
||||
|
||||
|
||||
class OrgMemberFinancialPage(BaseModel):
|
||||
"""Paginated response for organization member financial data."""
|
||||
|
||||
items: list[OrgMemberFinancialResponse]
|
||||
current_page: int = 1
|
||||
per_page: int = 10
|
||||
next_page_id: str | None = None
|
||||
|
||||
@@ -4,11 +4,15 @@ from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from server.auth.authorization import (
|
||||
Permission,
|
||||
require_financial_data_access,
|
||||
require_permission,
|
||||
)
|
||||
from server.email_validation import get_admin_user_id
|
||||
from server.routes.org_models import (
|
||||
CannotModifySelfError,
|
||||
GitOrgAlreadyClaimedError,
|
||||
GitOrgClaimRequest,
|
||||
GitOrgClaimResponse,
|
||||
InsufficientPermissionError,
|
||||
InvalidRoleError,
|
||||
LastOwnerError,
|
||||
@@ -22,6 +26,7 @@ from server.routes.org_models import (
|
||||
OrgDatabaseError,
|
||||
OrgLLMSettingsResponse,
|
||||
OrgLLMSettingsUpdate,
|
||||
OrgMemberFinancialPage,
|
||||
OrgMemberNotFoundError,
|
||||
OrgMemberPage,
|
||||
OrgMemberResponse,
|
||||
@@ -42,7 +47,10 @@ from server.services.org_llm_settings_service import (
|
||||
OrgLLMSettingsService,
|
||||
OrgLLMSettingsServiceInjector,
|
||||
)
|
||||
from server.services.org_member_financial_service import OrgMemberFinancialService
|
||||
from server.services.org_member_service import OrgMemberService
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from storage.org_git_claim_store import OrgGitClaimStore
|
||||
from storage.org_service import OrgService
|
||||
from storage.user_store import UserStore
|
||||
|
||||
@@ -883,6 +891,104 @@ async def get_org_members_count(
|
||||
)
|
||||
|
||||
|
||||
@org_router.get(
|
||||
'/{org_id}/members/financial',
|
||||
response_model=OrgMemberFinancialPage,
|
||||
)
|
||||
async def get_org_members_financial(
|
||||
org_id: UUID,
|
||||
page_id: Annotated[
|
||||
str | None,
|
||||
Query(
|
||||
title='Pagination offset encoded as string',
|
||||
description='Offset for pagination (e.g., "0", "10", "20")',
|
||||
),
|
||||
] = None,
|
||||
limit: Annotated[
|
||||
int,
|
||||
Query(
|
||||
title='Maximum items per page',
|
||||
gt=0,
|
||||
le=100,
|
||||
),
|
||||
] = 10,
|
||||
email: Annotated[
|
||||
str | None,
|
||||
Query(
|
||||
title='Filter members by email (case-insensitive partial match)',
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
),
|
||||
] = None,
|
||||
user_id: str = Depends(require_financial_data_access),
|
||||
) -> OrgMemberFinancialPage:
|
||||
"""Get paginated financial data for organization members.
|
||||
|
||||
Returns financial information (lifetime spend, current budget) for all members
|
||||
within the specified organization. Access is restricted to:
|
||||
- Organization Admins
|
||||
- Organization Owners
|
||||
- OpenHands members (users with @openhands.dev emails)
|
||||
|
||||
Args:
|
||||
org_id: Organization ID (UUID)
|
||||
page_id: Optional pagination offset encoded as string
|
||||
limit: Maximum items per page (1-100, default 10)
|
||||
email: Optional email filter (case-insensitive partial match)
|
||||
user_id: Authenticated user ID (injected by require_financial_data_access)
|
||||
|
||||
Returns:
|
||||
OrgMemberFinancialPage: Paginated response with member financial data
|
||||
- items: List of members with user_id, email, lifetime_spend,
|
||||
current_budget, and max_budget
|
||||
- current_page: Current page number (1-indexed)
|
||||
- per_page: Items per page
|
||||
- next_page_id: Offset for next page, or None if no more pages
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if user is not authenticated
|
||||
HTTPException: 403 if user lacks access (not admin/owner and not @openhands.dev)
|
||||
HTTPException: 400 if page_id is invalid
|
||||
HTTPException: 500 if retrieval fails
|
||||
"""
|
||||
logger.info(
|
||||
'Getting financial data for organization members',
|
||||
extra={
|
||||
'org_id': str(org_id),
|
||||
'user_id': user_id,
|
||||
'page_id': page_id,
|
||||
'limit': limit,
|
||||
'email_filter': email,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
return await OrgMemberFinancialService.get_org_members_financial_data(
|
||||
org_id=org_id,
|
||||
page_id=page_id,
|
||||
limit=limit,
|
||||
email_filter=email,
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.warning(
|
||||
'Invalid page_id for financial data request',
|
||||
extra={'org_id': str(org_id), 'page_id': page_id, 'error': str(e)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
'Error retrieving organization member financial data',
|
||||
extra={'org_id': str(org_id)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to retrieve member financial data',
|
||||
)
|
||||
|
||||
|
||||
@org_router.delete('/{org_id}/members/{user_id}')
|
||||
async def remove_org_member(
|
||||
org_id: UUID,
|
||||
@@ -1111,3 +1217,181 @@ async def update_org_member(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to update member',
|
||||
)
|
||||
|
||||
|
||||
@org_router.get(
|
||||
'/{org_id}/git-claims',
|
||||
response_model=list[GitOrgClaimResponse],
|
||||
)
|
||||
async def get_git_claims(
|
||||
org_id: UUID,
|
||||
user_id: str = Depends(require_permission(Permission.MANAGE_ORG_CLAIMS)),
|
||||
) -> list[GitOrgClaimResponse]:
|
||||
"""Get all Git organization claims for an OpenHands organization.
|
||||
|
||||
Only admin and owner roles can view Git organization claims.
|
||||
|
||||
Args:
|
||||
org_id: OpenHands organization UUID
|
||||
user_id: Authenticated user ID (injected by permission check)
|
||||
|
||||
Returns:
|
||||
List of GitOrgClaimResponse with claim details
|
||||
"""
|
||||
try:
|
||||
claims = await OrgGitClaimStore.get_claims_by_org_id(org_id=org_id)
|
||||
return [
|
||||
GitOrgClaimResponse(
|
||||
id=str(claim.id),
|
||||
org_id=str(claim.org_id),
|
||||
provider=claim.provider,
|
||||
git_organization=claim.git_organization,
|
||||
claimed_by=str(claim.claimed_by),
|
||||
claimed_at=claim.claimed_at.isoformat(),
|
||||
)
|
||||
for claim in claims
|
||||
]
|
||||
except Exception:
|
||||
logger.exception('Error fetching Git organization claims')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to fetch Git organization claims',
|
||||
)
|
||||
|
||||
|
||||
@org_router.post(
|
||||
'/{org_id}/git-claims',
|
||||
response_model=GitOrgClaimResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def claim_git_organization(
|
||||
org_id: UUID,
|
||||
request: GitOrgClaimRequest,
|
||||
user_id: str = Depends(require_permission(Permission.MANAGE_ORG_CLAIMS)),
|
||||
) -> GitOrgClaimResponse:
|
||||
"""Claim a Git organization for an OpenHands organization.
|
||||
|
||||
Only admin and owner roles can claim Git organizations.
|
||||
A Git organization can only be claimed by one OpenHands organization at a time.
|
||||
|
||||
Args:
|
||||
org_id: OpenHands organization UUID
|
||||
request: Claim request with provider and git_organization
|
||||
user_id: Authenticated user ID (injected by permission check)
|
||||
|
||||
Returns:
|
||||
GitOrgClaimResponse with the created claim details
|
||||
|
||||
Raises:
|
||||
HTTPException 409: If the Git organization is already claimed
|
||||
HTTPException 403: If user lacks permission
|
||||
"""
|
||||
try:
|
||||
# Check if this Git org is already claimed (early feedback for the common case)
|
||||
existing_claim = await OrgGitClaimStore.get_claim_by_provider_and_git_org(
|
||||
provider=request.provider,
|
||||
git_organization=request.git_organization,
|
||||
)
|
||||
|
||||
if existing_claim:
|
||||
raise GitOrgAlreadyClaimedError(
|
||||
provider=request.provider,
|
||||
git_organization=request.git_organization,
|
||||
)
|
||||
|
||||
# Create the claim — the DB unique constraint handles the race condition
|
||||
# where two concurrent requests both pass the check above.
|
||||
claim = await OrgGitClaimStore.create_claim(
|
||||
org_id=org_id,
|
||||
provider=request.provider,
|
||||
git_organization=request.git_organization,
|
||||
claimed_by=UUID(user_id),
|
||||
)
|
||||
|
||||
return GitOrgClaimResponse(
|
||||
id=str(claim.id),
|
||||
org_id=str(claim.org_id),
|
||||
provider=claim.provider,
|
||||
git_organization=claim.git_organization,
|
||||
claimed_by=str(claim.claimed_by),
|
||||
claimed_at=claim.claimed_at.isoformat(),
|
||||
)
|
||||
|
||||
except GitOrgAlreadyClaimedError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(e),
|
||||
)
|
||||
except IntegrityError as e:
|
||||
# Only treat the unique constraint violation as a duplicate claim.
|
||||
# Other integrity errors (e.g. FK violations) should surface as 500s.
|
||||
if 'uq_provider_git_org' in str(e.orig):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(
|
||||
GitOrgAlreadyClaimedError(
|
||||
provider=request.provider,
|
||||
git_organization=request.git_organization,
|
||||
)
|
||||
),
|
||||
)
|
||||
logger.exception('Integrity error claiming Git organization')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to claim Git organization',
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Error claiming Git organization')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to claim Git organization',
|
||||
)
|
||||
|
||||
|
||||
@org_router.delete(
|
||||
'/{org_id}/git-claims/{claim_id}',
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
async def disconnect_git_organization(
|
||||
org_id: UUID,
|
||||
claim_id: UUID,
|
||||
user_id: str = Depends(require_permission(Permission.MANAGE_ORG_CLAIMS)),
|
||||
) -> dict:
|
||||
"""Remove a Git organization claim from an OpenHands organization.
|
||||
|
||||
Only admin and owner roles can disconnect Git organization claims.
|
||||
|
||||
Args:
|
||||
org_id: OpenHands organization UUID
|
||||
claim_id: Claim UUID to remove
|
||||
user_id: Authenticated user ID (injected by permission check)
|
||||
|
||||
Returns:
|
||||
dict: Confirmation message on successful deletion
|
||||
|
||||
Raises:
|
||||
HTTPException 404: If the claim is not found for this organization
|
||||
HTTPException 403: If user lacks permission
|
||||
"""
|
||||
try:
|
||||
deleted = await OrgGitClaimStore.delete_claim(
|
||||
claim_id=claim_id,
|
||||
org_id=org_id,
|
||||
)
|
||||
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Git organization claim not found',
|
||||
)
|
||||
|
||||
return {'message': 'Git organization claim removed successfully'}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception('Error disconnecting Git organization')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to disconnect Git organization',
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ from utils.identity import resolve_display_name
|
||||
|
||||
from openhands.integrations.provider import (
|
||||
PROVIDER_TOKEN_TYPE,
|
||||
ProviderHandler,
|
||||
)
|
||||
from openhands.integrations.service_types import (
|
||||
Branch,
|
||||
@@ -67,6 +68,53 @@ async def saas_get_user_installations(
|
||||
)
|
||||
|
||||
|
||||
@saas_user_router.get('/git-organizations')
|
||||
async def saas_get_user_git_organizations(
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
access_token: SecretStr | None = Depends(get_access_token),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
):
|
||||
if not provider_tokens:
|
||||
retval = await _check_idp(
|
||||
access_token=access_token,
|
||||
default_value={},
|
||||
)
|
||||
if retval is not None:
|
||||
return retval
|
||||
# _check_idp returned None (tokens refreshed on Keycloak side),
|
||||
# but provider_tokens is still None for this request.
|
||||
return JSONResponse(
|
||||
content='Git provider token required.',
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
client = ProviderHandler(
|
||||
provider_tokens=provider_tokens,
|
||||
external_auth_token=access_token,
|
||||
external_auth_id=user_id,
|
||||
)
|
||||
|
||||
# SaaS users sign in with one provider at a time
|
||||
provider = next(iter(provider_tokens))
|
||||
|
||||
if provider == ProviderType.GITHUB:
|
||||
orgs = await client.get_github_organizations()
|
||||
elif provider == ProviderType.GITLAB:
|
||||
orgs = await client.get_gitlab_groups()
|
||||
elif provider == ProviderType.BITBUCKET:
|
||||
orgs = await client.get_bitbucket_workspaces()
|
||||
else:
|
||||
return JSONResponse(
|
||||
content=f"Provider {provider.value} doesn't support git organizations",
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
return {
|
||||
'provider': provider.value,
|
||||
'organizations': orgs,
|
||||
}
|
||||
|
||||
|
||||
@saas_user_router.get('/repositories', response_model=list[Repository])
|
||||
async def saas_get_user_repositories(
|
||||
sort: str = 'pushed',
|
||||
|
||||
171
enterprise/server/services/org_member_financial_service.py
Normal file
171
enterprise/server/services/org_member_financial_service.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""Service for managing organization member financial data."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from server.routes.org_models import (
|
||||
OrgMemberFinancialPage,
|
||||
OrgMemberFinancialResponse,
|
||||
)
|
||||
from storage.lite_llm_manager import LiteLlmManager
|
||||
from storage.org_member_store import OrgMemberStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
class OrgMemberFinancialService:
|
||||
"""Service for organization member financial data operations."""
|
||||
|
||||
@staticmethod
|
||||
async def get_org_members_financial_data(
|
||||
org_id: UUID,
|
||||
page_id: str | None = None,
|
||||
limit: int = 10,
|
||||
email_filter: str | None = None,
|
||||
) -> OrgMemberFinancialPage:
|
||||
"""Get paginated financial data for organization members.
|
||||
|
||||
Fetches member list from database and joins with financial data from LiteLLM.
|
||||
|
||||
Args:
|
||||
org_id: Organization UUID
|
||||
page_id: Offset encoded as string (e.g., "0", "10", "20")
|
||||
limit: Maximum items per page (default 10)
|
||||
email_filter: Optional case-insensitive partial email match
|
||||
|
||||
Returns:
|
||||
OrgMemberFinancialPage: Paginated response with financial data
|
||||
|
||||
Raises:
|
||||
ValueError: If page_id is invalid
|
||||
"""
|
||||
# Parse page_id to get offset
|
||||
offset = 0
|
||||
if page_id is not None:
|
||||
try:
|
||||
offset = int(page_id)
|
||||
if offset < 0:
|
||||
raise ValueError('page_id must be non-negative')
|
||||
except ValueError as e:
|
||||
raise ValueError(f'Invalid page_id: {page_id}') from e
|
||||
|
||||
# Fetch paginated members from database
|
||||
members, total_count = await OrgMemberStore.get_org_members_paginated(
|
||||
org_id=org_id,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
email_filter=email_filter,
|
||||
)
|
||||
|
||||
if not members:
|
||||
return OrgMemberFinancialPage(
|
||||
items=[],
|
||||
current_page=(offset // limit) + 1,
|
||||
per_page=limit,
|
||||
next_page_id=None,
|
||||
)
|
||||
|
||||
# Fetch financial data from LiteLLM for the entire team
|
||||
# This is a single API call that returns all team members' data
|
||||
try:
|
||||
financial_data = await LiteLlmManager.get_team_members_financial_data(
|
||||
str(org_id)
|
||||
)
|
||||
except httpx.HTTPStatusError as e:
|
||||
# Re-raise auth errors - these indicate configuration issues that need fixing
|
||||
if e.response.status_code in (401, 403):
|
||||
logger.error(
|
||||
'LiteLLM authentication/authorization failed',
|
||||
extra={
|
||||
'org_id': str(org_id),
|
||||
'status_code': e.response.status_code,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
raise
|
||||
# For other HTTP errors (404, 500, etc.), use graceful degradation
|
||||
logger.warning(
|
||||
'Failed to fetch financial data from LiteLLM',
|
||||
extra={
|
||||
'org_id': str(org_id),
|
||||
'status_code': e.response.status_code,
|
||||
'error_type': type(e).__name__,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
financial_data = {}
|
||||
except Exception as e:
|
||||
# For network errors, timeouts, etc., use graceful degradation
|
||||
logger.warning(
|
||||
'Failed to fetch financial data from LiteLLM',
|
||||
extra={
|
||||
'org_id': str(org_id),
|
||||
'error_type': type(e).__name__,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
financial_data = {}
|
||||
|
||||
# Extract team-level data for shared budget calculation
|
||||
team_spend = financial_data.get('team_spend', 0) or 0
|
||||
members_financial = financial_data.get('members', {})
|
||||
|
||||
# Build response items by joining DB members with LiteLLM financial data
|
||||
items: list[OrgMemberFinancialResponse] = []
|
||||
for member in members:
|
||||
user = member.user
|
||||
user_id_str = str(member.user_id)
|
||||
|
||||
# Get financial data for this user (or defaults if not found)
|
||||
user_financial = members_financial.get(user_id_str, {})
|
||||
individual_spend = user_financial.get('spend', 0) or 0
|
||||
max_budget = user_financial.get('max_budget')
|
||||
uses_shared_budget = user_financial.get('uses_shared_budget', False)
|
||||
|
||||
# Calculate current budget (remaining)
|
||||
# For shared team budgets, use team_spend to calculate remaining budget
|
||||
# This ensures all members see the same remaining budget
|
||||
if max_budget is not None:
|
||||
if uses_shared_budget:
|
||||
# Shared budget - use team's total spend
|
||||
current_budget = max(max_budget - team_spend, 0)
|
||||
else:
|
||||
# Individual budget - use individual spend
|
||||
current_budget = max(max_budget - individual_spend, 0)
|
||||
else:
|
||||
# If no max_budget, current_budget is unlimited (represented as 0)
|
||||
current_budget = 0
|
||||
|
||||
items.append(
|
||||
OrgMemberFinancialResponse(
|
||||
user_id=user_id_str,
|
||||
email=user.email if user else None,
|
||||
lifetime_spend=individual_spend,
|
||||
current_budget=current_budget,
|
||||
max_budget=max_budget,
|
||||
)
|
||||
)
|
||||
|
||||
# Calculate current page (1-indexed)
|
||||
current_page = (offset // limit) + 1
|
||||
|
||||
# Calculate next_page_id
|
||||
next_offset = offset + limit
|
||||
next_page_id = str(next_offset) if next_offset < total_count else None
|
||||
|
||||
logger.debug(
|
||||
'OrgMemberFinancialService:get_org_members_financial_data:success',
|
||||
extra={
|
||||
'org_id': str(org_id),
|
||||
'items_count': len(items),
|
||||
'current_page': current_page,
|
||||
'total_count': total_count,
|
||||
},
|
||||
)
|
||||
|
||||
return OrgMemberFinancialPage(
|
||||
items=items,
|
||||
current_page=current_page,
|
||||
per_page=limit,
|
||||
next_page_id=next_page_id,
|
||||
)
|
||||
@@ -29,7 +29,10 @@ def get_cookie_domain() -> str | None:
|
||||
|
||||
|
||||
def get_cookie_samesite() -> Literal['lax', 'strict']:
|
||||
# for localhost and feature/staging stacks we set it to 'lax' as the cookie domain won't allow 'strict'
|
||||
# Use 'strict' in production for maximum CSRF protection
|
||||
# Use 'lax' for local development and staging environments
|
||||
# Note: For invitation links from emails, the frontend handles acceptance via
|
||||
# an authenticated POST request (same-origin), which works with 'strict' cookies
|
||||
web_url = get_global_config().web_url
|
||||
return (
|
||||
'strict'
|
||||
|
||||
@@ -17,7 +17,7 @@ from server.verified_models.verified_model_service import (
|
||||
|
||||
from openhands.app_server.config import get_db_session
|
||||
from openhands.server.routes import public
|
||||
from openhands.utils.llm import get_supported_llm_models
|
||||
from openhands.utils.llm import ModelsResponse, get_supported_llm_models
|
||||
|
||||
api_router = APIRouter(prefix='/api/admin/verified-models', tags=['Verified Models'])
|
||||
|
||||
@@ -117,7 +117,7 @@ async def delete_verified_model(
|
||||
)
|
||||
|
||||
|
||||
async def get_saas_llm_models_dependency(request: Request) -> list[str]:
|
||||
async def get_saas_llm_models_dependency(request: Request) -> ModelsResponse:
|
||||
"""SaaS implementation for the LLM models endpoint."""
|
||||
async with get_db_session(request.state, request) as db_session:
|
||||
# Prevent circular import
|
||||
|
||||
@@ -19,6 +19,7 @@ from storage.linear_workspace import LinearWorkspace
|
||||
from storage.maintenance_task import MaintenanceTask, MaintenanceTaskStatus
|
||||
from storage.openhands_pr import OpenhandsPR
|
||||
from storage.org import Org
|
||||
from storage.org_git_claim import OrgGitClaim
|
||||
from storage.org_invitation import OrgInvitation
|
||||
from storage.org_member import OrgMember
|
||||
from storage.proactive_convos import ProactiveConversation
|
||||
@@ -65,6 +66,7 @@ __all__ = [
|
||||
'MaintenanceTaskStatus',
|
||||
'OpenhandsPR',
|
||||
'Org',
|
||||
'OrgGitClaim',
|
||||
'OrgInvitation',
|
||||
'OrgMember',
|
||||
'ProactiveConversation',
|
||||
|
||||
@@ -1524,6 +1524,83 @@ class LiteLlmManager:
|
||||
'LiteLlmManager:_delete_key:key_deleted',
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _get_team_members_financial_data(
|
||||
client: httpx.AsyncClient,
|
||||
team_id: str,
|
||||
) -> dict:
|
||||
"""
|
||||
Get financial data for all members in a team.
|
||||
|
||||
Fetches team info from LiteLLM and extracts spending/budget data for each member.
|
||||
|
||||
Args:
|
||||
client: HTTP client for LiteLLM API
|
||||
team_id: The team/organization ID
|
||||
|
||||
Returns:
|
||||
Dict with structure:
|
||||
{
|
||||
"team_max_budget": float | None, # Team's shared budget
|
||||
"team_spend": float, # Team's total spend (for shared budget calc)
|
||||
"members": {
|
||||
user_id: {
|
||||
"spend": float,
|
||||
"max_budget": float | None,
|
||||
"uses_shared_budget": bool # True if using team budget
|
||||
},
|
||||
...
|
||||
}
|
||||
}
|
||||
Returns empty dict if team not found or LiteLLM is not configured.
|
||||
"""
|
||||
if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None:
|
||||
logger.warning('LiteLLM API configuration not found')
|
||||
return {}
|
||||
|
||||
team_info = await LiteLlmManager._get_team(client, team_id)
|
||||
if not team_info:
|
||||
logger.warning(
|
||||
'LiteLlmManager:_get_team_members_financial_data:team_not_found',
|
||||
extra={'team_id': team_id},
|
||||
)
|
||||
return {}
|
||||
|
||||
members: dict[str, dict] = {}
|
||||
team_memberships = team_info.get('team_memberships', [])
|
||||
|
||||
# Get team-level budget info (shared across all members in team orgs)
|
||||
team_data = team_info.get('team_info', {})
|
||||
team_max_budget = team_data.get('max_budget')
|
||||
team_spend = team_data.get('spend', 0) or 0
|
||||
|
||||
for membership in team_memberships:
|
||||
user_id = membership.get('user_id')
|
||||
if not user_id:
|
||||
continue
|
||||
|
||||
# Use individual max_budget_in_team if set, otherwise fall back to team budget
|
||||
member_max_budget = membership.get('max_budget_in_team')
|
||||
uses_shared_budget = member_max_budget is None
|
||||
if uses_shared_budget:
|
||||
member_max_budget = team_max_budget
|
||||
|
||||
members[user_id] = {
|
||||
'spend': membership.get('spend', 0) or 0,
|
||||
'max_budget': member_max_budget,
|
||||
'uses_shared_budget': uses_shared_budget,
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
'LiteLlmManager:_get_team_members_financial_data:success',
|
||||
extra={'team_id': team_id, 'member_count': len(members)},
|
||||
)
|
||||
return {
|
||||
'team_max_budget': team_max_budget,
|
||||
'team_spend': team_spend,
|
||||
'members': members,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def with_http_client(
|
||||
internal_fn: Callable[..., Awaitable[Any]],
|
||||
@@ -1559,3 +1636,6 @@ class LiteLlmManager:
|
||||
get_user_keys = staticmethod(with_http_client(_get_user_keys))
|
||||
delete_key_by_alias = staticmethod(with_http_client(_delete_key_by_alias))
|
||||
update_user_keys = staticmethod(with_http_client(_update_user_keys))
|
||||
get_team_members_financial_data = staticmethod(
|
||||
with_http_client(_get_team_members_financial_data)
|
||||
)
|
||||
|
||||
@@ -64,6 +64,7 @@ class Org(Base): # type: ignore
|
||||
slack_conversations = relationship('SlackConversation', back_populates='org')
|
||||
slack_users = relationship('SlackUser', back_populates='org')
|
||||
stripe_customers = relationship('StripeCustomer', back_populates='org')
|
||||
git_claims = relationship('OrgGitClaim', back_populates='org')
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
# Handle known SQLAlchemy columns directly
|
||||
|
||||
30
enterprise/storage/org_git_claim.py
Normal file
30
enterprise/storage/org_git_claim.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
SQLAlchemy model for Git Organization Claims.
|
||||
"""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import UUID, Column, DateTime, ForeignKey, String, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
from storage.base import Base
|
||||
|
||||
|
||||
class OrgGitClaim(Base): # type: ignore
|
||||
"""Model for tracking which OpenHands org has claimed a Git organization."""
|
||||
|
||||
__tablename__ = 'org_git_claim'
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4)
|
||||
org_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey('org.id', ondelete='CASCADE'), nullable=False
|
||||
)
|
||||
provider = Column(String, nullable=False)
|
||||
git_organization = Column(String, nullable=False)
|
||||
claimed_by = Column(UUID(as_uuid=True), ForeignKey('user.id'), nullable=False)
|
||||
claimed_at = Column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('provider', 'git_organization', name='uq_provider_git_org'),
|
||||
)
|
||||
|
||||
org = relationship('Org', back_populates='git_claims')
|
||||
141
enterprise/storage/org_git_claim_store.py
Normal file
141
enterprise/storage/org_git_claim_store.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
Store class for managing Git organization claims.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import and_, select
|
||||
from storage.database import a_session_maker
|
||||
from storage.org_git_claim import OrgGitClaim
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
class OrgGitClaimStore:
|
||||
"""Store for managing Git organization claims."""
|
||||
|
||||
@staticmethod
|
||||
async def create_claim(
|
||||
org_id: UUID,
|
||||
provider: str,
|
||||
git_organization: str,
|
||||
claimed_by: UUID,
|
||||
) -> OrgGitClaim:
|
||||
"""Create a new Git organization claim.
|
||||
|
||||
Args:
|
||||
org_id: OpenHands organization UUID
|
||||
provider: Git provider ('github', 'gitlab', 'bitbucket')
|
||||
git_organization: Name of the Git organization being claimed
|
||||
claimed_by: User UUID who is making the claim
|
||||
|
||||
Returns:
|
||||
OrgGitClaim: The created claim record
|
||||
"""
|
||||
async with a_session_maker() as session:
|
||||
claim = OrgGitClaim(
|
||||
org_id=org_id,
|
||||
provider=provider,
|
||||
git_organization=git_organization,
|
||||
claimed_by=claimed_by,
|
||||
claimed_at=datetime.now(timezone.utc),
|
||||
)
|
||||
session.add(claim)
|
||||
await session.commit()
|
||||
await session.refresh(claim)
|
||||
|
||||
logger.info(
|
||||
'Created Git organization claim',
|
||||
extra={
|
||||
'claim_id': str(claim.id),
|
||||
'org_id': str(org_id),
|
||||
'provider': provider,
|
||||
'git_organization': git_organization,
|
||||
'claimed_by': str(claimed_by),
|
||||
},
|
||||
)
|
||||
|
||||
return claim
|
||||
|
||||
@staticmethod
|
||||
async def get_claim_by_provider_and_git_org(
|
||||
provider: str,
|
||||
git_organization: str,
|
||||
) -> Optional[OrgGitClaim]:
|
||||
"""Check if a Git organization is already claimed.
|
||||
|
||||
Args:
|
||||
provider: Git provider name
|
||||
git_organization: Name of the Git organization
|
||||
|
||||
Returns:
|
||||
OrgGitClaim or None if not claimed
|
||||
"""
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(OrgGitClaim).filter(
|
||||
and_(
|
||||
OrgGitClaim.provider == provider,
|
||||
OrgGitClaim.git_organization == git_organization,
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
@staticmethod
|
||||
async def get_claims_by_org_id(org_id: UUID) -> list[OrgGitClaim]:
|
||||
"""Get all Git organization claims for an OpenHands organization.
|
||||
|
||||
Args:
|
||||
org_id: OpenHands organization UUID
|
||||
|
||||
Returns:
|
||||
List of OrgGitClaim records
|
||||
"""
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(OrgGitClaim).filter(OrgGitClaim.org_id == org_id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@staticmethod
|
||||
async def delete_claim(claim_id: UUID, org_id: UUID) -> bool:
|
||||
"""Delete a Git organization claim.
|
||||
|
||||
Args:
|
||||
claim_id: Claim UUID to delete
|
||||
org_id: OpenHands organization UUID (for ownership verification)
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(OrgGitClaim).filter(
|
||||
and_(
|
||||
OrgGitClaim.id == claim_id,
|
||||
OrgGitClaim.org_id == org_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
claim = result.scalars().first()
|
||||
|
||||
if not claim:
|
||||
return False
|
||||
|
||||
await session.delete(claim)
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
'Deleted Git organization claim',
|
||||
extra={
|
||||
'claim_id': str(claim_id),
|
||||
'org_id': str(org_id),
|
||||
'provider': claim.provider,
|
||||
'git_organization': claim.git_organization,
|
||||
},
|
||||
)
|
||||
|
||||
return True
|
||||
@@ -3,7 +3,7 @@ SQLAlchemy model for Organization-Member relationship.
|
||||
"""
|
||||
|
||||
from pydantic import SecretStr
|
||||
from sqlalchemy import UUID, Column, ForeignKey, Integer, String
|
||||
from sqlalchemy import JSON, UUID, Column, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import relationship
|
||||
from storage.base import Base
|
||||
from storage.encrypt_utils import decrypt_value, encrypt_value
|
||||
@@ -23,6 +23,7 @@ class OrgMember(Base): # type: ignore
|
||||
_llm_api_key_for_byor = Column(String, nullable=True)
|
||||
llm_base_url = Column(String, nullable=True)
|
||||
status = Column(String, nullable=True)
|
||||
mcp_config = Column(JSON, nullable=True)
|
||||
|
||||
# Relationships
|
||||
org = relationship('Org', back_populates='org_members')
|
||||
|
||||
@@ -115,6 +115,9 @@ class SaasSettingsStore(SettingsStore):
|
||||
kwargs['llm_api_key_for_byor'] = org_member.llm_api_key_for_byor
|
||||
if org_member.llm_base_url:
|
||||
kwargs['llm_base_url'] = org_member.llm_base_url
|
||||
# MCP config is user-specific (stored on org_member, not org)
|
||||
if org_member.mcp_config is not None:
|
||||
kwargs['mcp_config'] = org_member.mcp_config
|
||||
if org.v1_enabled is None:
|
||||
kwargs['v1_enabled'] = True
|
||||
# Apply default if sandbox_grouping_strategy is None in the database
|
||||
@@ -187,6 +190,9 @@ class SaasSettingsStore(SettingsStore):
|
||||
kwargs = item.model_dump(context={'expose_secrets': True})
|
||||
for model in (user, org, org_member):
|
||||
for key, value in kwargs.items():
|
||||
# Skip mcp_config for org - it should only be stored on org_member (user-specific)
|
||||
if key == 'mcp_config' and model is org:
|
||||
continue
|
||||
if hasattr(model, key):
|
||||
setattr(model, key, value)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ SQLAlchemy model for User.
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
UUID,
|
||||
Boolean,
|
||||
Column,
|
||||
@@ -34,6 +35,7 @@ class User(Base): # type: ignore
|
||||
git_user_name = Column(String, nullable=True)
|
||||
git_user_email = Column(String, nullable=True)
|
||||
sandbox_grouping_strategy = Column(String, nullable=True)
|
||||
disabled_skills = Column(JSON, nullable=True)
|
||||
|
||||
# Relationships
|
||||
role = relationship('Role', back_populates='users')
|
||||
|
||||
@@ -13,7 +13,6 @@ Required environment variables:
|
||||
- RESEND_AUDIENCE_ID: ID of the Resend audience to add users to
|
||||
|
||||
Optional environment variables:
|
||||
- KEYCLOAK_PROVIDER_NAME: Provider name for Keycloak
|
||||
- KEYCLOAK_CLIENT_ID: Client ID for Keycloak
|
||||
- KEYCLOAK_CLIENT_SECRET: Client secret for Keycloak
|
||||
- RESEND_FROM_EMAIL: Email address to use as the sender (default: "OpenHands Team <no-reply@welcome.openhands.dev>")
|
||||
@@ -49,7 +48,6 @@ from openhands.core.logger import openhands_logger as logger
|
||||
# Get Keycloak configuration from environment variables
|
||||
KEYCLOAK_SERVER_URL = os.environ.get('KEYCLOAK_SERVER_URL', '')
|
||||
KEYCLOAK_REALM_NAME = os.environ.get('KEYCLOAK_REALM_NAME', '')
|
||||
KEYCLOAK_PROVIDER_NAME = os.environ.get('KEYCLOAK_PROVIDER_NAME', '')
|
||||
KEYCLOAK_CLIENT_ID = os.environ.get('KEYCLOAK_CLIENT_ID', '')
|
||||
KEYCLOAK_CLIENT_SECRET = os.environ.get('KEYCLOAK_CLIENT_SECRET', '')
|
||||
KEYCLOAK_ADMIN_PASSWORD = os.environ.get('KEYCLOAK_ADMIN_PASSWORD', '')
|
||||
|
||||
@@ -25,6 +25,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_git_claim import OrgGitClaim # noqa: F401
|
||||
from storage.org_invitation import OrgInvitation # noqa: F401
|
||||
from storage.org_member import OrgMember
|
||||
from storage.role import Role
|
||||
|
||||
603
enterprise/tests/unit/server/routes/test_org_git_claims.py
Normal file
603
enterprise/tests/unit/server/routes/test_org_git_claims.py
Normal file
@@ -0,0 +1,603 @@
|
||||
"""Tests for Git organization claim API endpoints.
|
||||
|
||||
Tests the following endpoints:
|
||||
- GET /api/organizations/{org_id}/git-claims (list claims)
|
||||
- POST /api/organizations/{org_id}/git-claims (claim)
|
||||
- DELETE /api/organizations/{org_id}/git-claims/{claim_id} (disconnect)
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, status
|
||||
from fastapi.testclient import TestClient
|
||||
from server.routes.orgs import (
|
||||
claim_git_organization,
|
||||
disconnect_git_organization,
|
||||
get_git_claims,
|
||||
org_router,
|
||||
)
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from storage.org_git_claim import OrgGitClaim
|
||||
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
TEST_USER_ID = str(uuid.uuid4())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def org_id():
|
||||
return uuid.uuid4()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_id():
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_claim():
|
||||
"""Factory to create mock OrgGitClaim objects."""
|
||||
|
||||
def _make(org_id, provider='github', git_organization='OpenHands', claimed_by=None):
|
||||
claim = MagicMock(spec=OrgGitClaim)
|
||||
claim.id = uuid.uuid4()
|
||||
claim.org_id = org_id
|
||||
claim.provider = provider
|
||||
claim.git_organization = git_organization
|
||||
claim.claimed_by = claimed_by or uuid.uuid4()
|
||||
claim.claimed_at = datetime(2026, 4, 1, 12, 0, 0)
|
||||
return claim
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GET /api/organizations/{org_id}/git-claims
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestGetGitClaims:
|
||||
"""Tests for the get Git organization claims endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_empty_list_when_no_claims(self, org_id, user_id):
|
||||
"""
|
||||
GIVEN: An organization with no Git claims
|
||||
WHEN: GET /api/organizations/{org_id}/git-claims is called
|
||||
THEN: An empty list is returned
|
||||
"""
|
||||
with patch(
|
||||
'server.routes.orgs.OrgGitClaimStore.get_claims_by_org_id',
|
||||
AsyncMock(return_value=[]),
|
||||
) as mock_get:
|
||||
result = await get_git_claims(org_id=org_id, user_id=user_id)
|
||||
|
||||
assert result == []
|
||||
mock_get.assert_called_once_with(org_id=org_id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_claims_for_organization(self, org_id, user_id, make_claim):
|
||||
"""
|
||||
GIVEN: An organization with multiple Git claims
|
||||
WHEN: GET /api/organizations/{org_id}/git-claims is called
|
||||
THEN: All claims are returned with correct details
|
||||
"""
|
||||
claim1 = make_claim(org_id, provider='github', git_organization='OpenHands')
|
||||
claim2 = make_claim(org_id, provider='gitlab', git_organization='AcmeCo')
|
||||
|
||||
with patch(
|
||||
'server.routes.orgs.OrgGitClaimStore.get_claims_by_org_id',
|
||||
AsyncMock(return_value=[claim1, claim2]),
|
||||
):
|
||||
result = await get_git_claims(org_id=org_id, user_id=user_id)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0].id == str(claim1.id)
|
||||
assert result[0].org_id == str(org_id)
|
||||
assert result[0].provider == 'github'
|
||||
assert result[0].git_organization == 'OpenHands'
|
||||
assert result[0].claimed_by == str(claim1.claimed_by)
|
||||
assert result[0].claimed_at == '2026-04-01T12:00:00'
|
||||
assert result[1].id == str(claim2.id)
|
||||
assert result[1].provider == 'gitlab'
|
||||
assert result[1].git_organization == 'AcmeCo'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_500_on_unexpected_error(self, org_id, user_id):
|
||||
"""
|
||||
GIVEN: An unexpected error occurs when fetching claims
|
||||
WHEN: GET /api/organizations/{org_id}/git-claims is called
|
||||
THEN: A 500 Internal Server Error is returned
|
||||
"""
|
||||
with patch(
|
||||
'server.routes.orgs.OrgGitClaimStore.get_claims_by_org_id',
|
||||
AsyncMock(side_effect=RuntimeError('db connection failed')),
|
||||
):
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await get_git_claims(org_id=org_id, user_id=user_id)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# POST /api/organizations/{org_id}/git-claims
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestClaimGitOrganization:
|
||||
"""Tests for the claim Git organization endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_succeeds_for_unclaimed_org(self, org_id, user_id, make_claim):
|
||||
"""
|
||||
GIVEN: A Git organization that has not been claimed
|
||||
WHEN: POST /api/organizations/{org_id}/git-claims is called
|
||||
THEN: The claim is created and returned with correct details
|
||||
"""
|
||||
# Arrange
|
||||
mock_claim = make_claim(org_id, claimed_by=uuid.UUID(user_id))
|
||||
request = MagicMock()
|
||||
request.provider = 'github'
|
||||
request.git_organization = 'OpenHands'
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.routes.orgs.OrgGitClaimStore.get_claim_by_provider_and_git_org',
|
||||
AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
'server.routes.orgs.OrgGitClaimStore.create_claim',
|
||||
AsyncMock(return_value=mock_claim),
|
||||
) as mock_create,
|
||||
):
|
||||
# Act
|
||||
response = await claim_git_organization(
|
||||
org_id=org_id, request=request, user_id=user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.id == str(mock_claim.id)
|
||||
assert response.org_id == str(org_id)
|
||||
assert response.provider == 'github'
|
||||
assert response.git_organization == 'OpenHands'
|
||||
assert response.claimed_by == user_id
|
||||
mock_create.assert_called_once_with(
|
||||
org_id=org_id,
|
||||
provider='github',
|
||||
git_organization='OpenHands',
|
||||
claimed_by=uuid.UUID(user_id),
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_fails_when_already_claimed(self, org_id, user_id, make_claim):
|
||||
"""
|
||||
GIVEN: A Git organization already claimed by another OpenHands org
|
||||
WHEN: POST /api/organizations/{org_id}/git-claims is called
|
||||
THEN: A 409 Conflict error is returned
|
||||
"""
|
||||
# Arrange
|
||||
other_org_id = uuid.uuid4()
|
||||
existing_claim = make_claim(
|
||||
other_org_id, provider='github', git_organization='AlreadyClaimed'
|
||||
)
|
||||
request = MagicMock()
|
||||
request.provider = 'github'
|
||||
request.git_organization = 'AlreadyClaimed'
|
||||
|
||||
with patch(
|
||||
'server.routes.orgs.OrgGitClaimStore.get_claim_by_provider_and_git_org',
|
||||
AsyncMock(return_value=existing_claim),
|
||||
):
|
||||
# Act & Assert
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await claim_git_organization(
|
||||
org_id=org_id, request=request, user_id=user_id
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_409_CONFLICT
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_returns_500_on_unexpected_error(self, org_id, user_id):
|
||||
"""
|
||||
GIVEN: An unexpected error occurs during claim creation
|
||||
WHEN: POST /api/organizations/{org_id}/git-claims is called
|
||||
THEN: A 500 Internal Server Error is returned
|
||||
"""
|
||||
# Arrange
|
||||
request = MagicMock()
|
||||
request.provider = 'github'
|
||||
request.git_organization = 'OpenHands'
|
||||
|
||||
with patch(
|
||||
'server.routes.orgs.OrgGitClaimStore.get_claim_by_provider_and_git_org',
|
||||
AsyncMock(side_effect=RuntimeError('db connection failed')),
|
||||
):
|
||||
# Act & Assert
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await claim_git_organization(
|
||||
org_id=org_id, request=request, user_id=user_id
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_race_condition_returns_409(self, org_id, user_id):
|
||||
"""
|
||||
GIVEN: Pre-check passes but a concurrent request claims the org first
|
||||
WHEN: create_claim raises IntegrityError (DB unique constraint)
|
||||
THEN: A 409 Conflict error is returned instead of 500
|
||||
"""
|
||||
# Arrange
|
||||
request = MagicMock()
|
||||
request.provider = 'github'
|
||||
request.git_organization = 'RaceOrg'
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.routes.orgs.OrgGitClaimStore.get_claim_by_provider_and_git_org',
|
||||
AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
'server.routes.orgs.OrgGitClaimStore.create_claim',
|
||||
AsyncMock(
|
||||
side_effect=IntegrityError(
|
||||
'duplicate',
|
||||
'',
|
||||
Exception('uq_provider_git_org'),
|
||||
)
|
||||
),
|
||||
),
|
||||
):
|
||||
# Act & Assert
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await claim_git_organization(
|
||||
org_id=org_id, request=request, user_id=user_id
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_409_CONFLICT
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DELETE /api/organizations/{org_id}/git-claims/{claim_id}
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestDisconnectGitOrganization:
|
||||
"""Tests for the disconnect Git organization endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_succeeds_for_existing_claim(self, org_id, user_id):
|
||||
"""
|
||||
GIVEN: A valid claim belonging to the organization
|
||||
WHEN: DELETE /api/organizations/{org_id}/git-claims/{claim_id} is called
|
||||
THEN: The claim is deleted and a success message is returned
|
||||
"""
|
||||
# Arrange
|
||||
claim_id = uuid.uuid4()
|
||||
|
||||
with patch(
|
||||
'server.routes.orgs.OrgGitClaimStore.delete_claim',
|
||||
AsyncMock(return_value=True),
|
||||
) as mock_delete:
|
||||
# Act
|
||||
result = await disconnect_git_organization(
|
||||
org_id=org_id, claim_id=claim_id, user_id=user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == {'message': 'Git organization claim removed successfully'}
|
||||
mock_delete.assert_called_once_with(claim_id=claim_id, org_id=org_id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_fails_when_claim_not_found(self, org_id, user_id):
|
||||
"""
|
||||
GIVEN: A claim_id that does not exist for this organization
|
||||
WHEN: DELETE /api/organizations/{org_id}/git-claims/{claim_id} is called
|
||||
THEN: A 404 Not Found error is returned
|
||||
"""
|
||||
# Arrange
|
||||
claim_id = uuid.uuid4()
|
||||
|
||||
with patch(
|
||||
'server.routes.orgs.OrgGitClaimStore.delete_claim',
|
||||
AsyncMock(return_value=False),
|
||||
):
|
||||
# Act & Assert
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await disconnect_git_organization(
|
||||
org_id=org_id, claim_id=claim_id, user_id=user_id
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_returns_500_on_unexpected_error(self, org_id, user_id):
|
||||
"""
|
||||
GIVEN: An unexpected error occurs during claim deletion
|
||||
WHEN: DELETE /api/organizations/{org_id}/git-claims/{claim_id} is called
|
||||
THEN: A 500 Internal Server Error is returned
|
||||
"""
|
||||
# Arrange
|
||||
claim_id = uuid.uuid4()
|
||||
|
||||
with patch(
|
||||
'server.routes.orgs.OrgGitClaimStore.delete_claim',
|
||||
AsyncMock(side_effect=RuntimeError('db connection failed')),
|
||||
):
|
||||
# Act & Assert
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await disconnect_git_organization(
|
||||
org_id=org_id, claim_id=claim_id, user_id=user_id
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Validation tests for GitOrgClaimRequest
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestGitOrgClaimRequestValidation:
|
||||
"""Tests for request model validation."""
|
||||
|
||||
def test_valid_providers_are_accepted(self):
|
||||
"""Each supported provider is accepted and normalized to lowercase."""
|
||||
from server.routes.org_models import GitOrgClaimRequest
|
||||
|
||||
for provider in ['github', 'GitLab', 'BITBUCKET']:
|
||||
req = GitOrgClaimRequest(provider=provider, git_organization='test-org')
|
||||
assert req.provider == provider.lower().strip()
|
||||
|
||||
def test_invalid_provider_is_rejected(self):
|
||||
"""An unsupported provider raises a validation error."""
|
||||
from pydantic import ValidationError
|
||||
from server.routes.org_models import GitOrgClaimRequest
|
||||
|
||||
with pytest.raises(ValidationError, match='Invalid provider'):
|
||||
GitOrgClaimRequest(provider='azure_devops', git_organization='test-org')
|
||||
|
||||
def test_empty_git_organization_is_rejected(self):
|
||||
"""An empty git_organization raises a validation error."""
|
||||
from pydantic import ValidationError
|
||||
from server.routes.org_models import GitOrgClaimRequest
|
||||
|
||||
with pytest.raises(ValidationError, match='git_organization must not be empty'):
|
||||
GitOrgClaimRequest(provider='github', git_organization=' ')
|
||||
|
||||
def test_git_organization_is_normalized_to_lowercase(self):
|
||||
"""git_organization is lowercased to prevent case-sensitive duplicates."""
|
||||
from server.routes.org_models import GitOrgClaimRequest
|
||||
|
||||
req = GitOrgClaimRequest(provider='github', git_organization='OpenHands')
|
||||
assert req.git_organization == 'openhands'
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Integration tests — TestClient with real HTTP, auth, and Pydantic validation
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app():
|
||||
"""FastAPI app with org routes and mocked user authentication."""
|
||||
app = FastAPI()
|
||||
app.include_router(org_router)
|
||||
|
||||
app.dependency_overrides[get_user_id] = lambda: TEST_USER_ID
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_owner_role():
|
||||
role = MagicMock()
|
||||
role.name = 'owner'
|
||||
return role
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_member_role():
|
||||
role = MagicMock()
|
||||
role.name = 'member'
|
||||
return role
|
||||
|
||||
|
||||
class TestGitClaimsAuthorization:
|
||||
"""Integration tests verifying authorization through the real HTTP cycle."""
|
||||
|
||||
def test_non_member_gets_403_on_get(self, mock_app):
|
||||
"""
|
||||
GIVEN: A user who is not a member of the target organization
|
||||
WHEN: GET /api/organizations/{org_id}/git-claims via HTTP
|
||||
THEN: 403 is returned by require_permission
|
||||
"""
|
||||
org_id = uuid.uuid4()
|
||||
|
||||
with patch(
|
||||
'server.auth.authorization.get_user_org_role',
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
client = TestClient(mock_app)
|
||||
response = client.get(f'/api/organizations/{org_id}/git-claims')
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert 'not a member' in response.json()['detail']
|
||||
|
||||
def test_member_without_permission_gets_403_on_post(
|
||||
self, mock_app, mock_member_role
|
||||
):
|
||||
"""
|
||||
GIVEN: A user with member role (lacks MANAGE_ORG_CLAIMS)
|
||||
WHEN: POST /api/organizations/{org_id}/git-claims via HTTP
|
||||
THEN: 403 is returned by require_permission
|
||||
"""
|
||||
org_id = uuid.uuid4()
|
||||
|
||||
with patch(
|
||||
'server.auth.authorization.get_user_org_role',
|
||||
AsyncMock(return_value=mock_member_role),
|
||||
):
|
||||
client = TestClient(mock_app)
|
||||
response = client.post(
|
||||
f'/api/organizations/{org_id}/git-claims',
|
||||
json={'provider': 'github', 'git_organization': 'SomeOrg'},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert 'manage_org_claims' in response.json()['detail']
|
||||
|
||||
def test_member_without_permission_gets_403_on_delete(
|
||||
self, mock_app, mock_member_role
|
||||
):
|
||||
"""
|
||||
GIVEN: A user with member role (lacks MANAGE_ORG_CLAIMS)
|
||||
WHEN: DELETE /api/organizations/{org_id}/git-claims/{claim_id} via HTTP
|
||||
THEN: 403 is returned by require_permission
|
||||
"""
|
||||
org_id = uuid.uuid4()
|
||||
claim_id = uuid.uuid4()
|
||||
|
||||
with patch(
|
||||
'server.auth.authorization.get_user_org_role',
|
||||
AsyncMock(return_value=mock_member_role),
|
||||
):
|
||||
client = TestClient(mock_app)
|
||||
response = client.delete(
|
||||
f'/api/organizations/{org_id}/git-claims/{claim_id}'
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert 'manage_org_claims' in response.json()['detail']
|
||||
|
||||
|
||||
class TestGitClaimsHTTPIntegration:
|
||||
"""Integration tests for the full request/response cycle via TestClient."""
|
||||
|
||||
def test_post_claim_with_invalid_provider_returns_422(
|
||||
self, mock_app, mock_owner_role
|
||||
):
|
||||
"""
|
||||
GIVEN: A request with an unsupported provider
|
||||
WHEN: POST /api/organizations/{org_id}/git-claims via HTTP
|
||||
THEN: 422 is returned by Pydantic validation
|
||||
"""
|
||||
org_id = uuid.uuid4()
|
||||
|
||||
with patch(
|
||||
'server.auth.authorization.get_user_org_role',
|
||||
AsyncMock(return_value=mock_owner_role),
|
||||
):
|
||||
client = TestClient(mock_app)
|
||||
response = client.post(
|
||||
f'/api/organizations/{org_id}/git-claims',
|
||||
json={'provider': 'azure_devops', 'git_organization': 'test'},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
def test_post_claim_success_returns_201(self, mock_app, mock_owner_role):
|
||||
"""
|
||||
GIVEN: A valid claim request by an authorized admin/owner
|
||||
WHEN: POST /api/organizations/{org_id}/git-claims via HTTP
|
||||
THEN: 201 is returned with the claim details
|
||||
"""
|
||||
org_id = uuid.uuid4()
|
||||
mock_claim = MagicMock(spec=OrgGitClaim)
|
||||
mock_claim.id = uuid.uuid4()
|
||||
mock_claim.org_id = org_id
|
||||
mock_claim.provider = 'github'
|
||||
mock_claim.git_organization = 'openhands'
|
||||
mock_claim.claimed_by = uuid.UUID(TEST_USER_ID)
|
||||
mock_claim.claimed_at = datetime(2026, 4, 1, 12, 0, 0)
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.auth.authorization.get_user_org_role',
|
||||
AsyncMock(return_value=mock_owner_role),
|
||||
),
|
||||
patch(
|
||||
'server.routes.orgs.OrgGitClaimStore.get_claim_by_provider_and_git_org',
|
||||
AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
'server.routes.orgs.OrgGitClaimStore.create_claim',
|
||||
AsyncMock(return_value=mock_claim),
|
||||
),
|
||||
):
|
||||
client = TestClient(mock_app)
|
||||
response = client.post(
|
||||
f'/api/organizations/{org_id}/git-claims',
|
||||
json={'provider': 'github', 'git_organization': 'OpenHands'},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
data = response.json()
|
||||
assert data['org_id'] == str(org_id)
|
||||
assert data['provider'] == 'github'
|
||||
assert data['git_organization'] == 'openhands'
|
||||
|
||||
def test_delete_claim_success_returns_200(self, mock_app, mock_owner_role):
|
||||
"""
|
||||
GIVEN: A valid disconnect request by an authorized admin/owner
|
||||
WHEN: DELETE /api/organizations/{org_id}/git-claims/{claim_id} via HTTP
|
||||
THEN: 200 is returned with a success message
|
||||
"""
|
||||
org_id = uuid.uuid4()
|
||||
claim_id = uuid.uuid4()
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.auth.authorization.get_user_org_role',
|
||||
AsyncMock(return_value=mock_owner_role),
|
||||
),
|
||||
patch(
|
||||
'server.routes.orgs.OrgGitClaimStore.delete_claim',
|
||||
AsyncMock(return_value=True),
|
||||
),
|
||||
):
|
||||
client = TestClient(mock_app)
|
||||
response = client.delete(
|
||||
f'/api/organizations/{org_id}/git-claims/{claim_id}'
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert (
|
||||
response.json()['message'] == 'Git organization claim removed successfully'
|
||||
)
|
||||
|
||||
def test_get_claims_success_returns_200(self, mock_app, mock_owner_role):
|
||||
"""
|
||||
GIVEN: An authorized user requests claims for their organization
|
||||
WHEN: GET /api/organizations/{org_id}/git-claims via HTTP
|
||||
THEN: 200 is returned with the list of claims
|
||||
"""
|
||||
org_id = uuid.uuid4()
|
||||
mock_claim = MagicMock(spec=OrgGitClaim)
|
||||
mock_claim.id = uuid.uuid4()
|
||||
mock_claim.org_id = org_id
|
||||
mock_claim.provider = 'github'
|
||||
mock_claim.git_organization = 'openhands'
|
||||
mock_claim.claimed_by = uuid.UUID(TEST_USER_ID)
|
||||
mock_claim.claimed_at = datetime(2026, 4, 1, 12, 0, 0)
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.auth.authorization.get_user_org_role',
|
||||
AsyncMock(return_value=mock_owner_role),
|
||||
),
|
||||
patch(
|
||||
'server.routes.orgs.OrgGitClaimStore.get_claims_by_org_id',
|
||||
AsyncMock(return_value=[mock_claim]),
|
||||
),
|
||||
):
|
||||
client = TestClient(mock_app)
|
||||
response = client.get(f'/api/organizations/{org_id}/git-claims')
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]['provider'] == 'github'
|
||||
assert data[0]['git_organization'] == 'openhands'
|
||||
@@ -0,0 +1,420 @@
|
||||
"""Tests for OrgMemberFinancialService."""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from server.routes.org_models import OrgMemberFinancialPage
|
||||
from server.services.org_member_financial_service import OrgMemberFinancialService
|
||||
from storage.org_member import OrgMember
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def org_id():
|
||||
"""Create a test organization ID."""
|
||||
return uuid.uuid4()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user():
|
||||
"""Create a mock user."""
|
||||
user = MagicMock()
|
||||
user.email = 'test@example.com'
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_role():
|
||||
"""Create a mock role."""
|
||||
role = MagicMock()
|
||||
role.id = 1
|
||||
role.name = 'member'
|
||||
role.rank = 1000
|
||||
return role
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_org_member(org_id, mock_user, mock_role):
|
||||
"""Create a mock org member with user and role."""
|
||||
member = MagicMock(spec=OrgMember)
|
||||
member.org_id = org_id
|
||||
member.user_id = uuid.uuid4()
|
||||
member.role_id = mock_role.id
|
||||
member.status = 'active'
|
||||
member.user = mock_user
|
||||
member.role = mock_role
|
||||
return member
|
||||
|
||||
|
||||
class TestOrgMemberFinancialServiceGetFinancialData:
|
||||
"""Test cases for OrgMemberFinancialService.get_org_members_financial_data."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_paginated_financial_data_with_individual_budget(
|
||||
self, org_id, mock_org_member
|
||||
):
|
||||
"""
|
||||
GIVEN: Organization with members having individual budget limits
|
||||
WHEN: get_org_members_financial_data is called
|
||||
THEN: Returns financial data using individual spend for current_budget calc
|
||||
"""
|
||||
# Arrange
|
||||
user_id_str = str(mock_org_member.user_id)
|
||||
litellm_data = {
|
||||
'team_max_budget': 1000.0,
|
||||
'team_spend': 200.0,
|
||||
'members': {
|
||||
user_id_str: {'spend': 125.50, 'max_budget': 500.0} # Individual budget
|
||||
},
|
||||
}
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.services.org_member_financial_service.OrgMemberStore.get_org_members_paginated',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_paginated,
|
||||
patch(
|
||||
'server.services.org_member_financial_service.LiteLlmManager.get_team_members_financial_data',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_financial,
|
||||
):
|
||||
mock_get_paginated.return_value = ([mock_org_member], 1)
|
||||
mock_get_financial.return_value = litellm_data
|
||||
|
||||
# Act
|
||||
result = await OrgMemberFinancialService.get_org_members_financial_data(
|
||||
org_id=org_id,
|
||||
page_id=None,
|
||||
limit=10,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, OrgMemberFinancialPage)
|
||||
assert len(result.items) == 1
|
||||
assert result.items[0].user_id == user_id_str
|
||||
assert result.items[0].email == 'test@example.com'
|
||||
assert result.items[0].lifetime_spend == 125.50
|
||||
assert result.items[0].max_budget == 500.0
|
||||
# Individual budget: 500 - 125.50 = 374.50
|
||||
assert result.items[0].current_budget == 374.50
|
||||
assert result.current_page == 1
|
||||
assert result.per_page == 10
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_shared_budget_using_team_spend(
|
||||
self, org_id, mock_org_member
|
||||
):
|
||||
"""
|
||||
GIVEN: Organization with shared team budget
|
||||
WHEN: get_org_members_financial_data is called
|
||||
THEN: Uses team_spend (not individual spend) for current_budget calculation
|
||||
"""
|
||||
# Arrange
|
||||
user_id_str = str(mock_org_member.user_id)
|
||||
litellm_data = {
|
||||
'team_max_budget': 500.0,
|
||||
'team_spend': 150.0, # Total team spend
|
||||
'members': {
|
||||
user_id_str: {
|
||||
'spend': 50.0,
|
||||
'max_budget': 500.0,
|
||||
'uses_shared_budget': True, # Explicitly using shared budget
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.services.org_member_financial_service.OrgMemberStore.get_org_members_paginated',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_paginated,
|
||||
patch(
|
||||
'server.services.org_member_financial_service.LiteLlmManager.get_team_members_financial_data',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_financial,
|
||||
):
|
||||
mock_get_paginated.return_value = ([mock_org_member], 1)
|
||||
mock_get_financial.return_value = litellm_data
|
||||
|
||||
# Act
|
||||
result = await OrgMemberFinancialService.get_org_members_financial_data(
|
||||
org_id=org_id,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(result.items) == 1
|
||||
assert result.items[0].lifetime_spend == 50.0 # Individual spend
|
||||
assert result.items[0].max_budget == 500.0
|
||||
# Shared budget: 500 - 150 (team_spend) = 350
|
||||
assert result.items[0].current_budget == 350.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_defaults_when_litellm_data_missing(
|
||||
self, org_id, mock_org_member
|
||||
):
|
||||
"""
|
||||
GIVEN: Organization with members but no LiteLLM data for them
|
||||
WHEN: get_org_members_financial_data is called
|
||||
THEN: Returns financial data with default values (spend=0, budget=None)
|
||||
"""
|
||||
# Arrange
|
||||
with (
|
||||
patch(
|
||||
'server.services.org_member_financial_service.OrgMemberStore.get_org_members_paginated',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_paginated,
|
||||
patch(
|
||||
'server.services.org_member_financial_service.LiteLlmManager.get_team_members_financial_data',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_financial,
|
||||
):
|
||||
mock_get_paginated.return_value = ([mock_org_member], 1)
|
||||
mock_get_financial.return_value = {
|
||||
'team_max_budget': None,
|
||||
'team_spend': 0,
|
||||
'members': {},
|
||||
}
|
||||
|
||||
# Act
|
||||
result = await OrgMemberFinancialService.get_org_members_financial_data(
|
||||
org_id=org_id,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(result.items) == 1
|
||||
assert result.items[0].lifetime_spend == 0
|
||||
assert result.items[0].max_budget is None
|
||||
assert result.items[0].current_budget == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_litellm_failure_gracefully(self, org_id, mock_org_member):
|
||||
"""
|
||||
GIVEN: LiteLLM service throws an exception
|
||||
WHEN: get_org_members_financial_data is called
|
||||
THEN: Returns financial data with default values (doesn't fail)
|
||||
"""
|
||||
# Arrange
|
||||
with (
|
||||
patch(
|
||||
'server.services.org_member_financial_service.OrgMemberStore.get_org_members_paginated',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_paginated,
|
||||
patch(
|
||||
'server.services.org_member_financial_service.LiteLlmManager.get_team_members_financial_data',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_financial,
|
||||
):
|
||||
mock_get_paginated.return_value = ([mock_org_member], 1)
|
||||
mock_get_financial.side_effect = Exception('LiteLLM unavailable')
|
||||
|
||||
# Act
|
||||
result = await OrgMemberFinancialService.get_org_members_financial_data(
|
||||
org_id=org_id,
|
||||
)
|
||||
|
||||
# Assert - should not raise, returns defaults
|
||||
assert len(result.items) == 1
|
||||
assert result.items[0].lifetime_spend == 0
|
||||
assert result.items[0].max_budget is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pagination_returns_next_page_id(self, org_id, mock_org_member):
|
||||
"""
|
||||
GIVEN: Organization with more members than limit
|
||||
WHEN: get_org_members_financial_data is called
|
||||
THEN: Returns next_page_id for pagination
|
||||
"""
|
||||
# Arrange
|
||||
with (
|
||||
patch(
|
||||
'server.services.org_member_financial_service.OrgMemberStore.get_org_members_paginated',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_paginated,
|
||||
patch(
|
||||
'server.services.org_member_financial_service.LiteLlmManager.get_team_members_financial_data',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_financial,
|
||||
):
|
||||
mock_get_paginated.return_value = ([mock_org_member], 25) # 25 total
|
||||
mock_get_financial.return_value = {
|
||||
'team_max_budget': None,
|
||||
'team_spend': 0,
|
||||
'members': {},
|
||||
}
|
||||
|
||||
# Act
|
||||
result = await OrgMemberFinancialService.get_org_members_financial_data(
|
||||
org_id=org_id,
|
||||
page_id='0',
|
||||
limit=10,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.current_page == 1
|
||||
assert result.next_page_id == '10'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pagination_no_next_page_on_last_page(self, org_id, mock_org_member):
|
||||
"""
|
||||
GIVEN: Organization on last page of results
|
||||
WHEN: get_org_members_financial_data is called
|
||||
THEN: Returns next_page_id as None
|
||||
"""
|
||||
# Arrange
|
||||
with (
|
||||
patch(
|
||||
'server.services.org_member_financial_service.OrgMemberStore.get_org_members_paginated',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_paginated,
|
||||
patch(
|
||||
'server.services.org_member_financial_service.LiteLlmManager.get_team_members_financial_data',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_financial,
|
||||
):
|
||||
mock_get_paginated.return_value = ([mock_org_member], 5) # 5 total
|
||||
mock_get_financial.return_value = {
|
||||
'team_max_budget': None,
|
||||
'team_spend': 0,
|
||||
'members': {},
|
||||
}
|
||||
|
||||
# Act
|
||||
result = await OrgMemberFinancialService.get_org_members_financial_data(
|
||||
org_id=org_id,
|
||||
page_id='0',
|
||||
limit=10,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.next_page_id is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_organization_returns_empty_items(self, org_id):
|
||||
"""
|
||||
GIVEN: Organization with no members
|
||||
WHEN: get_org_members_financial_data is called
|
||||
THEN: Returns empty items list
|
||||
"""
|
||||
# Arrange
|
||||
with patch(
|
||||
'server.services.org_member_financial_service.OrgMemberStore.get_org_members_paginated',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_paginated:
|
||||
mock_get_paginated.return_value = ([], 0)
|
||||
|
||||
# Act
|
||||
result = await OrgMemberFinancialService.get_org_members_financial_data(
|
||||
org_id=org_id,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(result.items) == 0
|
||||
assert result.next_page_id is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_page_id_raises_value_error(self, org_id):
|
||||
"""
|
||||
GIVEN: Invalid page_id format
|
||||
WHEN: get_org_members_financial_data is called
|
||||
THEN: Raises ValueError
|
||||
"""
|
||||
# Act & Assert
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
await OrgMemberFinancialService.get_org_members_financial_data(
|
||||
org_id=org_id,
|
||||
page_id='invalid',
|
||||
)
|
||||
|
||||
assert 'Invalid page_id' in str(exc_info.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_negative_page_id_raises_value_error(self, org_id):
|
||||
"""
|
||||
GIVEN: Negative page_id
|
||||
WHEN: get_org_members_financial_data is called
|
||||
THEN: Raises ValueError
|
||||
"""
|
||||
# Act & Assert
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
await OrgMemberFinancialService.get_org_members_financial_data(
|
||||
org_id=org_id,
|
||||
page_id='-5',
|
||||
)
|
||||
|
||||
assert 'Invalid page_id' in str(exc_info.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_passes_email_filter_to_store(self, org_id, mock_org_member):
|
||||
"""
|
||||
GIVEN: Email filter parameter
|
||||
WHEN: get_org_members_financial_data is called
|
||||
THEN: Passes email filter to the store
|
||||
"""
|
||||
# Arrange
|
||||
with (
|
||||
patch(
|
||||
'server.services.org_member_financial_service.OrgMemberStore.get_org_members_paginated',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_paginated,
|
||||
patch(
|
||||
'server.services.org_member_financial_service.LiteLlmManager.get_team_members_financial_data',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_financial,
|
||||
):
|
||||
mock_get_paginated.return_value = ([mock_org_member], 1)
|
||||
mock_get_financial.return_value = {
|
||||
'team_max_budget': None,
|
||||
'team_spend': 0,
|
||||
'members': {},
|
||||
}
|
||||
|
||||
# Act
|
||||
await OrgMemberFinancialService.get_org_members_financial_data(
|
||||
org_id=org_id,
|
||||
email_filter='alice',
|
||||
)
|
||||
|
||||
# Assert
|
||||
mock_get_paginated.assert_called_once_with(
|
||||
org_id=org_id, offset=0, limit=10, email_filter='alice'
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_missing_user_relationship(self, org_id, mock_role):
|
||||
"""
|
||||
GIVEN: Member with no user relationship loaded
|
||||
WHEN: get_org_members_financial_data is called
|
||||
THEN: Returns None for email
|
||||
"""
|
||||
# Arrange
|
||||
member_no_user = MagicMock(spec=OrgMember)
|
||||
member_no_user.org_id = org_id
|
||||
member_no_user.user_id = uuid.uuid4()
|
||||
member_no_user.role_id = mock_role.id
|
||||
member_no_user.user = None # No user relationship
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.services.org_member_financial_service.OrgMemberStore.get_org_members_paginated',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_paginated,
|
||||
patch(
|
||||
'server.services.org_member_financial_service.LiteLlmManager.get_team_members_financial_data',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_financial,
|
||||
):
|
||||
mock_get_paginated.return_value = ([member_no_user], 1)
|
||||
mock_get_financial.return_value = {
|
||||
'team_max_budget': None,
|
||||
'team_spend': 0,
|
||||
'members': {},
|
||||
}
|
||||
|
||||
# Act
|
||||
result = await OrgMemberFinancialService.get_org_members_financial_data(
|
||||
org_id=org_id,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(result.items) == 1
|
||||
assert result.items[0].email is None
|
||||
210
enterprise/tests/unit/storage/test_org_git_claim_store.py
Normal file
210
enterprise/tests/unit/storage/test_org_git_claim_store.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""Tests for OrgGitClaimStore with real in-memory SQLite database.
|
||||
|
||||
Covers CRUD operations and unique constraint enforcement.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from storage.org import Org
|
||||
from storage.org_git_claim_store import OrgGitClaimStore
|
||||
from storage.org_member import OrgMember
|
||||
from storage.role import Role
|
||||
from storage.user import User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def seed_org_and_user(async_session_maker):
|
||||
"""Create a minimal org, role, user, and org_member for FK satisfaction."""
|
||||
org_id = uuid.uuid4()
|
||||
user_id = uuid.uuid4()
|
||||
role_id = 1
|
||||
|
||||
async with async_session_maker() as session:
|
||||
session.add(Role(id=role_id, name='owner', rank=10))
|
||||
session.add(Org(id=org_id, name='test-org'))
|
||||
session.add(User(id=user_id, current_org_id=org_id, role_id=role_id))
|
||||
session.add(
|
||||
OrgMember(
|
||||
org_id=org_id,
|
||||
user_id=user_id,
|
||||
role_id=role_id,
|
||||
status='active',
|
||||
llm_api_key='test-key',
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
return org_id, user_id
|
||||
|
||||
|
||||
class TestOrgGitClaimStoreCreate:
|
||||
"""Tests for OrgGitClaimStore.create_claim."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_claim_persists_and_returns(
|
||||
self, async_session_maker, seed_org_and_user
|
||||
):
|
||||
"""A new claim is persisted with correct fields and returned."""
|
||||
org_id, user_id = seed_org_and_user
|
||||
|
||||
with patch('storage.org_git_claim_store.a_session_maker', async_session_maker):
|
||||
claim = await OrgGitClaimStore.create_claim(
|
||||
org_id=org_id,
|
||||
provider='github',
|
||||
git_organization='OpenHands',
|
||||
claimed_by=user_id,
|
||||
)
|
||||
|
||||
assert claim.org_id == org_id
|
||||
assert claim.provider == 'github'
|
||||
assert claim.git_organization == 'OpenHands'
|
||||
assert claim.claimed_by == user_id
|
||||
assert claim.claimed_at is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_duplicate_raises_integrity_error(
|
||||
self, async_session_maker, seed_org_and_user
|
||||
):
|
||||
"""Creating a duplicate (provider, git_organization) violates the unique constraint."""
|
||||
org_id, user_id = seed_org_and_user
|
||||
|
||||
with patch('storage.org_git_claim_store.a_session_maker', async_session_maker):
|
||||
await OrgGitClaimStore.create_claim(
|
||||
org_id=org_id,
|
||||
provider='github',
|
||||
git_organization='DuplicateOrg',
|
||||
claimed_by=user_id,
|
||||
)
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
await OrgGitClaimStore.create_claim(
|
||||
org_id=org_id,
|
||||
provider='github',
|
||||
git_organization='DuplicateOrg',
|
||||
claimed_by=user_id,
|
||||
)
|
||||
|
||||
|
||||
class TestOrgGitClaimStoreLookup:
|
||||
"""Tests for OrgGitClaimStore lookup methods."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_claim_by_provider_and_git_org_found(
|
||||
self, async_session_maker, seed_org_and_user
|
||||
):
|
||||
"""Returns the claim when provider+git_organization exists."""
|
||||
org_id, user_id = seed_org_and_user
|
||||
|
||||
with patch('storage.org_git_claim_store.a_session_maker', async_session_maker):
|
||||
await OrgGitClaimStore.create_claim(
|
||||
org_id=org_id,
|
||||
provider='gitlab',
|
||||
git_organization='MyGroup',
|
||||
claimed_by=user_id,
|
||||
)
|
||||
|
||||
result = await OrgGitClaimStore.get_claim_by_provider_and_git_org(
|
||||
provider='gitlab', git_organization='MyGroup'
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.provider == 'gitlab'
|
||||
assert result.git_organization == 'MyGroup'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_claim_by_provider_and_git_org_not_found(
|
||||
self, async_session_maker
|
||||
):
|
||||
"""Returns None when no matching claim exists."""
|
||||
with patch('storage.org_git_claim_store.a_session_maker', async_session_maker):
|
||||
result = await OrgGitClaimStore.get_claim_by_provider_and_git_org(
|
||||
provider='github', git_organization='NonExistent'
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_claims_by_org_id(self, async_session_maker, seed_org_and_user):
|
||||
"""Returns all claims belonging to the given org."""
|
||||
org_id, user_id = seed_org_and_user
|
||||
|
||||
with patch('storage.org_git_claim_store.a_session_maker', async_session_maker):
|
||||
await OrgGitClaimStore.create_claim(
|
||||
org_id=org_id,
|
||||
provider='github',
|
||||
git_organization='Org1',
|
||||
claimed_by=user_id,
|
||||
)
|
||||
await OrgGitClaimStore.create_claim(
|
||||
org_id=org_id,
|
||||
provider='gitlab',
|
||||
git_organization='Org2',
|
||||
claimed_by=user_id,
|
||||
)
|
||||
|
||||
claims = await OrgGitClaimStore.get_claims_by_org_id(org_id)
|
||||
|
||||
assert len(claims) == 2
|
||||
|
||||
|
||||
class TestOrgGitClaimStoreDelete:
|
||||
"""Tests for OrgGitClaimStore.delete_claim."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_existing_claim_returns_true(
|
||||
self, async_session_maker, seed_org_and_user
|
||||
):
|
||||
"""Deleting an existing claim returns True and removes it from the DB."""
|
||||
org_id, user_id = seed_org_and_user
|
||||
|
||||
with patch('storage.org_git_claim_store.a_session_maker', async_session_maker):
|
||||
claim = await OrgGitClaimStore.create_claim(
|
||||
org_id=org_id,
|
||||
provider='github',
|
||||
git_organization='ToDelete',
|
||||
claimed_by=user_id,
|
||||
)
|
||||
|
||||
result = await OrgGitClaimStore.delete_claim(
|
||||
claim_id=claim.id, org_id=org_id
|
||||
)
|
||||
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_nonexistent_claim_returns_false(
|
||||
self, async_session_maker, seed_org_and_user
|
||||
):
|
||||
"""Deleting a claim that doesn't exist returns False."""
|
||||
org_id, _ = seed_org_and_user
|
||||
|
||||
with patch('storage.org_git_claim_store.a_session_maker', async_session_maker):
|
||||
result = await OrgGitClaimStore.delete_claim(
|
||||
claim_id=uuid.uuid4(), org_id=org_id
|
||||
)
|
||||
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_claim_wrong_org_returns_false(
|
||||
self, async_session_maker, seed_org_and_user
|
||||
):
|
||||
"""Deleting a claim with a mismatched org_id returns False."""
|
||||
org_id, user_id = seed_org_and_user
|
||||
|
||||
with patch('storage.org_git_claim_store.a_session_maker', async_session_maker):
|
||||
claim = await OrgGitClaimStore.create_claim(
|
||||
org_id=org_id,
|
||||
provider='github',
|
||||
git_organization='WrongOrg',
|
||||
claimed_by=user_id,
|
||||
)
|
||||
|
||||
result = await OrgGitClaimStore.delete_claim(
|
||||
claim_id=claim.id, org_id=uuid.uuid4()
|
||||
)
|
||||
|
||||
assert result is False
|
||||
@@ -280,6 +280,8 @@ class TestSaasSQLAppConversationInfoService:
|
||||
stored_metadata.reasoning_tokens = 0
|
||||
stored_metadata.context_window = 0
|
||||
stored_metadata.per_turn_token = 0
|
||||
stored_metadata.public = None
|
||||
stored_metadata.tags = {}
|
||||
|
||||
saas_metadata = MagicMock(spec=StoredConversationMetadataSaas)
|
||||
saas_metadata.user_id = UUID('a1111111-1111-1111-1111-111111111111')
|
||||
|
||||
@@ -25,6 +25,7 @@ def middleware():
|
||||
def mock_request():
|
||||
request = MagicMock(spec=Request)
|
||||
request.cookies = {}
|
||||
request.query_params = {}
|
||||
return request
|
||||
|
||||
|
||||
@@ -356,6 +357,7 @@ async def test_middleware_does_not_skip_similar_non_webhook_paths(
|
||||
mock_request.url.path = path
|
||||
mock_request.headers = MagicMock()
|
||||
mock_request.headers.get = MagicMock(side_effect=lambda k: None)
|
||||
mock_request.query_params = {}
|
||||
|
||||
# Since these paths start with /api, _should_attach returns True
|
||||
# Since there's no auth, middleware catches NoCredentialsError and returns 401
|
||||
|
||||
@@ -1008,3 +1008,234 @@ class TestGetApiKeyOrgIdFromRequest:
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for require_financial_data_access dependency
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _create_mock_request_with_email(api_key_org_id=None, user_email='user@example.com'):
|
||||
"""Helper to create a mock request with optional api_key_org_id and email."""
|
||||
mock_request = MagicMock()
|
||||
mock_user_auth = MagicMock()
|
||||
# get_api_key_org_id is sync, not async
|
||||
mock_user_auth.get_api_key_org_id.return_value = api_key_org_id
|
||||
# get_user_email is async
|
||||
mock_user_auth.get_user_email = AsyncMock(return_value=user_email)
|
||||
mock_request.state.user_auth = mock_user_auth
|
||||
return mock_request
|
||||
|
||||
|
||||
class TestRequireFinancialDataAccess:
|
||||
"""Tests for require_financial_data_access compound authorization dependency."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grants_access_for_openhands_email(self):
|
||||
"""
|
||||
GIVEN: User with @openhands.dev email
|
||||
WHEN: require_financial_data_access is called
|
||||
THEN: Returns user_id (access granted)
|
||||
"""
|
||||
from server.auth.authorization import require_financial_data_access
|
||||
|
||||
# Arrange
|
||||
user_id = str(uuid4())
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request_with_email(user_email='admin@openhands.dev')
|
||||
|
||||
with patch(
|
||||
'server.auth.authorization.get_user_auth',
|
||||
AsyncMock(return_value=mock_request.state.user_auth),
|
||||
):
|
||||
# Act
|
||||
result = await require_financial_data_access(
|
||||
request=mock_request, org_id=org_id, user_id=user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == user_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grants_access_for_owner_role(self):
|
||||
"""
|
||||
GIVEN: User with owner role in organization (non-@openhands.dev email)
|
||||
WHEN: require_financial_data_access is called
|
||||
THEN: Returns user_id (access granted)
|
||||
"""
|
||||
from server.auth.authorization import require_financial_data_access
|
||||
|
||||
# Arrange
|
||||
user_id = str(uuid4())
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request_with_email(user_email='user@company.com')
|
||||
mock_role = MagicMock()
|
||||
mock_role.name = 'owner'
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.auth.authorization.get_user_auth',
|
||||
AsyncMock(return_value=mock_request.state.user_auth),
|
||||
),
|
||||
patch(
|
||||
'server.auth.authorization.get_user_org_role',
|
||||
AsyncMock(return_value=mock_role),
|
||||
),
|
||||
):
|
||||
# Act
|
||||
result = await require_financial_data_access(
|
||||
request=mock_request, org_id=org_id, user_id=user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == user_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grants_access_for_admin_role(self):
|
||||
"""
|
||||
GIVEN: User with admin role in organization (non-@openhands.dev email)
|
||||
WHEN: require_financial_data_access is called
|
||||
THEN: Returns user_id (access granted)
|
||||
"""
|
||||
from server.auth.authorization import require_financial_data_access
|
||||
|
||||
# Arrange
|
||||
user_id = str(uuid4())
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request_with_email(user_email='user@company.com')
|
||||
mock_role = MagicMock()
|
||||
mock_role.name = 'admin'
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.auth.authorization.get_user_auth',
|
||||
AsyncMock(return_value=mock_request.state.user_auth),
|
||||
),
|
||||
patch(
|
||||
'server.auth.authorization.get_user_org_role',
|
||||
AsyncMock(return_value=mock_role),
|
||||
),
|
||||
):
|
||||
# Act
|
||||
result = await require_financial_data_access(
|
||||
request=mock_request, org_id=org_id, user_id=user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == user_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_denies_access_for_member_role_without_openhands_email(self):
|
||||
"""
|
||||
GIVEN: User with member role (not admin/owner) and non-@openhands.dev email
|
||||
WHEN: require_financial_data_access is called
|
||||
THEN: Raises 403 Forbidden
|
||||
"""
|
||||
from server.auth.authorization import require_financial_data_access
|
||||
|
||||
# Arrange
|
||||
user_id = str(uuid4())
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request_with_email(user_email='user@company.com')
|
||||
mock_role = MagicMock()
|
||||
mock_role.name = 'member'
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.auth.authorization.get_user_auth',
|
||||
AsyncMock(return_value=mock_request.state.user_auth),
|
||||
),
|
||||
patch(
|
||||
'server.auth.authorization.get_user_org_role',
|
||||
AsyncMock(return_value=mock_role),
|
||||
),
|
||||
):
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await require_financial_data_access(
|
||||
request=mock_request, org_id=org_id, user_id=user_id
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert 'admins, owners, or OpenHands' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_denies_access_for_non_member(self):
|
||||
"""
|
||||
GIVEN: User who is not a member of the organization
|
||||
WHEN: require_financial_data_access is called
|
||||
THEN: Raises 403 Forbidden
|
||||
"""
|
||||
from server.auth.authorization import require_financial_data_access
|
||||
|
||||
# Arrange
|
||||
user_id = str(uuid4())
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request_with_email(user_email='user@company.com')
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.auth.authorization.get_user_auth',
|
||||
AsyncMock(return_value=mock_request.state.user_auth),
|
||||
),
|
||||
patch(
|
||||
'server.auth.authorization.get_user_org_role',
|
||||
AsyncMock(return_value=None),
|
||||
),
|
||||
):
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await require_financial_data_access(
|
||||
request=mock_request, org_id=org_id, user_id=user_id
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert 'not a member' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_denies_access_when_not_authenticated(self):
|
||||
"""
|
||||
GIVEN: No user_id (not authenticated)
|
||||
WHEN: require_financial_data_access is called
|
||||
THEN: Raises 401 Unauthorized
|
||||
"""
|
||||
from server.auth.authorization import require_financial_data_access
|
||||
|
||||
# Arrange
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request_with_email()
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await require_financial_data_access(
|
||||
request=mock_request, org_id=org_id, user_id=None
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert 'not authenticated' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_denies_access_when_api_key_org_mismatch(self):
|
||||
"""
|
||||
GIVEN: API key created for Org A, but user tries to access Org B
|
||||
WHEN: require_financial_data_access is called
|
||||
THEN: Raises 403 Forbidden with org mismatch message
|
||||
"""
|
||||
from server.auth.authorization import require_financial_data_access
|
||||
|
||||
# Arrange
|
||||
user_id = str(uuid4())
|
||||
api_key_org_id = uuid4() # Org A
|
||||
target_org_id = uuid4() # Org B
|
||||
mock_request = _create_mock_request_with_email(
|
||||
api_key_org_id=api_key_org_id, user_email='admin@openhands.dev'
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await require_financial_data_access(
|
||||
request=mock_request, org_id=target_org_id, user_id=user_id
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert 'API key is not authorized' in exc_info.value.detail
|
||||
|
||||
@@ -2576,3 +2576,304 @@ class TestBudgetPayloadHandling:
|
||||
'max_budget_in_team' in json_payload
|
||||
), 'max_budget_in_team should be in payload when set to a value'
|
||||
assert json_payload['max_budget_in_team'] == 75.0
|
||||
|
||||
|
||||
class TestGetTeamMembersFinancialData:
|
||||
"""Test cases for _get_team_members_financial_data method."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_http_client(self):
|
||||
"""Create a mock HTTP client."""
|
||||
return AsyncMock(spec=httpx.AsyncClient)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_financial_data_for_all_team_members(self, mock_http_client):
|
||||
"""
|
||||
GIVEN: Team with multiple members having financial data
|
||||
WHEN: _get_team_members_financial_data is called
|
||||
THEN: Returns dict with team info and member data
|
||||
"""
|
||||
# Arrange
|
||||
mock_response = MagicMock()
|
||||
mock_response.is_success = True
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
'team_info': {'team_id': 'test-team', 'max_budget': 500.0, 'spend': 125.5},
|
||||
'team_memberships': [
|
||||
{
|
||||
'user_id': 'user-1',
|
||||
'spend': 50.0,
|
||||
'max_budget_in_team': 200.0,
|
||||
},
|
||||
{
|
||||
'user_id': 'user-2',
|
||||
'spend': 75.5,
|
||||
'max_budget_in_team': 150.0,
|
||||
},
|
||||
],
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_http_client.get.return_value = mock_response
|
||||
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key'):
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
|
||||
# Act
|
||||
result = await LiteLlmManager._get_team_members_financial_data(
|
||||
mock_http_client, 'test-team'
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result['team_max_budget'] == 500.0
|
||||
assert result['team_spend'] == 125.5
|
||||
assert len(result['members']) == 2
|
||||
# Both users have individual budgets (max_budget_in_team is set)
|
||||
assert result['members']['user-1'] == {
|
||||
'spend': 50.0,
|
||||
'max_budget': 200.0,
|
||||
'uses_shared_budget': False,
|
||||
}
|
||||
assert result['members']['user-2'] == {
|
||||
'spend': 75.5,
|
||||
'max_budget': 150.0,
|
||||
'uses_shared_budget': False,
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_empty_dict_when_litellm_not_configured(
|
||||
self, mock_http_client
|
||||
):
|
||||
"""
|
||||
GIVEN: LiteLLM API key or URL not configured
|
||||
WHEN: _get_team_members_financial_data is called
|
||||
THEN: Returns empty dict
|
||||
"""
|
||||
# Arrange - no patching, so LITE_LLM_API_KEY/URL are None
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', None):
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', None):
|
||||
# Act
|
||||
result = await LiteLlmManager._get_team_members_financial_data(
|
||||
mock_http_client, 'test-team'
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == {}
|
||||
mock_http_client.get.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_empty_dict_when_team_not_found(self, mock_http_client):
|
||||
"""
|
||||
GIVEN: Team does not exist in LiteLLM
|
||||
WHEN: _get_team_members_financial_data is called
|
||||
THEN: Returns empty dict
|
||||
"""
|
||||
# Arrange
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 404
|
||||
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
'Not found', request=MagicMock(), response=mock_response
|
||||
)
|
||||
mock_http_client.get.return_value = mock_response
|
||||
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key'):
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
|
||||
# Act & Assert
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
await LiteLlmManager._get_team_members_financial_data(
|
||||
mock_http_client, 'nonexistent-team'
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_empty_members_when_team_has_no_members(
|
||||
self, mock_http_client
|
||||
):
|
||||
"""
|
||||
GIVEN: Team exists but has no members
|
||||
WHEN: _get_team_members_financial_data is called
|
||||
THEN: Returns structure with empty members dict
|
||||
"""
|
||||
# Arrange
|
||||
mock_response = MagicMock()
|
||||
mock_response.is_success = True
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
'team_info': {'team_id': 'empty-team', 'max_budget': 100.0, 'spend': 0},
|
||||
'team_memberships': [],
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_http_client.get.return_value = mock_response
|
||||
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key'):
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
|
||||
# Act
|
||||
result = await LiteLlmManager._get_team_members_financial_data(
|
||||
mock_http_client, 'empty-team'
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result['team_max_budget'] == 100.0
|
||||
assert result['team_spend'] == 0
|
||||
assert result['members'] == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_falls_back_to_team_budget_when_member_budget_missing(
|
||||
self, mock_http_client
|
||||
):
|
||||
"""
|
||||
GIVEN: Team with shared budget, members without individual max_budget_in_team
|
||||
WHEN: _get_team_members_financial_data is called
|
||||
THEN: Falls back to team_info.max_budget for members without individual budget
|
||||
"""
|
||||
# Arrange
|
||||
mock_response = MagicMock()
|
||||
mock_response.is_success = True
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
'team_info': {'team_id': 'test-team', 'max_budget': 500.0, 'spend': 150.0},
|
||||
'team_memberships': [
|
||||
{
|
||||
'user_id': 'user-no-individual-budget',
|
||||
'spend': 50.0,
|
||||
# No max_budget_in_team - should fall back to team budget
|
||||
},
|
||||
{
|
||||
'user_id': 'user-with-individual-budget',
|
||||
'spend': 75.0,
|
||||
'max_budget_in_team': 200.0, # Individual budget set
|
||||
},
|
||||
{
|
||||
'user_id': 'user-null-budget',
|
||||
'spend': 25.0,
|
||||
'max_budget_in_team': None, # Explicit null - fall back to team
|
||||
},
|
||||
],
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_http_client.get.return_value = mock_response
|
||||
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key'):
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
|
||||
# Act
|
||||
result = await LiteLlmManager._get_team_members_financial_data(
|
||||
mock_http_client, 'test-team'
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result['team_max_budget'] == 500.0
|
||||
assert result['team_spend'] == 150.0
|
||||
members = result['members']
|
||||
assert members['user-no-individual-budget'] == {
|
||||
'spend': 50.0,
|
||||
'max_budget': 500.0,
|
||||
'uses_shared_budget': True,
|
||||
}
|
||||
assert members['user-with-individual-budget'] == {
|
||||
'spend': 75.0,
|
||||
'max_budget': 200.0,
|
||||
'uses_shared_budget': False,
|
||||
}
|
||||
assert members['user-null-budget'] == {
|
||||
'spend': 25.0,
|
||||
'max_budget': 500.0,
|
||||
'uses_shared_budget': True,
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uses_defaults_when_no_budget_data_available(self, mock_http_client):
|
||||
"""
|
||||
GIVEN: Team without budget and members without individual budgets
|
||||
WHEN: _get_team_members_financial_data is called
|
||||
THEN: Returns default values (spend=0, max_budget=None)
|
||||
"""
|
||||
# Arrange
|
||||
mock_response = MagicMock()
|
||||
mock_response.is_success = True
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
'team_info': {'team_id': 'test-team'}, # No max_budget at team level
|
||||
'team_memberships': [
|
||||
{
|
||||
'user_id': 'user-no-data',
|
||||
# No spend or max_budget_in_team
|
||||
},
|
||||
{
|
||||
'user_id': 'user-null-spend',
|
||||
'spend': None, # Explicit null
|
||||
},
|
||||
],
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_http_client.get.return_value = mock_response
|
||||
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key'):
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
|
||||
# Act
|
||||
result = await LiteLlmManager._get_team_members_financial_data(
|
||||
mock_http_client, 'test-team'
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result['team_max_budget'] is None
|
||||
assert result['team_spend'] == 0
|
||||
members = result['members']
|
||||
# Both users fall back to team budget (which is None)
|
||||
assert members['user-no-data'] == {
|
||||
'spend': 0,
|
||||
'max_budget': None,
|
||||
'uses_shared_budget': True,
|
||||
}
|
||||
assert members['user-null-spend'] == {
|
||||
'spend': 0,
|
||||
'max_budget': None,
|
||||
'uses_shared_budget': True,
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_members_without_user_id(self, mock_http_client):
|
||||
"""
|
||||
GIVEN: Team with members, some missing user_id
|
||||
WHEN: _get_team_members_financial_data is called
|
||||
THEN: Skips members without user_id
|
||||
"""
|
||||
# Arrange
|
||||
mock_response = MagicMock()
|
||||
mock_response.is_success = True
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
'team_info': {'team_id': 'test-team', 'max_budget': 300.0, 'spend': 105.0},
|
||||
'team_memberships': [
|
||||
{
|
||||
'user_id': 'valid-user',
|
||||
'spend': 25.0,
|
||||
'max_budget_in_team': 100.0,
|
||||
},
|
||||
{
|
||||
# Missing user_id
|
||||
'spend': 50.0,
|
||||
'max_budget_in_team': 200.0,
|
||||
},
|
||||
{
|
||||
'user_id': None, # Explicit null
|
||||
'spend': 30.0,
|
||||
},
|
||||
],
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_http_client.get.return_value = mock_response
|
||||
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key'):
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
|
||||
# Act
|
||||
result = await LiteLlmManager._get_team_members_financial_data(
|
||||
mock_http_client, 'test-team'
|
||||
)
|
||||
|
||||
# Assert - only valid user should be included
|
||||
assert result['team_max_budget'] == 300.0
|
||||
assert result['team_spend'] == 105.0
|
||||
assert len(result['members']) == 1
|
||||
assert 'valid-user' in result['members']
|
||||
assert result['members']['valid-user'] == {
|
||||
'spend': 25.0,
|
||||
'max_budget': 100.0,
|
||||
'uses_shared_budget': False,
|
||||
}
|
||||
|
||||
@@ -32,11 +32,12 @@ class TestLogOutput:
|
||||
|
||||
logger.info('Test message')
|
||||
output = json.loads(string_io.getvalue())
|
||||
assert output == {
|
||||
'message': 'Test message',
|
||||
'severity': 'INFO',
|
||||
'ts': FROZEN_TIMESTAMP,
|
||||
}
|
||||
assert output['message'] == 'Test message'
|
||||
assert output['severity'] == 'INFO'
|
||||
assert output['ts'] == FROZEN_TIMESTAMP
|
||||
assert output['module'] == 'test_logger'
|
||||
assert output['funcName'] == 'test_info'
|
||||
assert 'lineno' in output
|
||||
|
||||
@freeze_time(FROZEN_TIMESTAMP)
|
||||
def test_error(self, log_output):
|
||||
@@ -44,11 +45,12 @@ class TestLogOutput:
|
||||
|
||||
logger.error('Test message')
|
||||
output = json.loads(string_io.getvalue())
|
||||
assert output == {
|
||||
'message': 'Test message',
|
||||
'severity': 'ERROR',
|
||||
'ts': FROZEN_TIMESTAMP,
|
||||
}
|
||||
assert output['message'] == 'Test message'
|
||||
assert output['severity'] == 'ERROR'
|
||||
assert output['ts'] == FROZEN_TIMESTAMP
|
||||
assert output['module'] == 'test_logger'
|
||||
assert output['funcName'] == 'test_error'
|
||||
assert 'lineno' in output
|
||||
|
||||
@freeze_time(FROZEN_TIMESTAMP)
|
||||
def test_extra_fields(self, log_output):
|
||||
@@ -56,12 +58,13 @@ class TestLogOutput:
|
||||
|
||||
logger.info('Test message', extra={'key': '..val..'})
|
||||
output = json.loads(string_io.getvalue())
|
||||
assert output == {
|
||||
'key': '..val..',
|
||||
'message': 'Test message',
|
||||
'severity': 'INFO',
|
||||
'ts': FROZEN_TIMESTAMP,
|
||||
}
|
||||
assert output['key'] == '..val..'
|
||||
assert output['message'] == 'Test message'
|
||||
assert output['severity'] == 'INFO'
|
||||
assert output['ts'] == FROZEN_TIMESTAMP
|
||||
assert output['module'] == 'test_logger'
|
||||
assert output['funcName'] == 'test_extra_fields'
|
||||
assert 'lineno' in output
|
||||
|
||||
def test_format_stack(self):
|
||||
stack = (
|
||||
@@ -284,11 +287,12 @@ class TestLogOutput:
|
||||
):
|
||||
openhands_logger.info('The secret key was supersecretvalue')
|
||||
output = json.loads(string_io.getvalue())
|
||||
assert output == {
|
||||
'message': 'The secret key was ******',
|
||||
'severity': 'INFO',
|
||||
'ts': FROZEN_TIMESTAMP,
|
||||
}
|
||||
assert output['message'] == 'The secret key was ******'
|
||||
assert output['severity'] == 'INFO'
|
||||
assert output['ts'] == FROZEN_TIMESTAMP
|
||||
assert 'module' in output
|
||||
assert 'funcName' in output
|
||||
assert 'lineno' in output
|
||||
|
||||
@freeze_time(FROZEN_TIMESTAMP)
|
||||
def test_console_serializer_uses_ts_not_timestamp(self):
|
||||
|
||||
@@ -41,191 +41,157 @@ class TestRouterPrefixes:
|
||||
assert accept_router.prefix == '/api/organizations/members/invite'
|
||||
|
||||
|
||||
class TestAcceptInvitationEndpoint:
|
||||
"""Test cases for the accept invitation endpoint."""
|
||||
class TestAcceptInvitationGetEndpoint:
|
||||
"""Test cases for the GET accept invitation endpoint (redirect flow)."""
|
||||
|
||||
def test_get_accept_redirects_to_home_with_token(self, client):
|
||||
"""Test that GET request always redirects to home with invitation_token.
|
||||
|
||||
The GET endpoint is accessed via the link in invitation emails.
|
||||
It always redirects to the home page with the token, allowing the
|
||||
frontend to handle acceptance via a modal with authenticated POST.
|
||||
"""
|
||||
response = client.get(
|
||||
'/api/organizations/members/invite/accept?token=inv-test-token-123',
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
location = response.headers.get('location', '')
|
||||
assert '/?invitation_token=inv-test-token-123' in location
|
||||
|
||||
|
||||
class TestAcceptInvitationPostEndpoint:
|
||||
"""Test cases for the POST accept invitation endpoint (authenticated flow)."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user_auth(self):
|
||||
"""Create a mock user auth."""
|
||||
user_auth = MagicMock()
|
||||
user_auth.get_user_id = AsyncMock(
|
||||
return_value='87654321-4321-8765-4321-876543218765'
|
||||
def auth_app(self):
|
||||
"""Create a FastAPI app with dependency overrides for authenticated tests."""
|
||||
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(accept_router)
|
||||
|
||||
# Override the get_user_id dependency
|
||||
app.dependency_overrides[get_user_id] = (
|
||||
lambda: '87654321-4321-8765-4321-876543218765'
|
||||
)
|
||||
return user_auth
|
||||
|
||||
return app
|
||||
|
||||
@pytest.fixture
|
||||
def auth_client(self, auth_app):
|
||||
"""Create a test client with authentication dependency overrides."""
|
||||
return TestClient(auth_app)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accept_unauthenticated_redirects_to_login(self, client):
|
||||
"""Test that unauthenticated users are redirected to login with invitation token."""
|
||||
with patch(
|
||||
'server.routes.org_invitations.get_user_auth',
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
):
|
||||
response = client.get(
|
||||
'/api/organizations/members/invite/accept?token=inv-test-token-123',
|
||||
follow_redirects=False,
|
||||
)
|
||||
async def test_post_accept_success_returns_org_details(self, auth_client):
|
||||
"""Test that successful POST acceptance returns organization details."""
|
||||
from uuid import UUID
|
||||
|
||||
assert response.status_code == 302
|
||||
assert '/login?invitation_token=inv-test-token-123' in response.headers.get(
|
||||
'location', ''
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accept_authenticated_success_redirects_home(
|
||||
self, client, mock_user_auth
|
||||
):
|
||||
"""Test that successful acceptance redirects to home page."""
|
||||
mock_invitation = MagicMock()
|
||||
mock_invitation.org_id = UUID('12345678-1234-5678-1234-567812345678')
|
||||
mock_invitation.role_id = 3
|
||||
|
||||
mock_org = MagicMock()
|
||||
mock_org.name = 'Test Organization'
|
||||
|
||||
mock_role = MagicMock()
|
||||
mock_role.name = 'member'
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.routes.org_invitations.get_user_auth',
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_user_auth,
|
||||
),
|
||||
patch(
|
||||
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_invitation,
|
||||
),
|
||||
patch(
|
||||
'server.routes.org_invitations.OrgStore.get_org_by_id',
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_org,
|
||||
),
|
||||
patch(
|
||||
'server.routes.org_invitations.RoleStore.get_role_by_id',
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_role,
|
||||
),
|
||||
):
|
||||
response = client.get(
|
||||
'/api/organizations/members/invite/accept?token=inv-test-token-123',
|
||||
follow_redirects=False,
|
||||
response = auth_client.post(
|
||||
'/api/organizations/members/invite/accept',
|
||||
json={'token': 'inv-test-token-123'},
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
location = response.headers.get('location', '')
|
||||
assert location.endswith('/')
|
||||
assert 'invitation_expired' not in location
|
||||
assert 'invitation_invalid' not in location
|
||||
assert 'email_mismatch' not in location
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data['success'] is True
|
||||
assert data['org_id'] == '12345678-1234-5678-1234-567812345678'
|
||||
assert data['org_name'] == 'Test Organization'
|
||||
assert data['role'] == 'member'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accept_expired_invitation_redirects_with_flag(
|
||||
self, client, mock_user_auth
|
||||
):
|
||||
"""Test that expired invitation redirects with invitation_expired=true."""
|
||||
with (
|
||||
patch(
|
||||
'server.routes.org_invitations.get_user_auth',
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_user_auth,
|
||||
),
|
||||
patch(
|
||||
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
|
||||
new_callable=AsyncMock,
|
||||
side_effect=InvitationExpiredError(),
|
||||
),
|
||||
async def test_post_accept_expired_returns_400(self, auth_client):
|
||||
"""Test that expired invitation returns 400 with detail."""
|
||||
with patch(
|
||||
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
|
||||
new_callable=AsyncMock,
|
||||
side_effect=InvitationExpiredError(),
|
||||
):
|
||||
response = client.get(
|
||||
'/api/organizations/members/invite/accept?token=inv-test-token-123',
|
||||
follow_redirects=False,
|
||||
response = auth_client.post(
|
||||
'/api/organizations/members/invite/accept',
|
||||
json={'token': 'inv-test-token-123'},
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert 'invitation_expired=true' in response.headers.get('location', '')
|
||||
assert response.status_code == 400
|
||||
assert response.json()['detail'] == 'invitation_expired'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accept_invalid_invitation_redirects_with_flag(
|
||||
self, client, mock_user_auth
|
||||
):
|
||||
"""Test that invalid invitation redirects with invitation_invalid=true."""
|
||||
with (
|
||||
patch(
|
||||
'server.routes.org_invitations.get_user_auth',
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_user_auth,
|
||||
),
|
||||
patch(
|
||||
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
|
||||
new_callable=AsyncMock,
|
||||
side_effect=InvitationInvalidError(),
|
||||
),
|
||||
async def test_post_accept_invalid_returns_400(self, auth_client):
|
||||
"""Test that invalid invitation returns 400 with detail."""
|
||||
with patch(
|
||||
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
|
||||
new_callable=AsyncMock,
|
||||
side_effect=InvitationInvalidError(),
|
||||
):
|
||||
response = client.get(
|
||||
'/api/organizations/members/invite/accept?token=inv-test-token-123',
|
||||
follow_redirects=False,
|
||||
response = auth_client.post(
|
||||
'/api/organizations/members/invite/accept',
|
||||
json={'token': 'inv-test-token-123'},
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert 'invitation_invalid=true' in response.headers.get('location', '')
|
||||
assert response.status_code == 400
|
||||
assert response.json()['detail'] == 'invitation_invalid'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accept_already_member_redirects_with_flag(
|
||||
self, client, mock_user_auth
|
||||
):
|
||||
"""Test that already member error redirects with already_member=true."""
|
||||
with (
|
||||
patch(
|
||||
'server.routes.org_invitations.get_user_auth',
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_user_auth,
|
||||
),
|
||||
patch(
|
||||
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
|
||||
new_callable=AsyncMock,
|
||||
side_effect=UserAlreadyMemberError(),
|
||||
),
|
||||
async def test_post_accept_already_member_returns_409(self, auth_client):
|
||||
"""Test that already member error returns 409 with detail."""
|
||||
with patch(
|
||||
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
|
||||
new_callable=AsyncMock,
|
||||
side_effect=UserAlreadyMemberError(),
|
||||
):
|
||||
response = client.get(
|
||||
'/api/organizations/members/invite/accept?token=inv-test-token-123',
|
||||
follow_redirects=False,
|
||||
response = auth_client.post(
|
||||
'/api/organizations/members/invite/accept',
|
||||
json={'token': 'inv-test-token-123'},
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert 'already_member=true' in response.headers.get('location', '')
|
||||
assert response.status_code == 409
|
||||
assert response.json()['detail'] == 'already_member'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accept_email_mismatch_redirects_with_flag(
|
||||
self, client, mock_user_auth
|
||||
):
|
||||
"""Test that email mismatch error redirects with email_mismatch=true."""
|
||||
with (
|
||||
patch(
|
||||
'server.routes.org_invitations.get_user_auth',
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_user_auth,
|
||||
),
|
||||
patch(
|
||||
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
|
||||
new_callable=AsyncMock,
|
||||
side_effect=EmailMismatchError(),
|
||||
),
|
||||
async def test_post_accept_email_mismatch_returns_403(self, auth_client):
|
||||
"""Test that email mismatch error returns 403 with detail."""
|
||||
with patch(
|
||||
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
|
||||
new_callable=AsyncMock,
|
||||
side_effect=EmailMismatchError(),
|
||||
):
|
||||
response = client.get(
|
||||
'/api/organizations/members/invite/accept?token=inv-test-token-123',
|
||||
follow_redirects=False,
|
||||
response = auth_client.post(
|
||||
'/api/organizations/members/invite/accept',
|
||||
json={'token': 'inv-test-token-123'},
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert 'email_mismatch=true' in response.headers.get('location', '')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accept_unexpected_error_redirects_with_flag(
|
||||
self, client, mock_user_auth
|
||||
):
|
||||
"""Test that unexpected errors redirect with invitation_error=true."""
|
||||
with (
|
||||
patch(
|
||||
'server.routes.org_invitations.get_user_auth',
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_user_auth,
|
||||
),
|
||||
patch(
|
||||
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
|
||||
new_callable=AsyncMock,
|
||||
side_effect=Exception('Unexpected error'),
|
||||
),
|
||||
):
|
||||
response = client.get(
|
||||
'/api/organizations/members/invite/accept?token=inv-test-token-123',
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert 'invitation_error=true' in response.headers.get('location', '')
|
||||
assert response.status_code == 403
|
||||
assert response.json()['detail'] == 'email_mismatch'
|
||||
|
||||
|
||||
class TestCreateInvitationBatchEndpoint:
|
||||
|
||||
@@ -437,3 +437,167 @@ async def test_store_updates_org_default_llm_settings(
|
||||
assert org.default_llm_model == 'anthropic/claude-sonnet-4'
|
||||
assert org.default_llm_base_url == 'https://api.anthropic.com/v1'
|
||||
assert org.default_max_iterations == 75
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_saves_mcp_config_to_user_org_member_only(
|
||||
session_maker, async_session_maker, mock_config, org_with_multiple_members_fixture
|
||||
):
|
||||
"""When user saves MCP config, it should be stored ONLY on their org_member, not propagated to others.
|
||||
|
||||
This test verifies that MCP settings are user-specific:
|
||||
1. The saving user's org_member.mcp_config is set
|
||||
2. Other members' org_member.mcp_config remains unchanged (NULL)
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
from storage.org_member import OrgMember
|
||||
|
||||
# Arrange
|
||||
fixture = org_with_multiple_members_fixture
|
||||
org_id = fixture['org_id']
|
||||
admin_user_id = str(fixture['admin_user_id'])
|
||||
member1_user_id = fixture['member1_user_id']
|
||||
member2_user_id = fixture['member2_user_id']
|
||||
|
||||
store = SaasSettingsStore(admin_user_id, mock_config)
|
||||
|
||||
user_mcp_config = {
|
||||
'sse_servers': [{'url': 'https://user1-mcp-server.com', 'api_key': None}],
|
||||
'stdio_servers': [],
|
||||
'shttp_servers': [],
|
||||
}
|
||||
|
||||
new_settings = DataSettings(
|
||||
llm_model='test-model',
|
||||
llm_base_url='http://non-litellm-url.com', # Non-LiteLLM URL to skip API key verification
|
||||
llm_api_key=SecretStr('test-api-key'),
|
||||
mcp_config=user_mcp_config,
|
||||
)
|
||||
|
||||
# Act
|
||||
with patch('storage.saas_settings_store.a_session_maker', async_session_maker):
|
||||
await store.store(new_settings)
|
||||
|
||||
# Assert
|
||||
with session_maker() as session:
|
||||
result = session.execute(select(OrgMember).where(OrgMember.org_id == org_id))
|
||||
members = {str(m.user_id): m for m in result.scalars().all()}
|
||||
|
||||
# Admin's mcp_config should be set
|
||||
assert members[admin_user_id].mcp_config == user_mcp_config
|
||||
|
||||
# Other members' mcp_config should remain NULL (not propagated)
|
||||
assert members[str(member1_user_id)].mcp_config is None
|
||||
assert members[str(member2_user_id)].mcp_config is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_does_not_update_org_mcp_config(
|
||||
session_maker, async_session_maker, mock_config, org_with_multiple_members_fixture
|
||||
):
|
||||
"""When user saves MCP config, org.mcp_config should NOT be updated.
|
||||
|
||||
MCP settings are user-specific and should be stored on org_member, not org.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
from storage.org import Org
|
||||
|
||||
# Arrange
|
||||
fixture = org_with_multiple_members_fixture
|
||||
org_id = fixture['org_id']
|
||||
admin_user_id = str(fixture['admin_user_id'])
|
||||
|
||||
store = SaasSettingsStore(admin_user_id, mock_config)
|
||||
|
||||
user_mcp_config = {
|
||||
'sse_servers': [{'url': 'https://private-mcp-server.com', 'api_key': None}],
|
||||
'stdio_servers': [],
|
||||
'shttp_servers': [],
|
||||
}
|
||||
|
||||
new_settings = DataSettings(
|
||||
llm_model='test-model',
|
||||
llm_base_url='http://non-litellm-url.com', # Non-LiteLLM URL to skip API key verification
|
||||
llm_api_key=SecretStr('test-api-key'),
|
||||
mcp_config=user_mcp_config,
|
||||
)
|
||||
|
||||
# Act
|
||||
with patch('storage.saas_settings_store.a_session_maker', async_session_maker):
|
||||
await store.store(new_settings)
|
||||
|
||||
# Assert - org.mcp_config should remain NULL
|
||||
with session_maker() as session:
|
||||
result = session.execute(select(Org).where(Org.id == org_id))
|
||||
org = result.scalars().first()
|
||||
|
||||
assert org is not None
|
||||
assert org.mcp_config is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_returns_user_specific_mcp_config(
|
||||
session_maker, async_session_maker, mock_config, org_with_multiple_members_fixture
|
||||
):
|
||||
"""When loading settings, mcp_config should come from the user's org_member, not from org or other members.
|
||||
|
||||
This test verifies user isolation:
|
||||
1. User1 stores their MCP config
|
||||
2. User2 stores a different MCP config
|
||||
3. Loading as User1 returns User1's config (not User2's)
|
||||
"""
|
||||
|
||||
# Arrange
|
||||
fixture = org_with_multiple_members_fixture
|
||||
admin_user_id = str(fixture['admin_user_id'])
|
||||
member1_user_id = str(fixture['member1_user_id'])
|
||||
|
||||
user1_mcp_config = {
|
||||
'sse_servers': [{'url': 'https://user1-private-server.com', 'api_key': None}],
|
||||
'stdio_servers': [],
|
||||
'shttp_servers': [],
|
||||
}
|
||||
user2_mcp_config = {
|
||||
'sse_servers': [{'url': 'https://user2-private-server.com', 'api_key': None}],
|
||||
'stdio_servers': [],
|
||||
'shttp_servers': [],
|
||||
}
|
||||
|
||||
# Store MCP config for user1 (admin)
|
||||
store1 = SaasSettingsStore(admin_user_id, mock_config)
|
||||
settings1 = DataSettings(
|
||||
llm_model='test-model',
|
||||
llm_base_url='http://non-litellm-url.com', # Non-LiteLLM URL to skip API key verification
|
||||
llm_api_key=SecretStr('test-api-key'),
|
||||
mcp_config=user1_mcp_config,
|
||||
)
|
||||
with patch('storage.saas_settings_store.a_session_maker', async_session_maker):
|
||||
await store1.store(settings1)
|
||||
|
||||
# Store different MCP config for user2 (member1)
|
||||
store2 = SaasSettingsStore(member1_user_id, mock_config)
|
||||
settings2 = DataSettings(
|
||||
llm_model='test-model',
|
||||
llm_base_url='http://non-litellm-url.com', # Non-LiteLLM URL to skip API key verification
|
||||
llm_api_key=SecretStr('test-api-key'),
|
||||
mcp_config=user2_mcp_config,
|
||||
)
|
||||
with patch('storage.saas_settings_store.a_session_maker', async_session_maker):
|
||||
await store2.store(settings2)
|
||||
|
||||
# Act - load settings as user1
|
||||
# Need to patch all store modules since load() calls UserStore, OrgStore, etc.
|
||||
with patch(
|
||||
'storage.saas_settings_store.a_session_maker', async_session_maker
|
||||
), patch('storage.user_store.a_session_maker', async_session_maker), patch(
|
||||
'storage.org_store.a_session_maker', async_session_maker
|
||||
):
|
||||
loaded_settings = await store1.load()
|
||||
|
||||
# Assert - user1 should see their own MCP config, not user2's
|
||||
assert loaded_settings is not None
|
||||
assert loaded_settings.mcp_config is not None
|
||||
assert (
|
||||
loaded_settings.mcp_config.sse_servers[0].url
|
||||
== 'https://user1-private-server.com'
|
||||
)
|
||||
|
||||
@@ -31,6 +31,7 @@ def mock_request():
|
||||
request = MagicMock(spec=Request)
|
||||
request.headers = {}
|
||||
request.cookies = {}
|
||||
request.query_params = {}
|
||||
return request
|
||||
|
||||
|
||||
@@ -511,6 +512,7 @@ async def test_saas_user_auth_from_bearer_no_auth_header():
|
||||
"""Test that saas_user_auth_from_bearer returns None if no auth header."""
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {}
|
||||
mock_request.query_params = {}
|
||||
|
||||
result = await saas_user_auth_from_bearer(mock_request)
|
||||
|
||||
@@ -633,6 +635,7 @@ def test_get_api_key_from_header_with_authorization_header():
|
||||
# Create a mock request with Authorization header
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.headers = {'Authorization': 'Bearer test_api_key'}
|
||||
mock_request.query_params = {}
|
||||
|
||||
# Call the function
|
||||
api_key = get_api_key_from_header(mock_request)
|
||||
@@ -646,6 +649,7 @@ def test_get_api_key_from_header_with_x_session_api_key():
|
||||
# Create a mock request with X-Session-API-Key header
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.headers = {'X-Session-API-Key': 'session_api_key'}
|
||||
mock_request.query_params = {}
|
||||
|
||||
# Call the function
|
||||
api_key = get_api_key_from_header(mock_request)
|
||||
@@ -662,6 +666,7 @@ def test_get_api_key_from_header_with_both_headers():
|
||||
'Authorization': 'Bearer auth_api_key',
|
||||
'X-Session-API-Key': 'session_api_key',
|
||||
}
|
||||
mock_request.query_params = {}
|
||||
|
||||
# Call the function
|
||||
api_key = get_api_key_from_header(mock_request)
|
||||
@@ -671,10 +676,11 @@ def test_get_api_key_from_header_with_both_headers():
|
||||
|
||||
|
||||
def test_get_api_key_from_header_with_no_headers():
|
||||
"""Test that get_api_key_from_header returns None when no relevant headers are present."""
|
||||
"""Test that get_api_key_from_header returns None when no relevant headers or query params are present."""
|
||||
# Create a mock request with no relevant headers
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.headers = {'Other-Header': 'some_value'}
|
||||
mock_request.query_params = {}
|
||||
|
||||
# Call the function
|
||||
api_key = get_api_key_from_header(mock_request)
|
||||
@@ -688,6 +694,7 @@ def test_get_api_key_from_header_with_invalid_authorization_format():
|
||||
# Create a mock request with incorrectly formatted Authorization header
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.headers = {'Authorization': 'InvalidFormat api_key'}
|
||||
mock_request.query_params = {}
|
||||
|
||||
# Call the function
|
||||
api_key = get_api_key_from_header(mock_request)
|
||||
@@ -701,6 +708,7 @@ def test_get_api_key_from_header_with_x_access_token():
|
||||
# Create a mock request with X-Access-Token header
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.headers = {'X-Access-Token': 'access_token_key'}
|
||||
mock_request.query_params = {}
|
||||
|
||||
# Call the function
|
||||
api_key = get_api_key_from_header(mock_request)
|
||||
@@ -717,6 +725,7 @@ def test_get_api_key_from_header_priority_authorization_over_x_access_token():
|
||||
'Authorization': 'Bearer auth_api_key',
|
||||
'X-Access-Token': 'access_token_key',
|
||||
}
|
||||
mock_request.query_params = {}
|
||||
|
||||
# Call the function
|
||||
api_key = get_api_key_from_header(mock_request)
|
||||
@@ -733,6 +742,7 @@ def test_get_api_key_from_header_priority_x_session_over_x_access_token():
|
||||
'X-Session-API-Key': 'session_api_key',
|
||||
'X-Access-Token': 'access_token_key',
|
||||
}
|
||||
mock_request.query_params = {}
|
||||
|
||||
# Call the function
|
||||
api_key = get_api_key_from_header(mock_request)
|
||||
@@ -750,6 +760,7 @@ def test_get_api_key_from_header_all_three_headers():
|
||||
'X-Session-API-Key': 'session_api_key',
|
||||
'X-Access-Token': 'access_token_key',
|
||||
}
|
||||
mock_request.query_params = {}
|
||||
|
||||
# Call the function
|
||||
api_key = get_api_key_from_header(mock_request)
|
||||
@@ -766,6 +777,7 @@ def test_get_api_key_from_header_invalid_authorization_fallback_to_x_access_toke
|
||||
'Authorization': 'InvalidFormat api_key',
|
||||
'X-Access-Token': 'access_token_key',
|
||||
}
|
||||
mock_request.query_params = {}
|
||||
|
||||
# Call the function
|
||||
api_key = get_api_key_from_header(mock_request)
|
||||
@@ -783,6 +795,7 @@ def test_get_api_key_from_header_empty_headers():
|
||||
'X-Session-API-Key': '',
|
||||
'X-Access-Token': 'access_token_key',
|
||||
}
|
||||
mock_request.query_params = {}
|
||||
|
||||
# Call the function
|
||||
api_key = get_api_key_from_header(mock_request)
|
||||
@@ -799,6 +812,7 @@ def test_get_api_key_from_header_bearer_with_empty_token():
|
||||
'Authorization': 'Bearer ',
|
||||
'X-Access-Token': 'access_token_key',
|
||||
}
|
||||
mock_request.query_params = {}
|
||||
|
||||
# Call the function
|
||||
api_key = get_api_key_from_header(mock_request)
|
||||
@@ -808,6 +822,68 @@ def test_get_api_key_from_header_bearer_with_empty_token():
|
||||
assert api_key == ''
|
||||
|
||||
|
||||
def test_get_api_key_from_query_param_fallback():
|
||||
"""Test that get_api_key_from_header falls back to api_key query parameter (deprecated)."""
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.headers = {}
|
||||
mock_request.query_params = {'api_key': 'sk-oh-query-param-key'}
|
||||
mock_request.url = MagicMock()
|
||||
mock_request.url.path = '/mcp'
|
||||
|
||||
api_key = get_api_key_from_header(mock_request)
|
||||
|
||||
assert api_key == 'sk-oh-query-param-key'
|
||||
|
||||
|
||||
def test_get_api_key_from_header_takes_priority_over_query_param():
|
||||
"""Test that Authorization header takes priority over api_key query parameter."""
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.headers = {'Authorization': 'Bearer header_api_key'}
|
||||
mock_request.query_params = {'api_key': 'sk-oh-query-param-key'}
|
||||
|
||||
api_key = get_api_key_from_header(mock_request)
|
||||
|
||||
assert api_key == 'header_api_key'
|
||||
|
||||
|
||||
def test_get_api_key_x_session_header_takes_priority_over_query_param():
|
||||
"""Test that X-Session-API-Key header takes priority over api_key query parameter."""
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.headers = {'X-Session-API-Key': 'session_key'}
|
||||
mock_request.query_params = {'api_key': 'sk-oh-query-param-key'}
|
||||
|
||||
api_key = get_api_key_from_header(mock_request)
|
||||
|
||||
assert api_key == 'session_key'
|
||||
|
||||
|
||||
def test_get_api_key_x_access_token_takes_priority_over_query_param():
|
||||
"""Test that X-Access-Token header takes priority over api_key query parameter."""
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.headers = {'X-Access-Token': 'access_token_key'}
|
||||
mock_request.query_params = {'api_key': 'sk-oh-query-param-key'}
|
||||
|
||||
api_key = get_api_key_from_header(mock_request)
|
||||
|
||||
assert api_key == 'access_token_key'
|
||||
|
||||
|
||||
def test_get_api_key_from_query_param_logs_deprecation_warning(caplog):
|
||||
"""Test that using api_key query parameter logs a deprecation warning."""
|
||||
import logging
|
||||
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.headers = {}
|
||||
mock_request.query_params = {'api_key': 'sk-oh-query-param-key'}
|
||||
mock_request.url = MagicMock()
|
||||
mock_request.url.path = '/api/v1/auth/github'
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
api_key = get_api_key_from_header(mock_request)
|
||||
|
||||
assert api_key == 'sk-oh-query-param-key'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_saas_user_auth_from_signed_token_blocked_domain(mock_config):
|
||||
"""Test that saas_user_auth_from_signed_token raises AuthError when email domain is blocked."""
|
||||
|
||||
141
enterprise/tests/unit/test_user_git_organizations.py
Normal file
141
enterprise/tests/unit/test_user_git_organizations.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""Tests for the GET /api/user/git-organizations endpoint.
|
||||
|
||||
This endpoint returns git organizations for the user's active provider
|
||||
in SaaS mode (single provider at a time).
|
||||
"""
|
||||
|
||||
from types import MappingProxyType
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.provider import ProviderToken
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def github_provider_tokens():
|
||||
return MappingProxyType(
|
||||
{ProviderType.GITHUB: ProviderToken(token=SecretStr('gh-token'))}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gitlab_provider_tokens():
|
||||
return MappingProxyType(
|
||||
{ProviderType.GITLAB: ProviderToken(token=SecretStr('gl-token'))}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bitbucket_provider_tokens():
|
||||
return MappingProxyType(
|
||||
{ProviderType.BITBUCKET: ProviderToken(token=SecretStr('bb-token'))}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def azure_devops_provider_tokens():
|
||||
return MappingProxyType(
|
||||
{ProviderType.AZURE_DEVOPS: ProviderToken(token=SecretStr('az-token'))}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_check_idp():
|
||||
with patch('server.routes.user._check_idp', new_callable=AsyncMock) as mock_fn:
|
||||
yield mock_fn
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_provider_tokens_falls_back_to_idp(mock_check_idp):
|
||||
"""When no provider tokens exist, falls back to IDP check."""
|
||||
from server.routes.user import saas_get_user_git_organizations
|
||||
|
||||
mock_check_idp.return_value = {}
|
||||
|
||||
result = await saas_get_user_git_organizations(
|
||||
provider_tokens=None,
|
||||
access_token=SecretStr('token'),
|
||||
user_id='user-1',
|
||||
)
|
||||
|
||||
assert result == {}
|
||||
mock_check_idp.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsupported_provider_returns_400(azure_devops_provider_tokens):
|
||||
"""Unsupported provider returns a 400 error."""
|
||||
from server.routes.user import saas_get_user_git_organizations
|
||||
|
||||
with patch('server.routes.user.ProviderHandler'):
|
||||
result = await saas_get_user_git_organizations(
|
||||
provider_tokens=azure_devops_provider_tokens,
|
||||
access_token=SecretStr('token'),
|
||||
user_id='user-1',
|
||||
)
|
||||
|
||||
assert isinstance(result, JSONResponse)
|
||||
assert result.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
'provider_tokens_fixture, mock_method, mock_return, expected_provider',
|
||||
[
|
||||
(
|
||||
'github_provider_tokens',
|
||||
'get_organizations_from_installations',
|
||||
['All-Hands-AI', 'OpenHands'],
|
||||
'github',
|
||||
),
|
||||
(
|
||||
'gitlab_provider_tokens',
|
||||
'get_user_groups',
|
||||
['my-team', 'open-source'],
|
||||
'gitlab',
|
||||
),
|
||||
(
|
||||
'bitbucket_provider_tokens',
|
||||
'get_installations',
|
||||
['my-workspace'],
|
||||
'bitbucket',
|
||||
),
|
||||
],
|
||||
ids=['github', 'gitlab', 'bitbucket'],
|
||||
)
|
||||
async def test_provider_routing_with_real_handler(
|
||||
provider_tokens_fixture,
|
||||
mock_method,
|
||||
mock_return,
|
||||
expected_provider,
|
||||
request,
|
||||
):
|
||||
"""Each provider routes to the correct service method and returns the expected JSON structure.
|
||||
|
||||
Uses a real ProviderHandler so the endpoint's if/elif routing and ProviderHandler's
|
||||
delegation are both exercised. Only the low-level git service call is mocked.
|
||||
"""
|
||||
from server.routes.user import saas_get_user_git_organizations
|
||||
|
||||
provider_tokens = request.getfixturevalue(provider_tokens_fixture)
|
||||
|
||||
with patch(
|
||||
'openhands.integrations.provider.ProviderHandler.get_service'
|
||||
) as mock_get_service:
|
||||
mock_service = mock_get_service.return_value
|
||||
setattr(mock_service, mock_method, AsyncMock(return_value=mock_return))
|
||||
|
||||
result = await saas_get_user_git_organizations(
|
||||
provider_tokens=provider_tokens,
|
||||
access_token=SecretStr('token'),
|
||||
user_id='user-1',
|
||||
)
|
||||
|
||||
assert result == {
|
||||
'provider': expected_provider,
|
||||
'organizations': mock_return,
|
||||
}
|
||||
@@ -17,11 +17,11 @@ describe("LoginCTA", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderWithRouter = () => {
|
||||
const renderWithRouter = (source?: "login_page" | "device_verify") => {
|
||||
const Stub = createRoutesStub([
|
||||
{
|
||||
path: "/",
|
||||
Component: LoginCTA,
|
||||
Component: () => <LoginCTA source={source} />,
|
||||
},
|
||||
{
|
||||
path: "/information-request",
|
||||
@@ -75,4 +75,32 @@ describe("LoginCTA", () => {
|
||||
"/information-request",
|
||||
);
|
||||
});
|
||||
|
||||
it("should render external enterprise URL in device verify mode", () => {
|
||||
renderWithRouter("device_verify");
|
||||
|
||||
const learnMoreLink = screen.getByRole("link", {
|
||||
name: "CTA$LEARN_MORE",
|
||||
});
|
||||
expect(learnMoreLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://openhands.dev/enterprise",
|
||||
);
|
||||
expect(learnMoreLink).toHaveAttribute("target", "_blank");
|
||||
expect(learnMoreLink).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
|
||||
it("should track device_verify location when Learn More is clicked in device verify mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithRouter("device_verify");
|
||||
|
||||
const learnMoreLink = screen.getByRole("link", {
|
||||
name: "CTA$LEARN_MORE",
|
||||
});
|
||||
await user.click(learnMoreLink);
|
||||
|
||||
expect(mockTrackSaasSelfhostedInquiry).toHaveBeenCalledWith({
|
||||
location: "device_verify",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { ContextMenuNavLink } from "#/components/features/context-menu/context-menu-nav-link";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
const mockNavItem = {
|
||||
to: "/settings/test",
|
||||
icon: <span data-testid="test-icon">Icon</span>,
|
||||
text: I18nKey.SETTINGS$NAV_API_KEYS,
|
||||
};
|
||||
|
||||
const renderContextMenuNavLink = (item = mockNavItem, onClick = vi.fn()) =>
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ContextMenuNavLink item={item} onClick={onClick} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
describe("ContextMenuNavLink", () => {
|
||||
it("should render the link with icon and text", () => {
|
||||
// Arrange & Act
|
||||
renderContextMenuNavLink();
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole("link")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("test-icon")).toBeInTheDocument();
|
||||
expect(screen.getByText("SETTINGS$NAV_API_KEYS")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should navigate to the correct route", () => {
|
||||
// Arrange & Act
|
||||
renderContextMenuNavLink();
|
||||
|
||||
// Assert
|
||||
const link = screen.getByRole("link");
|
||||
expect(link).toHaveAttribute("href", "/settings/test");
|
||||
});
|
||||
|
||||
it("should call onClick when clicked", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
const onClick = vi.fn();
|
||||
renderContextMenuNavLink(mockNavItem, onClick);
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByRole("link"));
|
||||
|
||||
// Assert
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -434,6 +434,46 @@ describe("ConversationCard", () => {
|
||||
expect(screen.queryByTestId("ellipsis-button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the llm model when provided", () => {
|
||||
renderWithProviders(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
llmModel="anthropic/claude-sonnet-4-20250514"
|
||||
/>,
|
||||
);
|
||||
|
||||
const model = screen.getByTestId("conversation-card-llm-model");
|
||||
expect(model).toBeInTheDocument();
|
||||
expect(model).toHaveTextContent("anthropic/claude-sonnet-4-20250514");
|
||||
expect(model).toHaveAttribute("title", "anthropic/claude-sonnet-4-20250514");
|
||||
expect(model.querySelector("svg")).toBeInTheDocument();
|
||||
|
||||
// Verify truncation structure: text is wrapped in a span with truncate class
|
||||
const textSpan = model.querySelector("span.truncate");
|
||||
expect(textSpan).toBeInTheDocument();
|
||||
expect(textSpan).toHaveTextContent("anthropic/claude-sonnet-4-20250514");
|
||||
});
|
||||
|
||||
it("should not render the llm model when not provided", () => {
|
||||
renderWithProviders(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("conversation-card-llm-model"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const statusTable: [ConversationStatus, boolean][] = [
|
||||
["RUNNING", true],
|
||||
["STARTING", true],
|
||||
|
||||
@@ -204,4 +204,84 @@ describe("HookEventItem", () => {
|
||||
);
|
||||
expect(screen.getByText("unknown_event")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not crash when a matcher has undefined hooks", () => {
|
||||
const hookEventWithUndefinedHooks: HookEvent = {
|
||||
event_type: "stop",
|
||||
matchers: [
|
||||
{
|
||||
matcher: "*",
|
||||
hooks: undefined,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
render(
|
||||
<HookEventItem
|
||||
{...defaultProps}
|
||||
hookEvent={hookEventWithUndefinedHooks}
|
||||
/>,
|
||||
),
|
||||
).not.toThrow();
|
||||
|
||||
expect(screen.getByText("0 hooks")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not crash when a matcher has undefined hooks in expanded state", () => {
|
||||
const hookEventWithUndefinedHooks: HookEvent = {
|
||||
event_type: "stop",
|
||||
matchers: [
|
||||
{
|
||||
matcher: "*",
|
||||
hooks: undefined,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
render(
|
||||
<HookEventItem
|
||||
{...defaultProps}
|
||||
hookEvent={hookEventWithUndefinedHooks}
|
||||
isExpanded={true}
|
||||
/>,
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("should handle a mix of matchers with and without hooks", () => {
|
||||
const mixedHookEvent: HookEvent = {
|
||||
event_type: "pre_tool_use",
|
||||
matchers: [
|
||||
{
|
||||
matcher: "terminal",
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: "check.sh",
|
||||
timeout: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
matcher: "browser",
|
||||
hooks: undefined,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
render(
|
||||
<HookEventItem
|
||||
{...defaultProps}
|
||||
hookEvent={mixedHookEvent}
|
||||
isExpanded={true}
|
||||
/>,
|
||||
),
|
||||
).not.toThrow();
|
||||
|
||||
// Should count only the valid hooks
|
||||
expect(screen.getByText("1 hooks")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -296,6 +296,46 @@ describe("ConversationName", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the llm model when available", () => {
|
||||
useActiveConversationMock.mockReturnValue({
|
||||
data: {
|
||||
conversation_id: "test-conversation-id",
|
||||
title: "Test Conversation",
|
||||
status: "RUNNING",
|
||||
llm_model: "openai/gpt-4o",
|
||||
} as Conversation,
|
||||
});
|
||||
|
||||
renderConversationNameWithRouter();
|
||||
|
||||
const model = screen.getByTestId("conversation-name-llm-model");
|
||||
expect(model).toBeInTheDocument();
|
||||
expect(model).toHaveTextContent("openai/gpt-4o");
|
||||
expect(model).toHaveAttribute("title", "openai/gpt-4o");
|
||||
expect(model.querySelector("svg")).toBeInTheDocument();
|
||||
|
||||
// Verify truncation structure: text is wrapped in a span with truncate class
|
||||
const textSpan = model.querySelector("span.truncate");
|
||||
expect(textSpan).toBeInTheDocument();
|
||||
expect(textSpan).toHaveTextContent("openai/gpt-4o");
|
||||
});
|
||||
|
||||
it("should not render the llm model when not available", () => {
|
||||
useActiveConversationMock.mockReturnValue({
|
||||
data: {
|
||||
conversation_id: "test-conversation-id",
|
||||
title: "Test Conversation",
|
||||
status: "RUNNING",
|
||||
},
|
||||
});
|
||||
|
||||
renderConversationNameWithRouter();
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("conversation-name-llm-model"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should focus input when entering edit mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderConversationNameWithRouter();
|
||||
|
||||
@@ -2,6 +2,7 @@ import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { ConversationTabsContextMenu } from "#/components/features/conversation/conversation-tabs/conversation-tabs-context-menu";
|
||||
import { useConversationStore } from "#/stores/conversation-store";
|
||||
|
||||
const CONVERSATION_ID = "conv-abc123";
|
||||
|
||||
@@ -21,6 +22,11 @@ describe("ConversationTabsContextMenu", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
mockHasTaskList = false;
|
||||
useConversationStore.setState({
|
||||
selectedTab: "editor",
|
||||
isRightPanelShown: true,
|
||||
hasRightPanelToggled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should render nothing when isOpen is false", () => {
|
||||
@@ -69,6 +75,33 @@ describe("ConversationTabsContextMenu", () => {
|
||||
expect(storedState.unpinnedTabs).not.toContain("terminal");
|
||||
});
|
||||
|
||||
it("should close the right panel when unpinning the currently active tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ConversationTabsContextMenu isOpen={true} onClose={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByText("COMMON$CHANGES"));
|
||||
|
||||
const storeState = useConversationStore.getState();
|
||||
expect(storeState.hasRightPanelToggled).toBe(false);
|
||||
|
||||
const storedState = JSON.parse(
|
||||
localStorage.getItem(`conversation-state-${CONVERSATION_ID}`)!,
|
||||
);
|
||||
expect(storedState.rightPanelShown).toBe(false);
|
||||
});
|
||||
|
||||
it("should not close the right panel when unpinning a non-active tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ConversationTabsContextMenu isOpen={true} onClose={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByText("COMMON$TERMINAL"));
|
||||
|
||||
const storeState = useConversationStore.getState();
|
||||
expect(storeState.hasRightPanelToggled).toBe(true);
|
||||
});
|
||||
|
||||
describe("with tasklist", () => {
|
||||
beforeEach(() => {
|
||||
mockHasTaskList = true;
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { FileDiffViewer } from "#/components/features/diff-viewer/file-diff-viewer";
|
||||
|
||||
const MOCK_DIFF = { original: "old content", modified: "new content" };
|
||||
const MOCK_MD_DIFF = {
|
||||
original: "# Old Heading",
|
||||
modified: "# New Heading\n\nSome **bold** text",
|
||||
};
|
||||
|
||||
let mockDiff = MOCK_DIFF;
|
||||
let mockIsSuccess = true;
|
||||
let mockIsLoading = false;
|
||||
|
||||
vi.mock("#/hooks/query/use-unified-git-diff", () => ({
|
||||
useUnifiedGitDiff: () => ({
|
||||
data: mockDiff,
|
||||
isLoading: mockIsLoading,
|
||||
isSuccess: mockIsSuccess,
|
||||
isRefetching: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@monaco-editor/react", () => ({
|
||||
DiffEditor: (props: Record<string, unknown>) => (
|
||||
<div data-testid="file-diff-viewer" data-original={props.original} data-modified={props.modified} />
|
||||
),
|
||||
Editor: (props: Record<string, unknown>) => (
|
||||
<div data-testid="file-single-viewer" data-value={props.value} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("#/components/features/markdown/markdown-renderer", () => ({
|
||||
MarkdownRenderer: ({ content }: { content: string }) => (
|
||||
<div data-testid="markdown-renderer">{content}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const expand = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await user.click(screen.getByTestId("collapse"));
|
||||
};
|
||||
|
||||
describe("FileDiffViewer", () => {
|
||||
beforeEach(() => {
|
||||
mockDiff = MOCK_DIFF;
|
||||
mockIsSuccess = true;
|
||||
mockIsLoading = false;
|
||||
});
|
||||
|
||||
it("starts collapsed with no view mode buttons", () => {
|
||||
render(<FileDiffViewer path="src/index.ts" type="M" />);
|
||||
|
||||
expect(screen.queryByTestId("view-mode-old")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("view-mode-diff")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("view-mode-new")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows view mode buttons when expanded", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileDiffViewer path="src/index.ts" type="M" />);
|
||||
|
||||
await expand(user);
|
||||
|
||||
expect(screen.getByTestId("view-mode-old")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("view-mode-diff")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("view-mode-new")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows diff editor by default when expanded", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileDiffViewer path="src/index.ts" type="M" />);
|
||||
|
||||
await expand(user);
|
||||
|
||||
expect(screen.getByTestId("file-diff-viewer")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("file-single-viewer")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to single editor on 'new' mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileDiffViewer path="src/index.ts" type="M" />);
|
||||
|
||||
await expand(user);
|
||||
await user.click(screen.getByTestId("view-mode-new"));
|
||||
|
||||
expect(screen.getByTestId("file-single-viewer")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("file-single-viewer")).toHaveAttribute("data-value", "new content");
|
||||
expect(screen.queryByTestId("file-diff-viewer")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to single editor on 'old' mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileDiffViewer path="src/index.ts" type="M" />);
|
||||
|
||||
await expand(user);
|
||||
await user.click(screen.getByTestId("view-mode-old"));
|
||||
|
||||
expect(screen.getByTestId("file-single-viewer")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("file-single-viewer")).toHaveAttribute("data-value", "old content");
|
||||
});
|
||||
|
||||
it("returns to diff editor when switching back to 'diff' mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileDiffViewer path="src/index.ts" type="M" />);
|
||||
|
||||
await expand(user);
|
||||
await user.click(screen.getByTestId("view-mode-new"));
|
||||
await user.click(screen.getByTestId("view-mode-diff"));
|
||||
|
||||
expect(screen.getByTestId("file-diff-viewer")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("file-single-viewer")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders markdown preview for .md files in 'new' mode", async () => {
|
||||
mockDiff = MOCK_MD_DIFF;
|
||||
const user = userEvent.setup();
|
||||
render(<FileDiffViewer path="README.md" type="M" />);
|
||||
|
||||
await expand(user);
|
||||
await user.click(screen.getByTestId("view-mode-new"));
|
||||
|
||||
expect(screen.getByTestId("markdown-preview")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent(/New Heading/);
|
||||
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent(/bold/);
|
||||
expect(screen.queryByTestId("file-single-viewer")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders markdown preview for .md files in 'old' mode", async () => {
|
||||
mockDiff = MOCK_MD_DIFF;
|
||||
const user = userEvent.setup();
|
||||
render(<FileDiffViewer path="README.md" type="M" />);
|
||||
|
||||
await expand(user);
|
||||
await user.click(screen.getByTestId("view-mode-old"));
|
||||
|
||||
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent(MOCK_MD_DIFF.original);
|
||||
});
|
||||
|
||||
it("shows diff editor for .md files in 'diff' mode", async () => {
|
||||
mockDiff = MOCK_MD_DIFF;
|
||||
const user = userEvent.setup();
|
||||
render(<FileDiffViewer path="README.md" type="M" />);
|
||||
|
||||
await expand(user);
|
||||
|
||||
expect(screen.getByTestId("file-diff-viewer")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("markdown-preview")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("highlights the active view mode button", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileDiffViewer path="src/index.ts" type="M" />);
|
||||
|
||||
await expand(user);
|
||||
|
||||
expect(screen.getByTestId("view-mode-diff").className).toContain("bg-neutral-600");
|
||||
expect(screen.getByTestId("view-mode-old").className).not.toContain("bg-neutral-600");
|
||||
|
||||
await user.click(screen.getByTestId("view-mode-old"));
|
||||
|
||||
expect(screen.getByTestId("view-mode-old").className).toContain("bg-neutral-600");
|
||||
expect(screen.getByTestId("view-mode-diff").className).not.toContain("bg-neutral-600");
|
||||
});
|
||||
});
|
||||
@@ -3,9 +3,23 @@ import { render, screen } from "@testing-library/react";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
import { NewConversation } from "#/components/features/home/new-conversation/new-conversation";
|
||||
|
||||
vi.mock("#/hooks/query/use-settings", async () => {
|
||||
const actual = await vi.importActual<typeof import("#/hooks/query/use-settings")>(
|
||||
"#/hooks/query/use-settings",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
getSettingsQueryFn: vi.fn().mockResolvedValue({ v1_enabled: true }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("#/context/use-selected-organization", () => ({
|
||||
useSelectedOrganizationId: () => ({ organizationId: null }),
|
||||
}));
|
||||
|
||||
// Mock the translation function
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual = await vi.importActual("react-i18next");
|
||||
@@ -50,31 +64,52 @@ const renderNewConversation = () => {
|
||||
|
||||
describe("NewConversation", () => {
|
||||
it("should create an empty conversation and redirect when pressing the launch from scratch button", async () => {
|
||||
const createConversationSpy = vi.spyOn(
|
||||
ConversationService,
|
||||
"createConversation",
|
||||
);
|
||||
const createConversationSpy = vi
|
||||
.spyOn(V1ConversationService, "createConversation")
|
||||
.mockResolvedValue({
|
||||
id: "task-id",
|
||||
created_by_user_id: null,
|
||||
status: "READY",
|
||||
detail: null,
|
||||
app_conversation_id: "conv-123",
|
||||
sandbox_id: null,
|
||||
agent_server_url: "http://agent-server.local",
|
||||
request: {
|
||||
sandbox_id: null,
|
||||
initial_message: null,
|
||||
processors: [],
|
||||
llm_model: null,
|
||||
selected_repository: null,
|
||||
selected_branch: null,
|
||||
git_provider: "github",
|
||||
suggested_task: null,
|
||||
title: null,
|
||||
trigger: null,
|
||||
pr_number: [],
|
||||
parent_conversation_id: null,
|
||||
agent_type: "default",
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
renderNewConversation();
|
||||
|
||||
const launchButton = screen.getByTestId("launch-new-conversation-button");
|
||||
await userEvent.click(launchButton);
|
||||
|
||||
expect(createConversationSpy).toHaveBeenCalledExactlyOnceWith(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(createConversationSpy).toHaveBeenCalledOnce();
|
||||
|
||||
// expect to be redirected to /conversations/:conversationId
|
||||
await screen.findByTestId("conversation-screen");
|
||||
});
|
||||
|
||||
it("should change the launch button text to 'Loading...' when creating a conversation", async () => {
|
||||
// Mock V1 API to never resolve, keeping the mutation in loading state
|
||||
vi.spyOn(V1ConversationService, "createConversation").mockImplementation(
|
||||
() => new Promise(() => {}),
|
||||
);
|
||||
|
||||
renderNewConversation();
|
||||
|
||||
const launchButton = screen.getByTestId("launch-new-conversation-button");
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { BrowserRouter } from "react-router";
|
||||
import { RecentConversation } from "#/components/features/home/recent-conversations/recent-conversation";
|
||||
import type { Conversation } from "#/api/open-hands.types";
|
||||
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual = await vi.importActual("react-i18next");
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
CONVERSATION$AGO: "ago",
|
||||
COMMON$NO_REPOSITORY: "No repository",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
i18n: {
|
||||
changeLanguage: () => new Promise(() => {}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const baseConversation: Conversation = {
|
||||
conversation_id: "test-id",
|
||||
title: "Test Conversation",
|
||||
status: "RUNNING",
|
||||
last_updated_at: "2021-10-01T12:00:00Z",
|
||||
created_at: "2021-10-01T12:00:00Z",
|
||||
selected_repository: null,
|
||||
selected_branch: null,
|
||||
git_provider: null,
|
||||
runtime_status: null,
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
};
|
||||
|
||||
const renderRecentConversation = (conversation: Conversation) =>
|
||||
renderWithProviders(
|
||||
<BrowserRouter>
|
||||
<RecentConversation conversation={conversation} />
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
describe("RecentConversation - llm_model", () => {
|
||||
it("should render the llm model when provided", () => {
|
||||
renderRecentConversation({
|
||||
...baseConversation,
|
||||
llm_model: "anthropic/claude-sonnet-4-20250514",
|
||||
});
|
||||
|
||||
const model = screen.getByTestId("recent-conversation-llm-model");
|
||||
expect(model).toBeInTheDocument();
|
||||
expect(model).toHaveTextContent("anthropic/claude-sonnet-4-20250514");
|
||||
expect(model).toHaveAttribute(
|
||||
"title",
|
||||
"anthropic/claude-sonnet-4-20250514",
|
||||
);
|
||||
expect(model.querySelector("svg")).toBeInTheDocument();
|
||||
|
||||
// Verify truncation structure: text is wrapped in a span with truncate class
|
||||
const textSpan = model.querySelector("span.truncate");
|
||||
expect(textSpan).toBeInTheDocument();
|
||||
expect(textSpan).toHaveTextContent("anthropic/claude-sonnet-4-20250514");
|
||||
});
|
||||
|
||||
it("should not render the llm model when not provided", () => {
|
||||
renderRecentConversation(baseConversation);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("recent-conversation-llm-model"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
|
||||
import { createRoutesStub, Outlet } from "react-router";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
import GitService from "#/api/git-service/git-service.api";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import { GitRepository } from "#/types/git";
|
||||
@@ -314,23 +314,34 @@ describe("RepoConnector", () => {
|
||||
});
|
||||
|
||||
it("should create a conversation and redirect with the selected repo when pressing the launch button", async () => {
|
||||
const createConversationSpy = vi.spyOn(
|
||||
ConversationService,
|
||||
"createConversation",
|
||||
);
|
||||
createConversationSpy.mockResolvedValue({
|
||||
conversation_id: "mock-conversation-id",
|
||||
title: "Test Conversation",
|
||||
selected_repository: "user/repo1",
|
||||
selected_branch: "main",
|
||||
git_provider: "github",
|
||||
last_updated_at: "2023-01-01T00:00:00Z",
|
||||
created_at: "2023-01-01T00:00:00Z",
|
||||
status: "STARTING",
|
||||
runtime_status: null,
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
});
|
||||
const createConversationSpy = vi
|
||||
.spyOn(V1ConversationService, "createConversation")
|
||||
.mockResolvedValue({
|
||||
id: "task-id",
|
||||
created_by_user_id: null,
|
||||
status: "READY",
|
||||
detail: null,
|
||||
app_conversation_id: "mock-conversation-id",
|
||||
sandbox_id: null,
|
||||
agent_server_url: "http://agent-server.local",
|
||||
request: {
|
||||
sandbox_id: null,
|
||||
initial_message: null,
|
||||
processors: [],
|
||||
llm_model: null,
|
||||
selected_repository: "rbren/polaris",
|
||||
selected_branch: "main",
|
||||
git_provider: "github",
|
||||
suggested_task: null,
|
||||
title: null,
|
||||
trigger: null,
|
||||
pr_number: [],
|
||||
parent_conversation_id: null,
|
||||
agent_type: "default",
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
GitService,
|
||||
"retrieveUserGitRepositories",
|
||||
@@ -390,20 +401,24 @@ describe("RepoConnector", () => {
|
||||
|
||||
await userEvent.click(launchButton);
|
||||
|
||||
expect(createConversationSpy).toHaveBeenCalledExactlyOnceWith(
|
||||
expect(createConversationSpy).toHaveBeenCalledOnce();
|
||||
expect(createConversationSpy).toHaveBeenCalledWith(
|
||||
"rbren/polaris",
|
||||
"github",
|
||||
undefined,
|
||||
undefined,
|
||||
"main",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("should change the launch button text to 'Loading...' when creating a conversation", async () => {
|
||||
const createConversationSpy = vi.spyOn(
|
||||
ConversationService,
|
||||
V1ConversationService,
|
||||
"createConversation",
|
||||
);
|
||||
createConversationSpy.mockImplementation(() => new Promise(() => { })); // Never resolves to keep loading state
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import UserService from "#/api/user-service/user-service.api";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
import GitService from "#/api/git-service/git-service.api";
|
||||
import { TaskCard } from "#/components/features/home/tasks/task-card";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { SuggestedTask } from "#/utils/types";
|
||||
|
||||
vi.mock("#/hooks/query/use-settings", async () => {
|
||||
const actual = await vi.importActual<typeof import("#/hooks/query/use-settings")>(
|
||||
"#/hooks/query/use-settings",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
getSettingsQueryFn: vi.fn().mockResolvedValue({ v1_enabled: true }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("#/context/use-selected-organization", () => ({
|
||||
useSelectedOrganizationId: () => ({ organizationId: null }),
|
||||
}));
|
||||
|
||||
const MOCK_TASK_1: SuggestedTask = {
|
||||
issue_number: 123,
|
||||
repo: "repo1",
|
||||
@@ -56,17 +69,43 @@ describe("TaskCard", () => {
|
||||
});
|
||||
|
||||
it("should call createConversation when clicking the launch button", async () => {
|
||||
const createConversationSpy = vi.spyOn(
|
||||
ConversationService,
|
||||
"createConversation",
|
||||
);
|
||||
const createConversationSpy = vi
|
||||
.spyOn(V1ConversationService, "createConversation")
|
||||
.mockResolvedValue({
|
||||
id: "task-id",
|
||||
created_by_user_id: null,
|
||||
status: "READY",
|
||||
detail: null,
|
||||
app_conversation_id: "conv-123",
|
||||
sandbox_id: null,
|
||||
agent_server_url: "http://agent-server.local",
|
||||
request: {
|
||||
sandbox_id: null,
|
||||
initial_message: null,
|
||||
processors: [],
|
||||
llm_model: null,
|
||||
selected_repository: null,
|
||||
selected_branch: null,
|
||||
git_provider: "github",
|
||||
suggested_task: null,
|
||||
title: null,
|
||||
trigger: null,
|
||||
pr_number: [],
|
||||
parent_conversation_id: null,
|
||||
agent_type: "default",
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
renderTaskCard();
|
||||
|
||||
const launchButton = screen.getByTestId("task-launch-button");
|
||||
await userEvent.click(launchButton);
|
||||
|
||||
expect(createConversationSpy).toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
expect(createConversationSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("creating suggested task conversation", () => {
|
||||
@@ -82,10 +121,34 @@ describe("TaskCard", () => {
|
||||
});
|
||||
|
||||
it("should call create conversation with suggest task trigger and selected suggested task", async () => {
|
||||
const createConversationSpy = vi.spyOn(
|
||||
ConversationService,
|
||||
"createConversation",
|
||||
);
|
||||
const createConversationSpy = vi
|
||||
.spyOn(V1ConversationService, "createConversation")
|
||||
.mockResolvedValue({
|
||||
id: "task-id",
|
||||
created_by_user_id: null,
|
||||
status: "READY",
|
||||
detail: null,
|
||||
app_conversation_id: "conv-123",
|
||||
sandbox_id: null,
|
||||
agent_server_url: "http://agent-server.local",
|
||||
request: {
|
||||
sandbox_id: null,
|
||||
initial_message: null,
|
||||
processors: [],
|
||||
llm_model: null,
|
||||
selected_repository: MOCK_RESPOSITORIES[0].full_name,
|
||||
selected_branch: null,
|
||||
git_provider: "github",
|
||||
suggested_task: null,
|
||||
title: null,
|
||||
trigger: null,
|
||||
pr_number: [],
|
||||
parent_conversation_id: null,
|
||||
agent_type: "default",
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
renderTaskCard(MOCK_TASK_1);
|
||||
|
||||
@@ -96,6 +159,8 @@ describe("TaskCard", () => {
|
||||
MOCK_RESPOSITORIES[0].full_name,
|
||||
MOCK_RESPOSITORIES[0].git_provider,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
git_provider: "github",
|
||||
issue_number: 123,
|
||||
@@ -106,27 +171,37 @@ describe("TaskCard", () => {
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should navigate to the conversation page after creating a conversation", async () => {
|
||||
const createConversationSpy = vi.spyOn(
|
||||
ConversationService,
|
||||
"createConversation",
|
||||
);
|
||||
createConversationSpy.mockResolvedValue({
|
||||
conversation_id: "test-conversation-id",
|
||||
title: "Test Conversation",
|
||||
selected_repository: "repo1",
|
||||
selected_branch: "main",
|
||||
git_provider: "github",
|
||||
last_updated_at: "2023-01-01T00:00:00Z",
|
||||
created_at: "2023-01-01T00:00:00Z",
|
||||
status: "RUNNING",
|
||||
runtime_status: "STATUS$READY",
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
vi.spyOn(V1ConversationService, "createConversation").mockResolvedValue({
|
||||
id: "task-id",
|
||||
created_by_user_id: null,
|
||||
status: "READY",
|
||||
detail: null,
|
||||
app_conversation_id: "test-conversation-id",
|
||||
sandbox_id: null,
|
||||
agent_server_url: "http://agent-server.local",
|
||||
request: {
|
||||
sandbox_id: null,
|
||||
initial_message: null,
|
||||
processors: [],
|
||||
llm_model: null,
|
||||
selected_repository: "repo1",
|
||||
selected_branch: "main",
|
||||
git_provider: "github",
|
||||
suggested_task: null,
|
||||
title: null,
|
||||
trigger: null,
|
||||
pr_number: [],
|
||||
parent_conversation_id: null,
|
||||
agent_type: "default",
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
renderTaskCard();
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders, createAxiosError } from "test-utils";
|
||||
import { InvitationAcceptModal } from "#/components/features/invitations/invitation-accept-modal";
|
||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||
import * as toastHandlers from "#/utils/custom-toast-handlers";
|
||||
|
||||
// Mock the organization service
|
||||
vi.mock("#/api/organization-service/organization-service.api", () => ({
|
||||
organizationService: {
|
||||
acceptInvitation: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock toast handlers
|
||||
vi.mock("#/utils/custom-toast-handlers", () => ({
|
||||
displaySuccessToast: vi.fn(),
|
||||
displayErrorToast: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("InvitationAcceptModal", () => {
|
||||
const mockToken = "test-invitation-token-123";
|
||||
const mockOnClose = vi.fn();
|
||||
const mockOnSuccess = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render the modal with title and description", () => {
|
||||
renderWithProviders(
|
||||
<InvitationAcceptModal
|
||||
token={mockToken}
|
||||
onClose={mockOnClose}
|
||||
onSuccess={mockOnSuccess}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("invitation-accept-modal")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("ORG$INVITATION_ACCEPT_TITLE"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("ORG$INVITATION_ACCEPT_DESCRIPTION"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render accept and cancel buttons", () => {
|
||||
renderWithProviders(
|
||||
<InvitationAcceptModal
|
||||
token={mockToken}
|
||||
onClose={mockOnClose}
|
||||
onSuccess={mockOnSuccess}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("accept-invitation-button")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("cancel-invitation-button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onClose when cancel button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(
|
||||
<InvitationAcceptModal
|
||||
token={mockToken}
|
||||
onClose={mockOnClose}
|
||||
onSuccess={mockOnSuccess}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("cancel-invitation-button"));
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should call acceptInvitation when accept button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockResponse = {
|
||||
success: true,
|
||||
org_id: "org-123",
|
||||
org_name: "Test Organization",
|
||||
role: "member",
|
||||
};
|
||||
|
||||
vi.mocked(organizationService.acceptInvitation).mockResolvedValueOnce(
|
||||
mockResponse,
|
||||
);
|
||||
|
||||
renderWithProviders(
|
||||
<InvitationAcceptModal
|
||||
token={mockToken}
|
||||
onClose={mockOnClose}
|
||||
onSuccess={mockOnSuccess}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("accept-invitation-button"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(organizationService.acceptInvitation).toHaveBeenCalledWith({
|
||||
token: mockToken,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should call onSuccess with org_id and show success toast on successful acceptance", async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockResponse = {
|
||||
success: true,
|
||||
org_id: "org-123",
|
||||
org_name: "Test Organization",
|
||||
role: "member",
|
||||
};
|
||||
|
||||
vi.mocked(organizationService.acceptInvitation).mockResolvedValueOnce(
|
||||
mockResponse,
|
||||
);
|
||||
|
||||
renderWithProviders(
|
||||
<InvitationAcceptModal
|
||||
token={mockToken}
|
||||
onClose={mockOnClose}
|
||||
onSuccess={mockOnSuccess}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("accept-invitation-button"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSuccess).toHaveBeenCalledWith({
|
||||
orgId: "org-123",
|
||||
orgName: "Test Organization",
|
||||
isPersonal: false,
|
||||
});
|
||||
});
|
||||
|
||||
expect(toastHandlers.displaySuccessToast).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show loading spinner and disable buttons while accepting", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Create a promise that we can control
|
||||
let resolvePromise: (value: unknown) => void;
|
||||
const pendingPromise = new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
|
||||
vi.mocked(organizationService.acceptInvitation).mockReturnValueOnce(
|
||||
pendingPromise as Promise<{
|
||||
success: boolean;
|
||||
org_id: string;
|
||||
org_name: string;
|
||||
role: string;
|
||||
}>,
|
||||
);
|
||||
|
||||
renderWithProviders(
|
||||
<InvitationAcceptModal
|
||||
token={mockToken}
|
||||
onClose={mockOnClose}
|
||||
onSuccess={mockOnSuccess}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Click accept to trigger loading state
|
||||
await user.click(screen.getByTestId("accept-invitation-button"));
|
||||
|
||||
// Check loading state
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId("accept-invitation-button")).toBeDisabled();
|
||||
expect(screen.getByTestId("cancel-invitation-button")).toBeDisabled();
|
||||
|
||||
// Resolve the promise to clean up
|
||||
resolvePromise!({
|
||||
success: true,
|
||||
org_id: "org-123",
|
||||
org_name: "Test Organization",
|
||||
role: "member",
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should show expired error toast and call onClose when invitation is expired", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(organizationService.acceptInvitation).mockRejectedValueOnce(
|
||||
createAxiosError(400, "Bad Request", { detail: "invitation_expired" }),
|
||||
);
|
||||
|
||||
renderWithProviders(
|
||||
<InvitationAcceptModal
|
||||
token={mockToken}
|
||||
onClose={mockOnClose}
|
||||
onSuccess={mockOnSuccess}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("accept-invitation-button"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastHandlers.displayErrorToast).toHaveBeenCalledWith(
|
||||
"ORG$INVITATION_EXPIRED",
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledOnce();
|
||||
expect(mockOnSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show invalid error toast and call onClose when invitation is invalid", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(organizationService.acceptInvitation).mockRejectedValueOnce(
|
||||
createAxiosError(400, "Bad Request", { detail: "invitation_invalid" }),
|
||||
);
|
||||
|
||||
renderWithProviders(
|
||||
<InvitationAcceptModal
|
||||
token={mockToken}
|
||||
onClose={mockOnClose}
|
||||
onSuccess={mockOnSuccess}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("accept-invitation-button"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastHandlers.displayErrorToast).toHaveBeenCalledWith(
|
||||
"ORG$INVITATION_INVALID",
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should show already member error toast when user is already a member", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(organizationService.acceptInvitation).mockRejectedValueOnce(
|
||||
createAxiosError(409, "Conflict", { detail: "already_member" }),
|
||||
);
|
||||
|
||||
renderWithProviders(
|
||||
<InvitationAcceptModal
|
||||
token={mockToken}
|
||||
onClose={mockOnClose}
|
||||
onSuccess={mockOnSuccess}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("accept-invitation-button"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastHandlers.displayErrorToast).toHaveBeenCalledWith(
|
||||
"ORG$ALREADY_MEMBER",
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should show email mismatch error toast when email does not match", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(organizationService.acceptInvitation).mockRejectedValueOnce(
|
||||
createAxiosError(403, "Forbidden", { detail: "email_mismatch" }),
|
||||
);
|
||||
|
||||
renderWithProviders(
|
||||
<InvitationAcceptModal
|
||||
token={mockToken}
|
||||
onClose={mockOnClose}
|
||||
onSuccess={mockOnSuccess}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("accept-invitation-button"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastHandlers.displayErrorToast).toHaveBeenCalledWith(
|
||||
"ORG$INVITATION_EMAIL_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should show generic error toast for unknown errors", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(organizationService.acceptInvitation).mockRejectedValueOnce(
|
||||
createAxiosError(500, "Internal Server Error", {
|
||||
detail: "unexpected_error",
|
||||
}),
|
||||
);
|
||||
|
||||
renderWithProviders(
|
||||
<InvitationAcceptModal
|
||||
token={mockToken}
|
||||
onClose={mockOnClose}
|
||||
onSuccess={mockOnSuccess}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("accept-invitation-button"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastHandlers.displayErrorToast).toHaveBeenCalledWith(
|
||||
"ORG$INVITATION_ACCEPT_ERROR",
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
});
|
||||
113
frontend/__tests__/components/features/org/claim-button.test.tsx
Normal file
113
frontend/__tests__/components/features/org/claim-button.test.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import {
|
||||
ClaimButton,
|
||||
getButtonState,
|
||||
} from "#/components/features/org/claim-button";
|
||||
import type { GitOrg } from "#/types/org";
|
||||
|
||||
const createOrg = (overrides: Partial<GitOrg> = {}): GitOrg => ({
|
||||
id: "1",
|
||||
claimId: null,
|
||||
provider: "GitHub",
|
||||
name: "TestOrg",
|
||||
status: "unclaimed",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("getButtonState", () => {
|
||||
it("returns 'claiming' during claiming transition regardless of hover", () => {
|
||||
expect(getButtonState("claiming", false)).toBe("claiming");
|
||||
expect(getButtonState("claiming", true)).toBe("claiming");
|
||||
});
|
||||
|
||||
it("returns 'disconnecting' during disconnecting transition regardless of hover", () => {
|
||||
expect(getButtonState("disconnecting", false)).toBe("disconnecting");
|
||||
expect(getButtonState("disconnecting", true)).toBe("disconnecting");
|
||||
});
|
||||
|
||||
it("returns 'disconnect' when claimed and hovered", () => {
|
||||
expect(getButtonState("claimed", true)).toBe("disconnect");
|
||||
});
|
||||
|
||||
it("returns 'claimed' when claimed and not hovered", () => {
|
||||
expect(getButtonState("claimed", false)).toBe("claimed");
|
||||
});
|
||||
|
||||
it("returns 'unclaimed' when unclaimed", () => {
|
||||
expect(getButtonState("unclaimed", false)).toBe("unclaimed");
|
||||
expect(getButtonState("unclaimed", true)).toBe("unclaimed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClaimButton", () => {
|
||||
it("calls onClaim when clicking an unclaimed org", async () => {
|
||||
// Arrange
|
||||
const onClaim = vi.fn();
|
||||
const org = createOrg({ status: "unclaimed" });
|
||||
renderWithProviders(
|
||||
<ClaimButton org={org} onClaim={onClaim} onDisconnect={vi.fn()} />,
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByTestId("claim-button-1"));
|
||||
|
||||
// Assert
|
||||
expect(onClaim).toHaveBeenCalledWith("1");
|
||||
});
|
||||
|
||||
it("calls onDisconnect when clicking a claimed org", async () => {
|
||||
// Arrange
|
||||
const onDisconnect = vi.fn();
|
||||
const org = createOrg({ status: "claimed" });
|
||||
renderWithProviders(
|
||||
<ClaimButton org={org} onClaim={vi.fn()} onDisconnect={onDisconnect} />,
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByTestId("claim-button-1"));
|
||||
|
||||
// Assert
|
||||
expect(onDisconnect).toHaveBeenCalledWith("1");
|
||||
});
|
||||
|
||||
it("does not call handlers when button is disabled during claiming", async () => {
|
||||
// Arrange
|
||||
const onClaim = vi.fn();
|
||||
const onDisconnect = vi.fn();
|
||||
const org = createOrg({ status: "claiming" });
|
||||
renderWithProviders(
|
||||
<ClaimButton org={org} onClaim={onClaim} onDisconnect={onDisconnect} />,
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByTestId("claim-button-1"));
|
||||
|
||||
// Assert
|
||||
expect(onClaim).not.toHaveBeenCalled();
|
||||
expect(onDisconnect).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("claim-button-1")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows 'Disconnect' label on hover when claimed", async () => {
|
||||
// Arrange
|
||||
const org = createOrg({ status: "claimed" });
|
||||
renderWithProviders(
|
||||
<ClaimButton org={org} onClaim={vi.fn()} onDisconnect={vi.fn()} />,
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Act
|
||||
await user.hover(screen.getByTestId("claim-button-1"));
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId("claim-button-1")).toHaveTextContent(
|
||||
"ORG$DISCONNECT",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { GitConversationRouting } from "#/components/features/org/git-conversation-routing";
|
||||
|
||||
const mockMutate = vi.fn();
|
||||
const mockDisconnectMutate = vi.fn();
|
||||
|
||||
vi.mock("#/hooks/query/use-git-organizations", () => ({
|
||||
useUserGitOrganizations: () => ({
|
||||
data: {
|
||||
provider: "github",
|
||||
organizations: ["OpenHands", "AcmeCo"],
|
||||
},
|
||||
isLoading: false,
|
||||
}),
|
||||
useGitClaims: () => ({
|
||||
data: [
|
||||
{
|
||||
id: "claim-1",
|
||||
org_id: "org-1",
|
||||
provider: "github",
|
||||
git_organization: "OpenHands",
|
||||
claimed_by: "user-1",
|
||||
claimed_at: "2026-01-01T00:00:00",
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/mutation/use-claim-git-org", () => ({
|
||||
useClaimGitOrg: () => ({
|
||||
mutate: mockMutate,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/mutation/use-disconnect-git-org", () => ({
|
||||
useDisconnectGitOrg: () => ({
|
||||
mutate: mockDisconnectMutate,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("GitConversationRouting", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should render organizations from API data", () => {
|
||||
renderWithProviders(<GitConversationRouting />);
|
||||
|
||||
expect(
|
||||
screen.getByTestId("org-row-github:openhands"),
|
||||
).toHaveTextContent("github/OpenHands");
|
||||
expect(
|
||||
screen.getByTestId("org-row-github:acmeco"),
|
||||
).toHaveTextContent("github/AcmeCo");
|
||||
});
|
||||
|
||||
it("should show claimed org with 'Claimed' label", () => {
|
||||
renderWithProviders(<GitConversationRouting />);
|
||||
|
||||
const claimedButton = screen.getByTestId("claim-button-github:openhands");
|
||||
expect(claimedButton).toHaveTextContent("ORG$CLAIMED");
|
||||
});
|
||||
|
||||
it("should show unclaimed orgs with 'Claim' label", () => {
|
||||
renderWithProviders(<GitConversationRouting />);
|
||||
|
||||
expect(
|
||||
screen.getByTestId("claim-button-github:acmeco"),
|
||||
).toHaveTextContent("ORG$CLAIM");
|
||||
});
|
||||
|
||||
it("should call claim mutation when clicking claim on unclaimed org", async () => {
|
||||
renderWithProviders(<GitConversationRouting />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByTestId("claim-button-github:acmeco"));
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith(
|
||||
{ provider: "github", gitOrganization: "AcmeCo" },
|
||||
expect.objectContaining({ onSettled: expect.any(Function) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("should call disconnect mutation when clicking disconnect on claimed org", async () => {
|
||||
renderWithProviders(<GitConversationRouting />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByTestId("claim-button-github:openhands"));
|
||||
|
||||
expect(mockDisconnectMutate).toHaveBeenCalledWith(
|
||||
{ claimId: "claim-1" },
|
||||
expect.objectContaining({ onSettled: expect.any(Function) }),
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { GitOrgRow } from "#/components/features/org/git-org-row";
|
||||
import type { GitOrg } from "#/types/org";
|
||||
|
||||
const createOrg = (overrides: Partial<GitOrg> = {}): GitOrg => ({
|
||||
id: "1",
|
||||
claimId: null,
|
||||
provider: "GitHub",
|
||||
name: "TestOrg",
|
||||
status: "unclaimed",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("GitOrgRow", () => {
|
||||
it("renders the provider and organization name", () => {
|
||||
// Arrange & Act
|
||||
renderWithProviders(
|
||||
<GitOrgRow
|
||||
org={createOrg({ provider: "GitLab", name: "MyOrg" })}
|
||||
isLast={false}
|
||||
onClaim={vi.fn()}
|
||||
onDisconnect={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId("org-row-1")).toHaveTextContent("GitLab/MyOrg");
|
||||
});
|
||||
|
||||
it("renders a claim button for the organization", () => {
|
||||
// Arrange & Act
|
||||
renderWithProviders(
|
||||
<GitOrgRow
|
||||
org={createOrg()}
|
||||
isLast={false}
|
||||
onClaim={vi.fn()}
|
||||
onDisconnect={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId("claim-button-1")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
import { screen, render, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
|
||||
import { OrgSelector } from "#/components/features/org/org-selector";
|
||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||
import * as ToastHandlers from "#/utils/custom-toast-handlers";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
import {
|
||||
MOCK_PERSONAL_ORG,
|
||||
MOCK_TEAM_ORG_ACME,
|
||||
@@ -32,10 +34,13 @@ vi.mock("react-i18next", async () => {
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
t: (key: string, params?: Record<string, string>) => {
|
||||
const translations: Record<string, string> = {
|
||||
"ORG$SELECT_ORGANIZATION_PLACEHOLDER": "Please select an organization",
|
||||
"ORG$PERSONAL_WORKSPACE": "Personal Workspace",
|
||||
"ORG$SWITCHED_TO_ORGANIZATION": `You have switched to organization: ${params?.name ?? ""}`,
|
||||
"ORG$SWITCHED_TO_PERSONAL_WORKSPACE":
|
||||
"You have switched to your personal workspace.",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
@@ -56,6 +61,9 @@ const renderOrgSelector = () =>
|
||||
});
|
||||
|
||||
describe("OrgSelector", () => {
|
||||
beforeEach(() => {
|
||||
useSelectedOrganizationStore.setState({ organizationId: null });
|
||||
});
|
||||
it("should not render when user only has a personal workspace", async () => {
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_PERSONAL_ORG],
|
||||
@@ -200,4 +208,80 @@ describe("OrgSelector", () => {
|
||||
expect(screen.getByTestId("dropdown-trigger")).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display toast with organization name when switching to a team organization", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_PERSONAL_ORG, MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_PERSONAL_ORG.id,
|
||||
});
|
||||
vi.spyOn(organizationService, "switchOrganization").mockResolvedValue(
|
||||
MOCK_TEAM_ORG_ACME,
|
||||
);
|
||||
const displaySuccessToastSpy = vi.spyOn(
|
||||
ToastHandlers,
|
||||
"displaySuccessToast",
|
||||
);
|
||||
|
||||
renderOrgSelector();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("combobox")).toHaveValue("Personal Workspace");
|
||||
});
|
||||
|
||||
// Act
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
const listbox = await screen.findByRole("listbox");
|
||||
const acmeOption = within(listbox).getByText("Acme Corp");
|
||||
await user.click(acmeOption);
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(displaySuccessToastSpy).toHaveBeenCalledWith(
|
||||
"You have switched to organization: Acme Corp",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should display toast for personal workspace when switching to personal workspace", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
// Pre-set the store to have team org selected
|
||||
useSelectedOrganizationStore.setState({
|
||||
organizationId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_TEAM_ORG_ACME, MOCK_PERSONAL_ORG],
|
||||
currentOrgId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
vi.spyOn(organizationService, "switchOrganization").mockResolvedValue(
|
||||
MOCK_PERSONAL_ORG,
|
||||
);
|
||||
const displaySuccessToastSpy = vi.spyOn(
|
||||
ToastHandlers,
|
||||
"displaySuccessToast",
|
||||
);
|
||||
|
||||
renderOrgSelector();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("combobox")).toHaveValue("Acme Corp");
|
||||
});
|
||||
|
||||
// Act
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
const listbox = await screen.findByRole("listbox");
|
||||
const personalOption = within(listbox).getByText("Personal Workspace");
|
||||
await user.click(personalOption);
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(displaySuccessToastSpy).toHaveBeenCalledWith(
|
||||
"You have switched to your personal workspace.",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { OrgWideSettingsBadge } from "#/components/features/settings/org-wide-settings-badge";
|
||||
|
||||
describe("OrgWideSettingsBadge", () => {
|
||||
it("should render the badge with translated text", () => {
|
||||
// Arrange & Act
|
||||
render(<OrgWideSettingsBadge />);
|
||||
|
||||
// Assert
|
||||
const badge = screen.getByTestId("org-wide-settings-badge");
|
||||
expect(badge).toBeInTheDocument();
|
||||
expect(screen.getByText("SETTINGS$ORG_WIDE_SETTING_BADGE")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the info circle icon", () => {
|
||||
// Arrange & Act
|
||||
render(<OrgWideSettingsBadge />);
|
||||
|
||||
// Assert
|
||||
const badge = screen.getByTestId("org-wide-settings-badge");
|
||||
const icon = badge.querySelector("svg");
|
||||
expect(icon).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { SettingsNavDivider } from "#/components/features/settings/settings-nav-divider";
|
||||
|
||||
describe("SettingsNavDivider", () => {
|
||||
it("should render the divider element", () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<SettingsNavDivider />);
|
||||
|
||||
// Assert
|
||||
const divider = container.firstChild;
|
||||
expect(divider).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should accept custom className", () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<SettingsNavDivider className="my-4" />);
|
||||
|
||||
// Assert
|
||||
const divider = container.firstChild;
|
||||
expect(divider).toHaveClass("my-4");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { SettingsNavHeader } from "#/components/features/settings/settings-nav-header";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
describe("SettingsNavHeader", () => {
|
||||
it("should render the translated header text", () => {
|
||||
// Arrange & Act
|
||||
render(<SettingsNavHeader text={I18nKey.SETTINGS$ORG_SETTINGS_HEADER} />);
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText("SETTINGS$ORG_SETTINGS_HEADER")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render different header text based on prop", () => {
|
||||
// Arrange & Act
|
||||
render(<SettingsNavHeader text={I18nKey.SETTINGS$PERSONAL_SETTINGS_HEADER} />);
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText("SETTINGS$PERSONAL_SETTINGS_HEADER")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should accept custom className", () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<SettingsNavHeader
|
||||
text={I18nKey.SETTINGS$ORG_SETTINGS_HEADER}
|
||||
className="px-2 pt-2 pb-1"
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
const wrapper = container.firstChild;
|
||||
expect(wrapper).toHaveClass("px-2");
|
||||
expect(wrapper).toHaveClass("pt-2");
|
||||
expect(wrapper).toHaveClass("pb-1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { SettingsNavLink } from "#/components/features/settings/settings-nav-link";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
const mockNavItem = {
|
||||
to: "/settings/test",
|
||||
icon: <span data-testid="test-icon">Icon</span>,
|
||||
text: I18nKey.SETTINGS$NAV_API_KEYS,
|
||||
};
|
||||
|
||||
const renderSettingsNavLink = (
|
||||
item = mockNavItem,
|
||||
onClick = vi.fn(),
|
||||
initialPath = "/",
|
||||
) =>
|
||||
render(
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<SettingsNavLink item={item} onClick={onClick} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
describe("SettingsNavLink", () => {
|
||||
it("should render the link with icon and text", () => {
|
||||
// Arrange & Act
|
||||
renderSettingsNavLink();
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole("link")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("test-icon")).toBeInTheDocument();
|
||||
expect(screen.getByText("SETTINGS$NAV_API_KEYS")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should navigate to the correct route", () => {
|
||||
// Arrange & Act
|
||||
renderSettingsNavLink();
|
||||
|
||||
// Assert
|
||||
const link = screen.getByRole("link");
|
||||
expect(link).toHaveAttribute("href", "/settings/test");
|
||||
});
|
||||
|
||||
it("should call onClick when clicked", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
const onClick = vi.fn();
|
||||
renderSettingsNavLink(mockNavItem, onClick);
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByRole("link"));
|
||||
|
||||
// Assert
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should render different text based on item prop", () => {
|
||||
// Arrange
|
||||
const customItem = {
|
||||
to: "/settings/secrets",
|
||||
icon: <span>Icon</span>,
|
||||
text: I18nKey.SETTINGS$NAV_SECRETS,
|
||||
};
|
||||
|
||||
// Act
|
||||
renderSettingsNavLink(customItem);
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText("SETTINGS$NAV_SECRETS")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link")).toHaveAttribute("href", "/settings/secrets");
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { SettingsNavigation } from "#/components/features/settings/settings-navi
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
import { SAAS_NAV_ITEMS, SettingsNavItem } from "#/constants/settings-nav";
|
||||
import { SettingsNavRenderedItem } from "#/hooks/use-settings-nav-items";
|
||||
|
||||
vi.mock("react-router", async () => ({
|
||||
...(await vi.importActual("react-router")),
|
||||
@@ -18,13 +19,17 @@ const mockConfig = () => {
|
||||
} as Awaited<ReturnType<typeof OptionService.getConfig>>);
|
||||
};
|
||||
|
||||
// Convert SettingsNavItem[] to SettingsNavRenderedItem[]
|
||||
const toRenderedItems = (items: SettingsNavItem[]): SettingsNavRenderedItem[] =>
|
||||
items.map((item) => ({ type: "item", item }));
|
||||
|
||||
const ITEMS_WITHOUT_ORG = SAAS_NAV_ITEMS.filter(
|
||||
(item) =>
|
||||
item.to !== "/settings/org" && item.to !== "/settings/org-members",
|
||||
);
|
||||
|
||||
const renderSettingsNavigation = (
|
||||
items: SettingsNavItem[] = SAAS_NAV_ITEMS,
|
||||
items: SettingsNavRenderedItem[] = toRenderedItems(SAAS_NAV_ITEMS),
|
||||
) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -56,31 +61,31 @@ describe("SettingsNavigation", () => {
|
||||
|
||||
describe("renders navigation items passed via props", () => {
|
||||
it("should render org routes when included in navigation items", async () => {
|
||||
renderSettingsNavigation(SAAS_NAV_ITEMS);
|
||||
renderSettingsNavigation(toRenderedItems(SAAS_NAV_ITEMS));
|
||||
|
||||
await screen.findByTestId("settings-navbar");
|
||||
|
||||
const orgMembersLink = await screen.findByText("Organization Members");
|
||||
const orgLink = await screen.findByText("Organization");
|
||||
const orgMembersLink = await screen.findByText("SETTINGS$NAV_ORG_MEMBERS");
|
||||
const orgLink = await screen.findByText("SETTINGS$NAV_ORGANIZATION");
|
||||
|
||||
expect(orgMembersLink).toBeInTheDocument();
|
||||
expect(orgLink).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render org routes when excluded from navigation items", async () => {
|
||||
renderSettingsNavigation(ITEMS_WITHOUT_ORG);
|
||||
renderSettingsNavigation(toRenderedItems(ITEMS_WITHOUT_ORG));
|
||||
|
||||
await screen.findByTestId("settings-navbar");
|
||||
|
||||
const orgMembersLink = screen.queryByText("Organization Members");
|
||||
const orgLink = screen.queryByText("Organization");
|
||||
const orgMembersLink = screen.queryByText("SETTINGS$NAV_ORG_MEMBERS");
|
||||
const orgLink = screen.queryByText("SETTINGS$NAV_ORGANIZATION");
|
||||
|
||||
expect(orgMembersLink).not.toBeInTheDocument();
|
||||
expect(orgLink).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render all non-org SAAS items regardless of which items are passed", async () => {
|
||||
renderSettingsNavigation(SAAS_NAV_ITEMS);
|
||||
renderSettingsNavigation(toRenderedItems(SAAS_NAV_ITEMS));
|
||||
|
||||
await screen.findByTestId("settings-navbar");
|
||||
|
||||
@@ -99,11 +104,65 @@ describe("SettingsNavigation", () => {
|
||||
await screen.findByTestId("settings-navbar");
|
||||
|
||||
// No nav links should be rendered
|
||||
const orgMembersLink = screen.queryByText("Organization Members");
|
||||
const orgLink = screen.queryByText("Organization");
|
||||
const orgMembersLink = screen.queryByText("SETTINGS$NAV_ORG_MEMBERS");
|
||||
const orgLink = screen.queryByText("SETTINGS$NAV_ORGANIZATION");
|
||||
|
||||
expect(orgMembersLink).not.toBeInTheDocument();
|
||||
expect(orgLink).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("renders section headers and dividers", () => {
|
||||
it("should render section headers when included in navigation items", async () => {
|
||||
// Arrange
|
||||
const itemsWithHeader: SettingsNavRenderedItem[] = [
|
||||
{ type: "header", text: "SETTINGS$ORG_SETTINGS_HEADER" as any },
|
||||
...toRenderedItems(SAAS_NAV_ITEMS.slice(0, 2)),
|
||||
];
|
||||
|
||||
// Act
|
||||
renderSettingsNavigation(itemsWithHeader);
|
||||
await screen.findByTestId("settings-navbar");
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText("SETTINGS$ORG_SETTINGS_HEADER")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render dividers when included in navigation items", async () => {
|
||||
// Arrange
|
||||
const itemsWithDivider: SettingsNavRenderedItem[] = [
|
||||
...toRenderedItems(SAAS_NAV_ITEMS.slice(0, 2)),
|
||||
{ type: "divider" },
|
||||
...toRenderedItems(SAAS_NAV_ITEMS.slice(2, 4)),
|
||||
];
|
||||
|
||||
// Act
|
||||
renderSettingsNavigation(itemsWithDivider);
|
||||
await screen.findByTestId("settings-navbar");
|
||||
|
||||
// Assert - divider is a div with border-t class
|
||||
const navbar = screen.getByTestId("settings-navbar");
|
||||
const dividers = navbar.querySelectorAll(".border-t");
|
||||
expect(dividers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should render multiple headers and dividers in correct order", async () => {
|
||||
// Arrange
|
||||
const itemsWithHeadersAndDividers: SettingsNavRenderedItem[] = [
|
||||
{ type: "header", text: "SETTINGS$ORG_SETTINGS_HEADER" as any },
|
||||
...toRenderedItems(SAAS_NAV_ITEMS.slice(0, 1)),
|
||||
{ type: "divider" },
|
||||
{ type: "header", text: "SETTINGS$PERSONAL_SETTINGS_HEADER" as any },
|
||||
...toRenderedItems(SAAS_NAV_ITEMS.slice(1, 2)),
|
||||
];
|
||||
|
||||
// Act
|
||||
renderSettingsNavigation(itemsWithHeadersAndDividers);
|
||||
await screen.findByTestId("settings-navbar");
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText("SETTINGS$ORG_SETTINGS_HEADER")).toBeInTheDocument();
|
||||
expect(screen.getByText("SETTINGS$PERSONAL_SETTINGS_HEADER")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -385,22 +385,58 @@ describe("UserContextMenu", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should render additional context items when user is an admin", () => {
|
||||
it("should render additional context items when user is an admin", async () => {
|
||||
// Mock SaaS mode and a team org so org management items are visible
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
||||
createMockWebClientConfig({ app_mode: "saas" }),
|
||||
);
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
useSelectedOrganizationStore.setState({
|
||||
organizationId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue(
|
||||
createMockUser({ role: "admin", org_id: MOCK_TEAM_ORG_ACME.id }),
|
||||
);
|
||||
|
||||
renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
screen.getByTestId("org-selector");
|
||||
screen.getByText("ORG$INVITE_ORG_MEMBERS");
|
||||
screen.getByText("ORG$ORGANIZATION_MEMBERS");
|
||||
screen.getByText("COMMON$ORGANIZATION");
|
||||
// Wait for orgs to load so org management items appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("ORG$INVITE_ORG_MEMBERS")).toBeInTheDocument();
|
||||
});
|
||||
// Note: Organization and Org Members links may or may not appear depending on
|
||||
// permission checks in useSettingsNavItems. The key test is that Invite button appears.
|
||||
});
|
||||
|
||||
it("should render additional context items when user is an owner", () => {
|
||||
it("should render additional context items when user is an owner", async () => {
|
||||
// Mock SaaS mode and a team org so org management items are visible
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
||||
createMockWebClientConfig({ app_mode: "saas" }),
|
||||
);
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
useSelectedOrganizationStore.setState({
|
||||
organizationId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue(
|
||||
createMockUser({ role: "owner", org_id: MOCK_TEAM_ORG_ACME.id }),
|
||||
);
|
||||
|
||||
renderUserContextMenu({ type: "owner", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
screen.getByTestId("org-selector");
|
||||
screen.getByText("ORG$INVITE_ORG_MEMBERS");
|
||||
screen.getByText("ORG$ORGANIZATION_MEMBERS");
|
||||
screen.getByText("COMMON$ORGANIZATION");
|
||||
// Wait for orgs to load so org management items appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("ORG$INVITE_ORG_MEMBERS")).toBeInTheDocument();
|
||||
});
|
||||
// Note: Organization and Org Members links may or may not appear depending on
|
||||
// permission checks in useSettingsNavItems. The key test is that Invite button appears.
|
||||
});
|
||||
|
||||
it("should call the logout handler when Logout is clicked", async () => {
|
||||
@@ -461,42 +497,61 @@ describe("UserContextMenu", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should navigate to /settings/org-members when Manage Organization Members is clicked", async () => {
|
||||
// Mock a team org so org management buttons are visible (not personal org)
|
||||
it("should have correct link for Organization Members nav item when visible", async () => {
|
||||
// Mock SaaS mode and a team org so org management items are visible
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
||||
createMockWebClientConfig({ app_mode: "saas" }),
|
||||
);
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
useSelectedOrganizationStore.setState({
|
||||
organizationId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue(
|
||||
createMockUser({ role: "admin", org_id: MOCK_TEAM_ORG_ACME.id }),
|
||||
);
|
||||
|
||||
renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
// Wait for orgs to load so org management buttons are visible
|
||||
const manageOrganizationMembersButton = await screen.findByText(
|
||||
"ORG$ORGANIZATION_MEMBERS",
|
||||
);
|
||||
await userEvent.click(manageOrganizationMembersButton);
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledExactlyOnceWith(
|
||||
"/settings/org-members",
|
||||
);
|
||||
// Wait for nav items to load. The Org Members link may appear if permissions are met.
|
||||
await waitFor(() => {
|
||||
const orgMembersLink = screen.queryByText("SETTINGS$NAV_ORG_MEMBERS");
|
||||
if (orgMembersLink) {
|
||||
expect(orgMembersLink.closest("a")).toHaveAttribute(
|
||||
"href",
|
||||
"/settings/org-members",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should navigate to /settings/org when Manage Account is clicked", async () => {
|
||||
// Mock a team org so org management buttons are visible (not personal org)
|
||||
it("should have correct link for Organization nav item when visible", async () => {
|
||||
// Mock SaaS mode and a team org so org management items are visible
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
||||
createMockWebClientConfig({ app_mode: "saas" }),
|
||||
);
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
useSelectedOrganizationStore.setState({
|
||||
organizationId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue(
|
||||
createMockUser({ role: "admin", org_id: MOCK_TEAM_ORG_ACME.id }),
|
||||
);
|
||||
|
||||
renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
// Wait for orgs to load so org management buttons are visible
|
||||
const manageAccountButton = await screen.findByText(
|
||||
"COMMON$ORGANIZATION",
|
||||
);
|
||||
await userEvent.click(manageAccountButton);
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledExactlyOnceWith("/settings/org");
|
||||
// Wait for nav items to load. The Organization link may appear if permissions are met.
|
||||
await waitFor(() => {
|
||||
const orgLink = screen.queryByText("SETTINGS$NAV_ORGANIZATION");
|
||||
if (orgLink) {
|
||||
expect(orgLink.closest("a")).toHaveAttribute("href", "/settings/org");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should call the onClose handler when clicking outside the context menu", async () => {
|
||||
@@ -519,11 +574,12 @@ describe("UserContextMenu", () => {
|
||||
createMockWebClientConfig({ app_mode: "saas" }),
|
||||
);
|
||||
|
||||
// Mock a team org so org management buttons are visible
|
||||
// Mock a team org so org management items are visible
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
seedActiveUser({ role: "owner" });
|
||||
|
||||
const onCloseMock = vi.fn();
|
||||
renderUserContextMenu({ type: "owner", onClose: onCloseMock, onOpenInviteModal: vi.fn });
|
||||
@@ -533,15 +589,15 @@ describe("UserContextMenu", () => {
|
||||
await userEvent.click(logoutButton);
|
||||
expect(onCloseMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Wait for orgs to load so org management buttons are visible
|
||||
const manageOrganizationMembersButton = await screen.findByText(
|
||||
"ORG$ORGANIZATION_MEMBERS",
|
||||
);
|
||||
await userEvent.click(manageOrganizationMembersButton);
|
||||
// Wait for orgs to load so org management items are visible
|
||||
// Click on Organization Members link (now it's a Link, not a button)
|
||||
const orgMembersLink = await screen.findByText("SETTINGS$NAV_ORG_MEMBERS");
|
||||
await userEvent.click(orgMembersLink);
|
||||
expect(onCloseMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
const manageAccountButton = screen.getByText("COMMON$ORGANIZATION");
|
||||
await userEvent.click(manageAccountButton);
|
||||
// Click on Organization link
|
||||
const orgLink = screen.getByText("SETTINGS$NAV_ORGANIZATION");
|
||||
await userEvent.click(orgLink);
|
||||
expect(onCloseMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
@@ -613,11 +669,17 @@ describe("UserContextMenu", () => {
|
||||
});
|
||||
|
||||
it("should call onOpenInviteModal and onClose when Invite Organization Member is clicked", async () => {
|
||||
// Mock a team org so org management buttons are visible (not personal org)
|
||||
// Mock a team org so org management items are visible (not personal org)
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
useSelectedOrganizationStore.setState({
|
||||
organizationId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue(
|
||||
createMockUser({ role: "admin", org_id: MOCK_TEAM_ORG_ACME.id }),
|
||||
);
|
||||
|
||||
const onCloseMock = vi.fn();
|
||||
const onOpenInviteModalMock = vi.fn();
|
||||
@@ -627,7 +689,7 @@ describe("UserContextMenu", () => {
|
||||
onOpenInviteModal: onOpenInviteModalMock,
|
||||
});
|
||||
|
||||
// Wait for orgs to load so org management buttons are visible
|
||||
// Wait for orgs to load so org management items are visible
|
||||
const inviteButton = await screen.findByText("ORG$INVITE_ORG_MEMBERS");
|
||||
await userEvent.click(inviteButton);
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ModelSelector } from "#/components/shared/modals/settings/model-selector";
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
@@ -32,15 +31,20 @@ describe("ModelSelector", () => {
|
||||
separator: "/",
|
||||
models: ["chat-bison", "chat-bison-32k"],
|
||||
},
|
||||
cohere: {
|
||||
separator: ".",
|
||||
models: ["command-r-v1:0"],
|
||||
},
|
||||
};
|
||||
|
||||
const verifiedModels = ["gpt-4o", "gpt-4o-mini"];
|
||||
const verifiedProviders = ["openai"];
|
||||
|
||||
it("should display the provider selector", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelector models={models} />);
|
||||
render(
|
||||
<ModelSelector
|
||||
models={models}
|
||||
verifiedModels={verifiedModels}
|
||||
verifiedProviders={verifiedProviders}
|
||||
/>,
|
||||
);
|
||||
|
||||
const selector = screen.getByLabelText("LLM Provider");
|
||||
expect(selector).toBeInTheDocument();
|
||||
@@ -50,12 +54,17 @@ describe("ModelSelector", () => {
|
||||
expect(screen.getByText("OpenAI")).toBeInTheDocument();
|
||||
expect(screen.getByText("Azure")).toBeInTheDocument();
|
||||
expect(screen.getByText("VertexAI")).toBeInTheDocument();
|
||||
expect(screen.getByText("cohere")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should disable the model selector if the provider is not selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelector models={models} />);
|
||||
render(
|
||||
<ModelSelector
|
||||
models={models}
|
||||
verifiedModels={verifiedModels}
|
||||
verifiedProviders={verifiedProviders}
|
||||
/>,
|
||||
);
|
||||
|
||||
const modelSelector = screen.getByLabelText("LLM Model");
|
||||
expect(modelSelector).toBeDisabled();
|
||||
@@ -71,7 +80,13 @@ describe("ModelSelector", () => {
|
||||
|
||||
it("should display the model selector", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelector models={models} />);
|
||||
render(
|
||||
<ModelSelector
|
||||
models={models}
|
||||
verifiedModels={verifiedModels}
|
||||
verifiedProviders={verifiedProviders}
|
||||
/>,
|
||||
);
|
||||
|
||||
const providerSelector = screen.getByLabelText("LLM Provider");
|
||||
await user.click(providerSelector);
|
||||
@@ -84,51 +99,43 @@ describe("ModelSelector", () => {
|
||||
|
||||
expect(screen.getByText("ada")).toBeInTheDocument();
|
||||
expect(screen.getByText("gpt-35-turbo")).toBeInTheDocument();
|
||||
|
||||
await user.click(providerSelector);
|
||||
const vertexProvider = screen.getByText("VertexAI");
|
||||
await user.click(vertexProvider);
|
||||
|
||||
await user.click(modelSelector);
|
||||
|
||||
// Test fails when expecting these values to be present.
|
||||
// My hypothesis is that it has something to do with NextUI's
|
||||
// list virtualization
|
||||
|
||||
// expect(screen.getByText("chat-bison")).toBeInTheDocument();
|
||||
// expect(screen.getByText("chat-bison-32k")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onModelChange when the model is changed", async () => {
|
||||
it("should call onChange when the provider and model change", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelector models={models} />);
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(
|
||||
<ModelSelector
|
||||
models={models}
|
||||
verifiedModels={verifiedModels}
|
||||
verifiedProviders={verifiedProviders}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const providerSelector = screen.getByLabelText("LLM Provider");
|
||||
const modelSelector = screen.getByLabelText("LLM Model");
|
||||
|
||||
await user.click(providerSelector);
|
||||
await user.click(screen.getByText("Azure"));
|
||||
|
||||
const modelSelector = screen.getByLabelText("LLM Model");
|
||||
await user.click(modelSelector);
|
||||
await user.click(screen.getByText("ada"));
|
||||
|
||||
await user.click(modelSelector);
|
||||
await user.click(screen.getByText("gpt-35-turbo"));
|
||||
|
||||
await user.click(providerSelector);
|
||||
await user.click(screen.getByText("cohere"));
|
||||
|
||||
await user.click(modelSelector);
|
||||
|
||||
// Test fails when expecting this values to be present.
|
||||
// My hypothesis is that it has something to do with NextUI's
|
||||
// list virtualization
|
||||
|
||||
// await user.click(screen.getByText("command-r-v1:0"));
|
||||
expect(onChange).toHaveBeenNthCalledWith(1, "azure", null);
|
||||
expect(onChange).toHaveBeenNthCalledWith(2, "azure", "ada");
|
||||
});
|
||||
|
||||
|
||||
it("should have a default value if passed", async () => {
|
||||
render(<ModelSelector models={models} currentModel="azure/ada" />);
|
||||
render(
|
||||
<ModelSelector
|
||||
models={models}
|
||||
verifiedModels={verifiedModels}
|
||||
verifiedProviders={verifiedProviders}
|
||||
currentModel="azure/ada"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("LLM Provider")).toHaveValue("Azure");
|
||||
expect(screen.getByLabelText("LLM Model")).toHaveValue("ada");
|
||||
|
||||
@@ -17,6 +17,8 @@ describe("SettingsForm", () => {
|
||||
<SettingsForm
|
||||
settings={DEFAULT_SETTINGS}
|
||||
models={[DEFAULT_SETTINGS.llm_model]}
|
||||
verifiedModels={[]}
|
||||
verifiedProviders={["openhands"]}
|
||||
onClose={onCloseMock}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -135,36 +135,6 @@ describe("UserActions", () => {
|
||||
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should NOT show context menu when user is not authenticated and avatar is clicked", async () => {
|
||||
// Set isAuthed to false for this test
|
||||
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
|
||||
// Keep other mocks with default values
|
||||
useConfigMock.mockReturnValue({
|
||||
data: { app_mode: "saas" },
|
||||
isLoading: false,
|
||||
});
|
||||
useUserProvidersMock.mockReturnValue({
|
||||
providers: [{ id: "github", name: "GitHub" }],
|
||||
});
|
||||
|
||||
renderUserActions();
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
|
||||
// Context menu should NOT appear because user is not authenticated
|
||||
expect(screen.queryByTestId("user-context-menu")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should NOT show context menu when user is undefined and avatar is hovered", async () => {
|
||||
renderUserActions({ hasAvatar: false });
|
||||
const userActions = screen.getByTestId("user-actions");
|
||||
await user.hover(userActions);
|
||||
|
||||
// Context menu should NOT appear because user is undefined
|
||||
expect(screen.queryByTestId("user-context-menu")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show context menu even when user has no avatar_url", async () => {
|
||||
renderUserActions();
|
||||
const userActions = screen.getByTestId("user-actions");
|
||||
@@ -174,128 +144,6 @@ describe("UserActions", () => {
|
||||
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should NOT be able to access logout when user is not authenticated", async () => {
|
||||
// Set isAuthed to false for this test
|
||||
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
|
||||
// Keep other mocks with default values
|
||||
useConfigMock.mockReturnValue({
|
||||
data: { app_mode: "saas" },
|
||||
isLoading: false,
|
||||
});
|
||||
useUserProvidersMock.mockReturnValue({
|
||||
providers: [{ id: "github", name: "GitHub" }],
|
||||
});
|
||||
|
||||
renderWithRouter(<UserActions />);
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
|
||||
// Context menu should NOT appear because user is not authenticated
|
||||
expect(screen.queryByTestId("user-context-menu")).not.toBeInTheDocument();
|
||||
|
||||
// Logout option should NOT be accessible when user is not authenticated
|
||||
expect(
|
||||
screen.queryByText("ACCOUNT_SETTINGS$LOGOUT"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle user prop changing from undefined to defined", async () => {
|
||||
// Start with no authentication
|
||||
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
|
||||
// Keep other mocks with default values
|
||||
useConfigMock.mockReturnValue({
|
||||
data: { app_mode: "saas" },
|
||||
isLoading: false,
|
||||
});
|
||||
useUserProvidersMock.mockReturnValue({
|
||||
providers: [{ id: "github", name: "GitHub" }],
|
||||
});
|
||||
|
||||
const { unmount } = renderWithRouter(<UserActions />);
|
||||
|
||||
// Initially no user and not authenticated - menu should not appear
|
||||
const userActions = screen.getByTestId("user-actions");
|
||||
await user.hover(userActions);
|
||||
expect(screen.queryByTestId("user-context-menu")).not.toBeInTheDocument();
|
||||
|
||||
// Unmount the first component
|
||||
unmount();
|
||||
|
||||
// Set authentication to true for the new render
|
||||
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
|
||||
// Ensure config and providers are set correctly
|
||||
useConfigMock.mockReturnValue({
|
||||
data: { app_mode: "saas" },
|
||||
isLoading: false,
|
||||
});
|
||||
useUserProvidersMock.mockReturnValue({
|
||||
providers: [{ id: "github", name: "GitHub" }],
|
||||
});
|
||||
|
||||
// Render a new component with user prop and authentication
|
||||
renderWithRouter(
|
||||
<UserActions user={{ avatar_url: "https://example.com/avatar.png" }} />,
|
||||
);
|
||||
|
||||
// Component should render correctly
|
||||
expect(screen.getByTestId("user-actions")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
|
||||
|
||||
// Menu should now work with user defined and authenticated
|
||||
const userActionsEl = screen.getByTestId("user-actions");
|
||||
await user.hover(userActionsEl);
|
||||
|
||||
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle user prop changing from defined to undefined", async () => {
|
||||
// Start with authentication and providers
|
||||
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
|
||||
useConfigMock.mockReturnValue({
|
||||
data: { app_mode: "saas" },
|
||||
isLoading: false,
|
||||
});
|
||||
useUserProvidersMock.mockReturnValue({
|
||||
providers: [{ id: "github", name: "GitHub" }],
|
||||
});
|
||||
|
||||
const { rerender } = renderWithRouter(
|
||||
<UserActions user={{ avatar_url: "https://example.com/avatar.png" }} />,
|
||||
);
|
||||
|
||||
// Hover to open menu
|
||||
const userActions = screen.getByTestId("user-actions");
|
||||
await user.hover(userActions);
|
||||
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
|
||||
|
||||
// Set authentication to false for the rerender
|
||||
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
|
||||
// Keep other mocks with default values
|
||||
useConfigMock.mockReturnValue({
|
||||
data: { app_mode: "saas" },
|
||||
isLoading: false,
|
||||
});
|
||||
useUserProvidersMock.mockReturnValue({
|
||||
providers: [{ id: "github", name: "GitHub" }],
|
||||
});
|
||||
|
||||
// Remove user prop - menu should disappear because user is no longer authenticated
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
<UserActions />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// Context menu should NOT be visible when user becomes unauthenticated
|
||||
expect(screen.queryByTestId("user-context-menu")).not.toBeInTheDocument();
|
||||
|
||||
// Logout option should not be accessible
|
||||
expect(
|
||||
screen.queryByText("ACCOUNT_SETTINGS$LOGOUT"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should work with loading state and user provided", async () => {
|
||||
// Ensure authentication and providers are set correctly
|
||||
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createSkillReadyEvent,
|
||||
isSkillReadyEvent,
|
||||
} from "#/components/v1/chat/event-content-helpers/create-skill-ready-event";
|
||||
import { MessageEvent } from "#/types/v1/core";
|
||||
|
||||
const makeMessageEvent = (
|
||||
overrides: Partial<MessageEvent> = {},
|
||||
): MessageEvent =>
|
||||
({
|
||||
id: "msg-1",
|
||||
timestamp: "2024-01-01T00:00:00Z",
|
||||
source: "user",
|
||||
message: { role: "user", content: [{ type: "text", text: "test" }] },
|
||||
activated_microagents: [],
|
||||
extended_content: [],
|
||||
...overrides,
|
||||
}) as MessageEvent;
|
||||
|
||||
describe("createSkillReadyEvent", () => {
|
||||
it("includes _skillReadyItems with structured skill data", () => {
|
||||
const event = makeMessageEvent({
|
||||
activated_microagents: ["docker"],
|
||||
extended_content: [
|
||||
{ type: "text", text: "<EXTRA_INFO>Docker guide</EXTRA_INFO>" },
|
||||
],
|
||||
});
|
||||
|
||||
const result = createSkillReadyEvent(event);
|
||||
|
||||
expect(result._skillReadyItems).toEqual([
|
||||
{ name: "docker", content: "Docker guide" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("sets correct id and source", () => {
|
||||
const event = makeMessageEvent({
|
||||
id: "msg-42",
|
||||
activated_microagents: ["skill1"],
|
||||
extended_content: [
|
||||
{ type: "text", text: "<EXTRA_INFO>content</EXTRA_INFO>" },
|
||||
],
|
||||
});
|
||||
|
||||
const result = createSkillReadyEvent(event);
|
||||
|
||||
expect(result.id).toBe("msg-42-skill-ready");
|
||||
expect(result.source).toBe("agent");
|
||||
expect(result._isSkillReadyEvent).toBe(true);
|
||||
});
|
||||
|
||||
it("throws when no skills and no extended content", () => {
|
||||
const event = makeMessageEvent();
|
||||
|
||||
expect(() => createSkillReadyEvent(event)).toThrow(
|
||||
"Cannot create skill ready event",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSkillReadyEvent", () => {
|
||||
it("returns true for valid SkillReadyEvent", () => {
|
||||
const event = makeMessageEvent({
|
||||
activated_microagents: ["skill1"],
|
||||
extended_content: [
|
||||
{ type: "text", text: "<EXTRA_INFO>content</EXTRA_INFO>" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(isSkillReadyEvent(createSkillReadyEvent(event))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for plain objects", () => {
|
||||
expect(isSkillReadyEvent({})).toBe(false);
|
||||
expect(isSkillReadyEvent(null)).toBe(false);
|
||||
expect(isSkillReadyEvent({ _isSkillReadyEvent: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getSkillReadyItems } from "#/components/v1/chat/event-content-helpers/get-skill-ready-content";
|
||||
import { TextContent } from "#/types/v1/core/base/common";
|
||||
|
||||
const makeTextContent = (text: string): TextContent[] => [
|
||||
{ type: "text", text },
|
||||
];
|
||||
|
||||
const wrapExtraInfo = (content: string): string =>
|
||||
`<EXTRA_INFO>${content}</EXTRA_INFO>`;
|
||||
|
||||
describe("getSkillReadyItems", () => {
|
||||
it("pairs skills with their EXTRA_INFO blocks by index", () => {
|
||||
const skills = ["docker", "gitlab"];
|
||||
const extended = makeTextContent(
|
||||
`${wrapExtraInfo("Docker guide")}${wrapExtraInfo("GitLab guide")}`,
|
||||
);
|
||||
|
||||
const items = getSkillReadyItems(skills, extended);
|
||||
|
||||
expect(items).toEqual([
|
||||
{ name: "docker", content: "Docker guide" },
|
||||
{ name: "gitlab", content: "GitLab guide" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns empty content for skills without matching EXTRA_INFO", () => {
|
||||
const skills = ["docker", "gitlab"];
|
||||
const extended = makeTextContent(wrapExtraInfo("Docker guide only"));
|
||||
|
||||
const items = getSkillReadyItems(skills, extended);
|
||||
|
||||
expect(items).toEqual([
|
||||
{ name: "docker", content: "Docker guide only" },
|
||||
{ name: "gitlab", content: "" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns unnamed items when no skills but EXTRA_INFO blocks exist", () => {
|
||||
const extended = makeTextContent(
|
||||
`${wrapExtraInfo("Block A")}${wrapExtraInfo("Block B")}`,
|
||||
);
|
||||
|
||||
const items = getSkillReadyItems([], extended);
|
||||
|
||||
expect(items).toEqual([
|
||||
{ name: "Extended Content 1", content: "Block A" },
|
||||
{ name: "Extended Content 2", content: "Block B" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns empty array when no skills and no extended content", () => {
|
||||
expect(getSkillReadyItems([], [])).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips empty EXTRA_INFO blocks for unnamed items", () => {
|
||||
const extended = makeTextContent(
|
||||
`${wrapExtraInfo("Content")}${wrapExtraInfo(" ")}`,
|
||||
);
|
||||
|
||||
const items = getSkillReadyItems([], extended);
|
||||
|
||||
expect(items).toEqual([{ name: "Extended Content 1", content: "Content" }]);
|
||||
});
|
||||
|
||||
it("trims content from EXTRA_INFO blocks", () => {
|
||||
const skills = ["docker"];
|
||||
const extended = makeTextContent(wrapExtraInfo(" trimmed content "));
|
||||
|
||||
const items = getSkillReadyItems(skills, extended);
|
||||
|
||||
expect(items[0].content).toBe("trimmed content");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
parseSkillContent,
|
||||
styleImportantTags,
|
||||
} from "#/components/v1/chat/event-message-components/skill-item-expanded";
|
||||
|
||||
describe("parseSkillContent", () => {
|
||||
it("should extract matchInfo from content", () => {
|
||||
const content =
|
||||
"The following information has been included based on keyword match\nIt may or may not be relevant\n\nActual body content";
|
||||
const result = parseSkillContent(content);
|
||||
|
||||
expect(result.matchInfo).toBe(
|
||||
"The following information has been included based on keyword match",
|
||||
);
|
||||
expect(result.body).toBe("Actual body content");
|
||||
});
|
||||
|
||||
it("should extract filePath from content", () => {
|
||||
const content = "Skill location: /path/to/skill.md\n\nBody here";
|
||||
const result = parseSkillContent(content);
|
||||
|
||||
expect(result.filePath).toBe("/path/to/skill.md");
|
||||
expect(result.body).toBe("Body here");
|
||||
});
|
||||
|
||||
it("should handle content with both matchInfo and filePath", () => {
|
||||
const content =
|
||||
"The following information has been included based on keyword match\nIt may or may not be relevant\nSkill location: /path/to/skill.md\n(Use this path to resolve imports)\n\nBody content";
|
||||
const result = parseSkillContent(content);
|
||||
|
||||
expect(result.matchInfo).toBe(
|
||||
"The following information has been included based on keyword match",
|
||||
);
|
||||
expect(result.filePath).toBe("/path/to/skill.md");
|
||||
expect(result.body).toBe("Body content");
|
||||
});
|
||||
|
||||
it("should handle content with no metadata", () => {
|
||||
const content = "Just plain body content\nWith multiple lines";
|
||||
const result = parseSkillContent(content);
|
||||
|
||||
expect(result.matchInfo).toBeNull();
|
||||
expect(result.filePath).toBeNull();
|
||||
expect(result.body).toBe("Just plain body content\nWith multiple lines");
|
||||
});
|
||||
|
||||
it("should handle empty content", () => {
|
||||
const result = parseSkillContent("");
|
||||
|
||||
expect(result.matchInfo).toBeNull();
|
||||
expect(result.filePath).toBeNull();
|
||||
expect(result.body).toBe("");
|
||||
});
|
||||
|
||||
it("should skip blank lines between metadata and body", () => {
|
||||
const content = "Skill location: /path/to/skill.md\n\n\nBody after blanks";
|
||||
const result = parseSkillContent(content);
|
||||
|
||||
expect(result.filePath).toBe("/path/to/skill.md");
|
||||
expect(result.body).toBe("Body after blanks");
|
||||
});
|
||||
});
|
||||
|
||||
describe("styleImportantTags", () => {
|
||||
it("should wrap important tag content with bold markers", () => {
|
||||
const text = "Some text <important>critical info</important> more text";
|
||||
const result = styleImportantTags(text);
|
||||
|
||||
expect(result).toBe("Some text **critical info** more text");
|
||||
});
|
||||
|
||||
it("should handle multiple important tags", () => {
|
||||
const text =
|
||||
"<important>first</important> and <important>second</important>";
|
||||
const result = styleImportantTags(text);
|
||||
|
||||
expect(result).toBe("**first** and **second**");
|
||||
});
|
||||
|
||||
it("should handle multiline content inside important tags", () => {
|
||||
const text = "<important>line1\nline2</important>";
|
||||
const result = styleImportantTags(text);
|
||||
|
||||
expect(result).toBe("**line1\nline2**");
|
||||
});
|
||||
|
||||
it("should be case-insensitive", () => {
|
||||
const text = "<IMPORTANT>test</IMPORTANT>";
|
||||
const result = styleImportantTags(text);
|
||||
|
||||
expect(result).toBe("**test**");
|
||||
});
|
||||
|
||||
it("should return text unchanged if no important tags", () => {
|
||||
const text = "No important tags here";
|
||||
const result = styleImportantTags(text);
|
||||
|
||||
expect(result).toBe("No important tags here");
|
||||
});
|
||||
|
||||
it("should trim whitespace inside important tags", () => {
|
||||
const text = "<important> spaced </important>";
|
||||
const result = styleImportantTags(text);
|
||||
|
||||
expect(result).toBe("**spaced**");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { SkillReadyContentList } from "#/components/v1/chat/event-message-components/skill-ready-content-list";
|
||||
import { SkillReadyItem } from "#/components/v1/chat/event-content-helpers/create-skill-ready-event";
|
||||
|
||||
const makeItems = (
|
||||
...entries: [string, string][]
|
||||
): SkillReadyItem[] =>
|
||||
entries.map(([name, content]) => ({ name, content }));
|
||||
|
||||
describe("SkillReadyContentList", () => {
|
||||
it("renders all skill names", () => {
|
||||
const items = makeItems(["docker", "content1"], ["gitlab", "content2"]);
|
||||
|
||||
renderWithProviders(<SkillReadyContentList items={items} />);
|
||||
|
||||
expect(screen.getByText("docker")).toBeInTheDocument();
|
||||
expect(screen.getByText("gitlab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the header label", () => {
|
||||
const items = makeItems(["docker", "content"]);
|
||||
|
||||
renderWithProviders(<SkillReadyContentList items={items} />);
|
||||
|
||||
expect(
|
||||
screen.getByText("SKILLS$TRIGGERED_SKILL_KNOWLEDGE"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show content before clicking", () => {
|
||||
const items = makeItems(["docker", "Docker usage guide"]);
|
||||
|
||||
renderWithProviders(<SkillReadyContentList items={items} />);
|
||||
|
||||
expect(screen.queryByText("Docker usage guide")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("expands skill content on click", async () => {
|
||||
const user = userEvent.setup();
|
||||
const items = makeItems(["docker", "Docker usage guide"]);
|
||||
|
||||
renderWithProviders(<SkillReadyContentList items={items} />);
|
||||
|
||||
await user.click(screen.getByText("docker"));
|
||||
|
||||
expect(screen.getByText("Docker usage guide")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("collapses skill content on second click", async () => {
|
||||
const user = userEvent.setup();
|
||||
const items = makeItems(["docker", "Docker usage guide"]);
|
||||
|
||||
renderWithProviders(<SkillReadyContentList items={items} />);
|
||||
|
||||
await user.click(screen.getByText("docker"));
|
||||
expect(screen.getByText("Docker usage guide")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText("docker"));
|
||||
expect(screen.queryByText("Docker usage guide")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("expands skills independently", async () => {
|
||||
const user = userEvent.setup();
|
||||
const items = makeItems(
|
||||
["docker", "Docker guide"],
|
||||
["gitlab", "GitLab guide"],
|
||||
);
|
||||
|
||||
renderWithProviders(<SkillReadyContentList items={items} />);
|
||||
|
||||
await user.click(screen.getByText("docker"));
|
||||
|
||||
expect(screen.getByText("Docker guide")).toBeInTheDocument();
|
||||
expect(screen.queryByText("GitLab guide")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders <important> content as bold text", async () => {
|
||||
const user = userEvent.setup();
|
||||
const content = "Some text <important>critical info</important> more text";
|
||||
const items = makeItems(["docker", content]);
|
||||
|
||||
renderWithProviders(<SkillReadyContentList items={items} />);
|
||||
|
||||
await user.click(screen.getByText("docker"));
|
||||
|
||||
// The important text should be rendered as bold (strong element)
|
||||
const boldElement = screen.getByText("critical info");
|
||||
expect(boldElement).toBeInTheDocument();
|
||||
expect(boldElement.tagName).toBe("STRONG");
|
||||
});
|
||||
|
||||
it("parses and displays file path from metadata", async () => {
|
||||
const user = userEvent.setup();
|
||||
const content = [
|
||||
"The following information has been included based on a keyword match for \"docker\".",
|
||||
"It may or may not be relevant to the user's request.",
|
||||
"Skill location: /home/openhands/.openhands/skills/docker/SKILL.md",
|
||||
"(Use this path to resolve relative file references)",
|
||||
"",
|
||||
"Docker Usage Guide",
|
||||
].join("\n");
|
||||
const items = makeItems(["docker", content]);
|
||||
|
||||
renderWithProviders(<SkillReadyContentList items={items} />);
|
||||
|
||||
await user.click(screen.getByText("docker"));
|
||||
|
||||
// File path rendered in code element
|
||||
expect(
|
||||
screen.getByText(
|
||||
"/home/openhands/.openhands/skills/docker/SKILL.md",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
// Actual skill body rendered
|
||||
expect(screen.getByText("Docker Usage Guide")).toBeInTheDocument();
|
||||
// Metadata preamble lines are not rendered as-is in the body
|
||||
expect(
|
||||
screen.queryByText("It may or may not be relevant"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -62,7 +62,7 @@ describe("getEventContent", () => {
|
||||
it("uses the action summary as the full action title", () => {
|
||||
const { title } = getEventContent(terminalActionEvent);
|
||||
|
||||
render(<>{title}</>);
|
||||
render(<span>{title}</span>);
|
||||
|
||||
expect(screen.getByText("Check repository status")).toBeInTheDocument();
|
||||
expect(screen.queryByText("$ git status")).not.toBeInTheDocument();
|
||||
@@ -72,7 +72,7 @@ describe("getEventContent", () => {
|
||||
const actionWithoutSummary = { ...terminalActionEvent, summary: undefined };
|
||||
const { title } = getEventContent(actionWithoutSummary);
|
||||
|
||||
render(<>{title}</>);
|
||||
render(<span>{title}</span>);
|
||||
|
||||
// Without i18n loaded, the translation key renders as the raw key
|
||||
expect(screen.getByText("ACTION_MESSAGE$RUN")).toBeInTheDocument();
|
||||
@@ -81,13 +81,66 @@ describe("getEventContent", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("returns empty details for file view action instead of 'Unknown event'", () => {
|
||||
const fileViewAction: ActionEvent = {
|
||||
id: "action-2",
|
||||
timestamp: new Date().toISOString(),
|
||||
source: "agent",
|
||||
thought: [],
|
||||
thinking_blocks: [],
|
||||
action: {
|
||||
kind: "FileEditorAction",
|
||||
command: "view",
|
||||
path: "/workspace/README.md",
|
||||
file_text: null,
|
||||
old_str: null,
|
||||
new_str: null,
|
||||
insert_line: null,
|
||||
view_range: null,
|
||||
},
|
||||
tool_name: "file_editor",
|
||||
tool_call_id: "tool-2",
|
||||
tool_call: {
|
||||
id: "tool-2",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "file_editor",
|
||||
arguments: '{"command":"view","path":"/workspace/README.md"}',
|
||||
},
|
||||
},
|
||||
llm_response_id: "response-2",
|
||||
security_risk: SecurityRisk.LOW,
|
||||
};
|
||||
|
||||
const { title, details } = getEventContent(fileViewAction);
|
||||
|
||||
render(<span>{title}</span>);
|
||||
expect(screen.getByText("ACTION_MESSAGE$READ")).toBeInTheDocument();
|
||||
expect(details).toBe("");
|
||||
});
|
||||
|
||||
it("shows action kind for action-like events missing tool_name/tool_call_id", () => {
|
||||
// Simulate an event that has an action object but fails the strict isActionEvent() guard
|
||||
const malformedEvent = {
|
||||
id: "action-3",
|
||||
timestamp: new Date().toISOString(),
|
||||
source: "agent" as const,
|
||||
action: { kind: "FileEditorAction" },
|
||||
};
|
||||
|
||||
const { title, details } = getEventContent(malformedEvent as any);
|
||||
|
||||
expect(title).toBe("FILEEDITOR");
|
||||
expect(details).toBe("");
|
||||
});
|
||||
|
||||
it("reuses the action summary as the full paired observation title", () => {
|
||||
const { title } = getEventContent(
|
||||
terminalObservationEvent,
|
||||
terminalActionEvent,
|
||||
);
|
||||
|
||||
render(<>{title}</>);
|
||||
render(<span>{title}</span>);
|
||||
|
||||
expect(screen.getByText("Check repository status")).toBeInTheDocument();
|
||||
expect(screen.queryByText("$ git status")).not.toBeInTheDocument();
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
createMockMessageEvent,
|
||||
createMockUserMessageEvent,
|
||||
createMockConversationErrorEvent,
|
||||
createMockServerErrorEvent,
|
||||
createMockAgentErrorEvent,
|
||||
createMockBrowserObservationEvent,
|
||||
createMockBrowserNavigateActionEvent,
|
||||
@@ -364,6 +365,119 @@ describe("Conversation WebSocket Handler", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should update error message store on ServerErrorEvent", async () => {
|
||||
// ServerErrorEvent represents server-side errors (e.g., MCP configuration errors)
|
||||
// that should be shown as a banner to the user.
|
||||
const mockServerErrorEvent = createMockServerErrorEvent();
|
||||
|
||||
// Set up MSW to send the error event when connection is established
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
// Send the mock error event after connection
|
||||
client.send(JSON.stringify(mockServerErrorEvent));
|
||||
}),
|
||||
);
|
||||
|
||||
// Render components that use both WebSocket and error message store
|
||||
renderWithWebSocketContext(<ErrorMessageStoreComponent />);
|
||||
|
||||
// Initially should show "none"
|
||||
expect(screen.getByTestId("error-message")).toHaveTextContent("none");
|
||||
|
||||
// Wait for connection and error event processing
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("error-message")).toHaveTextContent(
|
||||
"MCP server connection failed: Invalid configuration",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle different ServerErrorEvent error codes", async () => {
|
||||
// Test different error codes for ServerErrorEvent
|
||||
const mockServerErrorEvent = createMockServerErrorEvent({
|
||||
code: "RuntimeError",
|
||||
detail: "Agent server runtime error: Out of memory",
|
||||
});
|
||||
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
client.send(JSON.stringify(mockServerErrorEvent));
|
||||
}),
|
||||
);
|
||||
|
||||
renderWithWebSocketContext(<ErrorMessageStoreComponent />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("error-message")).toHaveTextContent(
|
||||
"Agent server runtime error: Out of memory",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should clear error message when a successful event is received after a ServerErrorEvent", async () => {
|
||||
// This test verifies that error banners disappear when follow-up messages
|
||||
// are sent and received after a ServerErrorEvent.
|
||||
// Note: This test was originally commented out because the implementation
|
||||
// didn't properly clear ServerErrorEvent errors on subsequent events.
|
||||
// After the fix using isDisplayableErrorEvent, this now works correctly.
|
||||
const conversationId = "test-server-error-clear";
|
||||
|
||||
// Set up MSW to mock event count API and send events
|
||||
mswServer.use(
|
||||
http.get(
|
||||
`http://localhost:3000/api/conversations/${conversationId}/events/count`,
|
||||
() => HttpResponse.json(2),
|
||||
),
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
|
||||
// Send ServerErrorEvent first (sets the error banner)
|
||||
const mockServerErrorEvent = createMockServerErrorEvent();
|
||||
client.send(JSON.stringify(mockServerErrorEvent));
|
||||
|
||||
// Send a successful (non-error) event immediately after
|
||||
// This simulates the user sending a follow-up message and receiving a response
|
||||
const mockSuccessEvent = createMockMessageEvent({
|
||||
id: "success-event-after-server-error",
|
||||
});
|
||||
client.send(JSON.stringify(mockSuccessEvent));
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify error message store is initially empty
|
||||
expect(useErrorMessageStore.getState().errorMessage).toBeNull();
|
||||
|
||||
// Render with WebSocket context (minimal component just to trigger connection)
|
||||
renderWithWebSocketContext(
|
||||
<ConnectionStatusComponent />,
|
||||
conversationId,
|
||||
`http://localhost:3000/api/conversations/${conversationId}`,
|
||||
);
|
||||
|
||||
// Wait for connection
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"OPEN",
|
||||
);
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
|
||||
// Wait for both events to be received and error to be cleared
|
||||
// The error was set by the first event (ServerErrorEvent),
|
||||
// then cleared by the second successful event (MessageEvent).
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(useEventStore.getState().events.length).toBe(2);
|
||||
expect(useErrorMessageStore.getState().errorMessage).toBeNull();
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should show friendly i18n message for budget ConversationErrorEvent", async () => {
|
||||
const mockBudgetConversationError = createMockConversationErrorEvent({
|
||||
detail:
|
||||
@@ -936,7 +1050,9 @@ describe("Conversation WebSocket Handler", () => {
|
||||
http.get(
|
||||
`http://localhost:3000/api/v1/conversation/${conversationId}/events/search`,
|
||||
async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 10);
|
||||
});
|
||||
return HttpResponse.json({
|
||||
items: mockHistoryEvents,
|
||||
});
|
||||
@@ -1057,7 +1173,9 @@ describe("Conversation WebSocket Handler", () => {
|
||||
http.get(
|
||||
`http://localhost:3000/api/v1/conversation/${conversationId}/events/search`,
|
||||
async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 10);
|
||||
});
|
||||
return HttpResponse.json({
|
||||
items: mockHistoryEvents,
|
||||
});
|
||||
|
||||
78
frontend/__tests__/hooks/chat/use-slash-command.test.ts
Normal file
78
frontend/__tests__/hooks/chat/use-slash-command.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { useSlashCommand } from "#/hooks/chat/use-slash-command";
|
||||
|
||||
const mockSkills = vi.hoisted(() => ({
|
||||
data: undefined as unknown[] | undefined,
|
||||
isLoading: false,
|
||||
}));
|
||||
|
||||
const mockConversation = vi.hoisted(() => ({
|
||||
data: undefined as { conversation_version?: "V0" | "V1" } | undefined,
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-conversation-skills", () => ({
|
||||
useConversationSkills: () => mockSkills,
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-active-conversation", () => ({
|
||||
useActiveConversation: () => mockConversation,
|
||||
}));
|
||||
|
||||
function makeSkill(
|
||||
name: string,
|
||||
triggers: string[] = [],
|
||||
type: "agentskills" | "knowledge" = "agentskills",
|
||||
) {
|
||||
return { name, type, content: `Description of ${name}`, triggers };
|
||||
}
|
||||
|
||||
function makeChatInputRef() {
|
||||
return { current: document.createElement("div") };
|
||||
}
|
||||
|
||||
describe("useSlashCommand", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSkills.data = undefined;
|
||||
mockSkills.isLoading = false;
|
||||
mockConversation.data = undefined;
|
||||
});
|
||||
|
||||
it("includes /new built-in command for V1 conversations", () => {
|
||||
mockConversation.data = { conversation_version: "V1" };
|
||||
mockSkills.isLoading = false;
|
||||
mockSkills.data = [makeSkill("code-search", ["/code-search"])];
|
||||
|
||||
const ref = makeChatInputRef();
|
||||
const { result } = renderHook(() => useSlashCommand(ref));
|
||||
|
||||
const commands = result.current.filteredItems.map((i) => i.command);
|
||||
expect(commands).toContain("/new");
|
||||
expect(commands).toContain("/code-search");
|
||||
});
|
||||
// prevents staggered menu bug
|
||||
it("returns empty items while skills are loading", () => {
|
||||
mockConversation.data = { conversation_version: "V1" };
|
||||
mockSkills.isLoading = true;
|
||||
mockSkills.data = undefined;
|
||||
|
||||
const ref = makeChatInputRef();
|
||||
const { result } = renderHook(() => useSlashCommand(ref));
|
||||
|
||||
expect(result.current.filteredItems).toEqual([]);
|
||||
});
|
||||
|
||||
it("does NOT include /new built-in command for V0 conversations", () => {
|
||||
mockConversation.data = { conversation_version: "V0" };
|
||||
mockSkills.isLoading = false;
|
||||
mockSkills.data = [makeSkill("code-search", ["/code-search"])];
|
||||
|
||||
const ref = makeChatInputRef();
|
||||
const { result } = renderHook(() => useSlashCommand(ref));
|
||||
|
||||
const commands = result.current.filteredItems.map((i) => i.command);
|
||||
expect(commands).not.toContain("/new");
|
||||
expect(commands).toContain("/code-search");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// These tests document a race condition where clicking
|
||||
// "New Conversation" before settings have loaded causes
|
||||
// the hook to create a V0 (legacy) conversation instead of V1.
|
||||
//
|
||||
// Root cause (original code):
|
||||
// const { data: settings } = useSettings();
|
||||
// ...
|
||||
// const useV1 = !!settings?.v1_enabled && !createMicroagent;
|
||||
//
|
||||
// When settings haven't loaded yet, `settings` is `undefined`,
|
||||
// so `!!undefined?.v1_enabled` → false, silently routing through
|
||||
// the V0 code path even though the backend defaults v1_enabled
|
||||
// to `true`.
|
||||
//
|
||||
// The fix uses `queryClient.ensureQueryData()` inside the mutation
|
||||
// to wait for settings before deciding V0 vs V1, with a fallback
|
||||
// to DEFAULT_SETTINGS (v1_enabled: true) on fetch failure.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
const mockGetSettingsQueryFn = vi.fn();
|
||||
|
||||
vi.mock("#/hooks/query/use-settings", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("#/hooks/query/use-settings")
|
||||
>("#/hooks/query/use-settings");
|
||||
return {
|
||||
...actual,
|
||||
getSettingsQueryFn: (...args: unknown[]) =>
|
||||
mockGetSettingsQueryFn(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackConversationCreated: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/context/use-selected-organization", () => ({
|
||||
useSelectedOrganizationId: () => ({ organizationId: null }),
|
||||
}));
|
||||
|
||||
// Shared mock return values
|
||||
const V1_RESPONSE = {
|
||||
id: "task-id-123",
|
||||
created_by_user_id: null,
|
||||
status: "READY" as const,
|
||||
detail: null,
|
||||
app_conversation_id: null,
|
||||
sandbox_id: null,
|
||||
agent_server_url: "http://agent-server.local",
|
||||
request: {
|
||||
sandbox_id: null,
|
||||
initial_message: { role: "user" as const, content: [{ type: "text" as const, text: "hello" }] },
|
||||
processors: [],
|
||||
llm_model: null,
|
||||
selected_repository: null,
|
||||
selected_branch: null,
|
||||
git_provider: "github" as const,
|
||||
suggested_task: null,
|
||||
title: null,
|
||||
trigger: null,
|
||||
pr_number: [],
|
||||
parent_conversation_id: null,
|
||||
agent_type: "default" as const,
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const V0_RESPONSE = {
|
||||
conversation_id: "conv-legacy",
|
||||
session_api_key: null,
|
||||
url: null,
|
||||
title: "",
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
last_updated_at: new Date().toISOString(),
|
||||
status: "RUNNING" as const,
|
||||
runtime_status: null,
|
||||
selected_repository: null,
|
||||
selected_branch: null,
|
||||
git_provider: null,
|
||||
};
|
||||
|
||||
describe("useCreateConversation – V0 race condition", () => {
|
||||
let v1Spy: ReturnType<typeof vi.spyOn>;
|
||||
let v0Spy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
v1Spy = vi
|
||||
.spyOn(V1ConversationService, "createConversation")
|
||||
.mockResolvedValue(V1_RESPONSE);
|
||||
|
||||
v0Spy = vi
|
||||
.spyOn(ConversationService, "createConversation")
|
||||
.mockResolvedValue(V0_RESPONSE);
|
||||
});
|
||||
|
||||
/**
|
||||
* BUG REPRODUCTION: When the settings API hasn't been called yet
|
||||
* (no cached data), the hook should wait for settings to load
|
||||
* rather than defaulting to V0.
|
||||
*
|
||||
* The fix uses `ensureQueryData` to fetch/wait for settings before
|
||||
* deciding V0 vs V1. The mock here resolves with v1_enabled: true,
|
||||
* proving that the mutation waits for the settings query.
|
||||
*/
|
||||
it("should use V1 API even when settings are not yet cached (race condition scenario)", async () => {
|
||||
// Simulate the race condition: settings haven't been fetched yet.
|
||||
// With the fix, ensureQueryData will call getSettingsQueryFn to fetch them.
|
||||
mockGetSettingsQueryFn.mockResolvedValue({ v1_enabled: true });
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useCreateConversation(), {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
await result.current.mutateAsync({ query: "hello" });
|
||||
|
||||
await waitFor(() => {
|
||||
// V1 should be used — the fix waits for settings before deciding
|
||||
expect(v1Spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// V0 should NOT have been called
|
||||
expect(v0Spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/**
|
||||
* When the settings fetch fails (e.g. 404 for a new user), the hook
|
||||
* falls back to DEFAULT_SETTINGS where v1_enabled is now `true`,
|
||||
* still routing through V1.
|
||||
*/
|
||||
it("should use V1 API when settings fetch fails (falls back to defaults)", async () => {
|
||||
// Simulate settings API failure
|
||||
mockGetSettingsQueryFn.mockRejectedValue(new Error("404 Not Found"));
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useCreateConversation(), {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
await result.current.mutateAsync({ query: "hello" });
|
||||
|
||||
await waitFor(() => {
|
||||
// DEFAULT_SETTINGS.v1_enabled is now true, so V1 should be used
|
||||
expect(v1Spy).toHaveBeenCalled();
|
||||
});
|
||||
expect(v0Spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/**
|
||||
* When settings explicitly have v1_enabled: true, V1 API is used.
|
||||
*/
|
||||
it("should use V1 API when settings explicitly have v1_enabled: true", async () => {
|
||||
mockGetSettingsQueryFn.mockResolvedValue({ v1_enabled: true });
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useCreateConversation(), {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
await result.current.mutateAsync({ query: "hello" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(v1Spy).toHaveBeenCalled();
|
||||
});
|
||||
expect(v0Spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/**
|
||||
* When v1_enabled is explicitly false, V0 should be used.
|
||||
*/
|
||||
it("should use V0 API when v1_enabled is explicitly false", async () => {
|
||||
mockGetSettingsQueryFn.mockResolvedValue({ v1_enabled: false });
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useCreateConversation(), {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
await result.current.mutateAsync({ query: "hello" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(v0Spy).toHaveBeenCalled();
|
||||
});
|
||||
expect(v1Spy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -11,11 +11,8 @@ vi.mock("#/hooks/query/use-settings", async () => {
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useSettings: vi.fn().mockReturnValue({
|
||||
data: {
|
||||
v1_enabled: true,
|
||||
},
|
||||
isLoading: false,
|
||||
getSettingsQueryFn: vi.fn().mockResolvedValue({
|
||||
v1_enabled: true,
|
||||
}),
|
||||
};
|
||||
});
|
||||
@@ -26,6 +23,10 @@ vi.mock("#/hooks/use-tracking", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/context/use-selected-organization", () => ({
|
||||
useSelectedOrganizationId: () => ({ organizationId: null }),
|
||||
}));
|
||||
|
||||
describe("useCreateConversation", () => {
|
||||
it("passes suggested tasks to the V1 create conversation API", async () => {
|
||||
const createConversationSpy = vi
|
||||
|
||||
255
frontend/__tests__/hooks/use-git-conversation-routing.test.ts
Normal file
255
frontend/__tests__/hooks/use-git-conversation-routing.test.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
const mockClaimMutate = vi.fn();
|
||||
const mockDisconnectMutate = vi.fn();
|
||||
|
||||
vi.mock("#/hooks/query/use-git-organizations", () => ({
|
||||
useUserGitOrganizations: vi.fn(),
|
||||
useGitClaims: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/mutation/use-claim-git-org", () => ({
|
||||
useClaimGitOrg: () => ({ mutate: mockClaimMutate }),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/mutation/use-disconnect-git-org", () => ({
|
||||
useDisconnectGitOrg: () => ({ mutate: mockDisconnectMutate }),
|
||||
}));
|
||||
|
||||
import { useGitConversationRouting } from "#/hooks/organizations/use-git-conversation-routing";
|
||||
import {
|
||||
useUserGitOrganizations,
|
||||
useGitClaims,
|
||||
} from "#/hooks/query/use-git-organizations";
|
||||
|
||||
const mockUseUserGitOrganizations = vi.mocked(useUserGitOrganizations);
|
||||
const mockUseGitClaims = vi.mocked(useGitClaims);
|
||||
|
||||
function setupMocks({
|
||||
userOrgs = { provider: "github", organizations: ["OpenHands", "AcmeCo"] },
|
||||
claims = [] as Array<{
|
||||
id: string;
|
||||
org_id: string;
|
||||
provider: string;
|
||||
git_organization: string;
|
||||
claimed_by: string;
|
||||
claimed_at: string;
|
||||
}>,
|
||||
isLoadingUserOrgs = false,
|
||||
isLoadingClaims = false,
|
||||
} = {}) {
|
||||
mockUseUserGitOrganizations.mockReturnValue({
|
||||
data: userOrgs,
|
||||
isLoading: isLoadingUserOrgs,
|
||||
} as ReturnType<typeof useUserGitOrganizations>);
|
||||
|
||||
mockUseGitClaims.mockReturnValue({
|
||||
data: claims,
|
||||
isLoading: isLoadingClaims,
|
||||
} as ReturnType<typeof useGitClaims>);
|
||||
}
|
||||
|
||||
describe("useGitConversationRouting", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns empty orgs when user git orgs is undefined", () => {
|
||||
mockUseUserGitOrganizations.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
} as ReturnType<typeof useUserGitOrganizations>);
|
||||
mockUseGitClaims.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
} as ReturnType<typeof useGitClaims>);
|
||||
|
||||
const { result } = renderHook(() => useGitConversationRouting());
|
||||
|
||||
expect(result.current.orgs).toEqual([]);
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
});
|
||||
|
||||
it("merges user git orgs with claims correctly", () => {
|
||||
setupMocks({
|
||||
claims: [
|
||||
{
|
||||
id: "claim-1",
|
||||
org_id: "org-1",
|
||||
provider: "github",
|
||||
git_organization: "openhands",
|
||||
claimed_by: "user-1",
|
||||
claimed_at: "2026-01-01T00:00:00",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useGitConversationRouting());
|
||||
|
||||
expect(result.current.orgs).toHaveLength(2);
|
||||
|
||||
const claimedOrg = result.current.orgs.find(
|
||||
(o) => o.name === "OpenHands",
|
||||
);
|
||||
expect(claimedOrg).toMatchObject({
|
||||
id: "github:openhands",
|
||||
claimId: "claim-1",
|
||||
provider: "github",
|
||||
status: "claimed",
|
||||
});
|
||||
|
||||
const unclaimedOrg = result.current.orgs.find(
|
||||
(o) => o.name === "AcmeCo",
|
||||
);
|
||||
expect(unclaimedOrg).toMatchObject({
|
||||
id: "github:acmeco",
|
||||
claimId: null,
|
||||
provider: "github",
|
||||
status: "unclaimed",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles case-insensitive matching between user orgs and claims", () => {
|
||||
setupMocks({
|
||||
userOrgs: {
|
||||
provider: "GitHub",
|
||||
organizations: ["All-Hands-AI"],
|
||||
},
|
||||
claims: [
|
||||
{
|
||||
id: "claim-1",
|
||||
org_id: "org-1",
|
||||
provider: "github",
|
||||
git_organization: "all-hands-ai",
|
||||
claimed_by: "user-1",
|
||||
claimed_at: "2026-01-01T00:00:00",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useGitConversationRouting());
|
||||
|
||||
expect(result.current.orgs[0]).toMatchObject({
|
||||
status: "claimed",
|
||||
claimId: "claim-1",
|
||||
name: "All-Hands-AI",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns unclaimed when no claims data is available", () => {
|
||||
setupMocks({ claims: [] });
|
||||
|
||||
const { result } = renderHook(() => useGitConversationRouting());
|
||||
|
||||
expect(result.current.orgs).toHaveLength(2);
|
||||
expect(result.current.orgs.every((o) => o.status === "unclaimed")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(result.current.orgs.every((o) => o.claimId === null)).toBe(true);
|
||||
});
|
||||
|
||||
it("sets pending claiming state when claimOrg is called", () => {
|
||||
setupMocks();
|
||||
|
||||
const { result } = renderHook(() => useGitConversationRouting());
|
||||
|
||||
act(() => {
|
||||
result.current.claimOrg("github:acmeco");
|
||||
});
|
||||
|
||||
expect(mockClaimMutate).toHaveBeenCalledWith(
|
||||
{ provider: "github", gitOrganization: "AcmeCo" },
|
||||
expect.objectContaining({ onSettled: expect.any(Function) }),
|
||||
);
|
||||
|
||||
const org = result.current.orgs.find((o) => o.id === "github:acmeco");
|
||||
expect(org?.status).toBe("claiming");
|
||||
});
|
||||
|
||||
it("sets pending disconnecting state when disconnectOrg is called", () => {
|
||||
setupMocks({
|
||||
claims: [
|
||||
{
|
||||
id: "claim-1",
|
||||
org_id: "org-1",
|
||||
provider: "github",
|
||||
git_organization: "openhands",
|
||||
claimed_by: "user-1",
|
||||
claimed_at: "2026-01-01T00:00:00",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useGitConversationRouting());
|
||||
|
||||
act(() => {
|
||||
result.current.disconnectOrg("github:openhands");
|
||||
});
|
||||
|
||||
expect(mockDisconnectMutate).toHaveBeenCalledWith(
|
||||
{ claimId: "claim-1" },
|
||||
expect.objectContaining({ onSettled: expect.any(Function) }),
|
||||
);
|
||||
|
||||
const org = result.current.orgs.find((o) => o.id === "github:openhands");
|
||||
expect(org?.status).toBe("disconnecting");
|
||||
});
|
||||
|
||||
it("clears pending state on settle", () => {
|
||||
setupMocks();
|
||||
|
||||
mockClaimMutate.mockImplementation(
|
||||
(
|
||||
_args: unknown,
|
||||
options: { onSettled?: () => void },
|
||||
) => {
|
||||
options?.onSettled?.();
|
||||
},
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGitConversationRouting());
|
||||
|
||||
act(() => {
|
||||
result.current.claimOrg("github:acmeco");
|
||||
});
|
||||
|
||||
const org = result.current.orgs.find((o) => o.id === "github:acmeco");
|
||||
expect(org?.status).toBe("unclaimed");
|
||||
});
|
||||
|
||||
it("does not claim an already claimed org", () => {
|
||||
setupMocks({
|
||||
claims: [
|
||||
{
|
||||
id: "claim-1",
|
||||
org_id: "org-1",
|
||||
provider: "github",
|
||||
git_organization: "openhands",
|
||||
claimed_by: "user-1",
|
||||
claimed_at: "2026-01-01T00:00:00",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useGitConversationRouting());
|
||||
|
||||
act(() => {
|
||||
result.current.claimOrg("github:openhands");
|
||||
});
|
||||
|
||||
expect(mockClaimMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not disconnect an unclaimed org", () => {
|
||||
setupMocks();
|
||||
|
||||
const { result } = renderHook(() => useGitConversationRouting());
|
||||
|
||||
act(() => {
|
||||
result.current.disconnectOrg("github:acmeco");
|
||||
});
|
||||
|
||||
expect(mockDisconnectMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user