mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
119 Commits
fix/git-di
...
cloud-1.19
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0a97f33c5 | ||
|
|
2a45e60ca4 | ||
|
|
0f0d1881b9 | ||
|
|
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 | ||
|
|
3cd85a07b7 | ||
|
|
0b935669f3 | ||
|
|
889754abfd | ||
|
|
06cd53d752 | ||
|
|
eb189144f2 | ||
|
|
c9b2ce2fb9 | ||
|
|
abdc58cd28 | ||
|
|
9f47727da5 | ||
|
|
19da63aae6 | ||
|
|
f1b65d9534 | ||
|
|
3516c3cdbe | ||
|
|
1f275a7cfe | ||
|
|
ff240c968b | ||
|
|
36039d2bb8 | ||
|
|
45529fa451 | ||
|
|
0fc4b0fb55 | ||
|
|
810fc340fc | ||
|
|
33a0f95dac | ||
|
|
bdd0214266 | ||
|
|
7fbb499f03 | ||
|
|
abbfbda450 | ||
|
|
7774f43ca1 | ||
|
|
b705b015fa | ||
|
|
1581b95ab9 | ||
|
|
94b45c6c36 | ||
|
|
cbc380fe49 | ||
|
|
fb776ef650 | ||
|
|
a75b576f1c | ||
|
|
63956c3292 | ||
|
|
f75141af3e | ||
|
|
e4515b21eb | ||
|
|
a8f6a35341 | ||
|
|
f706a217d0 | ||
|
|
0137201903 | ||
|
|
49a98885ab | ||
|
|
38648bddb3 | ||
|
|
b44774d2be | ||
|
|
04330898b6 | ||
|
|
120fd7516a | ||
|
|
2224127ac3 | ||
|
|
2d1e9fa35b | ||
|
|
0ec962e96b | ||
|
|
3a9f00aa37 | ||
|
|
e02dbb8974 | ||
|
|
8039807c3f | ||
|
|
a96760eea7 | ||
|
|
dcb2e21b87 | ||
|
|
7edebcbc0c | ||
|
|
abd1f9948f | ||
|
|
2879e58781 | ||
|
|
1d1ffc2be0 | ||
|
|
db41148396 | ||
|
|
39a4ca422f | ||
|
|
6d86803f41 | ||
|
|
8e0386c416 | ||
|
|
48cd85e47e | ||
|
|
c62b47dcb1 | ||
|
|
eb9a822d4c | ||
|
|
fb7333aa62 | ||
|
|
fb23418803 | ||
|
|
991585c05d | ||
|
|
35a40ddee8 | ||
|
|
5d1f9f815a | ||
|
|
d3bf989e77 | ||
|
|
6589e592e3 | ||
|
|
fe4c0569f7 | ||
|
|
28ecf06404 | ||
|
|
26fa1185a4 | ||
|
|
d3a8b037f2 | ||
|
|
af1fa8961a | ||
|
|
3b215c4ad1 | ||
|
|
7516b53f5a | ||
|
|
855ef7ba5f | ||
|
|
09ca1b882f | ||
|
|
79cfffce60 | ||
|
|
b68c75252d | ||
|
|
d58e12ad74 | ||
|
|
bd837039dd | ||
|
|
8a7779068a | ||
|
|
38099934b6 | ||
|
|
75c823c486 |
202
.agents/skills/cross-repo-testing/SKILL.md
Normal file
202
.agents/skills/cross-repo-testing/SKILL.md
Normal file
@@ -0,0 +1,202 @@
|
||||
---
|
||||
name: cross-repo-testing
|
||||
description: This skill should be used when the user asks to "test a cross-repo feature", "deploy a feature branch to staging", "test SDK against OH Cloud", "e2e test a cloud workspace feature", "test provider tokens", "test secrets inheritance", or when changes span the SDK and OpenHands server repos and need end-to-end validation against a staging deployment.
|
||||
triggers:
|
||||
- cross-repo
|
||||
- staging deployment
|
||||
- feature branch deploy
|
||||
- test against cloud
|
||||
- e2e cloud
|
||||
---
|
||||
|
||||
# Cross-Repo Testing: SDK ↔ OpenHands Cloud
|
||||
|
||||
How to end-to-end test features that span `OpenHands/software-agent-sdk` and `OpenHands/OpenHands` (the Cloud backend).
|
||||
|
||||
## Repository Map
|
||||
|
||||
| Repo | Role | What lives here |
|
||||
|------|------|-----------------|
|
||||
| [`software-agent-sdk`](https://github.com/OpenHands/software-agent-sdk) | Agent core | `openhands-sdk`, `openhands-workspace`, `openhands-tools` packages. `OpenHandsCloudWorkspace` lives here. |
|
||||
| [`OpenHands`](https://github.com/OpenHands/OpenHands) | Cloud backend | FastAPI server (`openhands/app_server/`), sandbox management, auth, enterprise integrations. Deployed as OH Cloud. |
|
||||
| [`deploy`](https://github.com/OpenHands/deploy) | Infrastructure | Helm charts + GitHub Actions that build the enterprise Docker image and deploy to staging/production. |
|
||||
|
||||
**Data flow:** SDK client → OH Cloud API (`/api/v1/...`) → sandbox agent-server (inside runtime container)
|
||||
|
||||
## When You Need This
|
||||
|
||||
There are **two flows** depending on which direction the dependency goes:
|
||||
|
||||
| Flow | When | Example |
|
||||
|------|------|---------|
|
||||
| **A — SDK client → new Cloud API** | The SDK calls an API that doesn't exist yet on production | `workspace.get_llm()` calling `GET /api/v1/users/me?expose_secrets=true` |
|
||||
| **B — OH server → new SDK code** | The Cloud server needs unreleased SDK packages or a new agent-server image | Server consumes a new tool, agent behavior, or workspace method from the SDK |
|
||||
|
||||
Flow A only requires deploying the server PR. Flow B requires pinning the SDK to an unreleased commit in the server PR **and** using the SDK PR's agent-server image. Both flows may apply simultaneously.
|
||||
|
||||
---
|
||||
|
||||
## Flow A: SDK Client Tests Against New Cloud API
|
||||
|
||||
Use this when the SDK calls an endpoint that only exists on the server PR branch.
|
||||
|
||||
### A1. Write and test the server-side changes
|
||||
|
||||
In the `OpenHands` repo, implement the new API endpoint(s). Run unit tests:
|
||||
|
||||
```bash
|
||||
cd OpenHands
|
||||
poetry run pytest tests/unit/app_server/test_<relevant>.py -v
|
||||
```
|
||||
|
||||
Push a PR. Wait for the **"Push Enterprise Image" (Docker) CI job** to succeed — this builds `ghcr.io/openhands/enterprise-server:sha-<COMMIT>`.
|
||||
|
||||
### A2. Write the SDK-side changes
|
||||
|
||||
In `software-agent-sdk`, implement the client code (e.g., new methods on `OpenHandsCloudWorkspace`). Run SDK unit tests:
|
||||
|
||||
```bash
|
||||
cd software-agent-sdk
|
||||
pip install -e openhands-sdk -e openhands-workspace
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
Push a PR. SDK CI is independent — it doesn't need the server changes to pass unit tests.
|
||||
|
||||
### A3. Deploy the server PR to staging
|
||||
|
||||
See [Deploying to a Staging Feature Environment](#deploying-to-a-staging-feature-environment) below.
|
||||
|
||||
### A4. Run the SDK e2e test against staging
|
||||
|
||||
See [Running E2E Tests Against Staging](#running-e2e-tests-against-staging) below.
|
||||
|
||||
---
|
||||
|
||||
## Flow B: OH Server Needs Unreleased SDK Code
|
||||
|
||||
Use this when the Cloud server depends on SDK changes that haven't been released to PyPI yet. The server's runtime containers run the `agent-server` image built from the SDK repo, so the server PR must be configured to use the SDK PR's image and packages.
|
||||
|
||||
### B1. Get the SDK PR merged (or identify the commit)
|
||||
|
||||
The SDK PR must have CI pass so its agent-server Docker image is built. The image is tagged with the **merge-commit SHA** from GitHub Actions — NOT the head-commit SHA shown in the PR.
|
||||
|
||||
Find the correct image tag:
|
||||
- Check the SDK PR description for an `AGENT_SERVER_IMAGES` section
|
||||
- Or check the "Consolidate Build Information" CI job for `"short_sha": "<tag>"`
|
||||
|
||||
### B2. Pin SDK packages to the commit in the OpenHands PR
|
||||
|
||||
In the `OpenHands` repo PR, pin all 3 SDK packages (`openhands-sdk`, `openhands-agent-server`, `openhands-tools`) to the unreleased commit and update the agent-server image tag. This involves editing 3 files and regenerating 3 lock files.
|
||||
|
||||
Follow the **`update-sdk` skill** → "Development: Pin SDK to an Unreleased Commit" section for the full procedure and file-by-file instructions.
|
||||
|
||||
### B3. Wait for the OpenHands enterprise image to build
|
||||
|
||||
Push the pinned changes. The OpenHands CI will build a new enterprise Docker image (`ghcr.io/openhands/enterprise-server:sha-<OH_COMMIT>`) that bundles the unreleased SDK. Wait for the "Push Enterprise Image" job to succeed.
|
||||
|
||||
### B4. Deploy and test
|
||||
|
||||
Follow [Deploying to a Staging Feature Environment](#deploying-to-a-staging-feature-environment) using the new OpenHands commit SHA.
|
||||
|
||||
### B5. Before merging: remove the pin
|
||||
|
||||
**CI guard:** `check-package-versions.yml` blocks merge to `main` if `[tool.poetry.dependencies]` contains `rev` fields. Before the OpenHands PR can merge, the SDK PR must be merged and released to PyPI, then the pin must be replaced with the released version number.
|
||||
|
||||
---
|
||||
|
||||
## Deploying to a Staging Feature Environment
|
||||
|
||||
The `deploy` repo creates preview environments from OpenHands PRs.
|
||||
|
||||
**Option A — GitHub Actions UI (preferred):**
|
||||
Go to `OpenHands/deploy` → Actions → "Create OpenHands preview PR" → enter the OpenHands PR number. This creates a branch `ohpr-<PR>-<random>` and opens a deploy PR.
|
||||
|
||||
**Option B — Update an existing feature branch:**
|
||||
```bash
|
||||
cd deploy
|
||||
git checkout ohpr-<PR>-<random>
|
||||
# In .github/workflows/deploy.yaml, update BOTH:
|
||||
# OPENHANDS_SHA: "<full-40-char-commit>"
|
||||
# OPENHANDS_RUNTIME_IMAGE_TAG: "<same-commit>-nikolaik"
|
||||
git commit -am "Update OPENHANDS_SHA to <commit>" && git push
|
||||
```
|
||||
|
||||
**Before updating the SHA**, verify the enterprise Docker image exists:
|
||||
```bash
|
||||
gh api repos/OpenHands/OpenHands/actions/runs \
|
||||
--jq '.workflow_runs[] | select(.head_sha=="<COMMIT>") | "\(.name): \(.conclusion)"' \
|
||||
| grep Docker
|
||||
# Must show: "Docker: success"
|
||||
```
|
||||
|
||||
The deploy CI auto-triggers and creates the environment at:
|
||||
```
|
||||
https://ohpr-<PR>-<random>.staging.all-hands.dev
|
||||
```
|
||||
|
||||
**Wait for it to be live:**
|
||||
```bash
|
||||
curl -s -o /dev/null -w "%{http_code}" https://ohpr-<PR>-<random>.staging.all-hands.dev/api/v1/health
|
||||
# 401 = server is up (auth required). DNS may take 1-2 min on first deploy.
|
||||
```
|
||||
|
||||
## Running E2E Tests Against Staging
|
||||
|
||||
**Critical: Feature deployments have their own Keycloak instance.** API keys from `app.all-hands.dev` or `$OPENHANDS_API_KEY` will NOT work. You need a test API key issued by the specific feature deployment's Keycloak.
|
||||
|
||||
**You (the agent) cannot obtain this key yourself** — the feature environment requires interactive browser login with credentials you do not have. You must **ask the user** to:
|
||||
1. Log in to the feature deployment at `https://ohpr-<PR>-<random>.staging.all-hands.dev` in their browser
|
||||
2. Generate a test API key from the UI
|
||||
3. Provide the key to you so you can proceed with e2e testing
|
||||
|
||||
Do **not** attempt to log in via the browser or guess credentials. Wait for the user to supply the key before running any e2e tests.
|
||||
|
||||
```python
|
||||
from openhands.workspace import OpenHandsCloudWorkspace
|
||||
|
||||
STAGING = "https://ohpr-<PR>-<random>.staging.all-hands.dev"
|
||||
|
||||
with OpenHandsCloudWorkspace(
|
||||
cloud_api_url=STAGING,
|
||||
cloud_api_key="<test-api-key-for-this-deployment>",
|
||||
) as workspace:
|
||||
# Test the new feature
|
||||
llm = workspace.get_llm()
|
||||
secrets = workspace.get_secrets()
|
||||
print(f"LLM: {llm.model}, secrets: {list(secrets.keys())}")
|
||||
```
|
||||
|
||||
Or run an example script:
|
||||
```bash
|
||||
OPENHANDS_CLOUD_API_KEY="<key>" \
|
||||
OPENHANDS_CLOUD_API_URL="https://ohpr-<PR>-<random>.staging.all-hands.dev" \
|
||||
python examples/02_remote_agent_server/10_cloud_workspace_saas_credentials.py
|
||||
```
|
||||
|
||||
### Recording results
|
||||
|
||||
Both repos support a `.pr/` directory for temporary PR artifacts (design docs, test logs, scripts). These files are automatically removed when the PR is approved — see `.github/workflows/pr-artifacts.yml` and the "PR-Specific Artifacts" section in each repo's `AGENTS.md`.
|
||||
|
||||
Push test output to the `.pr/logs/` directory of whichever repo you're working in:
|
||||
```bash
|
||||
mkdir -p .pr/logs
|
||||
python test_script.py 2>&1 | tee .pr/logs/<test_name>.log
|
||||
git add -f .pr/logs/
|
||||
git commit -m "docs: add e2e test results" && git push
|
||||
```
|
||||
|
||||
Comment on **both PRs** with pass/fail summary and link to logs.
|
||||
|
||||
## Key Gotchas
|
||||
|
||||
| Gotcha | Details |
|
||||
|--------|---------|
|
||||
| **Feature env auth is isolated** | Each `ohpr-*` deployment has its own Keycloak. Production API keys don't work. Agents cannot log in — you must ask the user to provide a test API key from the feature deployment's UI. |
|
||||
| **Two SHAs in deploy.yaml** | `OPENHANDS_SHA` and `OPENHANDS_RUNTIME_IMAGE_TAG` must both be updated. The runtime tag is `<sha>-nikolaik`. |
|
||||
| **Enterprise image must exist** | The Docker CI job on the OpenHands PR must succeed before you can deploy. If it hasn't run, push an empty commit to trigger it. |
|
||||
| **DNS propagation** | First deployment of a new branch takes 1-2 min for DNS. Subsequent deploys are instant. |
|
||||
| **Merge-commit SHA ≠ head SHA** | SDK CI tags Docker images with GitHub Actions' merge-commit SHA, not the PR head SHA. Check the SDK PR description or CI logs for the correct tag. |
|
||||
| **SDK pin blocks merge** | `check-package-versions.yml` prevents merging an OpenHands PR that has `rev` fields in `[tool.poetry.dependencies]`. The SDK must be released to PyPI first. |
|
||||
| **Flow A: stock agent-server is fine** | When only the Cloud API changes, `OpenHandsCloudWorkspace` talks to the Cloud server, not the agent-server. No custom image needed. |
|
||||
| **Flow B: agent-server image is required** | When the server needs new SDK code inside runtime containers, you must pin to the SDK PR's agent-server image. |
|
||||
8
.github/dependabot.yml
vendored
8
.github/dependabot.yml
vendored
@@ -4,7 +4,7 @@ updates:
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 1
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
# put packages in their own group if they have a history of breaking the build or needing to be reverted
|
||||
pre-commit:
|
||||
@@ -29,7 +29,7 @@ updates:
|
||||
directory: "/frontend"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 1
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
docusaurus:
|
||||
patterns:
|
||||
@@ -51,7 +51,7 @@ updates:
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "wednesday"
|
||||
open-pull-requests-limit: 1
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
docusaurus:
|
||||
patterns:
|
||||
@@ -72,9 +72,11 @@ updates:
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directories:
|
||||
- "containers/*"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
28
.github/workflows/ghcr-build.yml
vendored
28
.github/workflows/ghcr-build.yml
vendored
@@ -33,28 +33,33 @@ jobs:
|
||||
runs-on: blacksmith
|
||||
outputs:
|
||||
base_image: ${{ steps.define-base-images.outputs.base_image }}
|
||||
platforms: ${{ steps.define-base-images.outputs.platforms }}
|
||||
steps:
|
||||
- name: Define base images
|
||||
shell: bash
|
||||
id: define-base-images
|
||||
run: |
|
||||
if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
|
||||
json=$(jq -n -c '[
|
||||
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" }
|
||||
platforms="linux/amd64"
|
||||
json=$(jq -n -c --arg platforms "$platforms" '[
|
||||
{ image: "nikolaik/python-nodejs:python3.12-nodejs22-slim", tag: "nikolaik", platforms: $platforms }
|
||||
]')
|
||||
else
|
||||
json=$(jq -n -c '[
|
||||
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" },
|
||||
{ image: "ubuntu:24.04", tag: "ubuntu" }
|
||||
platforms="linux/amd64,linux/arm64"
|
||||
json=$(jq -n -c --arg platforms "$platforms" '[
|
||||
{ image: "nikolaik/python-nodejs:python3.12-nodejs22-slim", tag: "nikolaik", platforms: $platforms },
|
||||
{ image: "ubuntu:24.04", tag: "ubuntu", platforms: $platforms }
|
||||
]')
|
||||
fi
|
||||
echo "base_image=$json" >> "$GITHUB_OUTPUT"
|
||||
echo "platforms=$platforms" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Builds the OpenHands Docker images
|
||||
ghcr_build_app:
|
||||
name: Build App Image
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
|
||||
needs: define-matrix
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -219,11 +225,9 @@ jobs:
|
||||
- name: Determine app image tag
|
||||
shell: bash
|
||||
run: |
|
||||
# Duplicated with build.sh
|
||||
sanitized_ref_name=$(echo "$GITHUB_REF_NAME" | sed 's/[^a-zA-Z0-9.-]\+/-/g')
|
||||
OPENHANDS_BUILD_VERSION=$sanitized_ref_name
|
||||
sanitized_ref_name=$(echo "$sanitized_ref_name" | tr '[:upper:]' '[:lower:]') # lower case is required in tagging
|
||||
echo "OPENHANDS_DOCKER_TAG=${sanitized_ref_name}" >> $GITHUB_ENV
|
||||
# Use the commit SHA to pin the exact app image built by ghcr_build_app,
|
||||
# rather than a mutable branch tag like "main" which can serve stale cached layers.
|
||||
echo "OPENHANDS_DOCKER_TAG=${RELEVANT_SHA}" >> $GITHUB_ENV
|
||||
- name: Build and push Docker image
|
||||
uses: useblacksmith/build-push-action@v1
|
||||
with:
|
||||
|
||||
136
.github/workflows/pr-artifacts.yml
vendored
Normal file
136
.github/workflows/pr-artifacts.yml
vendored
Normal file
@@ -0,0 +1,136 @@
|
||||
---
|
||||
name: PR Artifacts
|
||||
|
||||
on:
|
||||
workflow_dispatch: # Manual trigger for testing
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
branches: [main]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
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
|
||||
|
||||
- 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 }}
|
||||
|
||||
- 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,
|
||||
});
|
||||
}
|
||||
4
.github/workflows/pypi-release.yml
vendored
4
.github/workflows/pypi-release.yml
vendored
@@ -18,10 +18,10 @@ 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: useblacksmith/setup-python@v6
|
||||
|
||||
63
AGENTS.md
63
AGENTS.md
@@ -36,9 +36,45 @@ 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
|
||||
|
||||
## 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.
|
||||
|
||||
### Usage
|
||||
|
||||
```
|
||||
.pr/
|
||||
├── design.md # Design decisions and architecture notes
|
||||
├── analysis.md # Investigation or debugging notes
|
||||
├── logs/ # Test output or CI logs for reviewer reference
|
||||
└── notes.md # Any other PR-specific content
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Notification**: When `.pr/` exists, a comment is posted to the PR conversation alerting reviewers
|
||||
2. **Auto-cleanup**: When the PR is approved, the `.pr/` directory is automatically removed via `.github/workflows/pr-artifacts.yml`
|
||||
3. **Fork PRs**: Auto-cleanup cannot push to forks, so manual removal is required before merging
|
||||
|
||||
### Important Notes
|
||||
|
||||
- Do NOT put anything in `.pr/` that needs to be preserved after merge
|
||||
- The `.pr/` check passes (green ✅) during development — it only posts a notification, not a blocking error
|
||||
- For fork PRs: You must manually remove `.pr/` before the PR can be merged
|
||||
|
||||
### When to Use
|
||||
|
||||
- Complex refactoring that benefits from written design rationale
|
||||
- Debugging sessions where you want to document your investigation
|
||||
- E2E test results or logs that demonstrate a cross-repo feature works
|
||||
- Feature implementations that need temporary planning docs
|
||||
- Any analysis that helps reviewers understand the PR but isn't needed long-term
|
||||
|
||||
## Repository Structure
|
||||
Backend:
|
||||
- Located in the `openhands` directory
|
||||
- The current V1 application server lives in `openhands/app_server/`. `make start-backend` still launches `openhands.server.listen:app`, which includes the V1 routes by default unless `ENABLE_V1=0`.
|
||||
- For V1 web-app docs, LLM setup should point users to the Settings UI.
|
||||
- Testing:
|
||||
- All tests are in `tests/unit/test_*.py`
|
||||
- To test new code, run `poetry run pytest tests/unit/test_xxx.py` where `xxx` is the appropriate file for the current functionality
|
||||
@@ -342,3 +378,30 @@ To add a new LLM model to OpenHands, you need to update multiple files across bo
|
||||
- Models appear in CLI provider selection based on the verified arrays
|
||||
- The `organize_models_and_providers` function groups models by provider
|
||||
- Default model selection prioritizes verified models for each provider
|
||||
|
||||
### Sandbox Settings API (SDK Credential Inheritance)
|
||||
|
||||
The sandbox settings API allows SDK-created conversations to inherit the user's SaaS credentials
|
||||
(LLM config, secrets) securely via `LookupSecret`. Raw secret values only flow SaaS→sandbox,
|
||||
never through the SDK client.
|
||||
|
||||
#### User Credentials with Exposed Secrets (in `openhands/app_server/user/user_router.py`):
|
||||
- `GET /api/v1/users/me?expose_secrets=true` → Full user settings with unmasked secrets (e.g., `llm_api_key`)
|
||||
- `GET /api/v1/users/me` → Full user settings (secrets masked, Bearer only)
|
||||
|
||||
Auth requirements for `expose_secrets=true`:
|
||||
- Bearer token (proves user identity via `OPENHANDS_API_KEY`)
|
||||
- `X-Session-API-Key` header (proves caller has an active sandbox owned by the authenticated user)
|
||||
|
||||
Called by `workspace.get_llm()` in the SDK to retrieve LLM config with the API key.
|
||||
|
||||
#### Sandbox-Scoped Secrets Endpoints (in `openhands/app_server/sandbox/sandbox_router.py`):
|
||||
- `GET /sandboxes/{id}/settings/secrets` → list secret names (no values)
|
||||
- `GET /sandboxes/{id}/settings/secrets/{name}` → raw secret value (called FROM sandbox)
|
||||
|
||||
#### Auth: `X-Session-API-Key` header, validated via `SandboxService.get_sandbox_by_session_api_key()`
|
||||
|
||||
#### Related SDK code (in `software-agent-sdk` repo):
|
||||
- `openhands/sdk/llm/llm.py`: `LLM.api_key` accepts `SecretSource` (including `LookupSecret`)
|
||||
- `openhands/workspace/cloud/workspace.py`: `get_llm()` and `get_secrets()` return LookupSecret-backed objects
|
||||
- Tests: `tests/sdk/llm/test_llm_secret_source_api_key.py`, `tests/workspace/test_cloud_workspace_sdk_settings.py`
|
||||
|
||||
168
CONTRIBUTING.md
168
CONTRIBUTING.md
@@ -1,83 +1,105 @@
|
||||
# Contributing
|
||||
|
||||
Thanks for your interest in contributing to OpenHands! We welcome and appreciate contributions.
|
||||
Thanks for your interest in contributing to OpenHands! We're building the future of AI-powered software development, and we'd love for you to be part of this journey.
|
||||
|
||||
## Understanding OpenHands's CodeBase
|
||||
## Our Vision
|
||||
|
||||
To understand the codebase, please refer to the README in each module:
|
||||
- [frontend](./frontend/README.md)
|
||||
- [openhands](./openhands/README.md)
|
||||
- [agenthub](./openhands/agenthub/README.md)
|
||||
- [server](./openhands/server/README.md)
|
||||
The OpenHands community is built around the belief that AI and AI agents are going to fundamentally change the way we build software. If this is true, we should do everything we can to make sure that the benefits provided by such powerful technology are accessible to everyone.
|
||||
|
||||
For benchmarks and evaluation, see the [OpenHands/benchmarks](https://github.com/OpenHands/benchmarks) repository.
|
||||
We believe in the power of open source to democratize access to cutting-edge AI technology. Just as the internet transformed how we share information, we envision a world where AI-powered development tools are available to every developer, regardless of their background or resources.
|
||||
|
||||
## Setting up Your Development Environment
|
||||
## Getting Started
|
||||
|
||||
We have a separate doc [Development.md](https://github.com/OpenHands/OpenHands/blob/main/Development.md) that tells
|
||||
you how to set up a development workflow.
|
||||
### Quick Ways to Contribute
|
||||
|
||||
## How Can I Contribute?
|
||||
- **Use OpenHands** and [report issues](https://github.com/OpenHands/OpenHands/issues) you encounter
|
||||
- **Give feedback** using the thumbs-up/thumbs-down buttons after each session
|
||||
- **Star our repository** on [GitHub](https://github.com/OpenHands/OpenHands)
|
||||
- **Share OpenHands** with other developers
|
||||
|
||||
There are many ways that you can contribute:
|
||||
### Set Up Your Development Environment
|
||||
|
||||
1. **Download and use** OpenHands, and send [issues](https://github.com/OpenHands/OpenHands/issues) when you encounter something that isn't working or a feature that you'd like to see.
|
||||
2. **Send feedback** after each session by [clicking the thumbs-up thumbs-down buttons](https://docs.openhands.dev/usage/feedback), so we can see where things are working and failing, and also build an open dataset for training code agents.
|
||||
3. **Improve the Codebase** by sending [PRs](#sending-pull-requests-to-openhands) (see details below). In particular, we have some [good first issues](https://github.com/OpenHands/OpenHands/labels/good%20first%20issue) that may be ones to start on.
|
||||
- **Requirements**: Linux/Mac/WSL, Docker, Python 3.12, Node.js 22+, Poetry 1.8+
|
||||
- **Quick setup**: `make build`
|
||||
- **Run locally**: `make run`
|
||||
- **LLM setup (V1 web app)**: configure your model and API key in the Settings UI after the app starts
|
||||
|
||||
## What Can I Build?
|
||||
Full details in our [Development Guide](./Development.md).
|
||||
|
||||
Here are a few ways you can help improve the codebase.
|
||||
### Find Your First Issue
|
||||
|
||||
#### UI/UX
|
||||
- Browse [good first issues](https://github.com/OpenHands/OpenHands/labels/good%20first%20issue)
|
||||
- Check our [project boards](https://github.com/OpenHands/OpenHands/projects) for organized tasks
|
||||
- Join our [Slack community](https://openhands.dev/joinslack) to ask what needs help
|
||||
|
||||
We're always looking to improve the look and feel of the application. If you've got a small fix
|
||||
for something that's bugging you, feel free to open up a PR that changes the [`./frontend`](./frontend) directory.
|
||||
## Understanding the Codebase
|
||||
|
||||
If you're looking to make a bigger change, add a new UI element, or significantly alter the style
|
||||
of the application, please open an issue first, or better, join the #dev-ui-ux channel in our Slack
|
||||
to gather consensus from our design team first.
|
||||
- **[Frontend](./frontend/README.md)** - React application
|
||||
- **[App Server (V1)](./openhands/app_server/README.md)** - Current FastAPI application server and REST API modules
|
||||
- **[Agents](./openhands/agenthub/README.md)** - AI agent implementations
|
||||
- **[Runtime](./openhands/runtime/README.md)** - Execution environments
|
||||
- **[Evaluation](https://github.com/OpenHands/benchmarks)** - Testing and benchmarks
|
||||
|
||||
#### Improving the agent
|
||||
## What Can You Build?
|
||||
|
||||
Our main agent is the CodeAct agent. You can [see its prompts here](https://github.com/OpenHands/OpenHands/tree/main/openhands/agenthub/codeact_agent).
|
||||
### Frontend & UI/UX
|
||||
- React & TypeScript development
|
||||
- UI/UX improvements
|
||||
- Mobile responsiveness
|
||||
- Component libraries
|
||||
|
||||
Changes to these prompts, and to the underlying behavior in Python, can have a huge impact on user experience.
|
||||
You can try modifying the prompts to see how they change the behavior of the agent as you use the app
|
||||
locally, but we will need to do an end-to-end evaluation of any changes here to ensure that the agent
|
||||
is getting better over time.
|
||||
For bigger changes, join the #proj-gui channel in [Slack](https://openhands.dev/joinslack) first.
|
||||
|
||||
We use the [SWE-bench](https://www.swebench.com/) benchmark to test our agent. You can join the #evaluation
|
||||
channel in Slack to learn more.
|
||||
### Agent Development
|
||||
- Prompt engineering
|
||||
- New agent types
|
||||
- Agent evaluation
|
||||
- Multi-agent systems
|
||||
|
||||
#### Adding a new agent
|
||||
We use [SWE-bench](https://www.swebench.com/) to evaluate agents.
|
||||
|
||||
You may want to experiment with building new types of agents. You can add an agent to [`openhands/agenthub`](./openhands/agenthub)
|
||||
to help expand the capabilities of OpenHands.
|
||||
### Backend & Infrastructure
|
||||
- Python development
|
||||
- Runtime systems (Docker containers, sandboxes)
|
||||
- Cloud integrations
|
||||
- Performance optimization
|
||||
|
||||
#### Adding a new runtime
|
||||
### Testing & Quality Assurance
|
||||
- Unit testing
|
||||
- Integration testing
|
||||
- Bug hunting
|
||||
- Performance testing
|
||||
|
||||
The agent needs a place to run code and commands. When you run OpenHands on your laptop, it uses a Docker container
|
||||
to do this by default. But there are other ways of creating a sandbox for the agent.
|
||||
### Documentation & Education
|
||||
- Technical documentation
|
||||
- Translation
|
||||
- Community support
|
||||
|
||||
If you work for a company that provides a cloud-based runtime, you could help us add support for that runtime
|
||||
by implementing the [interface specified here](https://github.com/OpenHands/OpenHands/blob/main/openhands/runtime/base.py).
|
||||
## Pull Request Process
|
||||
|
||||
#### Testing
|
||||
### Small Improvements
|
||||
- Quick review and approval
|
||||
- Ensure CI tests pass
|
||||
- Include clear description of changes
|
||||
|
||||
When you write code, it is also good to write tests. Please navigate to the [`./tests`](./tests) folder to see existing
|
||||
test suites. At the moment, we have these kinds of tests: [`unit`](./tests/unit), [`runtime`](./tests/runtime), and [`end-to-end (e2e)`](./tests/e2e).
|
||||
Please refer to the README for each test suite. These tests also run on GitHub's continuous integration to ensure
|
||||
quality of the project.
|
||||
### Core Agent Changes
|
||||
These are evaluated based on:
|
||||
- **Accuracy** - Does it make the agent better at solving problems?
|
||||
- **Efficiency** - Does it improve speed or reduce resource usage?
|
||||
- **Code Quality** - Is the code maintainable and well-tested?
|
||||
|
||||
Discuss major changes in [GitHub issues](https://github.com/OpenHands/OpenHands/issues) or [Slack](https://openhands.dev/joinslack) first.
|
||||
|
||||
## Sending Pull Requests to OpenHands
|
||||
|
||||
You'll need to fork our repository to send us a Pull Request. You can learn more
|
||||
about how to fork a GitHub repo and open a PR with your changes in [this article](https://medium.com/swlh/forks-and-pull-requests-how-to-contribute-to-github-repos-8843fac34ce8).
|
||||
|
||||
### Pull Request title
|
||||
You may also check out previous PRs in the [PR list](https://github.com/OpenHands/OpenHands/pulls).
|
||||
|
||||
As described [here](https://github.com/commitizen/conventional-commit-types/blob/master/index.json), ideally a valid PR title should begin with one of the following prefixes:
|
||||
### Pull Request Title Format
|
||||
|
||||
As described [here](https://github.com/commitizen/conventional-commit-types/blob/master/index.json), a valid PR title should begin with one of the following prefixes:
|
||||
|
||||
- `feat`: A new feature
|
||||
- `fix`: A bug fix
|
||||
@@ -95,45 +117,27 @@ For example, a PR title could be:
|
||||
- `refactor: modify package path`
|
||||
- `feat(frontend): xxxx`, where `(frontend)` means that this PR mainly focuses on the frontend component.
|
||||
|
||||
You may also check out previous PRs in the [PR list](https://github.com/OpenHands/OpenHands/pulls).
|
||||
### Pull Request Description
|
||||
|
||||
### Pull Request description
|
||||
- Explain what the PR does and why
|
||||
- Link to related issues
|
||||
- Include screenshots for UI changes
|
||||
- If your changes are user-facing (e.g. a new feature in the UI, a change in behavior, or a bugfix),
|
||||
please include a short message that we can add to our changelog
|
||||
|
||||
- If your PR is small (such as a typo fix), you can go brief.
|
||||
- If it contains a lot of changes, it's better to write more details.
|
||||
## Becoming a Maintainer
|
||||
|
||||
If your changes are user-facing (e.g. a new feature in the UI, a change in behavior, or a bugfix)
|
||||
please include a short message that we can add to our changelog.
|
||||
Contributors who have opened three meaningful PRs to the project may be eligible to join the maintainer team.
|
||||
The process for this is as follows:
|
||||
|
||||
## How to Make Effective Contributions
|
||||
1. Any contributor who has opened three meaningful PRs to the codebase can be nominated by any maintainer. If you feel that you may qualify, you can reach out to any of the maintainers that have reviewed your PRs and ask if you can be nominated.
|
||||
2. Once a maintainer nominates a new maintainer, there will be a discussion period among the maintainers for at least 3 days.
|
||||
3. If no concerns are raised the nomination will be accepted by acclamation, and if concerns are raised there will be a discussion and possible vote.
|
||||
|
||||
### Opening Issues
|
||||
Note that opening three meaningful PRs does not automatically mean that you will become a maintainer. We will also be looking at good teamwork and adherence to our [Code of Conduct](./CODE_OF_CONDUCT.md).
|
||||
|
||||
If you notice any bugs or have any feature requests please open them via the [issues page](https://github.com/OpenHands/OpenHands/issues). We will triage
|
||||
based on how critical the bug is or how potentially useful the improvement is, discuss, and implement the ones that
|
||||
the community has interest/effort for.
|
||||
## Need Help?
|
||||
|
||||
Further, if you see an issue you like, please leave a "thumbs-up" or a comment, which will help us prioritize.
|
||||
|
||||
### Making Pull Requests
|
||||
|
||||
We're generally happy to consider all pull requests with the evaluation process varying based on the type of change:
|
||||
|
||||
#### For Small Improvements
|
||||
|
||||
Small improvements with few downsides are typically reviewed and approved quickly.
|
||||
One thing to check when making changes is to ensure that all continuous integration tests pass, which you can check
|
||||
before getting a review.
|
||||
|
||||
#### For Core Agent Changes
|
||||
|
||||
We need to be more careful with changes to the core agent, as it is imperative to maintain high quality. These PRs are
|
||||
evaluated based on three key metrics:
|
||||
|
||||
1. **Accuracy**
|
||||
2. **Efficiency**
|
||||
3. **Code Complexity**
|
||||
|
||||
If it improves accuracy, efficiency, or both with only a minimal change to code quality, that's great we're happy to merge it in!
|
||||
If there are bigger tradeoffs (e.g. helping efficiency a lot and hurting accuracy a little) we might want to put it behind a feature flag.
|
||||
Either way, please feel free to discuss on github issues or slack, and we will give guidance and preliminary feedback.
|
||||
- **Slack**: [Join our community](https://openhands.dev/joinslack)
|
||||
- **GitHub Issues**: [Open an issue](https://github.com/OpenHands/OpenHands/issues)
|
||||
- **Email**: contact@openhands.dev
|
||||
|
||||
382
Development.md
382
Development.md
@@ -6,22 +6,196 @@ If you wish to contribute your changes, check out the
|
||||
on how to clone and setup the project initially before moving on. Otherwise,
|
||||
you can clone the OpenHands project directly.
|
||||
|
||||
## Start the Server for Development
|
||||
## Choose Your Setup
|
||||
|
||||
### 1. Requirements
|
||||
Select your operating system to see the specific setup instructions:
|
||||
|
||||
- Linux, Mac OS, or [WSL on Windows](https://learn.microsoft.com/en-us/windows/wsl/install) [Ubuntu >= 22.04]
|
||||
- [Docker](https://docs.docker.com/engine/install/) (For those on MacOS, make sure to allow the default Docker socket to be used from advanced settings!)
|
||||
- [Python](https://www.python.org/downloads/) = 3.12
|
||||
- [NodeJS](https://nodejs.org/en/download/package-manager) >= 22.x
|
||||
- [Poetry](https://python-poetry.org/docs/#installing-with-the-official-installer) >= 1.8
|
||||
- OS-specific dependencies:
|
||||
- Ubuntu: build-essential => `sudo apt-get install build-essential python3.12-dev`
|
||||
- WSL: netcat => `sudo apt-get install netcat`
|
||||
- [macOS](#macos-setup)
|
||||
- [Linux](#linux-setup)
|
||||
- [Windows WSL](#windows-wsl-setup)
|
||||
- [Dev Container](#dev-container)
|
||||
- [Developing in Docker](#developing-in-docker)
|
||||
- [No sudo access?](#develop-without-sudo-access)
|
||||
|
||||
Make sure you have all these dependencies installed before moving on to `make build`.
|
||||
---
|
||||
|
||||
#### Dev container
|
||||
## macOS Setup
|
||||
|
||||
### 1. Install Prerequisites
|
||||
|
||||
You'll need the following installed:
|
||||
|
||||
- **Python 3.12** — `brew install python@3.12` (see the [official Homebrew Python docs](https://docs.brew.sh/Homebrew-and-Python) for details). Make sure `python3.12` is available in your PATH (the `make build` step will verify this).
|
||||
- **Node.js >= 22** — `brew install node`
|
||||
- **Poetry >= 1.8** — `brew install poetry`
|
||||
- **Docker Desktop** — `brew install --cask docker`
|
||||
- After installing, open Docker Desktop → **Settings → Advanced** → Enable **"Allow the default Docker socket to be used"**
|
||||
|
||||
### 2. Build and Setup the Environment
|
||||
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
|
||||
### 3. Configure the Language Model
|
||||
|
||||
OpenHands supports a diverse array of Language Models (LMs) through the powerful [litellm](https://docs.litellm.ai) library.
|
||||
|
||||
For the V1 web app, start OpenHands and configure your model and API key in the Settings UI.
|
||||
|
||||
If you are running headless or CLI workflows, you can prepare local defaults with:
|
||||
|
||||
```bash
|
||||
make setup-config
|
||||
```
|
||||
|
||||
**Note on Alternative Models:**
|
||||
See [our documentation](https://docs.openhands.dev/usage/llms) for recommended models.
|
||||
|
||||
### 4. Run the Application
|
||||
|
||||
```bash
|
||||
# Run both backend and frontend
|
||||
make run
|
||||
|
||||
# Or run separately:
|
||||
make start-backend # Backend only on port 3000
|
||||
make start-frontend # Frontend only on port 3001
|
||||
```
|
||||
|
||||
These targets serve the current OpenHands V1 API by default. In the codebase, `make start-backend` runs `openhands.server.listen:app`, and that app includes the `openhands/app_server` V1 routes unless `ENABLE_V1=0`.
|
||||
|
||||
---
|
||||
|
||||
## Linux Setup
|
||||
|
||||
This guide covers Ubuntu/Debian. For other distributions, adapt the package manager commands accordingly.
|
||||
|
||||
### 1. Install Prerequisites
|
||||
|
||||
```bash
|
||||
# Update package list
|
||||
sudo apt update
|
||||
|
||||
# Install system dependencies
|
||||
sudo apt install -y build-essential curl netcat software-properties-common
|
||||
|
||||
# Install Python 3.12
|
||||
# Ubuntu 24.04+ and Debian 13+ ship with Python 3.12 — skip the PPA step if
|
||||
# python3.12 --version already works on your system.
|
||||
# The deadsnakes PPA is Ubuntu-only and needed for Ubuntu 22.04 or older:
|
||||
sudo add-apt-repository -y ppa:deadsnakes/ppa
|
||||
sudo apt update
|
||||
sudo apt install -y python3.12 python3.12-dev python3.12-venv
|
||||
|
||||
# Install Node.js 22.x
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
sudo apt install -y nodejs
|
||||
|
||||
# Install Poetry
|
||||
curl -sSL https://install.python-poetry.org | python3 -
|
||||
|
||||
# Add Poetry to your PATH
|
||||
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
|
||||
# Install Docker
|
||||
# Follow the official guide: https://docs.docker.com/engine/install/ubuntu/
|
||||
# Quick version:
|
||||
sudo install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
|
||||
sudo chmod a+r /etc/apt/keyrings/docker.asc
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
sudo apt update
|
||||
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
sudo usermod -aG docker $USER
|
||||
# Log out and back in for Docker group changes to take effect
|
||||
```
|
||||
|
||||
### 2. Build and Setup the Environment
|
||||
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
|
||||
### 3. Configure the Language Model
|
||||
|
||||
See the [macOS section above](#3-configure-the-language-model) for guidance: configure your model and API key in the Settings UI.
|
||||
|
||||
### 4. Run the Application
|
||||
|
||||
```bash
|
||||
# Run both backend and frontend
|
||||
make run
|
||||
|
||||
# Or run separately:
|
||||
make start-backend # Backend only on port 3000
|
||||
make start-frontend # Frontend only on port 3001
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Windows WSL Setup
|
||||
|
||||
WSL2 with Ubuntu is recommended. The setup is similar to Linux, with a few WSL-specific considerations.
|
||||
|
||||
### 1. Install WSL2
|
||||
|
||||
**Option A: Windows 11 (Microsoft Store)**
|
||||
The easiest way on Windows 11:
|
||||
1. Open the **Microsoft Store** app
|
||||
2. Search for **"Ubuntu 22.04 LTS"** or **"Ubuntu"**
|
||||
3. Click **Install**
|
||||
4. Launch Ubuntu from the Start menu
|
||||
|
||||
**Option B: PowerShell**
|
||||
```powershell
|
||||
# Run this in PowerShell as Administrator
|
||||
wsl --install -d Ubuntu-22.04
|
||||
```
|
||||
|
||||
After installation, restart your computer and open Ubuntu.
|
||||
|
||||
### 2. Install Prerequisites (in WSL Ubuntu)
|
||||
|
||||
Follow [Step 1 from the Linux setup](#1-install-prerequisites-1) to install system dependencies, Python 3.12, Node.js, and Poetry. Skip the Docker installation — Docker is provided through Docker Desktop below.
|
||||
|
||||
### 3. Configure Docker for WSL2
|
||||
|
||||
1. Install [Docker Desktop for Windows](https://www.docker.com/products/docker-desktop)
|
||||
2. Open Docker Desktop > Settings > General
|
||||
3. Enable: "Use the WSL 2 based engine"
|
||||
4. Go to Settings > Resources > WSL Integration
|
||||
5. Enable integration with your Ubuntu distribution
|
||||
|
||||
**Important:** Keep your project files in the WSL filesystem (e.g., `~/workspace/openhands`), not in `/mnt/c`. Files accessed via `/mnt/c` will be significantly slower.
|
||||
|
||||
### 4. Build and Setup the Environment
|
||||
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
|
||||
### 5. Configure the Language Model
|
||||
|
||||
See the [macOS section above](#3-configure-the-language-model) for the current V1 guidance: configure your model and API key in the Settings UI for the web app, and use `make setup-config` only for headless or CLI workflows.
|
||||
|
||||
### 6. Run the Application
|
||||
|
||||
```bash
|
||||
# Run both backend and frontend
|
||||
make run
|
||||
|
||||
# Or run separately:
|
||||
make start-backend # Backend only on port 3000
|
||||
make start-frontend # Frontend only on port 3001
|
||||
```
|
||||
|
||||
Access the frontend at `http://localhost:3001` from your Windows browser.
|
||||
|
||||
---
|
||||
|
||||
## Dev Container
|
||||
|
||||
There is a [dev container](https://containers.dev/) available which provides a
|
||||
pre-configured environment with all the necessary dependencies installed if you
|
||||
@@ -32,7 +206,38 @@ extension installed, you can open the project in a dev container by using the
|
||||
_Dev Container: Reopen in Container_ command from the Command Palette
|
||||
(Ctrl+Shift+P).
|
||||
|
||||
#### Develop without sudo access
|
||||
---
|
||||
|
||||
## Developing in Docker
|
||||
|
||||
If you don't want to install dependencies on your host machine, you can develop inside a Docker container.
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
make docker-dev
|
||||
```
|
||||
|
||||
For more details, see the [dev container documentation](./containers/dev/README.md).
|
||||
|
||||
### Alternative: Docker Run
|
||||
|
||||
If you just want to run OpenHands without setting up a dev environment:
|
||||
|
||||
```bash
|
||||
make docker-run
|
||||
```
|
||||
|
||||
If you don't have `make` installed, run:
|
||||
|
||||
```bash
|
||||
cd ./containers/dev
|
||||
./dev.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Develop without sudo access
|
||||
|
||||
If you want to develop without system admin/sudo access to upgrade/install `Python` and/or `NodeJS`, you can use
|
||||
`conda` or `mamba` to manage the packages for you:
|
||||
@@ -48,159 +253,90 @@ mamba install conda-forge::nodejs
|
||||
mamba install conda-forge::poetry
|
||||
```
|
||||
|
||||
### 2. Build and Setup The Environment
|
||||
---
|
||||
|
||||
Begin by building the project which includes setting up the environment and installing dependencies. This step ensures
|
||||
that OpenHands is ready to run on your system:
|
||||
## Running OpenHands with OpenHands
|
||||
|
||||
You can use OpenHands to develop and improve OpenHands itself!
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
make build
|
||||
export INSTALL_DOCKER=0
|
||||
export RUNTIME=local
|
||||
make build && make run
|
||||
```
|
||||
|
||||
### 3. Configuring the Language Model
|
||||
Access the interface at:
|
||||
- Local development: http://localhost:3001
|
||||
- Remote/cloud environments: Use the appropriate external URL
|
||||
|
||||
OpenHands supports a diverse array of Language Models (LMs) through the powerful [litellm](https://docs.litellm.ai) library.
|
||||
For external access:
|
||||
```bash
|
||||
make run FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0
|
||||
```
|
||||
|
||||
To configure the LM of your choice, run:
|
||||
---
|
||||
|
||||
## LLM Debugging
|
||||
|
||||
If you encounter issues with the Language Model, enable debug logging:
|
||||
|
||||
```bash
|
||||
make setup-config
|
||||
export DEBUG=1
|
||||
# Restart the backend
|
||||
make start-backend
|
||||
```
|
||||
|
||||
This command will prompt you to enter the LLM API key, model name, and other variables ensuring that OpenHands is
|
||||
tailored to your specific needs. Note that the model name will apply only when you run headless. If you use the UI,
|
||||
please set the model in the UI.
|
||||
Logs will be saved to `logs/llm/CURRENT_DATE/` for troubleshooting.
|
||||
|
||||
Note: If you have previously run OpenHands using the docker command, you may have already set some environment
|
||||
variables in your terminal. The final configurations are set from highest to lowest priority:
|
||||
Environment variables > config.toml variables > default variables
|
||||
---
|
||||
|
||||
**Note on Alternative Models:**
|
||||
See [our documentation](https://docs.openhands.dev/usage/llms) for recommended models.
|
||||
## Testing
|
||||
|
||||
### 4. Running the application
|
||||
|
||||
#### Option A: Run the Full Application
|
||||
|
||||
Once the setup is complete, this command starts both the backend and frontend servers, allowing you to interact with OpenHands:
|
||||
|
||||
```bash
|
||||
make run
|
||||
```
|
||||
|
||||
#### Option B: Individual Server Startup
|
||||
|
||||
- **Start the Backend Server:** If you prefer, you can start the backend server independently to focus on
|
||||
backend-related tasks or configurations.
|
||||
|
||||
```bash
|
||||
make start-backend
|
||||
```
|
||||
|
||||
- **Start the Frontend Server:** Similarly, you can start the frontend server on its own to work on frontend-related
|
||||
components or interface enhancements.
|
||||
```bash
|
||||
make start-frontend
|
||||
```
|
||||
|
||||
### 5. Running OpenHands with OpenHands
|
||||
|
||||
You can use OpenHands to develop and improve OpenHands itself! This is a powerful way to leverage AI assistance for contributing to the project.
|
||||
|
||||
#### Quick Start
|
||||
|
||||
1. **Build and run OpenHands:**
|
||||
|
||||
```bash
|
||||
export INSTALL_DOCKER=0
|
||||
export RUNTIME=local
|
||||
make build && make run
|
||||
```
|
||||
|
||||
2. **Access the interface:**
|
||||
|
||||
- Local development: http://localhost:3001
|
||||
- Remote/cloud environments: Use the appropriate external URL
|
||||
|
||||
3. **Configure for external access (if needed):**
|
||||
```bash
|
||||
# For external access (e.g., cloud environments)
|
||||
make run FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0
|
||||
```
|
||||
|
||||
### 6. LLM Debugging
|
||||
|
||||
If you encounter any issues with the Language Model (LM) or you're simply curious, export DEBUG=1 in the environment and restart the backend.
|
||||
OpenHands will log the prompts and responses in the logs/llm/CURRENT_DATE directory, allowing you to identify the causes.
|
||||
|
||||
### 7. Help
|
||||
|
||||
Need help or info on available targets and commands? Use the help command for all the guidance you need with OpenHands.
|
||||
|
||||
```bash
|
||||
make help
|
||||
```
|
||||
|
||||
### 8. Testing
|
||||
|
||||
To run tests, refer to the following:
|
||||
|
||||
#### Unit tests
|
||||
### Unit Tests
|
||||
|
||||
```bash
|
||||
poetry run pytest ./tests/unit/test_*.py
|
||||
```
|
||||
|
||||
### 9. Add or update dependency
|
||||
---
|
||||
|
||||
1. Add your dependency in `pyproject.toml` or use `poetry add xxx`.
|
||||
2. Update the poetry.lock file via `poetry lock --no-update`.
|
||||
## Adding Dependencies
|
||||
|
||||
### 10. Use existing Docker image
|
||||
1. Add your dependency in `pyproject.toml` or use `poetry add xxx`
|
||||
2. Update the lock file: `poetry lock --no-update`
|
||||
|
||||
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
|
||||
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
|
||||
---
|
||||
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:1.2-nikolaik`
|
||||
## Using Existing Docker Images
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
TL;DR
|
||||
To reduce build time, you can use an existing runtime image:
|
||||
|
||||
```bash
|
||||
make docker-dev
|
||||
export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:1.2-nikolaik
|
||||
```
|
||||
|
||||
See more details [here](./containers/dev/README.md).
|
||||
---
|
||||
|
||||
If you are just interested in running `OpenHands` without installing all the required tools on your host.
|
||||
## Help
|
||||
|
||||
```bash
|
||||
make docker-run
|
||||
make help
|
||||
```
|
||||
|
||||
If you do not have `make` on your host, run:
|
||||
|
||||
```bash
|
||||
cd ./containers/dev
|
||||
./dev.sh
|
||||
```
|
||||
|
||||
You do need [Docker](https://docs.docker.com/engine/install/) installed on your host though.
|
||||
---
|
||||
|
||||
## Key Documentation Resources
|
||||
|
||||
Here's a guide to the important documentation files in the repository:
|
||||
|
||||
- [/README.md](./README.md): Main project overview, features, and basic setup instructions
|
||||
- [/Development.md](./Development.md) (this file): Comprehensive guide for developers working on OpenHands
|
||||
- [/CONTRIBUTING.md](./CONTRIBUTING.md): Guidelines for contributing to the project, including code style and PR process
|
||||
- [DOC_STYLE_GUIDE.md](https://github.com/OpenHands/docs/blob/main/openhands/DOC_STYLE_GUIDE.md): Standards for writing and maintaining project documentation
|
||||
- [/openhands/README.md](./openhands/README.md): Details about the backend Python implementation
|
||||
- [/openhands/app_server/README.md](./openhands/app_server/README.md): Current V1 application server implementation and REST API modules
|
||||
- [/frontend/README.md](./frontend/README.md): Frontend React application setup and development guide
|
||||
- [/containers/README.md](./containers/README.md): Information about Docker containers and deployment
|
||||
- [/tests/unit/README.md](./tests/unit/README.md): Guide to writing and running unit tests
|
||||
- [OpenHands/benchmarks](https://github.com/OpenHands/benchmarks): Documentation for the evaluation framework and benchmarks
|
||||
- [/skills/README.md](./skills/README.md): Information about the skills architecture and implementation
|
||||
- [/openhands/server/README.md](./openhands/server/README.md): Server implementation details and API documentation
|
||||
- [/openhands/runtime/README.md](./openhands/runtime/README.md): Documentation for the runtime environment and execution model
|
||||
|
||||
22
README.md
22
README.md
@@ -23,11 +23,9 @@
|
||||
<a href="https://www.readme-i18n.com/OpenHands/OpenHands?lang=pt">Português</a> |
|
||||
<a href="https://www.readme-i18n.com/OpenHands/OpenHands?lang=ru">Русский</a> |
|
||||
<a href="https://www.readme-i18n.com/OpenHands/OpenHands?lang=zh">中文</a>
|
||||
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
🙌 Welcome to OpenHands, a [community](COMMUNITY.md) focused on AI-driven development. We’d love for you to [join us on Slack](https://dub.sh/openhands).
|
||||
|
||||
There are a few ways to work with OpenHands:
|
||||
@@ -84,3 +82,23 @@ 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/>
|
||||
<img src="https://cdn.prod.website-files.com/68ff4058b35616cdd47d5b59/69137f6974b71a1a4a932f82_TikTok_logo.svg" alt="TikTok" height="40">
|
||||
<img src="https://cdn.prod.website-files.com/68ff4058b35616cdd47d5b59/69137f523b08f91a5aa905b9_Vmware.svg" alt="VMware" height="40">
|
||||
<img src="https://cdn.prod.website-files.com/68ff4058b35616cdd47d5b59/69137f2cb537758796a9dba1_Roche_Logo.svg" alt="Roche" height="40">
|
||||
<img src="https://cdn.prod.website-files.com/68ff4058b35616cdd47d5b59/69137f10c3975e28b3932320_Amazon_logo%201.svg" alt="Amazon" height="40">
|
||||
<img src="https://cdn.prod.website-files.com/68ff4058b35616cdd47d5b59/69137ec5a6f77dd174e557ce_C3ai_logo%201.svg" alt="C3 AI" height="40">
|
||||
<img src="https://cdn.prod.website-files.com/68ff4058b35616cdd47d5b59/69137eac8f27ca27f5e48420_Netflix_2015_logo%201.svg" alt="Netflix" height="40">
|
||||
<br/>
|
||||
<img src="https://cdn.prod.website-files.com/68ff4058b35616cdd47d5b59/69137e8df2c028b9e1506ede_mastercard%201.svg" alt="Mastercard" height="40">
|
||||
<img src="https://cdn.prod.website-files.com/68ff4058b35616cdd47d5b59/69137e783790933dd06f9d59_Red_Hat_Logo_2019%201.svg" alt="Red Hat" height="40">
|
||||
<img src="https://cdn.prod.website-files.com/68ff4058b35616cdd47d5b59/69137e5fa006d963a1d1904d_mongodb-ar21%201.svg" alt="MongoDB" height="40">
|
||||
<img src="https://cdn.prod.website-files.com/68ff4058b35616cdd47d5b59/69137e47b45195da10c50f49_apple-11%201.svg" alt="Apple" height="40">
|
||||
<img src="https://cdn.prod.website-files.com/68ff4058b35616cdd47d5b59/69137e34e3a5ab71e37082a7_NVIDIA_logo%201.svg" alt="NVIDIA" height="40">
|
||||
<img src="https://cdn.prod.website-files.com/68ff4058b35616cdd47d5b59/69137e199ce2cb594b0210ab_google-ar21%201.svg" alt="Google" height="40">
|
||||
</div>
|
||||
|
||||
@@ -296,7 +296,7 @@ classpath = "my_package.my_module.MyCustomAgent"
|
||||
#user_id = 1000
|
||||
|
||||
# Container image to use for the sandbox
|
||||
#base_container_image = "nikolaik/python-nodejs:python3.12-nodejs22"
|
||||
#base_container_image = "nikolaik/python-nodejs:python3.12-nodejs22-slim"
|
||||
|
||||
# Use host network
|
||||
#use_host_network = false
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
ARG OPENHANDS_BUILD_VERSION=dev
|
||||
FROM node:25.2-trixie-slim AS frontend-builder
|
||||
FROM node:25.8-trixie-slim AS frontend-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,7 +10,7 @@ LABEL com.datadoghq.tags.env="${DD_ENV}"
|
||||
# Apply security updates to fix CVEs
|
||||
RUN apt-get update && \
|
||||
apt-get install -y curl && \
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
|
||||
curl -fsSL https://deb.nodesource.com/setup_24.x | bash - && \
|
||||
apt-get install -y nodejs && \
|
||||
apt-get install -y jq gettext && \
|
||||
# Apply security updates for packages with available fixes
|
||||
|
||||
@@ -51,6 +51,6 @@ NOTE: in the future we will simply replace the `GithubTokenManager` with keycloa
|
||||
## User ID vs User Token
|
||||
|
||||
- In OpenHands, the entire app revolves around the GitHub token the user sets. `openhands/server` uses `request.state.github_token` for the entire app
|
||||
- On Enterprise, the entire APP resolves around the Github User ID. This is because the cookie sets it, so `openhands/server` AND `enterprise/server` depend on it and completly ignore `request.state.github_token` (token is fetched from `GithubTokenManager` instead)
|
||||
- On Enterprise, the entire APP resolves around the Github User ID. This is because the cookie sets it, so `openhands/server` AND `enterprise/server` depend on it and completely ignore `request.state.github_token` (token is fetched from `GithubTokenManager` instead)
|
||||
|
||||
Note that introducing GitHub User ID in OpenHands, for instance, will cause large breakages.
|
||||
|
||||
13
enterprise/doc/architecture/README.md
Normal file
13
enterprise/doc/architecture/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Enterprise Architecture Documentation
|
||||
|
||||
Architecture diagrams specific to the OpenHands SaaS/Enterprise deployment.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Authentication Flow](./authentication.md) - Keycloak-based authentication for SaaS deployment
|
||||
- [External Integrations](./external-integrations.md) - GitHub, Slack, Jira, and other service integrations
|
||||
|
||||
## Related Documentation
|
||||
|
||||
For core OpenHands architecture (applicable to all deployments), see:
|
||||
- [Core Architecture Documentation](../../../openhands/architecture/README.md)
|
||||
58
enterprise/doc/architecture/authentication.md
Normal file
58
enterprise/doc/architecture/authentication.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Authentication Flow (SaaS Deployment)
|
||||
|
||||
OpenHands uses Keycloak for identity management in the SaaS deployment. The authentication flow involves multiple services:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant User as User (Browser)
|
||||
participant App as App Server
|
||||
participant KC as Keycloak
|
||||
participant IdP as Identity Provider<br/>(GitHub, Google, etc.)
|
||||
participant DB as User Database
|
||||
|
||||
Note over User,DB: OAuth 2.0 / OIDC Authentication Flow
|
||||
|
||||
User->>App: Access OpenHands
|
||||
App->>User: Redirect to Keycloak
|
||||
User->>KC: Login request
|
||||
KC->>User: Show login options
|
||||
User->>KC: Select provider (e.g., GitHub)
|
||||
KC->>IdP: OAuth redirect
|
||||
User->>IdP: Authenticate
|
||||
IdP-->>KC: OAuth callback + tokens
|
||||
Note over KC: Create/update user session
|
||||
KC-->>User: Redirect with auth code
|
||||
User->>App: Auth code
|
||||
App->>KC: Exchange code for tokens
|
||||
KC-->>App: Access token + Refresh token
|
||||
Note over App: Create signed JWT cookie
|
||||
App->>DB: Store/update user record
|
||||
App-->>User: Set keycloak_auth cookie
|
||||
|
||||
Note over User,DB: Subsequent Requests
|
||||
|
||||
User->>App: Request with cookie
|
||||
Note over App: Verify JWT signature
|
||||
App->>KC: Validate token (if needed)
|
||||
KC-->>App: Token valid
|
||||
Note over App: Extract user context
|
||||
App-->>User: Authorized response
|
||||
```
|
||||
|
||||
### Authentication Components
|
||||
|
||||
| Component | Purpose | Location |
|
||||
|-----------|---------|----------|
|
||||
| **Keycloak** | Identity provider, SSO, token management | External service |
|
||||
| **UserAuth** | Abstract auth interface | `openhands/server/user_auth/user_auth.py` |
|
||||
| **SaasUserAuth** | Keycloak implementation | `enterprise/server/auth/saas_user_auth.py` |
|
||||
| **JWT Service** | Token signing/verification | `openhands/app_server/services/jwt_service.py` |
|
||||
| **Auth Routes** | Login/logout endpoints | `enterprise/server/routes/auth.py` |
|
||||
|
||||
### Token Flow
|
||||
|
||||
1. **Keycloak Access Token**: Short-lived token for API access
|
||||
2. **Keycloak Refresh Token**: Long-lived token to obtain new access tokens
|
||||
3. **Signed JWT Cookie**: App Server's session cookie containing encrypted Keycloak tokens
|
||||
4. **Provider Tokens**: OAuth tokens for GitHub, GitLab, etc. (stored separately for git operations)
|
||||
88
enterprise/doc/architecture/external-integrations.md
Normal file
88
enterprise/doc/architecture/external-integrations.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# External Integrations
|
||||
|
||||
OpenHands integrates with external services (GitHub, Slack, Jira, etc.) through webhook-based event handling:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant Ext as External Service<br/>(GitHub/Slack/Jira)
|
||||
participant App as App Server
|
||||
participant IntRouter as Integration Router
|
||||
participant Manager as Integration Manager
|
||||
participant Conv as Conversation Service
|
||||
participant Sandbox as Sandbox
|
||||
|
||||
Note over Ext,Sandbox: Webhook Event Flow (e.g., GitHub Issue Created)
|
||||
|
||||
Ext->>App: POST /api/integration/{service}/events
|
||||
App->>IntRouter: Route to service handler
|
||||
Note over IntRouter: Verify signature (HMAC)
|
||||
|
||||
IntRouter->>Manager: Parse event payload
|
||||
Note over Manager: Extract context (repo, issue, user)
|
||||
Note over Manager: Map external user → OpenHands user
|
||||
|
||||
Manager->>Conv: Create conversation (with issue context)
|
||||
Conv->>Sandbox: Provision sandbox
|
||||
Sandbox-->>Conv: Ready
|
||||
|
||||
Manager->>Sandbox: Start agent with task
|
||||
|
||||
Note over Ext,Sandbox: Agent Works on Task...
|
||||
|
||||
Sandbox-->>Manager: Task complete
|
||||
Manager->>Ext: POST result<br/>(PR, comment, etc.)
|
||||
|
||||
Note over Ext,Sandbox: Callback Flow (Agent → External Service)
|
||||
|
||||
Sandbox->>App: Webhook callback<br/>/api/v1/webhooks
|
||||
App->>Manager: Process callback
|
||||
Manager->>Ext: Update external service
|
||||
```
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
| Integration | Trigger Events | Agent Actions |
|
||||
|-------------|----------------|---------------|
|
||||
| **GitHub** | Issue created, PR opened, @mention | Create PR, comment, push commits |
|
||||
| **GitLab** | Issue created, MR opened | Create MR, comment, push commits |
|
||||
| **Slack** | @mention in channel | Reply in thread, create tasks |
|
||||
| **Jira** | Issue created/updated | Update ticket, add comments |
|
||||
| **Linear** | Issue created | Update status, add comments |
|
||||
|
||||
### Integration Components
|
||||
|
||||
| Component | Purpose | Location |
|
||||
|-----------|---------|----------|
|
||||
| **Integration Routes** | Webhook endpoints per service | `enterprise/server/routes/integration/` |
|
||||
| **Integration Managers** | Business logic per service | `enterprise/integrations/{service}/` |
|
||||
| **Token Manager** | Store/retrieve OAuth tokens | `enterprise/server/auth/token_manager.py` |
|
||||
| **Callback Processor** | Handle agent → service updates | `enterprise/integrations/{service}/*_callback_processor.py` |
|
||||
|
||||
### Integration Authentication
|
||||
|
||||
```
|
||||
External Service (e.g., GitHub)
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────┐
|
||||
│ GitHub App Installation │
|
||||
│ - Webhook secret for signature │
|
||||
│ - App private key for API calls │
|
||||
└─────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────┐
|
||||
│ User Account Linking │
|
||||
│ - Keycloak user ID │
|
||||
│ - GitHub user ID │
|
||||
│ - Stored OAuth tokens │
|
||||
└─────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────┐
|
||||
│ Agent Execution │
|
||||
│ - Uses linked tokens for API │
|
||||
│ - Can push, create PRs, comment │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
@@ -43,15 +43,20 @@ class GithubV1CallbackProcessor(EventCallbackProcessor):
|
||||
event: Event,
|
||||
) -> EventCallbackResult | None:
|
||||
"""Process events for GitHub V1 integration."""
|
||||
# Only handle ConversationStateUpdateEvent
|
||||
# Only handle ConversationStateUpdateEvent for execution_status
|
||||
if not isinstance(event, ConversationStateUpdateEvent):
|
||||
return None
|
||||
|
||||
# Only act when execution has finished
|
||||
if not (event.key == 'execution_status' and event.value == 'finished'):
|
||||
if event.key != 'execution_status':
|
||||
return None
|
||||
|
||||
# Log ALL terminal states for monitoring (finished, error, stuck)
|
||||
_logger.info('[GitHub V1] Callback agent state was %s', event)
|
||||
|
||||
# Only request summary when execution has finished successfully
|
||||
if event.value != 'finished':
|
||||
return None
|
||||
|
||||
_logger.info(
|
||||
'[GitHub V1] Should request summary: %s', self.should_request_summary
|
||||
)
|
||||
|
||||
@@ -41,15 +41,20 @@ class GitlabV1CallbackProcessor(EventCallbackProcessor):
|
||||
event: Event,
|
||||
) -> EventCallbackResult | None:
|
||||
"""Process events for GitLab V1 integration."""
|
||||
# Only handle ConversationStateUpdateEvent
|
||||
# Only handle ConversationStateUpdateEvent for execution_status
|
||||
if not isinstance(event, ConversationStateUpdateEvent):
|
||||
return None
|
||||
|
||||
# Only act when execution has finished
|
||||
if not (event.key == 'execution_status' and event.value == 'finished'):
|
||||
if event.key != 'execution_status':
|
||||
return None
|
||||
|
||||
# Log ALL terminal states for monitoring (finished, error, stuck)
|
||||
_logger.info('[GitLab V1] Callback agent state was %s', event)
|
||||
|
||||
# Only request summary when execution has finished successfully
|
||||
if event.value != 'finished':
|
||||
return None
|
||||
|
||||
_logger.info(
|
||||
'[GitLab V1] Should request summary: %s', self.should_request_summary
|
||||
)
|
||||
|
||||
@@ -60,7 +60,9 @@ class ResolverUserContext(UserContext):
|
||||
return provider_token.token.get_secret_value()
|
||||
return None
|
||||
|
||||
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
|
||||
async def get_provider_tokens(
|
||||
self, as_env_vars: bool = False
|
||||
) -> PROVIDER_TOKEN_TYPE | dict[str, str] | None:
|
||||
return await self.saas_user_auth.get_provider_tokens()
|
||||
|
||||
async def get_secrets(self) -> dict[str, SecretSource]:
|
||||
|
||||
@@ -40,16 +40,20 @@ class SlackV1CallbackProcessor(EventCallbackProcessor):
|
||||
event: Event,
|
||||
) -> EventCallbackResult | None:
|
||||
"""Process events for Slack V1 integration."""
|
||||
# Only handle ConversationStateUpdateEvent
|
||||
# Only handle ConversationStateUpdateEvent for execution_status
|
||||
if not isinstance(event, ConversationStateUpdateEvent):
|
||||
return None
|
||||
|
||||
# Only act when execution has finished
|
||||
if not (event.key == 'execution_status' and event.value == 'finished'):
|
||||
if event.key != 'execution_status':
|
||||
return None
|
||||
|
||||
# Log ALL terminal states for monitoring (finished, error, stuck)
|
||||
_logger.info('[Slack V1] Callback agent state was %s', event)
|
||||
|
||||
# Only request summary when execution has finished successfully
|
||||
if event.value != 'finished':
|
||||
return None
|
||||
|
||||
try:
|
||||
summary = await self._request_summary(conversation_id)
|
||||
await self._post_summary_to_slack(summary)
|
||||
|
||||
@@ -100,27 +100,25 @@ async def has_payment_method_by_user_id(user_id: str) -> bool:
|
||||
return bool(payment_methods.data)
|
||||
|
||||
|
||||
async def migrate_customer(user_id: str, org: Org):
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(StripeCustomer).where(StripeCustomer.keycloak_user_id == user_id)
|
||||
)
|
||||
stripe_customer = result.scalar_one_or_none()
|
||||
if stripe_customer is None:
|
||||
return
|
||||
stripe_customer.org_id = org.id
|
||||
customer = await stripe.Customer.modify_async(
|
||||
id=stripe_customer.stripe_customer_id,
|
||||
email=org.contact_email,
|
||||
metadata={'user_id': '', 'org_id': str(org.id)},
|
||||
)
|
||||
async def migrate_customer(session, user_id: str, org: Org):
|
||||
result = await session.execute(
|
||||
select(StripeCustomer).where(StripeCustomer.keycloak_user_id == user_id)
|
||||
)
|
||||
stripe_customer = result.scalar_one_or_none()
|
||||
if stripe_customer is None:
|
||||
return
|
||||
stripe_customer.org_id = org.id
|
||||
customer = await stripe.Customer.modify_async(
|
||||
id=stripe_customer.stripe_customer_id,
|
||||
email=org.contact_email,
|
||||
metadata={'user_id': '', 'org_id': str(org.id)},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'migrated_customer',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org.id),
|
||||
'stripe_customer_id': customer.id,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
'migrated_customer',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org.id),
|
||||
'stripe_customer_id': customer.id,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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,28 @@
|
||||
"""Add disabled_skills to user_settings.
|
||||
|
||||
Revision ID: 102
|
||||
Revises: 101
|
||||
Create Date: 2026-02-25
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '102'
|
||||
down_revision: Union[str, None] = '101'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'user_settings', sa.Column('disabled_skills', sa.JSON(), nullable=True)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('user_settings', 'disabled_skills')
|
||||
@@ -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')
|
||||
238
enterprise/poetry.lock
generated
238
enterprise/poetry.lock
generated
@@ -602,14 +602,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "authlib"
|
||||
version = "1.6.7"
|
||||
version = "1.6.9"
|
||||
description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "authlib-1.6.7-py2.py3-none-any.whl", hash = "sha256:c637340d9a02789d2efa1d003a7437d10d3e565237bcb5fcbc6c134c7b95bab0"},
|
||||
{file = "authlib-1.6.7.tar.gz", hash = "sha256:dbf10100011d1e1b34048c9d120e83f13b35d69a826ae762b93d2fb5aafc337b"},
|
||||
{file = "authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3"},
|
||||
{file = "authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -1341,6 +1341,7 @@ description = "Generic pure Python loader for .NET runtimes"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
markers = "sys_platform == \"win32\""
|
||||
files = [
|
||||
{file = "clr_loader-0.2.10-py3-none-any.whl", hash = "sha256:ebbbf9d511a7fe95fa28a95a4e04cd195b097881dfe66158dc2c281d3536f282"},
|
||||
{file = "clr_loader-0.2.10.tar.gz", hash = "sha256:81f114afbc5005bafc5efe5af1341d400e22137e275b042a8979f3feb9fc9446"},
|
||||
@@ -2598,6 +2599,21 @@ files = [
|
||||
{file = "fqdn-1.5.1.tar.gz", hash = "sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "freezegun"
|
||||
version = "1.5.5"
|
||||
description = "Let your Python tests travel through time"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["test"]
|
||||
files = [
|
||||
{file = "freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2"},
|
||||
{file = "freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
python-dateutil = ">=2.7"
|
||||
|
||||
[[package]]
|
||||
name = "frozenlist"
|
||||
version = "1.8.0"
|
||||
@@ -3411,96 +3427,87 @@ protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4
|
||||
|
||||
[[package]]
|
||||
name = "grpcio"
|
||||
version = "1.76.0"
|
||||
version = "1.67.1"
|
||||
description = "HTTP/2-based RPC framework"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "grpcio-1.76.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:65a20de41e85648e00305c1bb09a3598f840422e522277641145a32d42dcefcc"},
|
||||
{file = "grpcio-1.76.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:40ad3afe81676fd9ec6d9d406eda00933f218038433980aa19d401490e46ecde"},
|
||||
{file = "grpcio-1.76.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:035d90bc79eaa4bed83f524331d55e35820725c9fbb00ffa1904d5550ed7ede3"},
|
||||
{file = "grpcio-1.76.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4215d3a102bd95e2e11b5395c78562967959824156af11fa93d18fdd18050990"},
|
||||
{file = "grpcio-1.76.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:49ce47231818806067aea3324d4bf13825b658ad662d3b25fada0bdad9b8a6af"},
|
||||
{file = "grpcio-1.76.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8cc3309d8e08fd79089e13ed4819d0af72aa935dd8f435a195fd152796752ff2"},
|
||||
{file = "grpcio-1.76.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:971fd5a1d6e62e00d945423a567e42eb1fa678ba89072832185ca836a94daaa6"},
|
||||
{file = "grpcio-1.76.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d9adda641db7207e800a7f089068f6f645959f2df27e870ee81d44701dd9db3"},
|
||||
{file = "grpcio-1.76.0-cp310-cp310-win32.whl", hash = "sha256:063065249d9e7e0782d03d2bca50787f53bd0fb89a67de9a7b521c4a01f1989b"},
|
||||
{file = "grpcio-1.76.0-cp310-cp310-win_amd64.whl", hash = "sha256:a6ae758eb08088d36812dd5d9af7a9859c05b1e0f714470ea243694b49278e7b"},
|
||||
{file = "grpcio-1.76.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2e1743fbd7f5fa713a1b0a8ac8ebabf0ec980b5d8809ec358d488e273b9cf02a"},
|
||||
{file = "grpcio-1.76.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a8c2cf1209497cf659a667d7dea88985e834c24b7c3b605e6254cbb5076d985c"},
|
||||
{file = "grpcio-1.76.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08caea849a9d3c71a542827d6df9d5a69067b0a1efbea8a855633ff5d9571465"},
|
||||
{file = "grpcio-1.76.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f0e34c2079d47ae9f6188211db9e777c619a21d4faba6977774e8fa43b085e48"},
|
||||
{file = "grpcio-1.76.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8843114c0cfce61b40ad48df65abcfc00d4dba82eae8718fab5352390848c5da"},
|
||||
{file = "grpcio-1.76.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8eddfb4d203a237da6f3cc8a540dad0517d274b5a1e9e636fd8d2c79b5c1d397"},
|
||||
{file = "grpcio-1.76.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:32483fe2aab2c3794101c2a159070584e5db11d0aa091b2c0ea9c4fc43d0d749"},
|
||||
{file = "grpcio-1.76.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dcfe41187da8992c5f40aa8c5ec086fa3672834d2be57a32384c08d5a05b4c00"},
|
||||
{file = "grpcio-1.76.0-cp311-cp311-win32.whl", hash = "sha256:2107b0c024d1b35f4083f11245c0e23846ae64d02f40b2b226684840260ed054"},
|
||||
{file = "grpcio-1.76.0-cp311-cp311-win_amd64.whl", hash = "sha256:522175aba7af9113c48ec10cc471b9b9bd4f6ceb36aeb4544a8e2c80ed9d252d"},
|
||||
{file = "grpcio-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:81fd9652b37b36f16138611c7e884eb82e0cec137c40d3ef7c3f9b3ed00f6ed8"},
|
||||
{file = "grpcio-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280"},
|
||||
{file = "grpcio-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d388087771c837cdb6515539f43b9d4bf0b0f23593a24054ac16f7a960be16f4"},
|
||||
{file = "grpcio-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f8f757bebaaea112c00dba718fc0d3260052ce714e25804a03f93f5d1c6cc11"},
|
||||
{file = "grpcio-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:980a846182ce88c4f2f7e2c22c56aefd515daeb36149d1c897f83cf57999e0b6"},
|
||||
{file = "grpcio-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f92f88e6c033db65a5ae3d97905c8fea9c725b63e28d5a75cb73b49bda5024d8"},
|
||||
{file = "grpcio-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4baf3cbe2f0be3289eb68ac8ae771156971848bb8aaff60bad42005539431980"},
|
||||
{file = "grpcio-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:615ba64c208aaceb5ec83bfdce7728b80bfeb8be97562944836a7a0a9647d882"},
|
||||
{file = "grpcio-1.76.0-cp312-cp312-win32.whl", hash = "sha256:45d59a649a82df5718fd9527ce775fd66d1af35e6d31abdcdc906a49c6822958"},
|
||||
{file = "grpcio-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:c088e7a90b6017307f423efbb9d1ba97a22aa2170876223f9709e9d1de0b5347"},
|
||||
{file = "grpcio-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:26ef06c73eb53267c2b319f43e6634c7556ea37672029241a056629af27c10e2"},
|
||||
{file = "grpcio-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:45e0111e73f43f735d70786557dc38141185072d7ff8dc1829d6a77ac1471468"},
|
||||
{file = "grpcio-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83d57312a58dcfe2a3a0f9d1389b299438909a02db60e2f2ea2ae2d8034909d3"},
|
||||
{file = "grpcio-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3e2a27c89eb9ac3d81ec8835e12414d73536c6e620355d65102503064a4ed6eb"},
|
||||
{file = "grpcio-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61f69297cba3950a524f61c7c8ee12e55c486cb5f7db47ff9dcee33da6f0d3ae"},
|
||||
{file = "grpcio-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a15c17af8839b6801d554263c546c69c4d7718ad4321e3166175b37eaacca77"},
|
||||
{file = "grpcio-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:25a18e9810fbc7e7f03ec2516addc116a957f8cbb8cbc95ccc80faa072743d03"},
|
||||
{file = "grpcio-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:931091142fd8cc14edccc0845a79248bc155425eee9a98b2db2ea4f00a235a42"},
|
||||
{file = "grpcio-1.76.0-cp313-cp313-win32.whl", hash = "sha256:5e8571632780e08526f118f74170ad8d50fb0a48c23a746bef2a6ebade3abd6f"},
|
||||
{file = "grpcio-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:f9f7bd5faab55f47231ad8dba7787866b69f5e93bc306e3915606779bbfb4ba8"},
|
||||
{file = "grpcio-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:ff8a59ea85a1f2191a0ffcc61298c571bc566332f82e5f5be1b83c9d8e668a62"},
|
||||
{file = "grpcio-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:06c3d6b076e7b593905d04fdba6a0525711b3466f43b3400266f04ff735de0cd"},
|
||||
{file = "grpcio-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd5ef5932f6475c436c4a55e4336ebbe47bd3272be04964a03d316bbf4afbcbc"},
|
||||
{file = "grpcio-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b331680e46239e090f5b3cead313cc772f6caa7d0fc8de349337563125361a4a"},
|
||||
{file = "grpcio-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2229ae655ec4e8999599469559e97630185fdd53ae1e8997d147b7c9b2b72cba"},
|
||||
{file = "grpcio-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:490fa6d203992c47c7b9e4a9d39003a0c2bcc1c9aa3c058730884bbbb0ee9f09"},
|
||||
{file = "grpcio-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:479496325ce554792dba6548fae3df31a72cef7bad71ca2e12b0e58f9b336bfc"},
|
||||
{file = "grpcio-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c9b93f79f48b03ada57ea24725d83a30284a012ec27eab2cf7e50a550cbbbcc"},
|
||||
{file = "grpcio-1.76.0-cp314-cp314-win32.whl", hash = "sha256:747fa73efa9b8b1488a95d0ba1039c8e2dca0f741612d80415b1e1c560febf4e"},
|
||||
{file = "grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e"},
|
||||
{file = "grpcio-1.76.0-cp39-cp39-linux_armv7l.whl", hash = "sha256:8ebe63ee5f8fa4296b1b8cfc743f870d10e902ca18afc65c68cf46fd39bb0783"},
|
||||
{file = "grpcio-1.76.0-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:3bf0f392c0b806905ed174dcd8bdd5e418a40d5567a05615a030a5aeddea692d"},
|
||||
{file = "grpcio-1.76.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0b7604868b38c1bfd5cf72d768aedd7db41d78cb6a4a18585e33fb0f9f2363fd"},
|
||||
{file = "grpcio-1.76.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e6d1db20594d9daba22f90da738b1a0441a7427552cc6e2e3d1297aeddc00378"},
|
||||
{file = "grpcio-1.76.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d099566accf23d21037f18a2a63d323075bebace807742e4b0ac210971d4dd70"},
|
||||
{file = "grpcio-1.76.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ebea5cc3aa8ea72e04df9913492f9a96d9348db876f9dda3ad729cfedf7ac416"},
|
||||
{file = "grpcio-1.76.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0c37db8606c258e2ee0c56b78c62fc9dee0e901b5dbdcf816c2dd4ad652b8b0c"},
|
||||
{file = "grpcio-1.76.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ebebf83299b0cb1721a8859ea98f3a77811e35dce7609c5c963b9ad90728f886"},
|
||||
{file = "grpcio-1.76.0-cp39-cp39-win32.whl", hash = "sha256:0aaa82d0813fd4c8e589fac9b65d7dd88702555f702fb10417f96e2a2a6d4c0f"},
|
||||
{file = "grpcio-1.76.0-cp39-cp39-win_amd64.whl", hash = "sha256:acab0277c40eff7143c2323190ea57b9ee5fd353d8190ee9652369fae735668a"},
|
||||
{file = "grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73"},
|
||||
{file = "grpcio-1.67.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:8b0341d66a57f8a3119b77ab32207072be60c9bf79760fa609c5609f2deb1f3f"},
|
||||
{file = "grpcio-1.67.1-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:f5a27dddefe0e2357d3e617b9079b4bfdc91341a91565111a21ed6ebbc51b22d"},
|
||||
{file = "grpcio-1.67.1-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:43112046864317498a33bdc4797ae6a268c36345a910de9b9c17159d8346602f"},
|
||||
{file = "grpcio-1.67.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9b929f13677b10f63124c1a410994a401cdd85214ad83ab67cc077fc7e480f0"},
|
||||
{file = "grpcio-1.67.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7d1797a8a3845437d327145959a2c0c47c05947c9eef5ff1a4c80e499dcc6fa"},
|
||||
{file = "grpcio-1.67.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:0489063974d1452436139501bf6b180f63d4977223ee87488fe36858c5725292"},
|
||||
{file = "grpcio-1.67.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9fd042de4a82e3e7aca44008ee2fb5da01b3e5adb316348c21980f7f58adc311"},
|
||||
{file = "grpcio-1.67.1-cp310-cp310-win32.whl", hash = "sha256:638354e698fd0c6c76b04540a850bf1db27b4d2515a19fcd5cf645c48d3eb1ed"},
|
||||
{file = "grpcio-1.67.1-cp310-cp310-win_amd64.whl", hash = "sha256:608d87d1bdabf9e2868b12338cd38a79969eaf920c89d698ead08f48de9c0f9e"},
|
||||
{file = "grpcio-1.67.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:7818c0454027ae3384235a65210bbf5464bd715450e30a3d40385453a85a70cb"},
|
||||
{file = "grpcio-1.67.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ea33986b70f83844cd00814cee4451055cd8cab36f00ac64a31f5bb09b31919e"},
|
||||
{file = "grpcio-1.67.1-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:c7a01337407dd89005527623a4a72c5c8e2894d22bead0895306b23c6695698f"},
|
||||
{file = "grpcio-1.67.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80b866f73224b0634f4312a4674c1be21b2b4afa73cb20953cbbb73a6b36c3cc"},
|
||||
{file = "grpcio-1.67.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fff78ba10d4250bfc07a01bd6254a6d87dc67f9627adece85c0b2ed754fa96"},
|
||||
{file = "grpcio-1.67.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:8a23cbcc5bb11ea7dc6163078be36c065db68d915c24f5faa4f872c573bb400f"},
|
||||
{file = "grpcio-1.67.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1a65b503d008f066e994f34f456e0647e5ceb34cfcec5ad180b1b44020ad4970"},
|
||||
{file = "grpcio-1.67.1-cp311-cp311-win32.whl", hash = "sha256:e29ca27bec8e163dca0c98084040edec3bc49afd10f18b412f483cc68c712744"},
|
||||
{file = "grpcio-1.67.1-cp311-cp311-win_amd64.whl", hash = "sha256:786a5b18544622bfb1e25cc08402bd44ea83edfb04b93798d85dca4d1a0b5be5"},
|
||||
{file = "grpcio-1.67.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:267d1745894200e4c604958da5f856da6293f063327cb049a51fe67348e4f953"},
|
||||
{file = "grpcio-1.67.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:85f69fdc1d28ce7cff8de3f9c67db2b0ca9ba4449644488c1e0303c146135ddb"},
|
||||
{file = "grpcio-1.67.1-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:f26b0b547eb8d00e195274cdfc63ce64c8fc2d3e2d00b12bf468ece41a0423a0"},
|
||||
{file = "grpcio-1.67.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4422581cdc628f77302270ff839a44f4c24fdc57887dc2a45b7e53d8fc2376af"},
|
||||
{file = "grpcio-1.67.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d7616d2ded471231c701489190379e0c311ee0a6c756f3c03e6a62b95a7146e"},
|
||||
{file = "grpcio-1.67.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8a00efecde9d6fcc3ab00c13f816313c040a28450e5e25739c24f432fc6d3c75"},
|
||||
{file = "grpcio-1.67.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:699e964923b70f3101393710793289e42845791ea07565654ada0969522d0a38"},
|
||||
{file = "grpcio-1.67.1-cp312-cp312-win32.whl", hash = "sha256:4e7b904484a634a0fff132958dabdb10d63e0927398273917da3ee103e8d1f78"},
|
||||
{file = "grpcio-1.67.1-cp312-cp312-win_amd64.whl", hash = "sha256:5721e66a594a6c4204458004852719b38f3d5522082be9061d6510b455c90afc"},
|
||||
{file = "grpcio-1.67.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:aa0162e56fd10a5547fac8774c4899fc3e18c1aa4a4759d0ce2cd00d3696ea6b"},
|
||||
{file = "grpcio-1.67.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:beee96c8c0b1a75d556fe57b92b58b4347c77a65781ee2ac749d550f2a365dc1"},
|
||||
{file = "grpcio-1.67.1-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:a93deda571a1bf94ec1f6fcda2872dad3ae538700d94dc283c672a3b508ba3af"},
|
||||
{file = "grpcio-1.67.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e6f255980afef598a9e64a24efce87b625e3e3c80a45162d111a461a9f92955"},
|
||||
{file = "grpcio-1.67.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e838cad2176ebd5d4a8bb03955138d6589ce9e2ce5d51c3ada34396dbd2dba8"},
|
||||
{file = "grpcio-1.67.1-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:a6703916c43b1d468d0756c8077b12017a9fcb6a1ef13faf49e67d20d7ebda62"},
|
||||
{file = "grpcio-1.67.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:917e8d8994eed1d86b907ba2a61b9f0aef27a2155bca6cbb322430fc7135b7bb"},
|
||||
{file = "grpcio-1.67.1-cp313-cp313-win32.whl", hash = "sha256:e279330bef1744040db8fc432becc8a727b84f456ab62b744d3fdb83f327e121"},
|
||||
{file = "grpcio-1.67.1-cp313-cp313-win_amd64.whl", hash = "sha256:fa0c739ad8b1996bd24823950e3cb5152ae91fca1c09cc791190bf1627ffefba"},
|
||||
{file = "grpcio-1.67.1-cp38-cp38-linux_armv7l.whl", hash = "sha256:178f5db771c4f9a9facb2ab37a434c46cb9be1a75e820f187ee3d1e7805c4f65"},
|
||||
{file = "grpcio-1.67.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0f3e49c738396e93b7ba9016e153eb09e0778e776df6090c1b8c91877cc1c426"},
|
||||
{file = "grpcio-1.67.1-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:24e8a26dbfc5274d7474c27759b54486b8de23c709d76695237515bc8b5baeab"},
|
||||
{file = "grpcio-1.67.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b6c16489326d79ead41689c4b84bc40d522c9a7617219f4ad94bc7f448c5085"},
|
||||
{file = "grpcio-1.67.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:60e6a4dcf5af7bbc36fd9f81c9f372e8ae580870a9e4b6eafe948cd334b81cf3"},
|
||||
{file = "grpcio-1.67.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:95b5f2b857856ed78d72da93cd7d09b6db8ef30102e5e7fe0961fe4d9f7d48e8"},
|
||||
{file = "grpcio-1.67.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b49359977c6ec9f5d0573ea4e0071ad278ef905aa74e420acc73fd28ce39e9ce"},
|
||||
{file = "grpcio-1.67.1-cp38-cp38-win32.whl", hash = "sha256:f5b76ff64aaac53fede0cc93abf57894ab2a7362986ba22243d06218b93efe46"},
|
||||
{file = "grpcio-1.67.1-cp38-cp38-win_amd64.whl", hash = "sha256:804c6457c3cd3ec04fe6006c739579b8d35c86ae3298ffca8de57b493524b771"},
|
||||
{file = "grpcio-1.67.1-cp39-cp39-linux_armv7l.whl", hash = "sha256:a25bdea92b13ff4d7790962190bf6bf5c4639876e01c0f3dda70fc2769616335"},
|
||||
{file = "grpcio-1.67.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cdc491ae35a13535fd9196acb5afe1af37c8237df2e54427be3eecda3653127e"},
|
||||
{file = "grpcio-1.67.1-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:85f862069b86a305497e74d0dc43c02de3d1d184fc2c180993aa8aa86fbd19b8"},
|
||||
{file = "grpcio-1.67.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ec74ef02010186185de82cc594058a3ccd8d86821842bbac9873fd4a2cf8be8d"},
|
||||
{file = "grpcio-1.67.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01f616a964e540638af5130469451cf580ba8c7329f45ca998ab66e0c7dcdb04"},
|
||||
{file = "grpcio-1.67.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:299b3d8c4f790c6bcca485f9963b4846dd92cf6f1b65d3697145d005c80f9fe8"},
|
||||
{file = "grpcio-1.67.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:60336bff760fbb47d7e86165408126f1dded184448e9a4c892189eb7c9d3f90f"},
|
||||
{file = "grpcio-1.67.1-cp39-cp39-win32.whl", hash = "sha256:5ed601c4c6008429e3d247ddb367fe8c7259c355757448d7c1ef7bd4a6739e8e"},
|
||||
{file = "grpcio-1.67.1-cp39-cp39-win_amd64.whl", hash = "sha256:5db70d32d6703b89912af16d6d45d78406374a8b8ef0d28140351dd0ec610e98"},
|
||||
{file = "grpcio-1.67.1.tar.gz", hash = "sha256:3dc2ed4cabea4dc14d5e708c2b426205956077cc5de419b4d4079315017e9732"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
typing-extensions = ">=4.12,<5.0"
|
||||
|
||||
[package.extras]
|
||||
protobuf = ["grpcio-tools (>=1.76.0)"]
|
||||
protobuf = ["grpcio-tools (>=1.67.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "grpcio-status"
|
||||
version = "1.71.2"
|
||||
version = "1.67.1"
|
||||
description = "Status proto mapping for gRPC"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "grpcio_status-1.71.2-py3-none-any.whl", hash = "sha256:803c98cb6a8b7dc6dbb785b1111aed739f241ab5e9da0bba96888aa74704cfd3"},
|
||||
{file = "grpcio_status-1.71.2.tar.gz", hash = "sha256:c7a97e176df71cdc2c179cd1847d7fc86cca5832ad12e9798d7fed6b7a1aab50"},
|
||||
{file = "grpcio_status-1.67.1-py3-none-any.whl", hash = "sha256:16e6c085950bdacac97c779e6a502ea671232385e6e37f258884d6883392c2bd"},
|
||||
{file = "grpcio_status-1.67.1.tar.gz", hash = "sha256:2bf38395e028ceeecfd8866b081f61628114b384da7d51ae064ddc8d766a5d11"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
googleapis-common-protos = ">=1.5.5"
|
||||
grpcio = ">=1.71.2"
|
||||
grpcio = ">=1.67.1"
|
||||
protobuf = ">=5.26.1,<6.0dev"
|
||||
|
||||
[[package]]
|
||||
@@ -4783,25 +4790,25 @@ valkey = ["valkey (>=6)"]
|
||||
|
||||
[[package]]
|
||||
name = "litellm"
|
||||
version = "1.80.16"
|
||||
version = "1.80.10"
|
||||
description = "Library to easily interface with LLM API providers"
|
||||
optional = false
|
||||
python-versions = "<4.0,>=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "litellm-1.80.16-py3-none-any.whl", hash = "sha256:21be641b350561b293b831addb25249676b72ebff973a5a1d73b5d7cf35bcd1d"},
|
||||
{file = "litellm-1.80.16.tar.gz", hash = "sha256:f96233649f99ab097f7d8a3ff9898680207b9eea7d2e23f438074a3dbcf50cca"},
|
||||
{file = "litellm-1.80.10-py3-none-any.whl", hash = "sha256:9b3e561efaba0eb1291cb1555d3dcb7283cf7f3cb65aadbcdb42e2a8765898c8"},
|
||||
{file = "litellm-1.80.10.tar.gz", hash = "sha256:4a4aff7558945c2f7e5c6523e67c1b5525a46b10b0e1ad6b8f847cb13b16779e"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
aiohttp = ">=3.10"
|
||||
click = "*"
|
||||
fastuuid = ">=0.13.0"
|
||||
grpcio = {version = ">=1.62.3,<1.68.dev0 || >1.71.0,<1.71.1 || >1.71.1,<1.72.0 || >1.72.0,<1.72.1 || >1.72.1,<1.73.0 || >1.73.0", markers = "python_version < \"3.14\""}
|
||||
grpcio = {version = ">=1.62.3,<1.68.0", markers = "python_version < \"3.14\""}
|
||||
httpx = ">=0.23.0"
|
||||
importlib-metadata = ">=6.8.0"
|
||||
jinja2 = ">=3.1.2,<4.0.0"
|
||||
jsonschema = ">=4.23.0,<5.0.0"
|
||||
jsonschema = ">=4.22.0,<5.0.0"
|
||||
openai = ">=2.8.0"
|
||||
pydantic = ">=2.5.0,<3.0.0"
|
||||
python-dotenv = ">=0.2.0"
|
||||
@@ -4812,7 +4819,7 @@ tokenizers = "*"
|
||||
caching = ["diskcache (>=5.6.1,<6.0.0)"]
|
||||
extra-proxy = ["azure-identity (>=1.15.0,<2.0.0) ; python_version >= \"3.9\"", "azure-keyvault-secrets (>=4.8.0,<5.0.0)", "google-cloud-iam (>=2.19.1,<3.0.0)", "google-cloud-kms (>=2.21.3,<3.0.0)", "prisma (==0.11.0)", "redisvl (>=0.4.1,<0.5.0) ; python_version >= \"3.9\" and python_version < \"3.14\"", "resend (>=0.8.0)"]
|
||||
mlflow = ["mlflow (>3.1.4) ; python_version >= \"3.10\""]
|
||||
proxy = ["PyJWT (>=2.10.1,<3.0.0) ; python_version >= \"3.9\"", "apscheduler (>=3.10.4,<4.0.0)", "azure-identity (>=1.15.0,<2.0.0) ; python_version >= \"3.9\"", "azure-storage-blob (>=12.25.1,<13.0.0)", "backoff", "boto3 (==1.36.0)", "cryptography", "fastapi (>=0.120.1)", "fastapi-sso (>=0.16.0,<0.17.0)", "gunicorn (>=23.0.0,<24.0.0)", "litellm-enterprise (==0.1.27)", "litellm-proxy-extras (==0.4.21)", "mcp (>=1.21.2,<2.0.0) ; python_version >= \"3.10\"", "orjson (>=3.9.7,<4.0.0)", "polars (>=1.31.0,<2.0.0) ; python_version >= \"3.10\"", "pynacl (>=1.5.0,<2.0.0)", "python-multipart (>=0.0.18,<0.0.19)", "pyyaml (>=6.0.1,<7.0.0)", "rich (==13.7.1)", "rq", "soundfile (>=0.12.1,<0.13.0)", "uvicorn (>=0.31.1,<0.32.0)", "uvloop (>=0.21.0,<0.22.0) ; sys_platform != \"win32\"", "websockets (>=15.0.1,<16.0.0)"]
|
||||
proxy = ["PyJWT (>=2.10.1,<3.0.0) ; python_version >= \"3.9\"", "apscheduler (>=3.10.4,<4.0.0)", "azure-identity (>=1.15.0,<2.0.0) ; python_version >= \"3.9\"", "azure-storage-blob (>=12.25.1,<13.0.0)", "backoff", "boto3 (==1.36.0)", "cryptography", "fastapi (>=0.120.1)", "fastapi-sso (>=0.16.0,<0.17.0)", "gunicorn (>=23.0.0,<24.0.0)", "litellm-enterprise (==0.1.25)", "litellm-proxy-extras (==0.4.14)", "mcp (>=1.21.2,<2.0.0) ; python_version >= \"3.10\"", "orjson (>=3.9.7,<4.0.0)", "polars (>=1.31.0,<2.0.0) ; python_version >= \"3.10\"", "pynacl (>=1.5.0,<2.0.0)", "python-multipart (>=0.0.18,<0.0.19)", "pyyaml (>=6.0.1,<7.0.0)", "rich (==13.7.1)", "rq", "soundfile (>=0.12.1,<0.13.0)", "uvicorn (>=0.31.1,<0.32.0)", "uvloop (>=0.21.0,<0.22.0) ; sys_platform != \"win32\"", "websockets (>=15.0.1,<16.0.0)"]
|
||||
semantic-router = ["semantic-router (>=0.1.12) ; python_version >= \"3.9\" and python_version < \"3.14\""]
|
||||
utils = ["numpydoc"]
|
||||
|
||||
@@ -5443,14 +5450,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
version = "1.25.0"
|
||||
version = "1.26.0"
|
||||
description = "Model Context Protocol SDK"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "mcp-1.25.0-py3-none-any.whl", hash = "sha256:b37c38144a666add0862614cc79ec276e97d72aa8ca26d622818d4e278b9721a"},
|
||||
{file = "mcp-1.25.0.tar.gz", hash = "sha256:56310361ebf0364e2d438e5b45f7668cbb124e158bb358333cd06e49e83a6802"},
|
||||
{file = "mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca"},
|
||||
{file = "mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6190,14 +6197,14 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
|
||||
|
||||
[[package]]
|
||||
name = "openhands-agent-server"
|
||||
version = "1.14.0"
|
||||
version = "1.15.0"
|
||||
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_agent_server-1.14.0-py3-none-any.whl", hash = "sha256:b1374b50d0ce93d825ba5ea907fcb8840b5ddc594c6752570c7c4c27be1a9fd1"},
|
||||
{file = "openhands_agent_server-1.14.0.tar.gz", hash = "sha256:396de8d878c0a6c1c23d830f7407e34801ac850f4283ba296d7fe436d8b61488"},
|
||||
{file = "openhands_agent_server-1.15.0-py3-none-any.whl", hash = "sha256:84f0d130cc2c10044d3dcdfecef1eb8f6793bf05c6633ca645cabd354ed038fa"},
|
||||
{file = "openhands_agent_server-1.15.0.tar.gz", hash = "sha256:faf588900a58ff80575cc499f0aa0eaf9b8648d9448185411041f42e2cb2c612"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6227,7 +6234,7 @@ aiohttp = ">=3.13.3"
|
||||
anthropic = {version = "*", extras = ["vertex"]}
|
||||
anyio = "4.9"
|
||||
asyncpg = ">=0.30"
|
||||
authlib = ">=1.6.7"
|
||||
authlib = ">=1.6.9"
|
||||
bashlex = ">=0.18"
|
||||
boto3 = "*"
|
||||
browsergym-core = "0.13.3"
|
||||
@@ -6259,9 +6266,9 @@ memory-profiler = ">=0.61"
|
||||
numpy = "*"
|
||||
openai = "2.8"
|
||||
openhands-aci = "0.3.3"
|
||||
openhands-agent-server = "1.14"
|
||||
openhands-sdk = "1.14"
|
||||
openhands-tools = "1.14"
|
||||
openhands-agent-server = "1.15"
|
||||
openhands-sdk = "1.15"
|
||||
openhands-tools = "1.15"
|
||||
opentelemetry-api = ">=1.33.1"
|
||||
opentelemetry-exporter-otlp-proto-grpc = ">=1.33.1"
|
||||
orjson = ">=3.11.6"
|
||||
@@ -6276,9 +6283,9 @@ protobuf = ">=5.29.6,<6"
|
||||
psutil = "*"
|
||||
pybase62 = ">=1"
|
||||
pygithub = ">=2.5"
|
||||
pyjwt = ">=2.12.0"
|
||||
pyjwt = ">=2.12"
|
||||
pylatexenc = "*"
|
||||
pypdf = ">=6.7.2"
|
||||
pypdf = ">=6.9.1"
|
||||
python-docx = "*"
|
||||
python-dotenv = "*"
|
||||
python-frontmatter = ">=1.1"
|
||||
@@ -6286,7 +6293,7 @@ python-json-logger = ">=3.2.1"
|
||||
python-multipart = ">=0.0.22"
|
||||
python-pptx = "*"
|
||||
python-socketio = "5.14"
|
||||
pythonnet = "*"
|
||||
pythonnet = {version = "*", markers = "sys_platform == \"win32\""}
|
||||
pyyaml = ">=6.0.2"
|
||||
qtconsole = ">=5.6.1"
|
||||
rapidfuzz = ">=3.9"
|
||||
@@ -6316,14 +6323,14 @@ url = ".."
|
||||
|
||||
[[package]]
|
||||
name = "openhands-sdk"
|
||||
version = "1.14.0"
|
||||
version = "1.15.0"
|
||||
description = "OpenHands SDK - Core functionality for building AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_sdk-1.14.0-py3-none-any.whl", hash = "sha256:64305b3a24445fd9480b63129e8e02f3a75fdbf8f4fcbf970760b7dc1d392090"},
|
||||
{file = "openhands_sdk-1.14.0.tar.gz", hash = "sha256:30bda4b10291420f753d14aaa4ee67c87ba8d59ef3908bca999aa76daa033615"},
|
||||
{file = "openhands_sdk-1.15.0-py3-none-any.whl", hash = "sha256:760473a0a35301e5c3fde9e5a5921c8f24d95e9c4694fc01d81fac828f2cca27"},
|
||||
{file = "openhands_sdk-1.15.0.tar.gz", hash = "sha256:d0f479db1a14e10ac922c9000c0c059ce0515fda8666ba10c7f8c64490cca565"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6333,7 +6340,7 @@ fakeredis = {version = ">=2.32.1", extras = ["lua"]}
|
||||
fastmcp = ">=3.0.0"
|
||||
filelock = ">=3.20.1"
|
||||
httpx = ">=0.27.0"
|
||||
litellm = ">=1.80.10"
|
||||
litellm = "1.80.10"
|
||||
lmnr = ">=0.7.24"
|
||||
pydantic = ">=2.12.5"
|
||||
python-frontmatter = ">=1.1.0"
|
||||
@@ -6346,14 +6353,14 @@ boto3 = ["boto3 (>=1.35.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "openhands-tools"
|
||||
version = "1.14.0"
|
||||
version = "1.15.0"
|
||||
description = "OpenHands Tools - Runtime tools for AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_tools-1.14.0-py3-none-any.whl", hash = "sha256:4df477fa53eafa15082d081143c80383aeb6d52b4448b989b86b811c297e5615"},
|
||||
{file = "openhands_tools-1.14.0.tar.gz", hash = "sha256:2655a7de839b171539464fa39729b6a338dc37f914b58bd551378c4fc0ec71b5"},
|
||||
{file = "openhands_tools-1.15.0-py3-none-any.whl", hash = "sha256:041f2f5483a0f5caa967067a1964c4ae0716236a360c9acaa51675d85853d453"},
|
||||
{file = "openhands_tools-1.15.0.tar.gz", hash = "sha256:e1cb1962573b3847642960f561414391f3a31e345c5e7094ae674baadf343a50"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -7597,14 +7604,14 @@ wrappers-encryption = ["cryptography (>=45.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1"
|
||||
version = "0.6.2"
|
||||
version = "0.6.3"
|
||||
description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf"},
|
||||
{file = "pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b"},
|
||||
{file = "pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde"},
|
||||
{file = "pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11587,14 +11594,14 @@ diagrams = ["jinja2", "railroad-diagrams"]
|
||||
|
||||
[[package]]
|
||||
name = "pypdf"
|
||||
version = "6.8.0"
|
||||
version = "6.9.1"
|
||||
description = "A pure-python PDF library capable of splitting, merging, cropping, and transforming PDF files"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pypdf-6.8.0-py3-none-any.whl", hash = "sha256:2a025080a8dd73f48123c89c57174a5ff3806c71763ee4e49572dc90454943c7"},
|
||||
{file = "pypdf-6.8.0.tar.gz", hash = "sha256:cb7eaeaa4133ce76f762184069a854e03f4d9a08568f0e0623f7ea810407833b"},
|
||||
{file = "pypdf-6.9.1-py3-none-any.whl", hash = "sha256:f35a6a022348fae47e092a908339a8f3dc993510c026bb39a96718fc7185e89f"},
|
||||
{file = "pypdf-6.9.1.tar.gz", hash = "sha256:ae052407d33d34de0c86c5c729be6d51010bf36e03035a8f23ab449bca52377d"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
@@ -11908,6 +11915,7 @@ description = ".NET and Mono integration for Python"
|
||||
optional = false
|
||||
python-versions = "<3.14,>=3.7"
|
||||
groups = ["main"]
|
||||
markers = "sys_platform == \"win32\""
|
||||
files = [
|
||||
{file = "pythonnet-3.0.5-py3-none-any.whl", hash = "sha256:f6702d694d5d5b163c9f3f5cc34e0bed8d6857150237fae411fefb883a656d20"},
|
||||
{file = "pythonnet-3.0.5.tar.gz", hash = "sha256:48e43ca463941b3608b32b4e236db92d8d40db4c58a75ace902985f76dac21cf"},
|
||||
@@ -13083,24 +13091,24 @@ test = ["pytest (>=8)"]
|
||||
|
||||
[[package]]
|
||||
name = "setuptools"
|
||||
version = "80.9.0"
|
||||
description = "Easily download, build, install, upgrade, and uninstall Python packages"
|
||||
version = "82.0.1"
|
||||
description = "Most extensible Python build backend with support for C/C++ extension modules"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "test"]
|
||||
files = [
|
||||
{file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"},
|
||||
{file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"},
|
||||
{file = "setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb"},
|
||||
{file = "setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""]
|
||||
core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"]
|
||||
check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.13.0) ; sys_platform != \"cygwin\""]
|
||||
core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"]
|
||||
cover = ["pytest-cov"]
|
||||
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"]
|
||||
enabler = ["pytest-enabler (>=2.2)"]
|
||||
test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"]
|
||||
type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"]
|
||||
type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.18.*)", "pytest-mypy"]
|
||||
|
||||
[[package]]
|
||||
name = "shap"
|
||||
@@ -14997,4 +15005,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12,<3.14"
|
||||
content-hash = "ef037f6d6085d26166d35c56ce266439f8f1a4fea90bc43ccf15cfeaf116cae5"
|
||||
content-hash = "c468b13e2d26e31e0e8f84518bcb8379234d431ca3819625f49b91aa3589359c"
|
||||
|
||||
@@ -64,6 +64,7 @@ pytest-asyncio = "*"
|
||||
pytest-forked = "*"
|
||||
pytest-xdist = "*"
|
||||
flake8 = "*"
|
||||
freezegun = "^1.5.1"
|
||||
openai = "*"
|
||||
opencv-python = "*"
|
||||
pandas = "*"
|
||||
|
||||
@@ -46,6 +46,7 @@ from server.routes.org_invitations import ( # noqa: E402
|
||||
)
|
||||
from server.routes.orgs import org_router # noqa: E402
|
||||
from server.routes.readiness import readiness_router # noqa: E402
|
||||
from server.routes.service import service_router # noqa: E402
|
||||
from server.routes.user import saas_user_router # noqa: E402
|
||||
from server.routes.user_app_settings import user_app_settings_router # noqa: E402
|
||||
from server.sharing.shared_conversation_router import ( # noqa: E402
|
||||
@@ -112,6 +113,7 @@ if GITLAB_APP_CLIENT_ID:
|
||||
base_app.include_router(gitlab_integration_router)
|
||||
|
||||
base_app.include_router(api_keys_router) # Add routes for API key management
|
||||
base_app.include_router(service_router) # Add routes for internal service API
|
||||
base_app.include_router(org_router) # Add routes for organization management
|
||||
base_app.include_router(
|
||||
verified_models_router
|
||||
|
||||
@@ -35,13 +35,13 @@ Usage:
|
||||
from enum import Enum
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from storage.org_member_store import OrgMemberStore
|
||||
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):
|
||||
@@ -214,6 +214,19 @@ def has_permission(user_role: Role, permission: Permission) -> bool:
|
||||
return permission in permissions
|
||||
|
||||
|
||||
async def get_api_key_org_id_from_request(request: Request) -> UUID | None:
|
||||
"""Get the org_id bound to the API key used for authentication.
|
||||
|
||||
Returns None if:
|
||||
- Not authenticated via API key (cookie auth)
|
||||
- API key is a legacy key without org binding
|
||||
"""
|
||||
user_auth = getattr(request.state, 'user_auth', None)
|
||||
if user_auth and hasattr(user_auth, 'get_api_key_org_id'):
|
||||
return user_auth.get_api_key_org_id()
|
||||
return None
|
||||
|
||||
|
||||
def require_permission(permission: Permission):
|
||||
"""
|
||||
Factory function that creates a dependency to require a specific permission.
|
||||
@@ -221,8 +234,9 @@ def require_permission(permission: Permission):
|
||||
This creates a FastAPI dependency that:
|
||||
1. Extracts org_id from the path parameter
|
||||
2. Gets the authenticated user_id
|
||||
3. Checks if the user has the required permission in the organization
|
||||
4. Returns the user_id if authorized, raises HTTPException otherwise
|
||||
3. Validates API key org binding (if using API key auth)
|
||||
4. Checks if the user has the required permission in the organization
|
||||
5. Returns the user_id if authorized, raises HTTPException otherwise
|
||||
|
||||
Usage:
|
||||
@router.get('/{org_id}/settings')
|
||||
@@ -240,6 +254,7 @@ def require_permission(permission: Permission):
|
||||
"""
|
||||
|
||||
async def permission_checker(
|
||||
request: Request,
|
||||
org_id: UUID | None = None,
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
) -> str:
|
||||
@@ -249,6 +264,23 @@ def require_permission(permission: Permission):
|
||||
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 and org_id is not None:
|
||||
if api_key_org_id != org_id:
|
||||
logger.warning(
|
||||
'API key organization mismatch',
|
||||
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',
|
||||
)
|
||||
|
||||
user_role = await get_user_org_role(user_id, org_id)
|
||||
|
||||
if not user_role:
|
||||
@@ -279,3 +311,96 @@ def require_permission(permission: Permission):
|
||||
return user_id
|
||||
|
||||
return permission_checker
|
||||
|
||||
|
||||
async def require_financial_data_access(
|
||||
request: Request,
|
||||
org_id: UUID,
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
) -> str:
|
||||
"""
|
||||
Authorization dependency for accessing organization financial data.
|
||||
|
||||
Allows access if ANY of these conditions are met:
|
||||
1. User has Admin or Owner role in the organization
|
||||
2. User has @openhands.dev email domain
|
||||
|
||||
This is used for the organization members financial data endpoint.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object
|
||||
org_id: Organization UUID from path parameter
|
||||
user_id: User ID from authentication
|
||||
|
||||
Returns:
|
||||
str: User ID if authorized
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if not authenticated, 403 if not authorized
|
||||
"""
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='User not authenticated',
|
||||
)
|
||||
|
||||
# Validate API key organization binding
|
||||
api_key_org_id = await get_api_key_org_id_from_request(request)
|
||||
if api_key_org_id is not None:
|
||||
if api_key_org_id != org_id:
|
||||
logger.warning(
|
||||
'API key organization mismatch for financial data access',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'api_key_org_id': str(api_key_org_id),
|
||||
'target_org_id': str(org_id),
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='API key is not authorized for this organization',
|
||||
)
|
||||
|
||||
# Check if user has @openhands.dev email
|
||||
user_auth = await get_user_auth(request)
|
||||
user_email = await user_auth.get_user_email()
|
||||
|
||||
if user_email and user_email.endswith('@openhands.dev'):
|
||||
logger.debug(
|
||||
'Financial data access granted via @openhands.dev email',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id)},
|
||||
)
|
||||
return user_id
|
||||
|
||||
# Check if user has Admin or Owner role in the organization
|
||||
user_role = await get_user_org_role(user_id, org_id)
|
||||
|
||||
if not user_role:
|
||||
logger.warning(
|
||||
'Financial data access denied - user not a member of organization',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='User is not a member of this organization',
|
||||
)
|
||||
|
||||
if user_role.name not in (RoleName.OWNER.value, RoleName.ADMIN.value):
|
||||
logger.warning(
|
||||
'Financial data access denied - insufficient role',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
'user_role': user_role.name,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Access restricted to organization admins, owners, or OpenHands members',
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
'Financial data access granted via admin/owner role',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id), 'role': user_role.name},
|
||||
)
|
||||
return user_id
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from uuid import UUID
|
||||
|
||||
import jwt
|
||||
from fastapi import Request
|
||||
@@ -59,6 +60,19 @@ class SaasUserAuth(UserAuth):
|
||||
_secrets: Secrets | None = None
|
||||
accepted_tos: bool | None = None
|
||||
auth_type: AuthType = AuthType.COOKIE
|
||||
# API key context fields - populated when authenticated via API key
|
||||
api_key_org_id: UUID | None = None # Org bound to the API key used for auth
|
||||
api_key_id: int | None = None
|
||||
api_key_name: str | None = None
|
||||
|
||||
def get_api_key_org_id(self) -> UUID | None:
|
||||
"""Get the organization ID bound to the API key used for authentication.
|
||||
|
||||
Returns:
|
||||
The org_id if authenticated via API key with org binding, None otherwise
|
||||
(cookie auth or legacy API keys without org binding).
|
||||
"""
|
||||
return self.api_key_org_id
|
||||
|
||||
async def get_user_id(self) -> str | None:
|
||||
return self.user_id
|
||||
@@ -283,14 +297,19 @@ async def saas_user_auth_from_bearer(request: Request) -> SaasUserAuth | None:
|
||||
return None
|
||||
|
||||
api_key_store = ApiKeyStore.get_instance()
|
||||
user_id = await api_key_store.validate_api_key(api_key)
|
||||
if not user_id:
|
||||
validation_result = await api_key_store.validate_api_key(api_key)
|
||||
if not validation_result:
|
||||
return None
|
||||
offline_token = await token_manager.load_offline_token(user_id)
|
||||
offline_token = await token_manager.load_offline_token(
|
||||
validation_result.user_id
|
||||
)
|
||||
saas_user_auth = SaasUserAuth(
|
||||
user_id=user_id,
|
||||
user_id=validation_result.user_id,
|
||||
refresh_token=SecretStr(offline_token),
|
||||
auth_type=AuthType.BEARER,
|
||||
api_key_org_id=validation_result.org_id,
|
||||
api_key_id=validation_result.key_id,
|
||||
api_key_name=validation_result.key_name,
|
||||
)
|
||||
await saas_user_auth.refresh()
|
||||
return saas_user_auth
|
||||
|
||||
@@ -80,10 +80,11 @@ def setup_json_logger(
|
||||
handler.setLevel(level)
|
||||
|
||||
formatter = JsonFormatter(
|
||||
'{message}{levelname}',
|
||||
style='{',
|
||||
'%(message)s%(levelname)s%(module)s%(funcName)s%(lineno)d',
|
||||
rename_fields={'levelname': 'severity'},
|
||||
json_serializer=custom_json_serializer,
|
||||
# Use 'ts' for consistency with LOG_JSON_FOR_CONSOLE mode (skip when console mode to avoid duplicates)
|
||||
timestamp='ts' if not LOG_JSON_FOR_CONSOLE else False,
|
||||
)
|
||||
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
@@ -182,6 +182,10 @@ class SetAuthCookieMiddleware:
|
||||
if path.startswith('/api/v1/webhooks/'):
|
||||
return False
|
||||
|
||||
# Service API uses its own authentication (X-Service-API-Key header)
|
||||
if path.startswith('/api/service/'):
|
||||
return False
|
||||
|
||||
is_mcp = path.startswith('/mcp')
|
||||
is_api_route = path.startswith('/api')
|
||||
return is_api_route or is_mcp
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from datetime import UTC, datetime
|
||||
from typing import cast
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel, field_validator
|
||||
from server.auth.saas_user_auth import SaasUserAuth
|
||||
from storage.api_key import ApiKey
|
||||
from storage.api_key_store import ApiKeyStore
|
||||
from storage.lite_llm_manager import LiteLlmManager
|
||||
@@ -11,7 +13,8 @@ from storage.org_service import OrgService
|
||||
from storage.user_store import UserStore
|
||||
|
||||
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
|
||||
from openhands.server.user_auth.user_auth import AuthType
|
||||
|
||||
|
||||
# Helper functions for BYOR API key management
|
||||
@@ -150,6 +153,16 @@ class MessageResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class CurrentApiKeyResponse(BaseModel):
|
||||
"""Response model for the current API key endpoint."""
|
||||
|
||||
id: int
|
||||
name: str | None
|
||||
org_id: str
|
||||
user_id: str
|
||||
auth_type: str
|
||||
|
||||
|
||||
def api_key_to_response(key: ApiKey) -> ApiKeyResponse:
|
||||
"""Convert an ApiKey model to an ApiKeyResponse."""
|
||||
return ApiKeyResponse(
|
||||
@@ -262,6 +275,46 @@ async def delete_api_key(
|
||||
)
|
||||
|
||||
|
||||
@api_router.get('/current', tags=['Keys'])
|
||||
async def get_current_api_key(
|
||||
request: Request,
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> CurrentApiKeyResponse:
|
||||
"""Get information about the currently authenticated API key.
|
||||
|
||||
This endpoint returns metadata about the API key used for the current request,
|
||||
including the org_id associated with the key. This is useful for API key
|
||||
callers who need to know which organization context their key operates in.
|
||||
|
||||
Returns 400 if not authenticated via API key (e.g., using cookie auth).
|
||||
"""
|
||||
user_auth = await get_user_auth(request)
|
||||
|
||||
# Check if authenticated via API key
|
||||
if user_auth.get_auth_type() != AuthType.BEARER:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='This endpoint requires API key authentication. Not available for cookie-based auth.',
|
||||
)
|
||||
|
||||
# In SaaS context, bearer auth always produces SaasUserAuth
|
||||
saas_user_auth = cast(SaasUserAuth, user_auth)
|
||||
|
||||
if saas_user_auth.api_key_org_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='This API key was created before organization support. Please regenerate your API key to use this endpoint.',
|
||||
)
|
||||
|
||||
return CurrentApiKeyResponse(
|
||||
id=saas_user_auth.api_key_id,
|
||||
name=saas_user_auth.api_key_name,
|
||||
org_id=str(saas_user_auth.api_key_org_id),
|
||||
user_id=user_id,
|
||||
auth_type=saas_user_auth.auth_type.value,
|
||||
)
|
||||
|
||||
|
||||
@api_router.get('/llm/byor', tags=['Keys'])
|
||||
async def get_llm_api_key_for_byor(
|
||||
user_id: str = Depends(get_user_id),
|
||||
|
||||
@@ -172,6 +172,23 @@ async def keycloak_callback(
|
||||
|
||||
authorization = await user_authorizer.authorize_user(user_info)
|
||||
if not authorization.success:
|
||||
# For duplicate_email errors, clean up the newly created Keycloak user
|
||||
# (only if they're not already in our UserStore, i.e., they're a new user)
|
||||
if authorization.error_detail == 'duplicate_email':
|
||||
try:
|
||||
existing_user = await UserStore.get_user_by_id(user_info.sub)
|
||||
if not existing_user:
|
||||
# New user created during OAuth should be deleted from Keycloak
|
||||
await token_manager.delete_keycloak_user(user_info.sub)
|
||||
logger.info(
|
||||
f'Deleted orphaned Keycloak user {user_info.sub} '
|
||||
'after duplicate_email rejection'
|
||||
)
|
||||
except Exception as e:
|
||||
# Log but don't fail - user should still get 401 response
|
||||
logger.warning(
|
||||
f'Failed to clean up orphaned Keycloak user {user_info.sub}: {e}'
|
||||
)
|
||||
# Return unauthorized
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
||||
@@ -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,22 @@ class OrgAppSettingsUpdate(BaseModel):
|
||||
if v is not None and v <= 0:
|
||||
raise ValueError('max_budget_per_task must be greater than 0')
|
||||
return v
|
||||
|
||||
|
||||
class OrgMemberFinancialResponse(BaseModel):
|
||||
"""Financial data for a single organization member."""
|
||||
|
||||
user_id: str
|
||||
email: str | None
|
||||
lifetime_spend: float # Total amount spent (from LiteLLM)
|
||||
current_budget: float # Remaining budget (max_budget - spend)
|
||||
max_budget: float | None # Total allocated budget (None = unlimited)
|
||||
|
||||
|
||||
class OrgMemberFinancialPage(BaseModel):
|
||||
"""Paginated response for organization member financial data."""
|
||||
|
||||
items: list[OrgMemberFinancialResponse]
|
||||
current_page: int = 1
|
||||
per_page: int = 10
|
||||
next_page_id: str | None = None
|
||||
|
||||
@@ -4,6 +4,7 @@ from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from server.auth.authorization import (
|
||||
Permission,
|
||||
require_financial_data_access,
|
||||
require_permission,
|
||||
)
|
||||
from server.email_validation import get_admin_user_id
|
||||
@@ -22,6 +23,7 @@ from server.routes.org_models import (
|
||||
OrgDatabaseError,
|
||||
OrgLLMSettingsResponse,
|
||||
OrgLLMSettingsUpdate,
|
||||
OrgMemberFinancialPage,
|
||||
OrgMemberNotFoundError,
|
||||
OrgMemberPage,
|
||||
OrgMemberResponse,
|
||||
@@ -42,6 +44,7 @@ from server.services.org_llm_settings_service import (
|
||||
OrgLLMSettingsService,
|
||||
OrgLLMSettingsServiceInjector,
|
||||
)
|
||||
from server.services.org_member_financial_service import OrgMemberFinancialService
|
||||
from server.services.org_member_service import OrgMemberService
|
||||
from storage.org_service import OrgService
|
||||
from storage.user_store import UserStore
|
||||
@@ -68,7 +71,7 @@ async def list_user_orgs(
|
||||
] = None,
|
||||
limit: Annotated[
|
||||
int,
|
||||
Query(title='The max number of results in the page', gt=0, lte=100),
|
||||
Query(title='The max number of results in the page', gt=0, le=100),
|
||||
] = 100,
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> OrgPage:
|
||||
@@ -734,7 +737,7 @@ async def get_org_members(
|
||||
Query(
|
||||
title='The max number of results in the page',
|
||||
gt=0,
|
||||
lte=100,
|
||||
le=100,
|
||||
),
|
||||
] = 10,
|
||||
email: Annotated[
|
||||
@@ -883,6 +886,104 @@ async def get_org_members_count(
|
||||
)
|
||||
|
||||
|
||||
@org_router.get(
|
||||
'/{org_id}/members/financial',
|
||||
response_model=OrgMemberFinancialPage,
|
||||
)
|
||||
async def get_org_members_financial(
|
||||
org_id: UUID,
|
||||
page_id: Annotated[
|
||||
str | None,
|
||||
Query(
|
||||
title='Pagination offset encoded as string',
|
||||
description='Offset for pagination (e.g., "0", "10", "20")',
|
||||
),
|
||||
] = None,
|
||||
limit: Annotated[
|
||||
int,
|
||||
Query(
|
||||
title='Maximum items per page',
|
||||
gt=0,
|
||||
le=100,
|
||||
),
|
||||
] = 10,
|
||||
email: Annotated[
|
||||
str | None,
|
||||
Query(
|
||||
title='Filter members by email (case-insensitive partial match)',
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
),
|
||||
] = None,
|
||||
user_id: str = Depends(require_financial_data_access),
|
||||
) -> OrgMemberFinancialPage:
|
||||
"""Get paginated financial data for organization members.
|
||||
|
||||
Returns financial information (lifetime spend, current budget) for all members
|
||||
within the specified organization. Access is restricted to:
|
||||
- Organization Admins
|
||||
- Organization Owners
|
||||
- OpenHands members (users with @openhands.dev emails)
|
||||
|
||||
Args:
|
||||
org_id: Organization ID (UUID)
|
||||
page_id: Optional pagination offset encoded as string
|
||||
limit: Maximum items per page (1-100, default 10)
|
||||
email: Optional email filter (case-insensitive partial match)
|
||||
user_id: Authenticated user ID (injected by require_financial_data_access)
|
||||
|
||||
Returns:
|
||||
OrgMemberFinancialPage: Paginated response with member financial data
|
||||
- items: List of members with user_id, email, lifetime_spend,
|
||||
current_budget, and max_budget
|
||||
- current_page: Current page number (1-indexed)
|
||||
- per_page: Items per page
|
||||
- next_page_id: Offset for next page, or None if no more pages
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if user is not authenticated
|
||||
HTTPException: 403 if user lacks access (not admin/owner and not @openhands.dev)
|
||||
HTTPException: 400 if page_id is invalid
|
||||
HTTPException: 500 if retrieval fails
|
||||
"""
|
||||
logger.info(
|
||||
'Getting financial data for organization members',
|
||||
extra={
|
||||
'org_id': str(org_id),
|
||||
'user_id': user_id,
|
||||
'page_id': page_id,
|
||||
'limit': limit,
|
||||
'email_filter': email,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
return await OrgMemberFinancialService.get_org_members_financial_data(
|
||||
org_id=org_id,
|
||||
page_id=page_id,
|
||||
limit=limit,
|
||||
email_filter=email,
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.warning(
|
||||
'Invalid page_id for financial data request',
|
||||
extra={'org_id': str(org_id), 'page_id': page_id, 'error': str(e)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
'Error retrieving organization member financial data',
|
||||
extra={'org_id': str(org_id)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to retrieve member financial data',
|
||||
)
|
||||
|
||||
|
||||
@org_router.delete('/{org_id}/members/{user_id}')
|
||||
async def remove_org_member(
|
||||
org_id: UUID,
|
||||
|
||||
270
enterprise/server/routes/service.py
Normal file
270
enterprise/server/routes/service.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""
|
||||
Service API routes for internal service-to-service communication.
|
||||
|
||||
This module provides endpoints for trusted internal services (e.g., automations service)
|
||||
to perform privileged operations like creating API keys on behalf of users.
|
||||
|
||||
Authentication is via a shared secret (X-Service-API-Key header) configured
|
||||
through the AUTOMATIONS_SERVICE_KEY environment variable.
|
||||
"""
|
||||
|
||||
import os
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, status
|
||||
from pydantic import BaseModel, field_validator
|
||||
from storage.api_key_store import ApiKeyStore
|
||||
from storage.org_member_store import OrgMemberStore
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
# Environment variable for the service API key
|
||||
AUTOMATIONS_SERVICE_KEY = os.getenv('AUTOMATIONS_SERVICE_KEY', '').strip()
|
||||
|
||||
service_router = APIRouter(prefix='/api/service', tags=['Service'])
|
||||
|
||||
|
||||
class CreateUserApiKeyRequest(BaseModel):
|
||||
"""Request model for creating an API key on behalf of a user."""
|
||||
|
||||
name: str # Required - used to identify the key
|
||||
|
||||
@field_validator('name')
|
||||
@classmethod
|
||||
def validate_name(cls, v: str) -> str:
|
||||
if not v or not v.strip():
|
||||
raise ValueError('name is required and cannot be empty')
|
||||
return v.strip()
|
||||
|
||||
|
||||
class CreateUserApiKeyResponse(BaseModel):
|
||||
"""Response model for created API key."""
|
||||
|
||||
key: str
|
||||
user_id: str
|
||||
org_id: str
|
||||
name: str
|
||||
|
||||
|
||||
class ServiceInfoResponse(BaseModel):
|
||||
"""Response model for service info endpoint."""
|
||||
|
||||
service: str
|
||||
authenticated: bool
|
||||
|
||||
|
||||
async def validate_service_api_key(
|
||||
x_service_api_key: str | None = Header(default=None, alias='X-Service-API-Key'),
|
||||
) -> str:
|
||||
"""
|
||||
Validate the service API key from the request header.
|
||||
|
||||
Args:
|
||||
x_service_api_key: The service API key from the X-Service-API-Key header
|
||||
|
||||
Returns:
|
||||
str: Service identifier for audit logging
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if key is missing or invalid
|
||||
HTTPException: 503 if service auth is not configured
|
||||
"""
|
||||
if not AUTOMATIONS_SERVICE_KEY:
|
||||
logger.warning(
|
||||
'Service authentication not configured (AUTOMATIONS_SERVICE_KEY not set)'
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail='Service authentication not configured',
|
||||
)
|
||||
|
||||
if not x_service_api_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='X-Service-API-Key header is required',
|
||||
)
|
||||
|
||||
if x_service_api_key != AUTOMATIONS_SERVICE_KEY:
|
||||
logger.warning('Invalid service API key attempted')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Invalid service API key',
|
||||
)
|
||||
|
||||
return 'automations-service'
|
||||
|
||||
|
||||
@service_router.get('/health')
|
||||
async def service_health() -> dict:
|
||||
"""Health check endpoint for the service API.
|
||||
|
||||
This endpoint does not require authentication and can be used
|
||||
to verify the service routes are accessible.
|
||||
"""
|
||||
return {
|
||||
'status': 'ok',
|
||||
'service_auth_configured': bool(AUTOMATIONS_SERVICE_KEY),
|
||||
}
|
||||
|
||||
|
||||
@service_router.post('/users/{user_id}/orgs/{org_id}/api-keys')
|
||||
async def get_or_create_api_key_for_user(
|
||||
user_id: str,
|
||||
org_id: UUID,
|
||||
request: CreateUserApiKeyRequest,
|
||||
x_service_api_key: str | None = Header(default=None, alias='X-Service-API-Key'),
|
||||
) -> CreateUserApiKeyResponse:
|
||||
"""
|
||||
Get or create an API key for a user on behalf of the automations service.
|
||||
|
||||
If a key with the given name already exists for the user/org and is not expired,
|
||||
returns the existing key. Otherwise, creates a new key.
|
||||
|
||||
The created/returned keys are system keys and are:
|
||||
- Not visible to the user in their API keys list
|
||||
- Not deletable by the user
|
||||
- Never expire
|
||||
|
||||
Args:
|
||||
user_id: The user ID
|
||||
org_id: The organization ID
|
||||
request: Request body containing name (required)
|
||||
x_service_api_key: Service API key header for authentication
|
||||
|
||||
Returns:
|
||||
CreateUserApiKeyResponse: The API key and metadata
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if service key is invalid
|
||||
HTTPException: 404 if user not found
|
||||
HTTPException: 403 if user is not a member of the specified org
|
||||
"""
|
||||
# Validate service API key
|
||||
service_id = await validate_service_api_key(x_service_api_key)
|
||||
|
||||
# Verify user exists
|
||||
user = await UserStore.get_user_by_id(user_id)
|
||||
if not user:
|
||||
logger.warning(
|
||||
'Service attempted to create key for non-existent user',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f'User {user_id} not found',
|
||||
)
|
||||
|
||||
# Verify user is a member of the specified org
|
||||
org_member = await OrgMemberStore.get_org_member(org_id, UUID(user_id))
|
||||
if not org_member:
|
||||
logger.warning(
|
||||
'Service attempted to create key for user not in org',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f'User {user_id} is not a member of org {org_id}',
|
||||
)
|
||||
|
||||
# Get or create the system API key
|
||||
api_key_store = ApiKeyStore.get_instance()
|
||||
|
||||
try:
|
||||
api_key = await api_key_store.get_or_create_system_api_key(
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name=request.name,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Failed to get or create system API key',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to get or create API key',
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Service created API key for user',
|
||||
extra={
|
||||
'service_id': service_id,
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
'key_name': request.name,
|
||||
},
|
||||
)
|
||||
|
||||
return CreateUserApiKeyResponse(
|
||||
key=api_key,
|
||||
user_id=user_id,
|
||||
org_id=str(org_id),
|
||||
name=request.name,
|
||||
)
|
||||
|
||||
|
||||
@service_router.delete('/users/{user_id}/orgs/{org_id}/api-keys/{key_name}')
|
||||
async def delete_user_api_key(
|
||||
user_id: str,
|
||||
org_id: UUID,
|
||||
key_name: str,
|
||||
x_service_api_key: str | None = Header(default=None, alias='X-Service-API-Key'),
|
||||
) -> dict:
|
||||
"""
|
||||
Delete a system API key created by the service.
|
||||
|
||||
This endpoint allows the automations service to clean up API keys
|
||||
it previously created for users.
|
||||
|
||||
Args:
|
||||
user_id: The user ID
|
||||
org_id: The organization ID
|
||||
key_name: The name of the key to delete (without __SYSTEM__: prefix)
|
||||
x_service_api_key: Service API key header for authentication
|
||||
|
||||
Returns:
|
||||
dict: Success message
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if service key is invalid
|
||||
HTTPException: 404 if key not found
|
||||
"""
|
||||
# Validate service API key
|
||||
service_id = await validate_service_api_key(x_service_api_key)
|
||||
|
||||
api_key_store = ApiKeyStore.get_instance()
|
||||
|
||||
# Delete the key by name (wrap with system key prefix since service creates system keys)
|
||||
system_key_name = api_key_store.make_system_key_name(key_name)
|
||||
success = await api_key_store.delete_api_key_by_name(
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name=system_key_name,
|
||||
allow_system=True,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f'API key with name "{key_name}" not found for user {user_id} in org {org_id}',
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Service deleted API key for user',
|
||||
extra={
|
||||
'service_id': service_id,
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
'key_name': key_name,
|
||||
},
|
||||
)
|
||||
|
||||
return {'message': 'API key deleted successfully'}
|
||||
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,
|
||||
)
|
||||
143
enterprise/server/sharing/filesystem_shared_event_service.py
Normal file
143
enterprise/server/sharing/filesystem_shared_event_service.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""Implementation of SharedEventService.
|
||||
|
||||
This implementation provides read-only access to events from shared conversations:
|
||||
- Validates that the conversation is shared before returning events
|
||||
- Uses existing EventService for actual event retrieval
|
||||
- Uses SharedConversationInfoService for shared conversation validation
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Request
|
||||
from server.sharing.shared_conversation_info_service import (
|
||||
SharedConversationInfoService,
|
||||
)
|
||||
from server.sharing.shared_event_service import (
|
||||
SharedEventService,
|
||||
SharedEventServiceInjector,
|
||||
)
|
||||
from server.sharing.sql_shared_conversation_info_service import (
|
||||
SQLSharedConversationInfoService,
|
||||
)
|
||||
|
||||
from openhands.agent_server.models import EventPage, EventSortOrder
|
||||
from openhands.app_server.config import get_global_config
|
||||
from openhands.app_server.event.event_service import EventService
|
||||
from openhands.app_server.event.filesystem_event_service import FilesystemEventService
|
||||
from openhands.app_server.event_callback.event_callback_models import EventKind
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.sdk import Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FilesystemSharedEventService(SharedEventService):
|
||||
"""Implementation of SharedEventService that validates shared access."""
|
||||
|
||||
shared_conversation_info_service: SharedConversationInfoService
|
||||
persistence_dir: Path
|
||||
|
||||
async def get_event_service(self, conversation_id: UUID) -> EventService | None:
|
||||
shared_conversation_info = (
|
||||
await self.shared_conversation_info_service.get_shared_conversation_info(
|
||||
conversation_id
|
||||
)
|
||||
)
|
||||
if shared_conversation_info is None:
|
||||
return None
|
||||
|
||||
return FilesystemEventService(
|
||||
prefix=self.persistence_dir,
|
||||
user_id=shared_conversation_info.created_by_user_id,
|
||||
app_conversation_info_service=None,
|
||||
app_conversation_info_load_tasks={},
|
||||
)
|
||||
|
||||
async def get_shared_event(
|
||||
self, conversation_id: UUID, event_id: UUID
|
||||
) -> Event | None:
|
||||
"""Given a conversation_id and event_id, retrieve an event if the conversation is shared."""
|
||||
# First check if the conversation is shared
|
||||
event_service = await self.get_event_service(conversation_id)
|
||||
if event_service is None:
|
||||
return None
|
||||
|
||||
# If conversation is shared, get the event
|
||||
return await event_service.get_event(conversation_id, event_id)
|
||||
|
||||
async def search_shared_events(
|
||||
self,
|
||||
conversation_id: UUID,
|
||||
kind__eq: EventKind | None = None,
|
||||
timestamp__gte: datetime | None = None,
|
||||
timestamp__lt: datetime | None = None,
|
||||
sort_order: EventSortOrder = EventSortOrder.TIMESTAMP,
|
||||
page_id: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> EventPage:
|
||||
"""Search events for a specific shared conversation."""
|
||||
# First check if the conversation is shared
|
||||
event_service = await self.get_event_service(conversation_id)
|
||||
if event_service is None:
|
||||
# Return empty page if conversation is not shared
|
||||
return EventPage(items=[], next_page_id=None)
|
||||
|
||||
# If conversation is shared, search events for this conversation
|
||||
return await event_service.search_events(
|
||||
conversation_id=conversation_id,
|
||||
kind__eq=kind__eq,
|
||||
timestamp__gte=timestamp__gte,
|
||||
timestamp__lt=timestamp__lt,
|
||||
sort_order=sort_order,
|
||||
page_id=page_id,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
async def count_shared_events(
|
||||
self,
|
||||
conversation_id: UUID,
|
||||
kind__eq: EventKind | None = None,
|
||||
timestamp__gte: datetime | None = None,
|
||||
timestamp__lt: datetime | None = None,
|
||||
) -> int:
|
||||
"""Count events for a specific shared conversation."""
|
||||
# First check if the conversation is shared
|
||||
event_service = await self.get_event_service(conversation_id)
|
||||
if event_service is None:
|
||||
# Return empty page if conversation is not shared
|
||||
return 0
|
||||
|
||||
# If conversation is shared, count events for this conversation
|
||||
return await event_service.count_events(
|
||||
conversation_id=conversation_id,
|
||||
kind__eq=kind__eq,
|
||||
timestamp__gte=timestamp__gte,
|
||||
timestamp__lt=timestamp__lt,
|
||||
)
|
||||
|
||||
|
||||
class FilesystemSharedEventServiceInjector(SharedEventServiceInjector):
|
||||
async def inject(
|
||||
self, state: InjectorState, request: Request | None = None
|
||||
) -> AsyncGenerator[SharedEventService, None]:
|
||||
# Define inline to prevent circular lookup
|
||||
from openhands.app_server.config import get_db_session
|
||||
|
||||
async with get_db_session(state, request) as db_session:
|
||||
shared_conversation_info_service = SQLSharedConversationInfoService(
|
||||
db_session=db_session
|
||||
)
|
||||
|
||||
service = FilesystemSharedEventService(
|
||||
shared_conversation_info_service=shared_conversation_info_service,
|
||||
persistence_dir=get_global_config().persistence_dir,
|
||||
)
|
||||
yield service
|
||||
@@ -4,7 +4,7 @@ from datetime import datetime
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from server.sharing.shared_conversation_info_service import (
|
||||
SharedConversationInfoService,
|
||||
)
|
||||
@@ -60,7 +60,7 @@ async def search_shared_conversations(
|
||||
Query(
|
||||
title='The max number of results in the page',
|
||||
gt=0,
|
||||
lte=100,
|
||||
le=100,
|
||||
),
|
||||
] = 100,
|
||||
include_sub_conversations: Annotated[
|
||||
@@ -72,8 +72,6 @@ async def search_shared_conversations(
|
||||
shared_conversation_service: SharedConversationInfoService = shared_conversation_info_service_dependency,
|
||||
) -> SharedConversationPage:
|
||||
"""Search / List shared conversations."""
|
||||
assert limit > 0
|
||||
assert limit <= 100
|
||||
return await shared_conversation_service.search_shared_conversation_info(
|
||||
title__contains=title__contains,
|
||||
created_at__gte=created_at__gte,
|
||||
@@ -127,7 +125,11 @@ async def batch_get_shared_conversations(
|
||||
shared_conversation_service: SharedConversationInfoService = shared_conversation_info_service_dependency,
|
||||
) -> list[SharedConversation | None]:
|
||||
"""Get a batch of shared conversations given their ids. Return None for any missing or non-shared."""
|
||||
assert len(ids) <= 100
|
||||
if len(ids) > 100:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f'Cannot request more than 100 conversations at once, got {len(ids)}',
|
||||
)
|
||||
uuids = [UUID(id_) for id_ in ids]
|
||||
shared_conversation_info = (
|
||||
await shared_conversation_service.batch_get_shared_conversation_info(uuids)
|
||||
|
||||
@@ -4,7 +4,7 @@ from datetime import datetime
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from server.sharing.shared_event_service import (
|
||||
SharedEventService,
|
||||
SharedEventServiceInjector,
|
||||
@@ -33,6 +33,12 @@ def get_shared_event_service_injector() -> SharedEventServiceInjector:
|
||||
)
|
||||
|
||||
return AwsSharedEventServiceInjector()
|
||||
elif provider == StorageProvider.FILESYSTEM:
|
||||
from server.sharing.filesystem_shared_event_service import (
|
||||
FilesystemSharedEventServiceInjector,
|
||||
)
|
||||
|
||||
return FilesystemSharedEventServiceInjector()
|
||||
else:
|
||||
# GCP is the default for shared events (including filesystem fallback)
|
||||
from server.sharing.google_cloud_shared_event_service import (
|
||||
@@ -77,13 +83,11 @@ async def search_shared_events(
|
||||
] = None,
|
||||
limit: Annotated[
|
||||
int,
|
||||
Query(title='The max number of results in the page', gt=0, lte=100),
|
||||
Query(title='The max number of results in the page', gt=0, le=100),
|
||||
] = 100,
|
||||
shared_event_service: SharedEventService = shared_event_service_dependency,
|
||||
) -> EventPage:
|
||||
"""Search / List events for a shared conversation."""
|
||||
assert limit > 0
|
||||
assert limit <= 100
|
||||
return await shared_event_service.search_shared_events(
|
||||
conversation_id=UUID(conversation_id),
|
||||
kind__eq=kind__eq,
|
||||
@@ -134,7 +138,11 @@ async def batch_get_shared_events(
|
||||
shared_event_service: SharedEventService = shared_event_service_dependency,
|
||||
) -> list[Event | None]:
|
||||
"""Get a batch of events for a shared conversation given their ids, returning null for any missing event."""
|
||||
assert len(id) <= 100
|
||||
if len(id) > 100:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f'Cannot request more than 100 events at once, got {len(id)}',
|
||||
)
|
||||
event_ids = [UUID(id_) for id_ in id]
|
||||
events = await shared_event_service.batch_get_shared_events(
|
||||
UUID(conversation_id), event_ids
|
||||
|
||||
@@ -354,6 +354,15 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
user = result.scalar_one_or_none()
|
||||
assert user
|
||||
|
||||
# Determine org_id: prefer API key's org_id if authenticated via API key
|
||||
org_id = user.current_org_id # Default fallback
|
||||
if hasattr(self.user_context, 'user_auth'):
|
||||
user_auth = self.user_context.user_auth
|
||||
if hasattr(user_auth, 'get_api_key_org_id'):
|
||||
api_key_org_id = user_auth.get_api_key_org_id()
|
||||
if api_key_org_id is not None:
|
||||
org_id = api_key_org_id
|
||||
|
||||
# Check if SAAS metadata already exists
|
||||
saas_query = select(StoredConversationMetadataSaas).where(
|
||||
StoredConversationMetadataSaas.conversation_id == str(info.id)
|
||||
@@ -362,16 +371,15 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
existing_saas_metadata = result.scalar_one_or_none()
|
||||
assert existing_saas_metadata is None or (
|
||||
existing_saas_metadata.user_id == user_id_uuid
|
||||
and existing_saas_metadata.org_id == user.current_org_id
|
||||
and existing_saas_metadata.org_id == org_id
|
||||
)
|
||||
|
||||
if not existing_saas_metadata:
|
||||
# Create new SAAS metadata
|
||||
# Set org_id to user_id as specified in requirements
|
||||
# Create new SAAS metadata with the determined org_id
|
||||
saas_metadata = StoredConversationMetadataSaas(
|
||||
conversation_id=str(info.id),
|
||||
user_id=user_id_uuid,
|
||||
org_id=user.current_org_id,
|
||||
org_id=org_id,
|
||||
)
|
||||
self.db_session.add(saas_metadata)
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -4,6 +4,7 @@ import secrets
|
||||
import string
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from storage.api_key import ApiKey
|
||||
@@ -13,9 +14,22 @@ from storage.user_store import UserStore
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApiKeyValidationResult:
|
||||
"""Result of API key validation containing user and organization info."""
|
||||
|
||||
user_id: str
|
||||
org_id: UUID | None # None for legacy API keys without org binding
|
||||
key_id: int
|
||||
key_name: str | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApiKeyStore:
|
||||
API_KEY_PREFIX = 'sk-oh-'
|
||||
# Prefix for system keys created by internal services (e.g., automations)
|
||||
# Keys with this prefix are hidden from users and cannot be deleted by users
|
||||
SYSTEM_KEY_NAME_PREFIX = '__SYSTEM__:'
|
||||
|
||||
def generate_api_key(self, length: int = 32) -> str:
|
||||
"""Generate a random API key with the sk-oh- prefix."""
|
||||
@@ -23,6 +37,19 @@ class ApiKeyStore:
|
||||
random_part = ''.join(secrets.choice(alphabet) for _ in range(length))
|
||||
return f'{self.API_KEY_PREFIX}{random_part}'
|
||||
|
||||
@classmethod
|
||||
def is_system_key_name(cls, name: str | None) -> bool:
|
||||
"""Check if a key name indicates a system key."""
|
||||
return name is not None and name.startswith(cls.SYSTEM_KEY_NAME_PREFIX)
|
||||
|
||||
@classmethod
|
||||
def make_system_key_name(cls, name: str) -> str:
|
||||
"""Create a system key name with the appropriate prefix.
|
||||
|
||||
Format: __SYSTEM__:<name>
|
||||
"""
|
||||
return f'{cls.SYSTEM_KEY_NAME_PREFIX}{name}'
|
||||
|
||||
async def create_api_key(
|
||||
self, user_id: str, name: str | None = None, expires_at: datetime | None = None
|
||||
) -> str:
|
||||
@@ -60,8 +87,120 @@ class ApiKeyStore:
|
||||
|
||||
return api_key
|
||||
|
||||
async def validate_api_key(self, api_key: str) -> str | None:
|
||||
"""Validate an API key and return the associated user_id if valid."""
|
||||
async def get_or_create_system_api_key(
|
||||
self,
|
||||
user_id: str,
|
||||
org_id: UUID,
|
||||
name: str,
|
||||
) -> str:
|
||||
"""Get or create a system API key for a user on behalf of an internal service.
|
||||
|
||||
If a key with the given name already exists for this user/org and is not expired,
|
||||
returns the existing key. Otherwise, creates a new key (and deletes any expired one).
|
||||
|
||||
System keys are:
|
||||
- Not visible to users in their API keys list (filtered by name prefix)
|
||||
- Not deletable by users (protected by name prefix check)
|
||||
- Associated with a specific org (not the user's current org)
|
||||
- Never expire (no expiration date)
|
||||
|
||||
Args:
|
||||
user_id: The ID of the user to create the key for
|
||||
org_id: The organization ID to associate the key with
|
||||
name: Required name for the key (will be prefixed with __SYSTEM__:)
|
||||
|
||||
Returns:
|
||||
The API key (existing or newly created)
|
||||
"""
|
||||
# Create system key name with prefix
|
||||
system_key_name = self.make_system_key_name(name)
|
||||
|
||||
async with a_session_maker() as session:
|
||||
# Check if key already exists for this user/org/name
|
||||
result = await session.execute(
|
||||
select(ApiKey).filter(
|
||||
ApiKey.user_id == user_id,
|
||||
ApiKey.org_id == org_id,
|
||||
ApiKey.name == system_key_name,
|
||||
)
|
||||
)
|
||||
existing_key = result.scalars().first()
|
||||
|
||||
if existing_key:
|
||||
# Check if expired
|
||||
if existing_key.expires_at:
|
||||
now = datetime.now(UTC)
|
||||
expires_at = existing_key.expires_at
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=UTC)
|
||||
|
||||
if expires_at < now:
|
||||
# Key is expired, delete it and create new one
|
||||
logger.info(
|
||||
'System API key expired, re-issuing',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
'key_name': system_key_name,
|
||||
},
|
||||
)
|
||||
await session.delete(existing_key)
|
||||
await session.commit()
|
||||
else:
|
||||
# Key exists and is not expired, return it
|
||||
logger.debug(
|
||||
'Returning existing system API key',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
'key_name': system_key_name,
|
||||
},
|
||||
)
|
||||
return existing_key.key
|
||||
else:
|
||||
# Key exists and has no expiration, return it
|
||||
logger.debug(
|
||||
'Returning existing system API key',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
'key_name': system_key_name,
|
||||
},
|
||||
)
|
||||
return existing_key.key
|
||||
|
||||
# Create new key (no expiration)
|
||||
api_key = self.generate_api_key()
|
||||
|
||||
async with a_session_maker() as session:
|
||||
key_record = ApiKey(
|
||||
key=api_key,
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name=system_key_name,
|
||||
expires_at=None, # System keys never expire
|
||||
)
|
||||
session.add(key_record)
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
'Created system API key',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
'key_name': system_key_name,
|
||||
},
|
||||
)
|
||||
|
||||
return api_key
|
||||
|
||||
async def validate_api_key(self, api_key: str) -> ApiKeyValidationResult | None:
|
||||
"""Validate an API key and return the associated user_id and org_id if valid.
|
||||
|
||||
Returns:
|
||||
ApiKeyValidationResult if the key is valid, None otherwise.
|
||||
The org_id may be None for legacy API keys that weren't bound to an organization.
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
|
||||
async with a_session_maker() as session:
|
||||
@@ -89,7 +228,12 @@ class ApiKeyStore:
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
return key_record.user_id
|
||||
return ApiKeyValidationResult(
|
||||
user_id=key_record.user_id,
|
||||
org_id=key_record.org_id,
|
||||
key_id=key_record.id,
|
||||
key_name=key_record.name,
|
||||
)
|
||||
|
||||
async def delete_api_key(self, api_key: str) -> bool:
|
||||
"""Delete an API key by the key value."""
|
||||
@@ -105,8 +249,18 @@ class ApiKeyStore:
|
||||
|
||||
return True
|
||||
|
||||
async def delete_api_key_by_id(self, key_id: int) -> bool:
|
||||
"""Delete an API key by its ID."""
|
||||
async def delete_api_key_by_id(
|
||||
self, key_id: int, allow_system: bool = False
|
||||
) -> bool:
|
||||
"""Delete an API key by its ID.
|
||||
|
||||
Args:
|
||||
key_id: The ID of the key to delete
|
||||
allow_system: If False (default), system keys cannot be deleted
|
||||
|
||||
Returns:
|
||||
True if the key was deleted, False if not found or is a protected system key
|
||||
"""
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(select(ApiKey).filter(ApiKey.id == key_id))
|
||||
key_record = result.scalars().first()
|
||||
@@ -114,13 +268,26 @@ class ApiKeyStore:
|
||||
if not key_record:
|
||||
return False
|
||||
|
||||
# Protect system keys from deletion unless explicitly allowed
|
||||
if self.is_system_key_name(key_record.name) and not allow_system:
|
||||
logger.warning(
|
||||
'Attempted to delete system API key',
|
||||
extra={'key_id': key_id, 'user_id': key_record.user_id},
|
||||
)
|
||||
return False
|
||||
|
||||
await session.delete(key_record)
|
||||
await session.commit()
|
||||
|
||||
return True
|
||||
|
||||
async def list_api_keys(self, user_id: str) -> list[ApiKey]:
|
||||
"""List all API keys for a user."""
|
||||
"""List all user-visible API keys for a user.
|
||||
|
||||
This excludes:
|
||||
- System keys (name starts with __SYSTEM__:) - created by internal services
|
||||
- MCP_API_KEY - internal MCP key
|
||||
"""
|
||||
user = await UserStore.get_user_by_id(user_id)
|
||||
if user is None:
|
||||
raise ValueError(f'User not found: {user_id}')
|
||||
@@ -129,11 +296,17 @@ class ApiKeyStore:
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(ApiKey).filter(
|
||||
ApiKey.user_id == user_id, ApiKey.org_id == org_id
|
||||
ApiKey.user_id == user_id,
|
||||
ApiKey.org_id == org_id,
|
||||
)
|
||||
)
|
||||
keys = result.scalars().all()
|
||||
return [key for key in keys if key.name != 'MCP_API_KEY']
|
||||
# Filter out system keys and MCP_API_KEY
|
||||
return [
|
||||
key
|
||||
for key in keys
|
||||
if key.name != 'MCP_API_KEY' and not self.is_system_key_name(key.name)
|
||||
]
|
||||
|
||||
async def retrieve_mcp_api_key(self, user_id: str) -> str | None:
|
||||
user = await UserStore.get_user_by_id(user_id)
|
||||
@@ -163,17 +336,44 @@ class ApiKeyStore:
|
||||
key_record = result.scalars().first()
|
||||
return key_record.key if key_record else None
|
||||
|
||||
async def delete_api_key_by_name(self, user_id: str, name: str) -> bool:
|
||||
"""Delete an API key by name for a specific user."""
|
||||
async def delete_api_key_by_name(
|
||||
self,
|
||||
user_id: str,
|
||||
name: str,
|
||||
org_id: UUID | None = None,
|
||||
allow_system: bool = False,
|
||||
) -> bool:
|
||||
"""Delete an API key by name for a specific user.
|
||||
|
||||
Args:
|
||||
user_id: The ID of the user whose key to delete
|
||||
name: The name of the key to delete
|
||||
org_id: Optional organization ID to filter by (required for system keys)
|
||||
allow_system: If False (default), system keys cannot be deleted
|
||||
|
||||
Returns:
|
||||
True if the key was deleted, False if not found or is a protected system key
|
||||
"""
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(ApiKey).filter(ApiKey.user_id == user_id, ApiKey.name == name)
|
||||
)
|
||||
# Build the query filters
|
||||
filters = [ApiKey.user_id == user_id, ApiKey.name == name]
|
||||
if org_id is not None:
|
||||
filters.append(ApiKey.org_id == org_id)
|
||||
|
||||
result = await session.execute(select(ApiKey).filter(*filters))
|
||||
key_record = result.scalars().first()
|
||||
|
||||
if not key_record:
|
||||
return False
|
||||
|
||||
# Protect system keys from deletion unless explicitly allowed
|
||||
if self.is_system_key_name(key_record.name) and not allow_system:
|
||||
logger.warning(
|
||||
'Attempted to delete system API key',
|
||||
extra={'user_id': user_id, 'key_name': name},
|
||||
)
|
||||
return False
|
||||
|
||||
await session.delete(key_record)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@@ -29,14 +29,37 @@ KEY_VERIFICATION_TIMEOUT = 5.0
|
||||
# A very large number to represent "unlimited" until LiteLLM fixes their unlimited update bug.
|
||||
UNLIMITED_BUDGET_SETTING = 1000000000.0
|
||||
|
||||
try:
|
||||
DEFAULT_INITIAL_BUDGET = float(os.environ.get('DEFAULT_INITIAL_BUDGET', 0.0))
|
||||
if DEFAULT_INITIAL_BUDGET < 0:
|
||||
# Check if billing is enabled (defaults to false for enterprise deployments)
|
||||
ENABLE_BILLING = os.environ.get('ENABLE_BILLING', 'false').lower() == 'true'
|
||||
|
||||
|
||||
def _get_default_initial_budget() -> float | None:
|
||||
"""Get the default initial budget for new teams.
|
||||
|
||||
When billing is disabled (ENABLE_BILLING=false), returns None to disable
|
||||
budget enforcement in LiteLLM. When billing is enabled, returns the
|
||||
DEFAULT_INITIAL_BUDGET environment variable value (default 0.0).
|
||||
|
||||
Returns:
|
||||
float | None: The default budget, or None to disable budget enforcement.
|
||||
"""
|
||||
if not ENABLE_BILLING:
|
||||
return None
|
||||
|
||||
try:
|
||||
budget = float(os.environ.get('DEFAULT_INITIAL_BUDGET', 0.0))
|
||||
if budget < 0:
|
||||
raise ValueError(
|
||||
f'DEFAULT_INITIAL_BUDGET must be non-negative, got {budget}'
|
||||
)
|
||||
return budget
|
||||
except ValueError as e:
|
||||
raise ValueError(
|
||||
f'DEFAULT_INITIAL_BUDGET must be non-negative, got {DEFAULT_INITIAL_BUDGET}'
|
||||
)
|
||||
except ValueError as e:
|
||||
raise ValueError(f'Invalid DEFAULT_INITIAL_BUDGET environment variable: {e}') from e
|
||||
f'Invalid DEFAULT_INITIAL_BUDGET environment variable: {e}'
|
||||
) from e
|
||||
|
||||
|
||||
DEFAULT_INITIAL_BUDGET: float | None = _get_default_initial_budget()
|
||||
|
||||
|
||||
def get_openhands_cloud_key_alias(keycloak_user_id: str, org_id: str) -> str:
|
||||
@@ -110,12 +133,15 @@ class LiteLlmManager:
|
||||
) as client:
|
||||
# Check if team already exists and get its budget
|
||||
# New users joining existing orgs should inherit the team's budget
|
||||
team_budget: float = DEFAULT_INITIAL_BUDGET
|
||||
# When billing is disabled, DEFAULT_INITIAL_BUDGET is None
|
||||
team_budget: float | None = DEFAULT_INITIAL_BUDGET
|
||||
try:
|
||||
existing_team = await LiteLlmManager._get_team(client, org_id)
|
||||
if existing_team:
|
||||
team_info = existing_team.get('team_info', {})
|
||||
team_budget = team_info.get('max_budget', 0.0) or 0.0
|
||||
# Preserve None from existing team (no budget enforcement)
|
||||
existing_budget = team_info.get('max_budget')
|
||||
team_budget = existing_budget
|
||||
logger.info(
|
||||
'LiteLlmManager:create_entries:existing_team_budget',
|
||||
extra={
|
||||
@@ -138,9 +164,33 @@ class LiteLlmManager:
|
||||
)
|
||||
|
||||
if create_user:
|
||||
await LiteLlmManager._create_user(
|
||||
user_created = await LiteLlmManager._create_user(
|
||||
client, keycloak_user_info.get('email'), keycloak_user_id
|
||||
)
|
||||
if not user_created:
|
||||
logger.error(
|
||||
'create_entries_failed_user_creation',
|
||||
extra={
|
||||
'org_id': org_id,
|
||||
'user_id': keycloak_user_id,
|
||||
},
|
||||
)
|
||||
return None
|
||||
|
||||
# Verify user exists before proceeding with key generation
|
||||
user_exists = await LiteLlmManager._user_exists(
|
||||
client, keycloak_user_id
|
||||
)
|
||||
if not user_exists:
|
||||
logger.error(
|
||||
'create_entries_user_not_found_before_key_generation',
|
||||
extra={
|
||||
'org_id': org_id,
|
||||
'user_id': keycloak_user_id,
|
||||
'create_user_flag': create_user,
|
||||
},
|
||||
)
|
||||
return None
|
||||
|
||||
await LiteLlmManager._add_user_to_team(
|
||||
client, keycloak_user_id, org_id, team_budget
|
||||
@@ -525,25 +575,40 @@ class LiteLlmManager:
|
||||
client: httpx.AsyncClient,
|
||||
team_alias: str,
|
||||
team_id: str,
|
||||
max_budget: float,
|
||||
max_budget: float | None,
|
||||
):
|
||||
"""Create a new team in LiteLLM.
|
||||
|
||||
Args:
|
||||
client: The HTTP client to use.
|
||||
team_alias: The alias for the team.
|
||||
team_id: The ID for the team.
|
||||
max_budget: The maximum budget for the team. When None, budget
|
||||
enforcement is disabled (unlimited usage).
|
||||
"""
|
||||
if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None:
|
||||
logger.warning('LiteLLM API configuration not found')
|
||||
return
|
||||
|
||||
json_data: dict[str, Any] = {
|
||||
'team_id': team_id,
|
||||
'team_alias': team_alias,
|
||||
'models': [],
|
||||
'spend': 0,
|
||||
'metadata': {
|
||||
'version': ORG_SETTINGS_VERSION,
|
||||
'model': get_default_litellm_model(),
|
||||
},
|
||||
}
|
||||
|
||||
if max_budget is not None:
|
||||
json_data['max_budget'] = max_budget
|
||||
|
||||
response = await client.post(
|
||||
f'{LITE_LLM_API_URL}/team/new',
|
||||
json={
|
||||
'team_id': team_id,
|
||||
'team_alias': team_alias,
|
||||
'models': [],
|
||||
'max_budget': max_budget,
|
||||
'spend': 0,
|
||||
'metadata': {
|
||||
'version': ORG_SETTINGS_VERSION,
|
||||
'model': get_default_litellm_model(),
|
||||
},
|
||||
},
|
||||
json=json_data,
|
||||
)
|
||||
|
||||
# Team failed to create in litellm - this is an unforseen error state...
|
||||
if not response.is_success:
|
||||
if (
|
||||
@@ -620,15 +685,48 @@ class LiteLlmManager:
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
@staticmethod
|
||||
async def _user_exists(
|
||||
client: httpx.AsyncClient,
|
||||
user_id: str,
|
||||
) -> bool:
|
||||
"""Check if a user exists in LiteLLM.
|
||||
|
||||
Returns True if the user exists, False otherwise.
|
||||
"""
|
||||
if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None:
|
||||
return False
|
||||
try:
|
||||
response = await client.get(
|
||||
f'{LITE_LLM_API_URL}/user/info?user_id={user_id}',
|
||||
)
|
||||
if response.is_success:
|
||||
user_data = response.json()
|
||||
# Check that user_info exists and has the user_id
|
||||
user_info = user_data.get('user_info', {})
|
||||
return user_info.get('user_id') == user_id
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
'litellm_user_exists_check_failed',
|
||||
extra={'user_id': user_id, 'error': str(e)},
|
||||
)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def _create_user(
|
||||
client: httpx.AsyncClient,
|
||||
email: str | None,
|
||||
keycloak_user_id: str,
|
||||
):
|
||||
) -> bool:
|
||||
"""Create a user in LiteLLM.
|
||||
|
||||
Returns True if the user was created or already exists and is verified,
|
||||
False if creation failed and user does not exist.
|
||||
"""
|
||||
if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None:
|
||||
logger.warning('LiteLLM API configuration not found')
|
||||
return
|
||||
return False
|
||||
response = await client.post(
|
||||
f'{LITE_LLM_API_URL}/user/new',
|
||||
json={
|
||||
@@ -681,17 +779,33 @@ class LiteLlmManager:
|
||||
'user_id': keycloak_user_id,
|
||||
},
|
||||
)
|
||||
return
|
||||
# Verify the user actually exists before returning success
|
||||
user_exists = await LiteLlmManager._user_exists(
|
||||
client, keycloak_user_id
|
||||
)
|
||||
if not user_exists:
|
||||
logger.error(
|
||||
'litellm_user_claimed_exists_but_not_found',
|
||||
extra={
|
||||
'user_id': keycloak_user_id,
|
||||
'status_code': response.status_code,
|
||||
'text': response.text,
|
||||
},
|
||||
)
|
||||
return False
|
||||
return True
|
||||
logger.error(
|
||||
'error_creating_litellm_user',
|
||||
extra={
|
||||
'status_code': response.status_code,
|
||||
'text': response.text,
|
||||
'user_id': [keycloak_user_id],
|
||||
'user_id': keycloak_user_id,
|
||||
'email': None,
|
||||
},
|
||||
)
|
||||
return False
|
||||
response.raise_for_status()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def _get_user(client: httpx.AsyncClient, user_id: str) -> dict | None:
|
||||
@@ -918,19 +1032,34 @@ class LiteLlmManager:
|
||||
client: httpx.AsyncClient,
|
||||
keycloak_user_id: str,
|
||||
team_id: str,
|
||||
max_budget: float,
|
||||
max_budget: float | None,
|
||||
):
|
||||
"""Add a user to a team in LiteLLM.
|
||||
|
||||
Args:
|
||||
client: The HTTP client to use.
|
||||
keycloak_user_id: The user's Keycloak ID.
|
||||
team_id: The team ID.
|
||||
max_budget: The maximum budget for the user in the team. When None,
|
||||
budget enforcement is disabled (unlimited usage).
|
||||
"""
|
||||
if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None:
|
||||
logger.warning('LiteLLM API configuration not found')
|
||||
return
|
||||
|
||||
json_data: dict[str, Any] = {
|
||||
'team_id': team_id,
|
||||
'member': {'user_id': keycloak_user_id, 'role': 'user'},
|
||||
}
|
||||
|
||||
if max_budget is not None:
|
||||
json_data['max_budget_in_team'] = max_budget
|
||||
|
||||
response = await client.post(
|
||||
f'{LITE_LLM_API_URL}/team/member_add',
|
||||
json={
|
||||
'team_id': team_id,
|
||||
'member': {'user_id': keycloak_user_id, 'role': 'user'},
|
||||
'max_budget_in_team': max_budget,
|
||||
},
|
||||
json=json_data,
|
||||
)
|
||||
|
||||
# Failed to add user to team - this is an unforseen error state...
|
||||
if not response.is_success:
|
||||
if (
|
||||
@@ -998,19 +1127,34 @@ class LiteLlmManager:
|
||||
client: httpx.AsyncClient,
|
||||
keycloak_user_id: str,
|
||||
team_id: str,
|
||||
max_budget: float,
|
||||
max_budget: float | None,
|
||||
):
|
||||
"""Update a user's budget in a team.
|
||||
|
||||
Args:
|
||||
client: The HTTP client to use.
|
||||
keycloak_user_id: The user's Keycloak ID.
|
||||
team_id: The team ID.
|
||||
max_budget: The maximum budget for the user in the team. When None,
|
||||
budget enforcement is disabled (unlimited usage).
|
||||
"""
|
||||
if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None:
|
||||
logger.warning('LiteLLM API configuration not found')
|
||||
return
|
||||
|
||||
json_data: dict[str, Any] = {
|
||||
'team_id': team_id,
|
||||
'user_id': keycloak_user_id,
|
||||
}
|
||||
|
||||
if max_budget is not None:
|
||||
json_data['max_budget_in_team'] = max_budget
|
||||
|
||||
response = await client.post(
|
||||
f'{LITE_LLM_API_URL}/team/member_update',
|
||||
json={
|
||||
'team_id': team_id,
|
||||
'user_id': keycloak_user_id,
|
||||
'max_budget_in_team': max_budget,
|
||||
},
|
||||
json=json_data,
|
||||
)
|
||||
|
||||
# Failed to update user in team - this is an unforseen error state...
|
||||
if not response.is_success:
|
||||
logger.error(
|
||||
@@ -1380,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]],
|
||||
@@ -1387,7 +1608,8 @@ class LiteLlmManager:
|
||||
@functools.wraps(internal_fn)
|
||||
async def wrapper(*args, **kwargs):
|
||||
async with httpx.AsyncClient(
|
||||
headers={'x-goog-api-key': LITE_LLM_API_KEY}
|
||||
headers={'x-goog-api-key': LITE_LLM_API_KEY},
|
||||
timeout=httpx.Timeout(30.0),
|
||||
) as client:
|
||||
return await internal_fn(client, *args, **kwargs)
|
||||
|
||||
@@ -1397,6 +1619,7 @@ class LiteLlmManager:
|
||||
create_team = staticmethod(with_http_client(_create_team))
|
||||
get_team = staticmethod(with_http_client(_get_team))
|
||||
update_team = staticmethod(with_http_client(_update_team))
|
||||
user_exists = staticmethod(with_http_client(_user_exists))
|
||||
create_user = staticmethod(with_http_client(_create_user))
|
||||
get_user = staticmethod(with_http_client(_get_user))
|
||||
update_user = staticmethod(with_http_client(_update_user))
|
||||
@@ -1413,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)
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -15,25 +15,27 @@ class SaasConversationValidator(ConversationValidator):
|
||||
|
||||
async def _validate_api_key(self, api_key: str) -> str | None:
|
||||
"""
|
||||
Validate an API key and return the user_id and github_user_id if valid.
|
||||
Validate an API key and return the user_id if valid.
|
||||
|
||||
Args:
|
||||
api_key: The API key to validate
|
||||
|
||||
Returns:
|
||||
A tuple of (user_id, github_user_id) if the API key is valid, None otherwise
|
||||
The user_id if the API key is valid, None otherwise
|
||||
"""
|
||||
try:
|
||||
token_manager = TokenManager()
|
||||
|
||||
# Validate the API key and get the user_id
|
||||
api_key_store = ApiKeyStore.get_instance()
|
||||
user_id = await api_key_store.validate_api_key(api_key)
|
||||
validation_result = await api_key_store.validate_api_key(api_key)
|
||||
|
||||
if not user_id:
|
||||
if not validation_result:
|
||||
logger.warning('Invalid API key')
|
||||
return None
|
||||
|
||||
user_id = validation_result.user_id
|
||||
|
||||
# Get the offline token for the user
|
||||
offline_token = await token_manager.load_offline_token(user_id)
|
||||
if not offline_token:
|
||||
|
||||
@@ -59,12 +59,15 @@ class SaasSecretsStore(SecretsStore):
|
||||
|
||||
async with a_session_maker() as session:
|
||||
# Incoming secrets are always the most updated ones
|
||||
# Delete all existing records and override with incoming ones
|
||||
await session.execute(
|
||||
delete(StoredCustomSecrets).filter(
|
||||
StoredCustomSecrets.keycloak_user_id == self.user_id
|
||||
)
|
||||
# Delete existing records for this user AND organization only
|
||||
delete_query = delete(StoredCustomSecrets).filter(
|
||||
StoredCustomSecrets.keycloak_user_id == self.user_id
|
||||
)
|
||||
if org_id is not None:
|
||||
delete_query = delete_query.filter(StoredCustomSecrets.org_id == org_id)
|
||||
else:
|
||||
delete_query = delete_query.filter(StoredCustomSecrets.org_id.is_(None))
|
||||
await session.execute(delete_query)
|
||||
|
||||
# Prepare the new secrets data
|
||||
kwargs = item.model_dump(context={'expose_secrets': True})
|
||||
|
||||
@@ -115,6 +115,9 @@ class SaasSettingsStore(SettingsStore):
|
||||
kwargs['llm_api_key_for_byor'] = org_member.llm_api_key_for_byor
|
||||
if org_member.llm_base_url:
|
||||
kwargs['llm_base_url'] = org_member.llm_base_url
|
||||
# MCP config is user-specific (stored on org_member, not org)
|
||||
if org_member.mcp_config is not None:
|
||||
kwargs['mcp_config'] = org_member.mcp_config
|
||||
if org.v1_enabled is None:
|
||||
kwargs['v1_enabled'] = True
|
||||
# Apply default if sandbox_grouping_strategy is None in the database
|
||||
@@ -179,7 +182,7 @@ class SaasSettingsStore(SettingsStore):
|
||||
return None
|
||||
|
||||
# Check if we need to generate an LLM key.
|
||||
if item.llm_base_url == LITE_LLM_API_URL:
|
||||
if not item.llm_base_url or item.llm_base_url == LITE_LLM_API_URL:
|
||||
await self._ensure_api_key(
|
||||
item, str(org_id), openhands_type=is_openhands_model(item.llm_model)
|
||||
)
|
||||
@@ -187,6 +190,9 @@ class SaasSettingsStore(SettingsStore):
|
||||
kwargs = item.model_dump(context={'expose_secrets': True})
|
||||
for model in (user, org, org_member):
|
||||
for key, value in kwargs.items():
|
||||
# Skip mcp_config for org - it should only be stored on org_member (user-specific)
|
||||
if key == 'mcp_config' and model is org:
|
||||
continue
|
||||
if hasattr(model, key):
|
||||
setattr(model, key, value)
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -31,6 +31,7 @@ class UserSettings(Base): # type: ignore
|
||||
user_version = Column(Integer, nullable=False, default=0)
|
||||
accepted_tos = Column(DateTime, nullable=True)
|
||||
mcp_config = Column(JSON, nullable=True)
|
||||
disabled_skills = Column(JSON, nullable=True)
|
||||
search_api_key = Column(String, nullable=True)
|
||||
sandbox_api_key = Column(String, nullable=True)
|
||||
max_budget_per_task = Column(Float, nullable=True)
|
||||
|
||||
@@ -214,14 +214,15 @@ class UserStore:
|
||||
decrypted_user_settings, user_settings.user_version
|
||||
)
|
||||
|
||||
# avoids circular reference. This migrate method is temprorary until all users are migrated.
|
||||
# Migrate stripe customer (pass session to avoid FK violation)
|
||||
# avoids circular reference. This migrate method is temporary until all users are migrated.
|
||||
from integrations.stripe_service import migrate_customer
|
||||
|
||||
logger.debug(
|
||||
'user_store:migrate_user:calling_stripe_migrate_customer',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
await migrate_customer(user_id, org)
|
||||
await migrate_customer(session, user_id, org)
|
||||
logger.debug(
|
||||
'user_store:migrate_user:done_stripe_migrate_customer',
|
||||
extra={'user_id': user_id},
|
||||
|
||||
0
enterprise/tests/unit/routes/__init__.py
Normal file
0
enterprise/tests/unit/routes/__init__.py
Normal file
325
enterprise/tests/unit/routes/test_service.py
Normal file
325
enterprise/tests/unit/routes/test_service.py
Normal file
@@ -0,0 +1,325 @@
|
||||
"""Unit tests for service API routes."""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from server.routes.service import (
|
||||
CreateUserApiKeyRequest,
|
||||
delete_user_api_key,
|
||||
get_or_create_api_key_for_user,
|
||||
validate_service_api_key,
|
||||
)
|
||||
|
||||
|
||||
class TestValidateServiceApiKey:
|
||||
"""Test cases for validate_service_api_key."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_service_key(self):
|
||||
"""Test validation with valid service API key."""
|
||||
with patch('server.routes.service.AUTOMATIONS_SERVICE_KEY', 'test-service-key'):
|
||||
result = await validate_service_api_key('test-service-key')
|
||||
assert result == 'automations-service'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_service_key(self):
|
||||
"""Test validation with missing service API key header."""
|
||||
with patch('server.routes.service.AUTOMATIONS_SERVICE_KEY', 'test-service-key'):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await validate_service_api_key(None)
|
||||
assert exc_info.value.status_code == 401
|
||||
assert 'X-Service-API-Key header is required' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_service_key(self):
|
||||
"""Test validation with invalid service API key."""
|
||||
with patch('server.routes.service.AUTOMATIONS_SERVICE_KEY', 'test-service-key'):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await validate_service_api_key('wrong-key')
|
||||
assert exc_info.value.status_code == 401
|
||||
assert 'Invalid service API key' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_auth_not_configured(self):
|
||||
"""Test validation when service auth is not configured."""
|
||||
with patch('server.routes.service.AUTOMATIONS_SERVICE_KEY', ''):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await validate_service_api_key('any-key')
|
||||
assert exc_info.value.status_code == 503
|
||||
assert 'Service authentication not configured' in exc_info.value.detail
|
||||
|
||||
|
||||
class TestCreateUserApiKeyRequest:
|
||||
"""Test cases for CreateUserApiKeyRequest validation."""
|
||||
|
||||
def test_valid_request(self):
|
||||
"""Test valid request with all fields."""
|
||||
request = CreateUserApiKeyRequest(
|
||||
name='automation',
|
||||
)
|
||||
assert request.name == 'automation'
|
||||
|
||||
def test_name_is_required(self):
|
||||
"""Test that name field is required."""
|
||||
with pytest.raises(ValueError):
|
||||
CreateUserApiKeyRequest(
|
||||
name='', # Empty name should fail
|
||||
)
|
||||
|
||||
def test_name_is_stripped(self):
|
||||
"""Test that name field is stripped of whitespace."""
|
||||
request = CreateUserApiKeyRequest(
|
||||
name=' automation ',
|
||||
)
|
||||
assert request.name == 'automation'
|
||||
|
||||
def test_whitespace_only_name_fails(self):
|
||||
"""Test that whitespace-only name fails validation."""
|
||||
with pytest.raises(ValueError):
|
||||
CreateUserApiKeyRequest(
|
||||
name=' ',
|
||||
)
|
||||
|
||||
|
||||
class TestGetOrCreateApiKeyForUser:
|
||||
"""Test cases for get_or_create_api_key_for_user endpoint."""
|
||||
|
||||
@pytest.fixture
|
||||
def valid_user_id(self):
|
||||
"""Return a valid user ID."""
|
||||
return '5594c7b6-f959-4b81-92e9-b09c206f5081'
|
||||
|
||||
@pytest.fixture
|
||||
def valid_org_id(self):
|
||||
"""Return a valid org ID."""
|
||||
return uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
|
||||
|
||||
@pytest.fixture
|
||||
def valid_request(self):
|
||||
"""Create a valid request object."""
|
||||
return CreateUserApiKeyRequest(
|
||||
name='automation',
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_not_found(self, valid_user_id, valid_org_id, valid_request):
|
||||
"""Test error when user doesn't exist."""
|
||||
with patch('server.routes.service.AUTOMATIONS_SERVICE_KEY', 'test-key'):
|
||||
with patch(
|
||||
'server.routes.service.UserStore.get_user_by_id', new_callable=AsyncMock
|
||||
) as mock_get_user:
|
||||
mock_get_user.return_value = None
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_or_create_api_key_for_user(
|
||||
user_id=valid_user_id,
|
||||
org_id=valid_org_id,
|
||||
request=valid_request,
|
||||
x_service_api_key='test-key',
|
||||
)
|
||||
assert exc_info.value.status_code == 404
|
||||
assert 'not found' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_not_in_org(self, valid_user_id, valid_org_id, valid_request):
|
||||
"""Test error when user is not a member of the org."""
|
||||
mock_user = MagicMock()
|
||||
|
||||
with patch('server.routes.service.AUTOMATIONS_SERVICE_KEY', 'test-key'):
|
||||
with patch(
|
||||
'server.routes.service.UserStore.get_user_by_id', new_callable=AsyncMock
|
||||
) as mock_get_user:
|
||||
with patch(
|
||||
'server.routes.service.OrgMemberStore.get_org_member',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_member:
|
||||
mock_get_user.return_value = mock_user
|
||||
mock_get_member.return_value = None
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_or_create_api_key_for_user(
|
||||
user_id=valid_user_id,
|
||||
org_id=valid_org_id,
|
||||
request=valid_request,
|
||||
x_service_api_key='test-key',
|
||||
)
|
||||
assert exc_info.value.status_code == 403
|
||||
assert 'not a member of org' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_key_creation(
|
||||
self, valid_user_id, valid_org_id, valid_request
|
||||
):
|
||||
"""Test successful API key creation."""
|
||||
mock_user = MagicMock()
|
||||
mock_org_member = MagicMock()
|
||||
mock_api_key_store = MagicMock()
|
||||
mock_api_key_store.get_or_create_system_api_key = AsyncMock(
|
||||
return_value='sk-oh-test-key-12345678901234567890'
|
||||
)
|
||||
|
||||
with patch('server.routes.service.AUTOMATIONS_SERVICE_KEY', 'test-key'):
|
||||
with patch(
|
||||
'server.routes.service.UserStore.get_user_by_id', new_callable=AsyncMock
|
||||
) as mock_get_user:
|
||||
with patch(
|
||||
'server.routes.service.OrgMemberStore.get_org_member',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_member:
|
||||
with patch(
|
||||
'server.routes.service.ApiKeyStore.get_instance'
|
||||
) as mock_get_store:
|
||||
mock_get_user.return_value = mock_user
|
||||
mock_get_member.return_value = mock_org_member
|
||||
mock_get_store.return_value = mock_api_key_store
|
||||
|
||||
response = await get_or_create_api_key_for_user(
|
||||
user_id=valid_user_id,
|
||||
org_id=valid_org_id,
|
||||
request=valid_request,
|
||||
x_service_api_key='test-key',
|
||||
)
|
||||
|
||||
assert response.key == 'sk-oh-test-key-12345678901234567890'
|
||||
assert response.user_id == valid_user_id
|
||||
assert response.org_id == str(valid_org_id)
|
||||
assert response.name == 'automation'
|
||||
|
||||
# Verify the store was called with correct arguments
|
||||
mock_api_key_store.get_or_create_system_api_key.assert_called_once_with(
|
||||
user_id=valid_user_id,
|
||||
org_id=valid_org_id,
|
||||
name='automation',
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_exception_handling(
|
||||
self, valid_user_id, valid_org_id, valid_request
|
||||
):
|
||||
"""Test error handling when store raises exception."""
|
||||
mock_user = MagicMock()
|
||||
mock_org_member = MagicMock()
|
||||
mock_api_key_store = MagicMock()
|
||||
mock_api_key_store.get_or_create_system_api_key = AsyncMock(
|
||||
side_effect=Exception('Database error')
|
||||
)
|
||||
|
||||
with patch('server.routes.service.AUTOMATIONS_SERVICE_KEY', 'test-key'):
|
||||
with patch(
|
||||
'server.routes.service.UserStore.get_user_by_id', new_callable=AsyncMock
|
||||
) as mock_get_user:
|
||||
with patch(
|
||||
'server.routes.service.OrgMemberStore.get_org_member',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_member:
|
||||
with patch(
|
||||
'server.routes.service.ApiKeyStore.get_instance'
|
||||
) as mock_get_store:
|
||||
mock_get_user.return_value = mock_user
|
||||
mock_get_member.return_value = mock_org_member
|
||||
mock_get_store.return_value = mock_api_key_store
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_or_create_api_key_for_user(
|
||||
user_id=valid_user_id,
|
||||
org_id=valid_org_id,
|
||||
request=valid_request,
|
||||
x_service_api_key='test-key',
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert 'Failed to get or create API key' in exc_info.value.detail
|
||||
|
||||
|
||||
class TestDeleteUserApiKey:
|
||||
"""Test cases for delete_user_api_key endpoint."""
|
||||
|
||||
@pytest.fixture
|
||||
def valid_org_id(self):
|
||||
"""Return a valid org ID."""
|
||||
return uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_delete(self, valid_org_id):
|
||||
"""Test successful deletion of a system API key."""
|
||||
mock_api_key_store = MagicMock()
|
||||
mock_api_key_store.make_system_key_name.return_value = '__SYSTEM__:automation'
|
||||
mock_api_key_store.delete_api_key_by_name = AsyncMock(return_value=True)
|
||||
|
||||
with patch('server.routes.service.AUTOMATIONS_SERVICE_KEY', 'test-key'):
|
||||
with patch(
|
||||
'server.routes.service.ApiKeyStore.get_instance'
|
||||
) as mock_get_store:
|
||||
mock_get_store.return_value = mock_api_key_store
|
||||
|
||||
response = await delete_user_api_key(
|
||||
user_id='user-123',
|
||||
org_id=valid_org_id,
|
||||
key_name='automation',
|
||||
x_service_api_key='test-key',
|
||||
)
|
||||
|
||||
assert response == {'message': 'API key deleted successfully'}
|
||||
|
||||
# Verify the store was called with correct arguments
|
||||
mock_api_key_store.make_system_key_name.assert_called_once_with('automation')
|
||||
mock_api_key_store.delete_api_key_by_name.assert_called_once_with(
|
||||
user_id='user-123',
|
||||
org_id=valid_org_id,
|
||||
name='__SYSTEM__:automation',
|
||||
allow_system=True,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_key_not_found(self, valid_org_id):
|
||||
"""Test error when key to delete is not found."""
|
||||
mock_api_key_store = MagicMock()
|
||||
mock_api_key_store.make_system_key_name.return_value = '__SYSTEM__:nonexistent'
|
||||
mock_api_key_store.delete_api_key_by_name = AsyncMock(return_value=False)
|
||||
|
||||
with patch('server.routes.service.AUTOMATIONS_SERVICE_KEY', 'test-key'):
|
||||
with patch(
|
||||
'server.routes.service.ApiKeyStore.get_instance'
|
||||
) as mock_get_store:
|
||||
mock_get_store.return_value = mock_api_key_store
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await delete_user_api_key(
|
||||
user_id='user-123',
|
||||
org_id=valid_org_id,
|
||||
key_name='nonexistent',
|
||||
x_service_api_key='test-key',
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert 'not found' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_invalid_service_key(self, valid_org_id):
|
||||
"""Test error when service API key is invalid."""
|
||||
with patch('server.routes.service.AUTOMATIONS_SERVICE_KEY', 'test-key'):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await delete_user_api_key(
|
||||
user_id='user-123',
|
||||
org_id=valid_org_id,
|
||||
key_name='automation',
|
||||
x_service_api_key='wrong-key',
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert 'Invalid service API key' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_missing_service_key(self, valid_org_id):
|
||||
"""Test error when service API key header is missing."""
|
||||
with patch('server.routes.service.AUTOMATIONS_SERVICE_KEY', 'test-key'):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await delete_user_api_key(
|
||||
user_id='user-123',
|
||||
org_id=valid_org_id,
|
||||
key_name='automation',
|
||||
x_service_api_key=None,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert 'X-Service-API-Key header is required' in exc_info.value.detail
|
||||
@@ -1,19 +1,26 @@
|
||||
"""Unit tests for API keys routes, focusing on BYOR key validation and retrieval."""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from pydantic import SecretStr
|
||||
from server.auth.saas_user_auth import SaasUserAuth
|
||||
from server.routes.api_keys import (
|
||||
ByorPermittedResponse,
|
||||
CurrentApiKeyResponse,
|
||||
LlmApiKeyResponse,
|
||||
check_byor_permitted,
|
||||
delete_byor_key_from_litellm,
|
||||
get_current_api_key,
|
||||
get_llm_api_key_for_byor,
|
||||
)
|
||||
from storage.lite_llm_manager import LiteLlmManager
|
||||
|
||||
from openhands.server.user_auth.user_auth import AuthType
|
||||
|
||||
|
||||
class TestVerifyByorKeyInLitellm:
|
||||
"""Test the verify_byor_key_in_litellm function."""
|
||||
@@ -512,3 +519,81 @@ class TestCheckByorPermitted:
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert 'Failed to check BYOR export permission' in exc_info.value.detail
|
||||
|
||||
|
||||
class TestGetCurrentApiKey:
|
||||
"""Test the get_current_api_key endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.api_keys.get_user_auth')
|
||||
async def test_returns_api_key_info_for_bearer_auth(self, mock_get_user_auth):
|
||||
"""Test that API key metadata including org_id is returned for bearer token auth."""
|
||||
# Arrange
|
||||
user_id = 'user-123'
|
||||
org_id = uuid.uuid4()
|
||||
mock_request = MagicMock()
|
||||
|
||||
user_auth = SaasUserAuth(
|
||||
refresh_token=SecretStr('mock-token'),
|
||||
user_id=user_id,
|
||||
auth_type=AuthType.BEARER,
|
||||
api_key_org_id=org_id,
|
||||
api_key_id=42,
|
||||
api_key_name='My Production Key',
|
||||
)
|
||||
mock_get_user_auth.return_value = user_auth
|
||||
|
||||
# Act
|
||||
result = await get_current_api_key(request=mock_request, user_id=user_id)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, CurrentApiKeyResponse)
|
||||
assert result.org_id == str(org_id)
|
||||
assert result.id == 42
|
||||
assert result.name == 'My Production Key'
|
||||
assert result.user_id == user_id
|
||||
assert result.auth_type == 'bearer'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.api_keys.get_user_auth')
|
||||
async def test_returns_400_for_cookie_auth(self, mock_get_user_auth):
|
||||
"""Test that 400 Bad Request is returned when using cookie authentication."""
|
||||
# Arrange
|
||||
user_id = 'user-123'
|
||||
mock_request = MagicMock()
|
||||
|
||||
mock_user_auth = MagicMock()
|
||||
mock_user_auth.get_auth_type.return_value = AuthType.COOKIE
|
||||
mock_get_user_auth.return_value = mock_user_auth
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_current_api_key(request=mock_request, user_id=user_id)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert 'API key authentication' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.api_keys.get_user_auth')
|
||||
async def test_returns_400_when_api_key_org_id_is_none(self, mock_get_user_auth):
|
||||
"""Test that 400 is returned when API key has no org_id (legacy key)."""
|
||||
# Arrange
|
||||
user_id = 'user-123'
|
||||
mock_request = MagicMock()
|
||||
|
||||
user_auth = SaasUserAuth(
|
||||
refresh_token=SecretStr('mock-token'),
|
||||
user_id=user_id,
|
||||
auth_type=AuthType.BEARER,
|
||||
api_key_org_id=None, # No org_id - legacy key
|
||||
api_key_id=42,
|
||||
api_key_name='Legacy Key',
|
||||
)
|
||||
mock_get_user_auth.return_value = user_auth
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_current_api_key(request=mock_request, user_id=user_id)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert 'created before organization support' in exc_info.value.detail
|
||||
|
||||
@@ -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
|
||||
314
enterprise/tests/unit/storage/test_api_key_store.py
Normal file
314
enterprise/tests/unit/storage/test_api_key_store.py
Normal file
@@ -0,0 +1,314 @@
|
||||
"""Unit tests for ApiKeyStore system key functionality."""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from storage.api_key import ApiKey
|
||||
from storage.api_key_store import ApiKeyStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_key_store():
|
||||
"""Create ApiKeyStore instance."""
|
||||
return ApiKeyStore()
|
||||
|
||||
|
||||
class TestApiKeyStoreSystemKeys:
|
||||
"""Test cases for system API key functionality."""
|
||||
|
||||
def test_is_system_key_name_with_prefix(self, api_key_store):
|
||||
"""Test that names with __SYSTEM__: prefix are identified as system keys."""
|
||||
assert api_key_store.is_system_key_name('__SYSTEM__:automation') is True
|
||||
assert api_key_store.is_system_key_name('__SYSTEM__:test-key') is True
|
||||
assert api_key_store.is_system_key_name('__SYSTEM__:') is True
|
||||
|
||||
def test_is_system_key_name_without_prefix(self, api_key_store):
|
||||
"""Test that names without __SYSTEM__: prefix are not system keys."""
|
||||
assert api_key_store.is_system_key_name('my-key') is False
|
||||
assert api_key_store.is_system_key_name('automation') is False
|
||||
assert api_key_store.is_system_key_name('MCP_API_KEY') is False
|
||||
assert api_key_store.is_system_key_name('') is False
|
||||
|
||||
def test_is_system_key_name_none(self, api_key_store):
|
||||
"""Test that None is not a system key."""
|
||||
assert api_key_store.is_system_key_name(None) is False
|
||||
|
||||
def test_make_system_key_name(self, api_key_store):
|
||||
"""Test system key name generation."""
|
||||
assert (
|
||||
api_key_store.make_system_key_name('automation') == '__SYSTEM__:automation'
|
||||
)
|
||||
assert api_key_store.make_system_key_name('test-key') == '__SYSTEM__:test-key'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_or_create_system_api_key_creates_new(
|
||||
self, api_key_store, async_session_maker
|
||||
):
|
||||
"""Test creating a new system API key when none exists."""
|
||||
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
|
||||
org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
|
||||
key_name = 'automation'
|
||||
|
||||
with patch('storage.api_key_store.a_session_maker', async_session_maker):
|
||||
api_key = await api_key_store.get_or_create_system_api_key(
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name=key_name,
|
||||
)
|
||||
|
||||
assert api_key.startswith('sk-oh-')
|
||||
assert len(api_key) == len('sk-oh-') + 32
|
||||
|
||||
# Verify the key was created in the database
|
||||
async with async_session_maker() as session:
|
||||
result = await session.execute(select(ApiKey).filter(ApiKey.key == api_key))
|
||||
key_record = result.scalars().first()
|
||||
assert key_record is not None
|
||||
assert key_record.user_id == user_id
|
||||
assert key_record.org_id == org_id
|
||||
assert key_record.name == '__SYSTEM__:automation'
|
||||
assert key_record.expires_at is None # System keys never expire
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_or_create_system_api_key_returns_existing(
|
||||
self, api_key_store, async_session_maker
|
||||
):
|
||||
"""Test that existing valid system key is returned."""
|
||||
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
|
||||
org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
|
||||
key_name = 'automation'
|
||||
|
||||
with patch('storage.api_key_store.a_session_maker', async_session_maker):
|
||||
# Create the first key
|
||||
first_key = await api_key_store.get_or_create_system_api_key(
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name=key_name,
|
||||
)
|
||||
|
||||
# Request again - should return the same key
|
||||
second_key = await api_key_store.get_or_create_system_api_key(
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name=key_name,
|
||||
)
|
||||
|
||||
assert first_key == second_key
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_or_create_system_api_key_different_names(
|
||||
self, api_key_store, async_session_maker
|
||||
):
|
||||
"""Test that different names create different keys."""
|
||||
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
|
||||
org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
|
||||
|
||||
with patch('storage.api_key_store.a_session_maker', async_session_maker):
|
||||
key1 = await api_key_store.get_or_create_system_api_key(
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name='automation-1',
|
||||
)
|
||||
|
||||
key2 = await api_key_store.get_or_create_system_api_key(
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name='automation-2',
|
||||
)
|
||||
|
||||
assert key1 != key2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_or_create_system_api_key_reissues_expired(
|
||||
self, api_key_store, async_session_maker
|
||||
):
|
||||
"""Test that expired system key is replaced with a new one."""
|
||||
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
|
||||
org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
|
||||
key_name = 'automation'
|
||||
system_key_name = '__SYSTEM__:automation'
|
||||
|
||||
# First, manually create an expired key
|
||||
expired_time = datetime.now(UTC) - timedelta(hours=1)
|
||||
async with async_session_maker() as session:
|
||||
expired_key = ApiKey(
|
||||
key='sk-oh-expired-key-12345678901234567890',
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name=system_key_name,
|
||||
expires_at=expired_time.replace(tzinfo=None),
|
||||
)
|
||||
session.add(expired_key)
|
||||
await session.commit()
|
||||
|
||||
with patch('storage.api_key_store.a_session_maker', async_session_maker):
|
||||
# Request the key - should create a new one
|
||||
new_key = await api_key_store.get_or_create_system_api_key(
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name=key_name,
|
||||
)
|
||||
|
||||
assert new_key != 'sk-oh-expired-key-12345678901234567890'
|
||||
assert new_key.startswith('sk-oh-')
|
||||
|
||||
# Verify old key was deleted and new key exists
|
||||
async with async_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(ApiKey).filter(ApiKey.name == system_key_name)
|
||||
)
|
||||
keys = result.scalars().all()
|
||||
assert len(keys) == 1
|
||||
assert keys[0].key == new_key
|
||||
assert keys[0].expires_at is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_api_keys_excludes_system_keys(
|
||||
self, api_key_store, async_session_maker
|
||||
):
|
||||
"""Test that list_api_keys excludes system keys."""
|
||||
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
|
||||
org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
|
||||
|
||||
# Create a user key and a system key
|
||||
async with async_session_maker() as session:
|
||||
user_key = ApiKey(
|
||||
key='sk-oh-user-key-123456789012345678901',
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name='my-user-key',
|
||||
)
|
||||
system_key = ApiKey(
|
||||
key='sk-oh-system-key-12345678901234567890',
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name='__SYSTEM__:automation',
|
||||
)
|
||||
mcp_key = ApiKey(
|
||||
key='sk-oh-mcp-key-1234567890123456789012',
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name='MCP_API_KEY',
|
||||
)
|
||||
session.add(user_key)
|
||||
session.add(system_key)
|
||||
session.add(mcp_key)
|
||||
await session.commit()
|
||||
|
||||
# Mock UserStore.get_user_by_id to return a user with the correct org
|
||||
mock_user = MagicMock()
|
||||
mock_user.current_org_id = org_id
|
||||
|
||||
with patch('storage.api_key_store.a_session_maker', async_session_maker):
|
||||
with patch(
|
||||
'storage.api_key_store.UserStore.get_user_by_id', new_callable=AsyncMock
|
||||
) as mock_get_user:
|
||||
mock_get_user.return_value = mock_user
|
||||
keys = await api_key_store.list_api_keys(user_id)
|
||||
|
||||
# Should only return the user key
|
||||
assert len(keys) == 1
|
||||
assert keys[0].name == 'my-user-key'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_api_key_by_id_protects_system_keys(
|
||||
self, api_key_store, async_session_maker
|
||||
):
|
||||
"""Test that system keys cannot be deleted by users."""
|
||||
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
|
||||
org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
|
||||
|
||||
# Create a system key
|
||||
async with async_session_maker() as session:
|
||||
system_key = ApiKey(
|
||||
key='sk-oh-system-key-12345678901234567890',
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name='__SYSTEM__:automation',
|
||||
)
|
||||
session.add(system_key)
|
||||
await session.commit()
|
||||
key_id = system_key.id
|
||||
|
||||
with patch('storage.api_key_store.a_session_maker', async_session_maker):
|
||||
# Attempt to delete without allow_system flag
|
||||
result = await api_key_store.delete_api_key_by_id(
|
||||
key_id, allow_system=False
|
||||
)
|
||||
|
||||
assert result is False
|
||||
|
||||
# Verify the key still exists
|
||||
async with async_session_maker() as session:
|
||||
result = await session.execute(select(ApiKey).filter(ApiKey.id == key_id))
|
||||
key_record = result.scalars().first()
|
||||
assert key_record is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_api_key_by_id_allows_system_with_flag(
|
||||
self, api_key_store, async_session_maker
|
||||
):
|
||||
"""Test that system keys can be deleted with allow_system=True."""
|
||||
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
|
||||
org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
|
||||
|
||||
# Create a system key
|
||||
async with async_session_maker() as session:
|
||||
system_key = ApiKey(
|
||||
key='sk-oh-system-key-12345678901234567890',
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name='__SYSTEM__:automation',
|
||||
)
|
||||
session.add(system_key)
|
||||
await session.commit()
|
||||
key_id = system_key.id
|
||||
|
||||
with patch('storage.api_key_store.a_session_maker', async_session_maker):
|
||||
# Delete with allow_system=True
|
||||
result = await api_key_store.delete_api_key_by_id(key_id, allow_system=True)
|
||||
|
||||
assert result is True
|
||||
|
||||
# Verify the key was deleted
|
||||
async with async_session_maker() as session:
|
||||
result = await session.execute(select(ApiKey).filter(ApiKey.id == key_id))
|
||||
key_record = result.scalars().first()
|
||||
assert key_record is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_api_key_by_id_allows_regular_keys(
|
||||
self, api_key_store, async_session_maker
|
||||
):
|
||||
"""Test that regular keys can be deleted normally."""
|
||||
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
|
||||
org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
|
||||
|
||||
# Create a regular key
|
||||
async with async_session_maker() as session:
|
||||
regular_key = ApiKey(
|
||||
key='sk-oh-regular-key-1234567890123456789',
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name='my-regular-key',
|
||||
)
|
||||
session.add(regular_key)
|
||||
await session.commit()
|
||||
key_id = regular_key.id
|
||||
|
||||
with patch('storage.api_key_store.a_session_maker', async_session_maker):
|
||||
# Delete without allow_system flag - should work for regular keys
|
||||
result = await api_key_store.delete_api_key_by_id(
|
||||
key_id, allow_system=False
|
||||
)
|
||||
|
||||
assert result is True
|
||||
|
||||
# Verify the key was deleted
|
||||
async with async_session_maker() as session:
|
||||
result = await session.execute(select(ApiKey).filter(ApiKey.id == key_id))
|
||||
key_record = result.scalars().first()
|
||||
assert key_record is None
|
||||
@@ -990,3 +990,317 @@ class TestSandboxIdFilterSaas:
|
||||
sandbox_id__eq=shared_sandbox_id
|
||||
)
|
||||
assert count == 1
|
||||
|
||||
|
||||
class TestApiKeyOrgIdHandling:
|
||||
"""Test suite for API key organization ID handling in save_app_conversation_info.
|
||||
|
||||
These tests verify that when a conversation is created using API key authentication,
|
||||
the conversation is associated with the API key's bound organization, not the user's
|
||||
currently selected organization.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_key_org_id_used_when_available(
|
||||
self,
|
||||
async_session_with_users: AsyncSession,
|
||||
):
|
||||
"""Test that API key's org_id is used when saving conversation via API key auth.
|
||||
|
||||
This tests the main bug fix: when a user creates an API key in Personal Workspace,
|
||||
then switches to OpenHands org in browser, and uses the API key to create a
|
||||
conversation, the conversation should be saved in Personal Workspace (API key's org),
|
||||
not OpenHands (user's current org).
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
|
||||
from storage.stored_conversation_metadata_saas import (
|
||||
StoredConversationMetadataSaas,
|
||||
)
|
||||
|
||||
# Create a mock UserAuth with API key org_id
|
||||
@dataclass
|
||||
class MockUserAuth:
|
||||
user_id: str
|
||||
api_key_org_id: UUID | None = None
|
||||
|
||||
async def get_user_id(self) -> str:
|
||||
return self.user_id
|
||||
|
||||
def get_api_key_org_id(self) -> UUID | None:
|
||||
return self.api_key_org_id
|
||||
|
||||
# Create a mock UserContext that wraps the MockUserAuth
|
||||
@dataclass
|
||||
class MockAuthUserContext:
|
||||
user_auth: MockUserAuth
|
||||
|
||||
async def get_user_id(self) -> str | None:
|
||||
return await self.user_auth.get_user_id()
|
||||
|
||||
# Simulate: User1's current org is ORG2, but API key is bound to ORG1
|
||||
# First, update user1's current_org_id to ORG2
|
||||
result = await async_session_with_users.execute(
|
||||
select(User).where(User.id == USER1_ID)
|
||||
)
|
||||
user_to_update = result.scalars().first()
|
||||
user_to_update.current_org_id = ORG2_ID # User is viewing ORG2
|
||||
await async_session_with_users.commit()
|
||||
async_session_with_users.expire_all()
|
||||
|
||||
# Create service with mock auth context where API key is bound to ORG1
|
||||
mock_user_auth = MockUserAuth(
|
||||
user_id=str(USER1_ID),
|
||||
api_key_org_id=ORG1_ID, # API key created in ORG1
|
||||
)
|
||||
mock_context = MockAuthUserContext(user_auth=mock_user_auth)
|
||||
|
||||
service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=mock_context,
|
||||
)
|
||||
|
||||
# Create and save a conversation
|
||||
conv_id = uuid4()
|
||||
conv_info = AppConversationInfo(
|
||||
id=conv_id,
|
||||
created_by_user_id=str(USER1_ID),
|
||||
sandbox_id='sandbox_api_key_test',
|
||||
title='API Key Created Conversation',
|
||||
)
|
||||
await service.save_app_conversation_info(conv_info)
|
||||
|
||||
# Verify: SAAS metadata should have ORG1 (API key's org), not ORG2 (user's current org)
|
||||
saas_query = select(StoredConversationMetadataSaas).where(
|
||||
StoredConversationMetadataSaas.conversation_id == str(conv_id)
|
||||
)
|
||||
result = await async_session_with_users.execute(saas_query)
|
||||
saas_metadata = result.scalar_one_or_none()
|
||||
|
||||
assert saas_metadata is not None, 'SAAS metadata should be created'
|
||||
assert saas_metadata.user_id == USER1_ID
|
||||
assert (
|
||||
saas_metadata.org_id == ORG1_ID
|
||||
), 'Conversation should be in API key org (ORG1), not user current org (ORG2)'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_api_key_without_org_uses_user_current_org(
|
||||
self,
|
||||
async_session_with_users: AsyncSession,
|
||||
):
|
||||
"""Test that legacy API keys (without org_id) fall back to user's current org.
|
||||
|
||||
Legacy API keys created before the org_id feature was added will have
|
||||
api_key_org_id = None. In this case, we should fall back to the user's
|
||||
current_org_id.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
|
||||
from storage.stored_conversation_metadata_saas import (
|
||||
StoredConversationMetadataSaas,
|
||||
)
|
||||
|
||||
# Create a mock UserAuth with API key but NO org_id (legacy key)
|
||||
@dataclass
|
||||
class MockUserAuth:
|
||||
user_id: str
|
||||
api_key_org_id: UUID | None = None
|
||||
|
||||
async def get_user_id(self) -> str:
|
||||
return self.user_id
|
||||
|
||||
def get_api_key_org_id(self) -> UUID | None:
|
||||
return self.api_key_org_id
|
||||
|
||||
@dataclass
|
||||
class MockAuthUserContext:
|
||||
user_auth: MockUserAuth
|
||||
|
||||
async def get_user_id(self) -> str | None:
|
||||
return await self.user_auth.get_user_id()
|
||||
|
||||
# Create service with mock auth context where API key has NO org_id
|
||||
mock_user_auth = MockUserAuth(
|
||||
user_id=str(USER1_ID),
|
||||
api_key_org_id=None, # Legacy key without org binding
|
||||
)
|
||||
mock_context = MockAuthUserContext(user_auth=mock_user_auth)
|
||||
|
||||
service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=mock_context,
|
||||
)
|
||||
|
||||
# Create and save a conversation
|
||||
conv_id = uuid4()
|
||||
conv_info = AppConversationInfo(
|
||||
id=conv_id,
|
||||
created_by_user_id=str(USER1_ID),
|
||||
sandbox_id='sandbox_legacy_key_test',
|
||||
title='Legacy API Key Conversation',
|
||||
)
|
||||
await service.save_app_conversation_info(conv_info)
|
||||
|
||||
# Verify: SAAS metadata should use user's current org (ORG1) as fallback
|
||||
saas_query = select(StoredConversationMetadataSaas).where(
|
||||
StoredConversationMetadataSaas.conversation_id == str(conv_id)
|
||||
)
|
||||
result = await async_session_with_users.execute(saas_query)
|
||||
saas_metadata = result.scalar_one_or_none()
|
||||
|
||||
assert saas_metadata is not None, 'SAAS metadata should be created'
|
||||
assert saas_metadata.user_id == USER1_ID
|
||||
assert (
|
||||
saas_metadata.org_id == ORG1_ID
|
||||
), 'Legacy key should fall back to user current org (ORG1)'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cookie_auth_without_api_key_uses_user_current_org(
|
||||
self,
|
||||
async_session_with_users: AsyncSession,
|
||||
):
|
||||
"""Test that cookie auth (no API key) uses user's current org.
|
||||
|
||||
When authenticated via browser cookie (no API key), there's no
|
||||
get_api_key_org_id method, so we use user's current_org_id.
|
||||
This is already tested by other tests using SpecifyUserContext,
|
||||
but we explicitly test the case where user_context doesn't have user_auth.
|
||||
"""
|
||||
from storage.stored_conversation_metadata_saas import (
|
||||
StoredConversationMetadataSaas,
|
||||
)
|
||||
|
||||
# Use SpecifyUserContext which doesn't have user_auth attribute
|
||||
service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=SpecifyUserContext(user_id=str(USER1_ID)),
|
||||
)
|
||||
|
||||
# Create and save a conversation
|
||||
conv_id = uuid4()
|
||||
conv_info = AppConversationInfo(
|
||||
id=conv_id,
|
||||
created_by_user_id=str(USER1_ID),
|
||||
sandbox_id='sandbox_cookie_auth_test',
|
||||
title='Cookie Auth Conversation',
|
||||
)
|
||||
await service.save_app_conversation_info(conv_info)
|
||||
|
||||
# Verify: SAAS metadata should use user's current org (ORG1)
|
||||
saas_query = select(StoredConversationMetadataSaas).where(
|
||||
StoredConversationMetadataSaas.conversation_id == str(conv_id)
|
||||
)
|
||||
result = await async_session_with_users.execute(saas_query)
|
||||
saas_metadata = result.scalar_one_or_none()
|
||||
|
||||
assert saas_metadata is not None, 'SAAS metadata should be created'
|
||||
assert saas_metadata.user_id == USER1_ID
|
||||
assert (
|
||||
saas_metadata.org_id == ORG1_ID
|
||||
), 'Cookie auth should use user current org (ORG1)'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_key_org_isolation_cross_org_visibility(
|
||||
self,
|
||||
async_session_with_users: AsyncSession,
|
||||
):
|
||||
"""Test end-to-end: conversation created via API key is visible in correct org.
|
||||
|
||||
Simulates the full bug scenario:
|
||||
1. Create conversation via API key (bound to ORG1)
|
||||
2. User switches to ORG2
|
||||
3. User should NOT see the conversation in ORG2
|
||||
4. User switches back to ORG1
|
||||
5. User should see the conversation in ORG1
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class MockUserAuth:
|
||||
user_id: str
|
||||
api_key_org_id: UUID | None = None
|
||||
|
||||
async def get_user_id(self) -> str:
|
||||
return self.user_id
|
||||
|
||||
def get_api_key_org_id(self) -> UUID | None:
|
||||
return self.api_key_org_id
|
||||
|
||||
@dataclass
|
||||
class MockAuthUserContext:
|
||||
user_auth: MockUserAuth
|
||||
|
||||
async def get_user_id(self) -> str | None:
|
||||
return await self.user_auth.get_user_id()
|
||||
|
||||
# Step 1: Create conversation via API key bound to ORG1
|
||||
mock_user_auth = MockUserAuth(
|
||||
user_id=str(USER1_ID),
|
||||
api_key_org_id=ORG1_ID,
|
||||
)
|
||||
mock_context = MockAuthUserContext(user_auth=mock_user_auth)
|
||||
|
||||
api_key_service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=mock_context,
|
||||
)
|
||||
|
||||
conv_id = uuid4()
|
||||
conv_info = AppConversationInfo(
|
||||
id=conv_id,
|
||||
created_by_user_id=str(USER1_ID),
|
||||
sandbox_id='sandbox_e2e_api_key',
|
||||
title='E2E API Key Conversation',
|
||||
)
|
||||
await api_key_service.save_app_conversation_info(conv_info)
|
||||
|
||||
# Step 2: Switch user to ORG2 in browser session
|
||||
result = await async_session_with_users.execute(
|
||||
select(User).where(User.id == USER1_ID)
|
||||
)
|
||||
user_to_update = result.scalars().first()
|
||||
user_to_update.current_org_id = ORG2_ID
|
||||
await async_session_with_users.commit()
|
||||
async_session_with_users.expire_all()
|
||||
|
||||
# Step 3: User in ORG2 should NOT see the conversation
|
||||
user_service_org2 = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=SpecifyUserContext(user_id=str(USER1_ID)),
|
||||
)
|
||||
page_org2 = await user_service_org2.search_app_conversation_info()
|
||||
assert (
|
||||
len(page_org2.items) == 0
|
||||
), 'User in ORG2 should not see conversation created via API key in ORG1'
|
||||
|
||||
# Also verify get_app_conversation_info returns None
|
||||
conv_from_org2 = await user_service_org2.get_app_conversation_info(conv_id)
|
||||
assert (
|
||||
conv_from_org2 is None
|
||||
), 'User in ORG2 should not access conversation from ORG1'
|
||||
|
||||
# Step 4: Switch user back to ORG1
|
||||
result = await async_session_with_users.execute(
|
||||
select(User).where(User.id == USER1_ID)
|
||||
)
|
||||
user_to_update = result.scalars().first()
|
||||
user_to_update.current_org_id = ORG1_ID
|
||||
await async_session_with_users.commit()
|
||||
async_session_with_users.expire_all()
|
||||
|
||||
# Step 5: User in ORG1 should see the conversation
|
||||
user_service_org1 = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=SpecifyUserContext(user_id=str(USER1_ID)),
|
||||
)
|
||||
page_org1 = await user_service_org1.search_app_conversation_info()
|
||||
assert (
|
||||
len(page_org1.items) == 1
|
||||
), 'User in ORG1 should see conversation created via API key in ORG1'
|
||||
assert page_org1.items[0].id == conv_id
|
||||
assert page_org1.items[0].title == 'E2E API Key Conversation'
|
||||
|
||||
# Also verify get_app_conversation_info works
|
||||
conv_from_org1 = await user_service_org1.get_app_conversation_info(conv_id)
|
||||
assert conv_from_org1 is not None
|
||||
assert conv_from_org1.id == conv_id
|
||||
|
||||
@@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from storage.api_key import ApiKey
|
||||
from storage.api_key_store import ApiKeyStore
|
||||
from storage.api_key_store import ApiKeyStore, ApiKeyValidationResult
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -110,8 +110,8 @@ async def test_create_api_key(
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_api_key_valid(api_key_store, async_session_maker):
|
||||
"""Test validating a valid API key."""
|
||||
# Setup - create an API key in the database
|
||||
"""Test validating a valid API key returns user_id and org_id."""
|
||||
# Arrange
|
||||
user_id = str(uuid.uuid4())
|
||||
org_id = uuid.uuid4()
|
||||
api_key_value = 'test-api-key'
|
||||
@@ -126,13 +126,19 @@ async def test_validate_api_key_valid(api_key_store, async_session_maker):
|
||||
)
|
||||
session.add(key_record)
|
||||
await session.commit()
|
||||
key_id = key_record.id
|
||||
|
||||
# Execute - patch a_session_maker to use test's async session maker
|
||||
# Act
|
||||
with patch('storage.api_key_store.a_session_maker', async_session_maker):
|
||||
result = await api_key_store.validate_api_key(api_key_value)
|
||||
|
||||
# Verify
|
||||
assert result == user_id
|
||||
# Assert
|
||||
assert isinstance(result, ApiKeyValidationResult)
|
||||
assert result is not None
|
||||
assert result.user_id == user_id
|
||||
assert result.org_id == org_id
|
||||
assert result.key_id == key_id
|
||||
assert result.key_name == 'Test Key'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -197,7 +203,7 @@ async def test_validate_api_key_valid_timezone_naive(
|
||||
api_key_store, async_session_maker
|
||||
):
|
||||
"""Test validating a valid API key with timezone-naive datetime from database."""
|
||||
# Setup - create a valid API key with timezone-naive datetime (future date)
|
||||
# Arrange
|
||||
user_id = str(uuid.uuid4())
|
||||
org_id = uuid.uuid4()
|
||||
api_key_value = 'test-valid-naive-key'
|
||||
@@ -214,12 +220,44 @@ async def test_validate_api_key_valid_timezone_naive(
|
||||
session.add(key_record)
|
||||
await session.commit()
|
||||
|
||||
# Execute - patch a_session_maker to use test's async session maker
|
||||
# Act
|
||||
with patch('storage.api_key_store.a_session_maker', async_session_maker):
|
||||
result = await api_key_store.validate_api_key(api_key_value)
|
||||
|
||||
# Verify
|
||||
assert result == user_id
|
||||
# Assert
|
||||
assert isinstance(result, ApiKeyValidationResult)
|
||||
assert result.user_id == user_id
|
||||
assert result.org_id == org_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_api_key_legacy_without_org_id(
|
||||
api_key_store, async_session_maker
|
||||
):
|
||||
"""Test validating a legacy API key without org_id returns None for org_id."""
|
||||
# Arrange
|
||||
user_id = str(uuid.uuid4())
|
||||
api_key_value = 'test-legacy-key-no-org'
|
||||
|
||||
async with async_session_maker() as session:
|
||||
key_record = ApiKey(
|
||||
key=api_key_value,
|
||||
user_id=user_id,
|
||||
org_id=None, # Legacy key without org binding
|
||||
name='Legacy Key',
|
||||
)
|
||||
session.add(key_record)
|
||||
await session.commit()
|
||||
|
||||
# Act
|
||||
with patch('storage.api_key_store.a_session_maker', async_session_maker):
|
||||
result = await api_key_store.validate_api_key(api_key_value)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, ApiKeyValidationResult)
|
||||
assert result is not None
|
||||
assert result.user_id == user_id
|
||||
assert result.org_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -846,10 +846,108 @@ async def test_keycloak_callback_duplicate_email_detected(
|
||||
assert exc_info.value.detail == 'duplicate_email'
|
||||
|
||||
|
||||
# Note: test_keycloak_callback_duplicate_email_deletion_fails was removed as part of
|
||||
# the user authorization refactor. The Keycloak user deletion logic for duplicate emails
|
||||
# has been removed from keycloak_callback. If this behavior needs to be restored,
|
||||
# it should be implemented in the DefaultUserAuthorizer or handled separately.
|
||||
@pytest.mark.asyncio
|
||||
async def test_keycloak_callback_duplicate_email_deletes_new_keycloak_user(
|
||||
mock_request, create_keycloak_user_info
|
||||
):
|
||||
"""Test that new Keycloak user is deleted when duplicate email is detected.
|
||||
|
||||
When a user attempts to sign up with a +modifier email (e.g., joe+1@example.com)
|
||||
and an account with the base email already exists, the newly created Keycloak
|
||||
user should be deleted to prevent orphaned accounts from blocking future sign-ins.
|
||||
"""
|
||||
with (
|
||||
patch('server.routes.auth.token_manager') as mock_token_manager,
|
||||
patch('server.routes.auth.UserStore') as mock_user_store,
|
||||
):
|
||||
# Arrange
|
||||
mock_token_manager.get_keycloak_tokens = AsyncMock(
|
||||
return_value=('test_access_token', 'test_refresh_token')
|
||||
)
|
||||
mock_token_manager.get_user_info = AsyncMock(
|
||||
return_value=create_keycloak_user_info(
|
||||
sub='new_user_id',
|
||||
preferred_username='test_user',
|
||||
email='joe+1@example.com',
|
||||
identity_provider='github',
|
||||
)
|
||||
)
|
||||
mock_token_manager.delete_keycloak_user = AsyncMock(return_value=True)
|
||||
|
||||
# User does NOT exist in UserStore (new signup attempt)
|
||||
mock_user_store.get_user_by_id = AsyncMock(return_value=None)
|
||||
|
||||
# Create mock authorizer that returns duplicate_email error
|
||||
mock_authorizer = create_mock_user_authorizer(
|
||||
success=False, error_detail='duplicate_email'
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await keycloak_callback(
|
||||
code='test_code',
|
||||
state='test_state',
|
||||
request=mock_request,
|
||||
user_authorizer=mock_authorizer,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert exc_info.value.detail == 'duplicate_email'
|
||||
# Keycloak user should be deleted since user doesn't exist in UserStore
|
||||
mock_token_manager.delete_keycloak_user.assert_called_once_with('new_user_id')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keycloak_callback_duplicate_email_preserves_existing_user(
|
||||
mock_request, create_keycloak_user_info
|
||||
):
|
||||
"""Test that existing users are not deleted when duplicate email is detected.
|
||||
|
||||
When an existing user signs in and duplicate email is detected (e.g., because
|
||||
another account with the same base email was created while duplicate checking
|
||||
was disabled), the existing user's Keycloak account should NOT be deleted.
|
||||
"""
|
||||
with (
|
||||
patch('server.routes.auth.token_manager') as mock_token_manager,
|
||||
patch('server.routes.auth.UserStore') as mock_user_store,
|
||||
):
|
||||
# Arrange
|
||||
mock_token_manager.get_keycloak_tokens = AsyncMock(
|
||||
return_value=('test_access_token', 'test_refresh_token')
|
||||
)
|
||||
mock_token_manager.get_user_info = AsyncMock(
|
||||
return_value=create_keycloak_user_info(
|
||||
sub='existing_user_id',
|
||||
preferred_username='test_user',
|
||||
email='joe@example.com',
|
||||
identity_provider='github',
|
||||
)
|
||||
)
|
||||
mock_token_manager.delete_keycloak_user = AsyncMock(return_value=True)
|
||||
|
||||
# User EXISTS in UserStore (legitimate existing user)
|
||||
mock_existing_user = MagicMock()
|
||||
mock_existing_user.id = 'existing_user_id'
|
||||
mock_user_store.get_user_by_id = AsyncMock(return_value=mock_existing_user)
|
||||
|
||||
# Create mock authorizer that returns duplicate_email error
|
||||
mock_authorizer = create_mock_user_authorizer(
|
||||
success=False, error_detail='duplicate_email'
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await keycloak_callback(
|
||||
code='test_code',
|
||||
state='test_state',
|
||||
request=mock_request,
|
||||
user_authorizer=mock_authorizer,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert exc_info.value.detail == 'duplicate_email'
|
||||
# Keycloak user should NOT be deleted since user exists in UserStore
|
||||
mock_token_manager.delete_keycloak_user.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -13,6 +13,7 @@ from server.auth.authorization import (
|
||||
ROLE_PERMISSIONS,
|
||||
Permission,
|
||||
RoleName,
|
||||
get_api_key_org_id_from_request,
|
||||
get_role_permissions,
|
||||
get_user_org_role,
|
||||
has_permission,
|
||||
@@ -444,6 +445,15 @@ class TestGetUserOrgRole:
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _create_mock_request(api_key_org_id=None):
|
||||
"""Helper to create a mock request with optional api_key_org_id."""
|
||||
mock_request = MagicMock()
|
||||
mock_user_auth = MagicMock()
|
||||
mock_user_auth.get_api_key_org_id.return_value = api_key_org_id
|
||||
mock_request.state.user_auth = mock_user_auth
|
||||
return mock_request
|
||||
|
||||
|
||||
class TestRequirePermission:
|
||||
"""Tests for require_permission dependency factory."""
|
||||
|
||||
@@ -456,6 +466,7 @@ class TestRequirePermission:
|
||||
"""
|
||||
user_id = str(uuid4())
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request()
|
||||
|
||||
mock_role = MagicMock()
|
||||
mock_role.name = 'admin'
|
||||
@@ -465,7 +476,9 @@ class TestRequirePermission:
|
||||
AsyncMock(return_value=mock_role),
|
||||
):
|
||||
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
|
||||
result = await permission_checker(org_id=org_id, user_id=user_id)
|
||||
result = await permission_checker(
|
||||
request=mock_request, org_id=org_id, user_id=user_id
|
||||
)
|
||||
assert result == user_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -476,10 +489,11 @@ class TestRequirePermission:
|
||||
THEN: 401 Unauthorized is raised
|
||||
"""
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request()
|
||||
|
||||
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await permission_checker(org_id=org_id, user_id=None)
|
||||
await permission_checker(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.lower()
|
||||
@@ -493,6 +507,7 @@ class TestRequirePermission:
|
||||
"""
|
||||
user_id = str(uuid4())
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request()
|
||||
|
||||
with patch(
|
||||
'server.auth.authorization.get_user_org_role',
|
||||
@@ -500,7 +515,9 @@ class TestRequirePermission:
|
||||
):
|
||||
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await permission_checker(org_id=org_id, user_id=user_id)
|
||||
await permission_checker(
|
||||
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.lower()
|
||||
@@ -514,6 +531,7 @@ class TestRequirePermission:
|
||||
"""
|
||||
user_id = str(uuid4())
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request()
|
||||
|
||||
mock_role = MagicMock()
|
||||
mock_role.name = 'member'
|
||||
@@ -524,7 +542,9 @@ class TestRequirePermission:
|
||||
):
|
||||
permission_checker = require_permission(Permission.DELETE_ORGANIZATION)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await permission_checker(org_id=org_id, user_id=user_id)
|
||||
await permission_checker(
|
||||
request=mock_request, org_id=org_id, user_id=user_id
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert 'delete_organization' in exc_info.value.detail.lower()
|
||||
@@ -538,6 +558,7 @@ class TestRequirePermission:
|
||||
"""
|
||||
user_id = str(uuid4())
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request()
|
||||
|
||||
mock_role = MagicMock()
|
||||
mock_role.name = 'owner'
|
||||
@@ -547,7 +568,9 @@ class TestRequirePermission:
|
||||
AsyncMock(return_value=mock_role),
|
||||
):
|
||||
permission_checker = require_permission(Permission.DELETE_ORGANIZATION)
|
||||
result = await permission_checker(org_id=org_id, user_id=user_id)
|
||||
result = await permission_checker(
|
||||
request=mock_request, org_id=org_id, user_id=user_id
|
||||
)
|
||||
assert result == user_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -559,6 +582,7 @@ class TestRequirePermission:
|
||||
"""
|
||||
user_id = str(uuid4())
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request()
|
||||
|
||||
mock_role = MagicMock()
|
||||
mock_role.name = 'admin'
|
||||
@@ -569,7 +593,9 @@ class TestRequirePermission:
|
||||
):
|
||||
permission_checker = require_permission(Permission.DELETE_ORGANIZATION)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await permission_checker(org_id=org_id, user_id=user_id)
|
||||
await permission_checker(
|
||||
request=mock_request, org_id=org_id, user_id=user_id
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
@@ -582,6 +608,7 @@ class TestRequirePermission:
|
||||
"""
|
||||
user_id = str(uuid4())
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request()
|
||||
|
||||
mock_role = MagicMock()
|
||||
mock_role.name = 'member'
|
||||
@@ -595,7 +622,9 @@ class TestRequirePermission:
|
||||
):
|
||||
permission_checker = require_permission(Permission.DELETE_ORGANIZATION)
|
||||
with pytest.raises(HTTPException):
|
||||
await permission_checker(org_id=org_id, user_id=user_id)
|
||||
await permission_checker(
|
||||
request=mock_request, org_id=org_id, user_id=user_id
|
||||
)
|
||||
|
||||
mock_logger.warning.assert_called()
|
||||
call_args = mock_logger.warning.call_args
|
||||
@@ -611,6 +640,7 @@ class TestRequirePermission:
|
||||
THEN: User ID is returned
|
||||
"""
|
||||
user_id = str(uuid4())
|
||||
mock_request = _create_mock_request()
|
||||
|
||||
mock_role = MagicMock()
|
||||
mock_role.name = 'admin'
|
||||
@@ -620,7 +650,9 @@ class TestRequirePermission:
|
||||
AsyncMock(return_value=mock_role),
|
||||
) as mock_get_role:
|
||||
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
|
||||
result = await permission_checker(org_id=None, user_id=user_id)
|
||||
result = await permission_checker(
|
||||
request=mock_request, org_id=None, user_id=user_id
|
||||
)
|
||||
assert result == user_id
|
||||
mock_get_role.assert_called_once_with(user_id, None)
|
||||
|
||||
@@ -632,6 +664,7 @@ class TestRequirePermission:
|
||||
THEN: HTTPException with 403 status is raised
|
||||
"""
|
||||
user_id = str(uuid4())
|
||||
mock_request = _create_mock_request()
|
||||
|
||||
with patch(
|
||||
'server.auth.authorization.get_user_org_role',
|
||||
@@ -639,7 +672,9 @@ class TestRequirePermission:
|
||||
):
|
||||
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await permission_checker(org_id=None, user_id=user_id)
|
||||
await permission_checker(
|
||||
request=mock_request, org_id=None, user_id=user_id
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert 'not a member' in exc_info.value.detail
|
||||
@@ -662,6 +697,7 @@ class TestPermissionScenarios:
|
||||
"""
|
||||
user_id = str(uuid4())
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request()
|
||||
|
||||
mock_role = MagicMock()
|
||||
mock_role.name = 'member'
|
||||
@@ -671,7 +707,9 @@ class TestPermissionScenarios:
|
||||
AsyncMock(return_value=mock_role),
|
||||
):
|
||||
permission_checker = require_permission(Permission.MANAGE_SECRETS)
|
||||
result = await permission_checker(org_id=org_id, user_id=user_id)
|
||||
result = await permission_checker(
|
||||
request=mock_request, org_id=org_id, user_id=user_id
|
||||
)
|
||||
assert result == user_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -683,6 +721,7 @@ class TestPermissionScenarios:
|
||||
"""
|
||||
user_id = str(uuid4())
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request()
|
||||
|
||||
mock_role = MagicMock()
|
||||
mock_role.name = 'member'
|
||||
@@ -695,7 +734,9 @@ class TestPermissionScenarios:
|
||||
Permission.INVITE_USER_TO_ORGANIZATION
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await permission_checker(org_id=org_id, user_id=user_id)
|
||||
await permission_checker(
|
||||
request=mock_request, org_id=org_id, user_id=user_id
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
@@ -708,6 +749,7 @@ class TestPermissionScenarios:
|
||||
"""
|
||||
user_id = str(uuid4())
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request()
|
||||
|
||||
mock_role = MagicMock()
|
||||
mock_role.name = 'admin'
|
||||
@@ -719,7 +761,9 @@ class TestPermissionScenarios:
|
||||
permission_checker = require_permission(
|
||||
Permission.INVITE_USER_TO_ORGANIZATION
|
||||
)
|
||||
result = await permission_checker(org_id=org_id, user_id=user_id)
|
||||
result = await permission_checker(
|
||||
request=mock_request, org_id=org_id, user_id=user_id
|
||||
)
|
||||
assert result == user_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -731,6 +775,7 @@ class TestPermissionScenarios:
|
||||
"""
|
||||
user_id = str(uuid4())
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request()
|
||||
|
||||
mock_role = MagicMock()
|
||||
mock_role.name = 'admin'
|
||||
@@ -741,7 +786,9 @@ class TestPermissionScenarios:
|
||||
):
|
||||
permission_checker = require_permission(Permission.CHANGE_USER_ROLE_OWNER)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await permission_checker(org_id=org_id, user_id=user_id)
|
||||
await permission_checker(
|
||||
request=mock_request, org_id=org_id, user_id=user_id
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
@@ -754,6 +801,7 @@ class TestPermissionScenarios:
|
||||
"""
|
||||
user_id = str(uuid4())
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request()
|
||||
|
||||
mock_role = MagicMock()
|
||||
mock_role.name = 'owner'
|
||||
@@ -763,5 +811,431 @@ class TestPermissionScenarios:
|
||||
AsyncMock(return_value=mock_role),
|
||||
):
|
||||
permission_checker = require_permission(Permission.CHANGE_USER_ROLE_OWNER)
|
||||
result = await permission_checker(org_id=org_id, user_id=user_id)
|
||||
result = await permission_checker(
|
||||
request=mock_request, org_id=org_id, user_id=user_id
|
||||
)
|
||||
assert result == user_id
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for API key organization validation
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestApiKeyOrgValidation:
|
||||
"""Tests for API key organization binding validation in require_permission."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allows_access_when_api_key_org_matches_target_org(self):
|
||||
"""
|
||||
GIVEN: API key with org_id that matches the target org_id in the request
|
||||
WHEN: Permission checker is called
|
||||
THEN: User ID is returned (access allowed)
|
||||
"""
|
||||
# Arrange
|
||||
user_id = str(uuid4())
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request(api_key_org_id=org_id)
|
||||
|
||||
mock_role = MagicMock()
|
||||
mock_role.name = 'admin'
|
||||
|
||||
# Act & Assert
|
||||
with patch(
|
||||
'server.auth.authorization.get_user_org_role',
|
||||
AsyncMock(return_value=mock_role),
|
||||
):
|
||||
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
|
||||
result = await permission_checker(
|
||||
request=mock_request, org_id=org_id, user_id=user_id
|
||||
)
|
||||
assert result == user_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_denies_access_when_api_key_org_mismatches_target_org(self):
|
||||
"""
|
||||
GIVEN: API key created for Org A, but user tries to access Org B
|
||||
WHEN: Permission checker is called
|
||||
THEN: 403 Forbidden is raised with org mismatch message
|
||||
"""
|
||||
# Arrange
|
||||
user_id = str(uuid4())
|
||||
api_key_org_id = uuid4() # Org A - where API key was created
|
||||
target_org_id = uuid4() # Org B - where user is trying to access
|
||||
mock_request = _create_mock_request(api_key_org_id=api_key_org_id)
|
||||
|
||||
# Act & Assert
|
||||
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await permission_checker(
|
||||
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 for this organization' in exc_info.value.detail
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allows_access_for_legacy_api_key_without_org_binding(self):
|
||||
"""
|
||||
GIVEN: Legacy API key without org_id binding (org_id is None)
|
||||
WHEN: Permission checker is called
|
||||
THEN: Falls through to normal permission check (backward compatible)
|
||||
"""
|
||||
# Arrange
|
||||
user_id = str(uuid4())
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request(api_key_org_id=None)
|
||||
|
||||
mock_role = MagicMock()
|
||||
mock_role.name = 'admin'
|
||||
|
||||
# Act & Assert
|
||||
with patch(
|
||||
'server.auth.authorization.get_user_org_role',
|
||||
AsyncMock(return_value=mock_role),
|
||||
):
|
||||
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
|
||||
result = await permission_checker(
|
||||
request=mock_request, org_id=org_id, user_id=user_id
|
||||
)
|
||||
assert result == user_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allows_access_for_cookie_auth_without_api_key_org_id(self):
|
||||
"""
|
||||
GIVEN: Cookie-based authentication (no api_key_org_id in user_auth)
|
||||
WHEN: Permission checker is called
|
||||
THEN: Falls through to normal permission check
|
||||
"""
|
||||
# Arrange
|
||||
user_id = str(uuid4())
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request(api_key_org_id=None)
|
||||
|
||||
mock_role = MagicMock()
|
||||
mock_role.name = 'admin'
|
||||
|
||||
# Act & Assert
|
||||
with patch(
|
||||
'server.auth.authorization.get_user_org_role',
|
||||
AsyncMock(return_value=mock_role),
|
||||
):
|
||||
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
|
||||
result = await permission_checker(
|
||||
request=mock_request, org_id=org_id, user_id=user_id
|
||||
)
|
||||
assert result == user_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_logs_warning_on_api_key_org_mismatch(self):
|
||||
"""
|
||||
GIVEN: API key org_id doesn't match target org_id
|
||||
WHEN: Permission checker is called
|
||||
THEN: Warning is logged with org mismatch details
|
||||
"""
|
||||
# Arrange
|
||||
user_id = str(uuid4())
|
||||
api_key_org_id = uuid4()
|
||||
target_org_id = uuid4()
|
||||
mock_request = _create_mock_request(api_key_org_id=api_key_org_id)
|
||||
|
||||
# Act & Assert
|
||||
with patch('server.auth.authorization.logger') as mock_logger:
|
||||
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
|
||||
with pytest.raises(HTTPException):
|
||||
await permission_checker(
|
||||
request=mock_request, org_id=target_org_id, user_id=user_id
|
||||
)
|
||||
|
||||
mock_logger.warning.assert_called()
|
||||
call_args = mock_logger.warning.call_args
|
||||
assert call_args[1]['extra']['user_id'] == user_id
|
||||
assert call_args[1]['extra']['api_key_org_id'] == str(api_key_org_id)
|
||||
assert call_args[1]['extra']['target_org_id'] == str(target_org_id)
|
||||
|
||||
|
||||
class TestGetApiKeyOrgIdFromRequest:
|
||||
"""Tests for get_api_key_org_id_from_request helper function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_org_id_when_user_auth_has_api_key_org_id(self):
|
||||
"""
|
||||
GIVEN: Request with user_auth that has api_key_org_id
|
||||
WHEN: get_api_key_org_id_from_request is called
|
||||
THEN: Returns the api_key_org_id
|
||||
"""
|
||||
# Arrange
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request(api_key_org_id=org_id)
|
||||
|
||||
# Act
|
||||
result = await get_api_key_org_id_from_request(mock_request)
|
||||
|
||||
# Assert
|
||||
assert result == org_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_when_user_auth_has_no_api_key_org_id(self):
|
||||
"""
|
||||
GIVEN: Request with user_auth that has no api_key_org_id (cookie auth)
|
||||
WHEN: get_api_key_org_id_from_request is called
|
||||
THEN: Returns None
|
||||
"""
|
||||
# Arrange
|
||||
mock_request = _create_mock_request(api_key_org_id=None)
|
||||
|
||||
# Act
|
||||
result = await get_api_key_org_id_from_request(mock_request)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_when_no_user_auth_in_request(self):
|
||||
"""
|
||||
GIVEN: Request without user_auth in state
|
||||
WHEN: get_api_key_org_id_from_request is called
|
||||
THEN: Returns None
|
||||
"""
|
||||
# Arrange
|
||||
mock_request = MagicMock()
|
||||
mock_request.state.user_auth = None
|
||||
|
||||
# Act
|
||||
result = await get_api_key_org_id_from_request(mock_request)
|
||||
|
||||
# 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
|
||||
|
||||
@@ -38,8 +38,9 @@ class TestDefaultInitialBudget:
|
||||
if 'storage.lite_llm_manager' in sys.modules:
|
||||
del sys.modules['storage.lite_llm_manager']
|
||||
|
||||
# Clear the env var
|
||||
# Clear the env vars
|
||||
os.environ.pop('DEFAULT_INITIAL_BUDGET', None)
|
||||
os.environ.pop('ENABLE_BILLING', None)
|
||||
|
||||
# Restore original module or reimport fresh
|
||||
if original_module is not None:
|
||||
@@ -47,31 +48,56 @@ class TestDefaultInitialBudget:
|
||||
else:
|
||||
importlib.import_module('storage.lite_llm_manager')
|
||||
|
||||
def test_default_initial_budget_defaults_to_zero(self):
|
||||
"""Test that DEFAULT_INITIAL_BUDGET defaults to 0.0 when env var not set."""
|
||||
def test_default_initial_budget_none_when_billing_disabled(self):
|
||||
"""Test that DEFAULT_INITIAL_BUDGET is None when billing is disabled."""
|
||||
# Temporarily remove the module so we can reimport with different env vars
|
||||
if 'storage.lite_llm_manager' in sys.modules:
|
||||
del sys.modules['storage.lite_llm_manager']
|
||||
|
||||
# Clear the env var and reimport
|
||||
# Ensure billing is disabled (default) and reimport
|
||||
os.environ.pop('ENABLE_BILLING', None)
|
||||
os.environ.pop('DEFAULT_INITIAL_BUDGET', None)
|
||||
module = importlib.import_module('storage.lite_llm_manager')
|
||||
assert module.DEFAULT_INITIAL_BUDGET is None
|
||||
|
||||
def test_default_initial_budget_defaults_to_zero_when_billing_enabled(self):
|
||||
"""Test that DEFAULT_INITIAL_BUDGET defaults to 0.0 when billing is enabled."""
|
||||
# Temporarily remove the module so we can reimport with different env vars
|
||||
if 'storage.lite_llm_manager' in sys.modules:
|
||||
del sys.modules['storage.lite_llm_manager']
|
||||
|
||||
# Enable billing and reimport
|
||||
os.environ['ENABLE_BILLING'] = 'true'
|
||||
os.environ.pop('DEFAULT_INITIAL_BUDGET', None)
|
||||
module = importlib.import_module('storage.lite_llm_manager')
|
||||
assert module.DEFAULT_INITIAL_BUDGET == 0.0
|
||||
|
||||
def test_default_initial_budget_uses_env_var(self):
|
||||
"""Test that DEFAULT_INITIAL_BUDGET uses value from environment variable."""
|
||||
def test_default_initial_budget_uses_env_var_when_billing_enabled(self):
|
||||
"""Test that DEFAULT_INITIAL_BUDGET uses value from environment variable when billing enabled."""
|
||||
if 'storage.lite_llm_manager' in sys.modules:
|
||||
del sys.modules['storage.lite_llm_manager']
|
||||
|
||||
os.environ['ENABLE_BILLING'] = 'true'
|
||||
os.environ['DEFAULT_INITIAL_BUDGET'] = '100.0'
|
||||
module = importlib.import_module('storage.lite_llm_manager')
|
||||
assert module.DEFAULT_INITIAL_BUDGET == 100.0
|
||||
|
||||
def test_default_initial_budget_ignores_env_var_when_billing_disabled(self):
|
||||
"""Test that DEFAULT_INITIAL_BUDGET returns None when billing disabled, ignoring env var."""
|
||||
if 'storage.lite_llm_manager' in sys.modules:
|
||||
del sys.modules['storage.lite_llm_manager']
|
||||
|
||||
os.environ.pop('ENABLE_BILLING', None) # billing disabled by default
|
||||
os.environ['DEFAULT_INITIAL_BUDGET'] = '100.0'
|
||||
module = importlib.import_module('storage.lite_llm_manager')
|
||||
assert module.DEFAULT_INITIAL_BUDGET is None
|
||||
|
||||
def test_default_initial_budget_rejects_invalid_value(self):
|
||||
"""Test that DEFAULT_INITIAL_BUDGET raises ValueError for invalid values."""
|
||||
if 'storage.lite_llm_manager' in sys.modules:
|
||||
del sys.modules['storage.lite_llm_manager']
|
||||
|
||||
os.environ['ENABLE_BILLING'] = 'true'
|
||||
os.environ['DEFAULT_INITIAL_BUDGET'] = 'abc'
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
importlib.import_module('storage.lite_llm_manager')
|
||||
@@ -82,6 +108,7 @@ class TestDefaultInitialBudget:
|
||||
if 'storage.lite_llm_manager' in sys.modules:
|
||||
del sys.modules['storage.lite_llm_manager']
|
||||
|
||||
os.environ['ENABLE_BILLING'] = 'true'
|
||||
os.environ['DEFAULT_INITIAL_BUDGET'] = '-10.0'
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
importlib.import_module('storage.lite_llm_manager')
|
||||
@@ -212,6 +239,16 @@ class TestLiteLlmManager:
|
||||
mock_404_response = MagicMock()
|
||||
mock_404_response.status_code = 404
|
||||
mock_404_response.is_success = False
|
||||
mock_404_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
message='Not Found', request=MagicMock(), response=mock_404_response
|
||||
)
|
||||
|
||||
# Mock user exists check response
|
||||
mock_user_exists_response = MagicMock()
|
||||
mock_user_exists_response.is_success = True
|
||||
mock_user_exists_response.json.return_value = {
|
||||
'user_info': {'user_id': 'test-user-id'}
|
||||
}
|
||||
|
||||
mock_token_manager = MagicMock()
|
||||
mock_token_manager.return_value.get_user_info_from_user_id = AsyncMock(
|
||||
@@ -219,12 +256,8 @@ class TestLiteLlmManager:
|
||||
)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.return_value = mock_404_response
|
||||
mock_client.get.return_value.raise_for_status.side_effect = (
|
||||
httpx.HTTPStatusError(
|
||||
message='Not Found', request=MagicMock(), response=mock_404_response
|
||||
)
|
||||
)
|
||||
# First GET is for _get_team (404), second GET is for _user_exists (success)
|
||||
mock_client.get.side_effect = [mock_404_response, mock_user_exists_response]
|
||||
mock_client.post.return_value = mock_response
|
||||
|
||||
mock_client_class = MagicMock()
|
||||
@@ -247,8 +280,8 @@ class TestLiteLlmManager:
|
||||
assert result.llm_api_key.get_secret_value() == 'test-api-key'
|
||||
assert result.llm_base_url == 'http://test.com'
|
||||
|
||||
# Verify API calls were made (get_team + 4 posts)
|
||||
assert mock_client.get.call_count == 1 # get_team
|
||||
# Verify API calls were made (get_team + user_exists + 4 posts)
|
||||
assert mock_client.get.call_count == 2 # get_team + user_exists
|
||||
assert (
|
||||
mock_client.post.call_count == 4
|
||||
) # create_team, add_user_to_team, delete_key_by_alias, generate_key
|
||||
@@ -267,13 +300,21 @@ class TestLiteLlmManager:
|
||||
}
|
||||
mock_team_response.raise_for_status = MagicMock()
|
||||
|
||||
# Mock user exists check response
|
||||
mock_user_exists_response = MagicMock()
|
||||
mock_user_exists_response.is_success = True
|
||||
mock_user_exists_response.json.return_value = {
|
||||
'user_info': {'user_id': 'test-user-id'}
|
||||
}
|
||||
|
||||
mock_token_manager = MagicMock()
|
||||
mock_token_manager.return_value.get_user_info_from_user_id = AsyncMock(
|
||||
return_value={'email': 'test@example.com'}
|
||||
)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.return_value = mock_team_response
|
||||
# First GET is for _get_team (success), second GET is for _user_exists (success)
|
||||
mock_client.get.side_effect = [mock_team_response, mock_user_exists_response]
|
||||
mock_client.post.return_value = mock_response
|
||||
|
||||
mock_client_class = MagicMock()
|
||||
@@ -293,8 +334,8 @@ class TestLiteLlmManager:
|
||||
assert result is not None
|
||||
|
||||
# Verify _get_team was called first
|
||||
mock_client.get.assert_called_once()
|
||||
get_call_url = mock_client.get.call_args[0][0]
|
||||
assert mock_client.get.call_count == 2 # get_team + user_exists
|
||||
get_call_url = mock_client.get.call_args_list[0][0][0]
|
||||
assert 'team/info' in get_call_url
|
||||
assert 'test-org-id' in get_call_url
|
||||
|
||||
@@ -316,19 +357,25 @@ class TestLiteLlmManager:
|
||||
mock_404_response = MagicMock()
|
||||
mock_404_response.status_code = 404
|
||||
mock_404_response.is_success = False
|
||||
mock_404_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
message='Not Found', request=MagicMock(), response=mock_404_response
|
||||
)
|
||||
|
||||
mock_token_manager = MagicMock()
|
||||
mock_token_manager.return_value.get_user_info_from_user_id = AsyncMock(
|
||||
return_value={'email': 'test@example.com'}
|
||||
)
|
||||
|
||||
# Mock user exists check response
|
||||
mock_user_exists_response = MagicMock()
|
||||
mock_user_exists_response.is_success = True
|
||||
mock_user_exists_response.json.return_value = {
|
||||
'user_info': {'user_id': 'test-user-id'}
|
||||
}
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.return_value = mock_404_response
|
||||
mock_client.get.return_value.raise_for_status.side_effect = (
|
||||
httpx.HTTPStatusError(
|
||||
message='Not Found', request=MagicMock(), response=mock_404_response
|
||||
)
|
||||
)
|
||||
# First GET is for _get_team (404), second GET is for _user_exists (success)
|
||||
mock_client.get.side_effect = [mock_404_response, mock_user_exists_response]
|
||||
mock_client.post.return_value = mock_response
|
||||
|
||||
mock_client_class = MagicMock()
|
||||
@@ -366,6 +413,16 @@ class TestLiteLlmManager:
|
||||
mock_404_response = MagicMock()
|
||||
mock_404_response.status_code = 404
|
||||
mock_404_response.is_success = False
|
||||
mock_404_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
message='Not Found', request=MagicMock(), response=mock_404_response
|
||||
)
|
||||
|
||||
# Mock user exists check response
|
||||
mock_user_exists_response = MagicMock()
|
||||
mock_user_exists_response.is_success = True
|
||||
mock_user_exists_response.json.return_value = {
|
||||
'user_info': {'user_id': 'test-user-id'}
|
||||
}
|
||||
|
||||
mock_token_manager = MagicMock()
|
||||
mock_token_manager.return_value.get_user_info_from_user_id = AsyncMock(
|
||||
@@ -373,12 +430,8 @@ class TestLiteLlmManager:
|
||||
)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.return_value = mock_404_response
|
||||
mock_client.get.return_value.raise_for_status.side_effect = (
|
||||
httpx.HTTPStatusError(
|
||||
message='Not Found', request=MagicMock(), response=mock_404_response
|
||||
)
|
||||
)
|
||||
# First GET is for _get_team (404), second GET is for _user_exists (success)
|
||||
mock_client.get.side_effect = [mock_404_response, mock_user_exists_response]
|
||||
mock_client.post.return_value = mock_response
|
||||
|
||||
mock_client_class = MagicMock()
|
||||
@@ -806,15 +859,16 @@ class TestLiteLlmManager:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user_success(self, mock_http_client, mock_response):
|
||||
"""Test successful _create_user operation."""
|
||||
"""Test successful _create_user operation returns True."""
|
||||
mock_http_client.post.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'):
|
||||
await LiteLlmManager._create_user(
|
||||
result = await LiteLlmManager._create_user(
|
||||
mock_http_client, 'test@example.com', 'test-user-id'
|
||||
)
|
||||
|
||||
assert result is True
|
||||
mock_http_client.post.assert_called_once()
|
||||
call_args = mock_http_client.post.call_args
|
||||
assert 'http://test.com/user/new' in call_args[0]
|
||||
@@ -823,7 +877,7 @@ class TestLiteLlmManager:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user_duplicate_email(self, mock_http_client, mock_response):
|
||||
"""Test _create_user with duplicate email handling."""
|
||||
"""Test _create_user with duplicate email handling returns True."""
|
||||
# First call fails with duplicate email
|
||||
error_response = MagicMock()
|
||||
error_response.is_success = False
|
||||
@@ -835,23 +889,81 @@ class TestLiteLlmManager:
|
||||
|
||||
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'):
|
||||
await LiteLlmManager._create_user(
|
||||
result = await LiteLlmManager._create_user(
|
||||
mock_http_client, 'test@example.com', 'test-user-id'
|
||||
)
|
||||
|
||||
assert result is True
|
||||
assert mock_http_client.post.call_count == 2
|
||||
# Second call should have None email
|
||||
second_call_args = mock_http_client.post.call_args_list[1]
|
||||
assert second_call_args[1]['json']['user_email'] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com')
|
||||
@patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key')
|
||||
async def test_user_exists_returns_true(self, mock_http_client):
|
||||
"""Test _user_exists returns True when user exists in LiteLLM."""
|
||||
# Arrange
|
||||
user_response = MagicMock()
|
||||
user_response.is_success = True
|
||||
user_response.json.return_value = {
|
||||
'user_info': {'user_id': 'test-user-id', 'email': 'test@example.com'}
|
||||
}
|
||||
mock_http_client.get.return_value = user_response
|
||||
|
||||
# Act
|
||||
result = await LiteLlmManager._user_exists(mock_http_client, 'test-user-id')
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
mock_http_client.get.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com')
|
||||
@patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key')
|
||||
async def test_user_exists_returns_false_when_not_found(self, mock_http_client):
|
||||
"""Test _user_exists returns False when user not found."""
|
||||
# Arrange
|
||||
user_response = MagicMock()
|
||||
user_response.is_success = False
|
||||
mock_http_client.get.return_value = user_response
|
||||
|
||||
# Act
|
||||
result = await LiteLlmManager._user_exists(mock_http_client, 'test-user-id')
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com')
|
||||
@patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key')
|
||||
async def test_user_exists_returns_false_on_mismatched_user_id(
|
||||
self, mock_http_client
|
||||
):
|
||||
"""Test _user_exists returns False when returned user_id doesn't match."""
|
||||
# Arrange
|
||||
user_response = MagicMock()
|
||||
user_response.is_success = True
|
||||
user_response.json.return_value = {
|
||||
'user_info': {'user_id': 'different-user-id'}
|
||||
}
|
||||
mock_http_client.get.return_value = user_response
|
||||
|
||||
# Act
|
||||
result = await LiteLlmManager._user_exists(mock_http_client, 'test-user-id')
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('storage.lite_llm_manager.logger')
|
||||
@patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com')
|
||||
@patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key')
|
||||
async def test_create_user_already_exists_with_409_status_code(
|
||||
async def test_create_user_already_exists_and_verified(
|
||||
self, mock_logger, mock_http_client
|
||||
):
|
||||
"""Test _create_user handles 409 Conflict when user already exists."""
|
||||
"""Test _create_user returns True when user already exists and is verified."""
|
||||
# Arrange
|
||||
first_response = MagicMock()
|
||||
first_response.is_success = False
|
||||
@@ -863,14 +975,141 @@ class TestLiteLlmManager:
|
||||
second_response.status_code = 409
|
||||
second_response.text = 'User with id test-user-id already exists'
|
||||
|
||||
user_exists_response = MagicMock()
|
||||
user_exists_response.is_success = True
|
||||
user_exists_response.json.return_value = {
|
||||
'user_info': {'user_id': 'test-user-id'}
|
||||
}
|
||||
|
||||
mock_http_client.post.side_effect = [first_response, second_response]
|
||||
mock_http_client.get.return_value = user_exists_response
|
||||
|
||||
# Act
|
||||
await LiteLlmManager._create_user(
|
||||
result = await LiteLlmManager._create_user(
|
||||
mock_http_client, 'test@example.com', 'test-user-id'
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
mock_logger.warning.assert_any_call(
|
||||
'litellm_user_already_exists',
|
||||
extra={'user_id': 'test-user-id'},
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('storage.lite_llm_manager.logger')
|
||||
@patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com')
|
||||
@patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key')
|
||||
async def test_create_user_already_exists_but_not_found_returns_false(
|
||||
self, mock_logger, mock_http_client
|
||||
):
|
||||
"""Test _create_user returns False when LiteLLM claims user exists but verification fails."""
|
||||
# Arrange
|
||||
first_response = MagicMock()
|
||||
first_response.is_success = False
|
||||
first_response.status_code = 400
|
||||
first_response.text = 'duplicate email'
|
||||
|
||||
second_response = MagicMock()
|
||||
second_response.is_success = False
|
||||
second_response.status_code = 409
|
||||
second_response.text = 'User with id test-user-id already exists'
|
||||
|
||||
user_not_exists_response = MagicMock()
|
||||
user_not_exists_response.is_success = False
|
||||
|
||||
mock_http_client.post.side_effect = [first_response, second_response]
|
||||
mock_http_client.get.return_value = user_not_exists_response
|
||||
|
||||
# Act
|
||||
result = await LiteLlmManager._create_user(
|
||||
mock_http_client, 'test@example.com', 'test-user-id'
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
mock_logger.error.assert_any_call(
|
||||
'litellm_user_claimed_exists_but_not_found',
|
||||
extra={
|
||||
'user_id': 'test-user-id',
|
||||
'status_code': 409,
|
||||
'text': 'User with id test-user-id already exists',
|
||||
},
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('storage.lite_llm_manager.logger')
|
||||
@patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com')
|
||||
@patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key')
|
||||
async def test_create_user_failure_returns_false(
|
||||
self, mock_logger, mock_http_client
|
||||
):
|
||||
"""Test _create_user returns False when creation fails with non-'already exists' error."""
|
||||
# Arrange
|
||||
first_response = MagicMock()
|
||||
first_response.is_success = False
|
||||
first_response.status_code = 400
|
||||
first_response.text = 'duplicate email'
|
||||
|
||||
second_response = MagicMock()
|
||||
second_response.is_success = False
|
||||
second_response.status_code = 500
|
||||
second_response.text = 'Internal server error'
|
||||
|
||||
mock_http_client.post.side_effect = [first_response, second_response]
|
||||
|
||||
# Act
|
||||
result = await LiteLlmManager._create_user(
|
||||
mock_http_client, 'test@example.com', 'test-user-id'
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
mock_logger.error.assert_any_call(
|
||||
'error_creating_litellm_user',
|
||||
extra={
|
||||
'status_code': 500,
|
||||
'text': 'Internal server error',
|
||||
'user_id': 'test-user-id',
|
||||
'email': None,
|
||||
},
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('storage.lite_llm_manager.logger')
|
||||
@patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com')
|
||||
@patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key')
|
||||
async def test_create_user_already_exists_with_409_status_code(
|
||||
self, mock_logger, mock_http_client
|
||||
):
|
||||
"""Test _create_user handles 409 Conflict when user already exists and verifies."""
|
||||
# Arrange
|
||||
first_response = MagicMock()
|
||||
first_response.is_success = False
|
||||
first_response.status_code = 400
|
||||
first_response.text = 'duplicate email'
|
||||
|
||||
second_response = MagicMock()
|
||||
second_response.is_success = False
|
||||
second_response.status_code = 409
|
||||
second_response.text = 'User with id test-user-id already exists'
|
||||
|
||||
user_exists_response = MagicMock()
|
||||
user_exists_response.is_success = True
|
||||
user_exists_response.json.return_value = {
|
||||
'user_info': {'user_id': 'test-user-id'}
|
||||
}
|
||||
|
||||
mock_http_client.post.side_effect = [first_response, second_response]
|
||||
mock_http_client.get.return_value = user_exists_response
|
||||
|
||||
# Act
|
||||
result = await LiteLlmManager._create_user(
|
||||
mock_http_client, 'test@example.com', 'test-user-id'
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
mock_logger.warning.assert_any_call(
|
||||
'litellm_user_already_exists',
|
||||
extra={'user_id': 'test-user-id'},
|
||||
@@ -883,7 +1122,7 @@ class TestLiteLlmManager:
|
||||
async def test_create_user_already_exists_with_400_status_code(
|
||||
self, mock_logger, mock_http_client
|
||||
):
|
||||
"""Test _create_user handles 400 Bad Request when user already exists."""
|
||||
"""Test _create_user handles 400 Bad Request when user already exists and verifies."""
|
||||
# Arrange
|
||||
first_response = MagicMock()
|
||||
first_response.is_success = False
|
||||
@@ -895,14 +1134,22 @@ class TestLiteLlmManager:
|
||||
second_response.status_code = 400
|
||||
second_response.text = 'User already exists'
|
||||
|
||||
user_exists_response = MagicMock()
|
||||
user_exists_response.is_success = True
|
||||
user_exists_response.json.return_value = {
|
||||
'user_info': {'user_id': 'test-user-id'}
|
||||
}
|
||||
|
||||
mock_http_client.post.side_effect = [first_response, second_response]
|
||||
mock_http_client.get.return_value = user_exists_response
|
||||
|
||||
# Act
|
||||
await LiteLlmManager._create_user(
|
||||
result = await LiteLlmManager._create_user(
|
||||
mock_http_client, 'test@example.com', 'test-user-id'
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
mock_logger.warning.assert_any_call(
|
||||
'litellm_user_already_exists',
|
||||
extra={'user_id': 'test-user-id'},
|
||||
@@ -2137,3 +2384,496 @@ class TestVerifyExistingKey:
|
||||
openhands_type=True,
|
||||
)
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestBudgetPayloadHandling:
|
||||
"""Test cases for budget field handling in API payloads.
|
||||
|
||||
These tests verify that when max_budget is None, the budget field is NOT
|
||||
included in the JSON payload (which tells LiteLLM to disable budget
|
||||
enforcement), and when max_budget has a value, it IS included.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_team_excludes_max_budget_when_none(self):
|
||||
"""Test that _create_team does NOT include max_budget when it is None."""
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_response = MagicMock()
|
||||
mock_response.is_success = True
|
||||
mock_response.status_code = 200
|
||||
mock_client.post.return_value = mock_response
|
||||
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-api-key'):
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
|
||||
await LiteLlmManager._create_team(
|
||||
mock_client,
|
||||
team_alias='test-team',
|
||||
team_id='test-team-id',
|
||||
max_budget=None, # None = no budget limit
|
||||
)
|
||||
|
||||
# Verify the call was made
|
||||
mock_client.post.assert_called_once()
|
||||
call_args = mock_client.post.call_args
|
||||
|
||||
# Verify URL
|
||||
assert call_args[0][0] == 'http://test.com/team/new'
|
||||
|
||||
# Verify that max_budget is NOT in the JSON payload
|
||||
json_payload = call_args[1]['json']
|
||||
assert 'max_budget' not in json_payload, (
|
||||
'max_budget should NOT be in payload when None '
|
||||
'(omitting it tells LiteLLM to disable budget enforcement)'
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_team_includes_max_budget_when_set(self):
|
||||
"""Test that _create_team includes max_budget when it has a value."""
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_response = MagicMock()
|
||||
mock_response.is_success = True
|
||||
mock_response.status_code = 200
|
||||
mock_client.post.return_value = mock_response
|
||||
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-api-key'):
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
|
||||
await LiteLlmManager._create_team(
|
||||
mock_client,
|
||||
team_alias='test-team',
|
||||
team_id='test-team-id',
|
||||
max_budget=100.0, # Explicit budget limit
|
||||
)
|
||||
|
||||
# Verify the call was made
|
||||
mock_client.post.assert_called_once()
|
||||
call_args = mock_client.post.call_args
|
||||
|
||||
# Verify that max_budget IS in the JSON payload with the correct value
|
||||
json_payload = call_args[1]['json']
|
||||
assert (
|
||||
'max_budget' in json_payload
|
||||
), 'max_budget should be in payload when set to a value'
|
||||
assert json_payload['max_budget'] == 100.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_user_to_team_excludes_max_budget_when_none(self):
|
||||
"""Test that _add_user_to_team does NOT include max_budget_in_team when None."""
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_response = MagicMock()
|
||||
mock_response.is_success = True
|
||||
mock_response.status_code = 200
|
||||
mock_client.post.return_value = mock_response
|
||||
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-api-key'):
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
|
||||
await LiteLlmManager._add_user_to_team(
|
||||
mock_client,
|
||||
keycloak_user_id='test-user-id',
|
||||
team_id='test-team-id',
|
||||
max_budget=None, # None = no budget limit
|
||||
)
|
||||
|
||||
# Verify the call was made
|
||||
mock_client.post.assert_called_once()
|
||||
call_args = mock_client.post.call_args
|
||||
|
||||
# Verify URL
|
||||
assert call_args[0][0] == 'http://test.com/team/member_add'
|
||||
|
||||
# Verify that max_budget_in_team is NOT in the JSON payload
|
||||
json_payload = call_args[1]['json']
|
||||
assert 'max_budget_in_team' not in json_payload, (
|
||||
'max_budget_in_team should NOT be in payload when None '
|
||||
'(omitting it tells LiteLLM to disable budget enforcement)'
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_user_to_team_includes_max_budget_when_set(self):
|
||||
"""Test that _add_user_to_team includes max_budget_in_team when set."""
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_response = MagicMock()
|
||||
mock_response.is_success = True
|
||||
mock_response.status_code = 200
|
||||
mock_client.post.return_value = mock_response
|
||||
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-api-key'):
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
|
||||
await LiteLlmManager._add_user_to_team(
|
||||
mock_client,
|
||||
keycloak_user_id='test-user-id',
|
||||
team_id='test-team-id',
|
||||
max_budget=50.0, # Explicit budget limit
|
||||
)
|
||||
|
||||
# Verify the call was made
|
||||
mock_client.post.assert_called_once()
|
||||
call_args = mock_client.post.call_args
|
||||
|
||||
# Verify that max_budget_in_team IS in the JSON payload
|
||||
json_payload = call_args[1]['json']
|
||||
assert (
|
||||
'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'] == 50.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_in_team_excludes_max_budget_when_none(self):
|
||||
"""Test that _update_user_in_team does NOT include max_budget_in_team when None."""
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_response = MagicMock()
|
||||
mock_response.is_success = True
|
||||
mock_response.status_code = 200
|
||||
mock_client.post.return_value = mock_response
|
||||
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-api-key'):
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
|
||||
await LiteLlmManager._update_user_in_team(
|
||||
mock_client,
|
||||
keycloak_user_id='test-user-id',
|
||||
team_id='test-team-id',
|
||||
max_budget=None, # None = no budget limit
|
||||
)
|
||||
|
||||
# Verify the call was made
|
||||
mock_client.post.assert_called_once()
|
||||
call_args = mock_client.post.call_args
|
||||
|
||||
# Verify URL
|
||||
assert call_args[0][0] == 'http://test.com/team/member_update'
|
||||
|
||||
# Verify that max_budget_in_team is NOT in the JSON payload
|
||||
json_payload = call_args[1]['json']
|
||||
assert 'max_budget_in_team' not in json_payload, (
|
||||
'max_budget_in_team should NOT be in payload when None '
|
||||
'(omitting it tells LiteLLM to disable budget enforcement)'
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_in_team_includes_max_budget_when_set(self):
|
||||
"""Test that _update_user_in_team includes max_budget_in_team when set."""
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_response = MagicMock()
|
||||
mock_response.is_success = True
|
||||
mock_response.status_code = 200
|
||||
mock_client.post.return_value = mock_response
|
||||
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-api-key'):
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
|
||||
await LiteLlmManager._update_user_in_team(
|
||||
mock_client,
|
||||
keycloak_user_id='test-user-id',
|
||||
team_id='test-team-id',
|
||||
max_budget=75.0, # Explicit budget limit
|
||||
)
|
||||
|
||||
# Verify the call was made
|
||||
mock_client.post.assert_called_once()
|
||||
call_args = mock_client.post.call_args
|
||||
|
||||
# Verify that max_budget_in_team IS in the JSON payload
|
||||
json_payload = call_args[1]['json']
|
||||
assert (
|
||||
'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,
|
||||
}
|
||||
|
||||
@@ -5,10 +5,15 @@ from io import StringIO
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from freezegun import freeze_time
|
||||
from server.logger import format_stack, setup_json_logger
|
||||
|
||||
from openhands.core.logger import openhands_logger
|
||||
|
||||
FROZEN_TIMESTAMP = '2024-01-15T10:30:00+00:00'
|
||||
# datetime.now().isoformat() doesn't include timezone info
|
||||
FROZEN_TIMESTAMP_NO_TZ = '2024-01-15T10:30:00'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def log_output():
|
||||
@@ -21,30 +26,45 @@ def log_output():
|
||||
|
||||
|
||||
class TestLogOutput:
|
||||
@freeze_time(FROZEN_TIMESTAMP)
|
||||
def test_info(self, log_output):
|
||||
logger, string_io = log_output
|
||||
|
||||
logger.info('Test message')
|
||||
output = json.loads(string_io.getvalue())
|
||||
assert output == {'message': 'Test message', 'severity': 'INFO'}
|
||||
assert output['message'] == 'Test message'
|
||||
assert output['severity'] == 'INFO'
|
||||
assert output['ts'] == FROZEN_TIMESTAMP
|
||||
assert output['module'] == 'test_logger'
|
||||
assert output['funcName'] == 'test_info'
|
||||
assert 'lineno' in output
|
||||
|
||||
@freeze_time(FROZEN_TIMESTAMP)
|
||||
def test_error(self, log_output):
|
||||
logger, string_io = log_output
|
||||
|
||||
logger.error('Test message')
|
||||
output = json.loads(string_io.getvalue())
|
||||
assert output == {'message': 'Test message', 'severity': 'ERROR'}
|
||||
assert output['message'] == 'Test message'
|
||||
assert output['severity'] == 'ERROR'
|
||||
assert output['ts'] == FROZEN_TIMESTAMP
|
||||
assert output['module'] == 'test_logger'
|
||||
assert output['funcName'] == 'test_error'
|
||||
assert 'lineno' in output
|
||||
|
||||
@freeze_time(FROZEN_TIMESTAMP)
|
||||
def test_extra_fields(self, log_output):
|
||||
logger, string_io = log_output
|
||||
|
||||
logger.info('Test message', extra={'key': '..val..'})
|
||||
output = json.loads(string_io.getvalue())
|
||||
assert output == {
|
||||
'key': '..val..',
|
||||
'message': 'Test message',
|
||||
'severity': 'INFO',
|
||||
}
|
||||
assert output['key'] == '..val..'
|
||||
assert output['message'] == 'Test message'
|
||||
assert output['severity'] == 'INFO'
|
||||
assert output['ts'] == FROZEN_TIMESTAMP
|
||||
assert output['module'] == 'test_logger'
|
||||
assert output['funcName'] == 'test_extra_fields'
|
||||
assert 'lineno' in output
|
||||
|
||||
def test_format_stack(self):
|
||||
stack = (
|
||||
@@ -257,6 +277,7 @@ class TestLogOutput:
|
||||
]
|
||||
assert formatted == expected
|
||||
|
||||
@freeze_time(FROZEN_TIMESTAMP)
|
||||
def test_filtering(self):
|
||||
# Ensure that secret values are still filtered
|
||||
string_io = StringIO()
|
||||
@@ -266,4 +287,63 @@ class TestLogOutput:
|
||||
):
|
||||
openhands_logger.info('The secret key was supersecretvalue')
|
||||
output = json.loads(string_io.getvalue())
|
||||
assert output == {'message': 'The secret key was ******', 'severity': 'INFO'}
|
||||
assert output['message'] == 'The secret key was ******'
|
||||
assert output['severity'] == 'INFO'
|
||||
assert output['ts'] == FROZEN_TIMESTAMP
|
||||
assert 'module' in output
|
||||
assert 'funcName' in output
|
||||
assert 'lineno' in output
|
||||
|
||||
@freeze_time(FROZEN_TIMESTAMP)
|
||||
def test_console_serializer_uses_ts_not_timestamp(self):
|
||||
"""When LOG_JSON_FOR_CONSOLE=1, use 'ts' from custom_json_serializer, not 'timestamp'."""
|
||||
import server.logger as logger_module
|
||||
|
||||
string_io = StringIO()
|
||||
logger = logging.Logger('test_console')
|
||||
|
||||
# Patch LOG_JSON_FOR_CONSOLE to 1 for both setup_json_logger and custom_json_serializer
|
||||
with patch.object(logger_module, 'LOG_JSON_FOR_CONSOLE', 1):
|
||||
setup_json_logger(logger, 'INFO', _out=string_io)
|
||||
logger.info('Test console message')
|
||||
|
||||
# Parse output - LOG_JSON_FOR_CONSOLE pretty-prints JSON across multiple lines
|
||||
output = json.loads(string_io.getvalue())
|
||||
|
||||
# Should have 'ts' from custom_json_serializer but NOT 'timestamp'
|
||||
assert 'ts' in output
|
||||
assert 'timestamp' not in output
|
||||
assert output['message'] == 'Test console message'
|
||||
assert output['severity'] == 'INFO'
|
||||
|
||||
@freeze_time(FROZEN_TIMESTAMP)
|
||||
def test_ts_not_duplicated_when_both_json_modes_enabled(self):
|
||||
"""When both LOG_JSON=1 and LOG_JSON_FOR_CONSOLE=1, 'ts' should appear only once."""
|
||||
import server.logger as logger_module
|
||||
|
||||
string_io = StringIO()
|
||||
logger = logging.Logger('test_both_modes')
|
||||
|
||||
# Patch both LOG_JSON and LOG_JSON_FOR_CONSOLE to 1
|
||||
with (
|
||||
patch.object(logger_module, 'LOG_JSON', True),
|
||||
patch.object(logger_module, 'LOG_JSON_FOR_CONSOLE', 1),
|
||||
):
|
||||
setup_json_logger(logger, 'INFO', _out=string_io)
|
||||
logger.info('Test both modes message')
|
||||
|
||||
raw_output = string_io.getvalue()
|
||||
output = json.loads(raw_output)
|
||||
|
||||
# Should have exactly one 'ts' field (not duplicated)
|
||||
assert 'ts' in output
|
||||
assert 'timestamp' not in output
|
||||
# Verify 'ts' appears only once in the raw output (not duplicated as key)
|
||||
assert (
|
||||
raw_output.count('"ts"') == 1
|
||||
), f"'ts' should appear exactly once, found in: {raw_output}"
|
||||
assert output['message'] == 'Test both modes message'
|
||||
assert output['severity'] == 'INFO'
|
||||
# When LOG_JSON_FOR_CONSOLE=1, custom_json_serializer uses datetime.now().isoformat()
|
||||
# which doesn't include timezone info
|
||||
assert output['ts'] == FROZEN_TIMESTAMP_NO_TZ
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -246,3 +246,82 @@ class TestSaasSecretsStore:
|
||||
assert isinstance(store, SaasSecretsStore)
|
||||
assert store.user_id == 'test-user-id'
|
||||
assert store.config == mock_config
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
'storage.saas_secrets_store.UserStore.get_user_by_id',
|
||||
new_callable=AsyncMock,
|
||||
)
|
||||
async def test_secrets_isolation_between_organizations(
|
||||
self, mock_get_user, secrets_store, mock_user
|
||||
):
|
||||
"""Test that secrets from one organization are not deleted when storing
|
||||
secrets in another organization. This reproduces a bug where switching
|
||||
organizations and creating a secret would delete all secrets from the
|
||||
user's personal workspace."""
|
||||
org1_id = UUID('a1111111-1111-1111-1111-111111111111')
|
||||
org2_id = UUID('b2222222-2222-2222-2222-222222222222')
|
||||
|
||||
# Store secrets in org1 (personal workspace)
|
||||
mock_user.current_org_id = org1_id
|
||||
mock_get_user.return_value = mock_user
|
||||
org1_secrets = Secrets(
|
||||
custom_secrets=MappingProxyType(
|
||||
{
|
||||
'personal_secret': CustomSecret.from_value(
|
||||
{
|
||||
'secret': 'personal_secret_value',
|
||||
'description': 'My personal secret',
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
await secrets_store.store(org1_secrets)
|
||||
|
||||
# Verify org1 secrets are stored
|
||||
loaded_org1 = await secrets_store.load()
|
||||
assert loaded_org1 is not None
|
||||
assert 'personal_secret' in loaded_org1.custom_secrets
|
||||
assert (
|
||||
loaded_org1.custom_secrets['personal_secret'].secret.get_secret_value()
|
||||
== 'personal_secret_value'
|
||||
)
|
||||
|
||||
# Switch to org2 and store secrets there
|
||||
mock_user.current_org_id = org2_id
|
||||
mock_get_user.return_value = mock_user
|
||||
org2_secrets = Secrets(
|
||||
custom_secrets=MappingProxyType(
|
||||
{
|
||||
'org2_secret': CustomSecret.from_value(
|
||||
{'secret': 'org2_secret_value', 'description': 'Org2 secret'}
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
await secrets_store.store(org2_secrets)
|
||||
|
||||
# Verify org2 secrets are stored
|
||||
loaded_org2 = await secrets_store.load()
|
||||
assert loaded_org2 is not None
|
||||
assert 'org2_secret' in loaded_org2.custom_secrets
|
||||
assert (
|
||||
loaded_org2.custom_secrets['org2_secret'].secret.get_secret_value()
|
||||
== 'org2_secret_value'
|
||||
)
|
||||
|
||||
# Switch back to org1 and verify secrets are still there
|
||||
mock_user.current_org_id = org1_id
|
||||
mock_get_user.return_value = mock_user
|
||||
loaded_org1_again = await secrets_store.load()
|
||||
assert loaded_org1_again is not None
|
||||
assert 'personal_secret' in loaded_org1_again.custom_secrets
|
||||
assert (
|
||||
loaded_org1_again.custom_secrets[
|
||||
'personal_secret'
|
||||
].secret.get_secret_value()
|
||||
== 'personal_secret_value'
|
||||
)
|
||||
# Verify org2 secrets are NOT visible in org1
|
||||
assert 'org2_secret' not in loaded_org1_again.custom_secrets
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import time
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import jwt
|
||||
@@ -18,6 +19,7 @@ from server.auth.saas_user_auth import (
|
||||
saas_user_auth_from_cookie,
|
||||
saas_user_auth_from_signed_token,
|
||||
)
|
||||
from storage.api_key_store import ApiKeyValidationResult
|
||||
from storage.user_authorization import UserAuthorizationType
|
||||
|
||||
from openhands.integrations.provider import ProviderToken, ProviderType
|
||||
@@ -457,7 +459,8 @@ async def test_get_instance_no_auth(mock_request):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_saas_user_auth_from_bearer_success():
|
||||
"""Test successful authentication from bearer token."""
|
||||
"""Test successful authentication from bearer token sets user_id and api_key_org_id."""
|
||||
# Arrange
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {'Authorization': 'Bearer test_api_key'}
|
||||
|
||||
@@ -468,12 +471,22 @@ async def test_saas_user_auth_from_bearer_success():
|
||||
algorithm='HS256',
|
||||
)
|
||||
|
||||
mock_org_id = uuid.uuid4()
|
||||
mock_validation_result = ApiKeyValidationResult(
|
||||
user_id='test_user_id',
|
||||
org_id=mock_org_id,
|
||||
key_id=42,
|
||||
key_name='Test Key',
|
||||
)
|
||||
|
||||
with (
|
||||
patch('server.auth.saas_user_auth.ApiKeyStore') as mock_api_key_store_cls,
|
||||
patch('server.auth.saas_user_auth.token_manager') as mock_token_manager,
|
||||
):
|
||||
mock_api_key_store = MagicMock()
|
||||
mock_api_key_store.validate_api_key = AsyncMock(return_value='test_user_id')
|
||||
mock_api_key_store.validate_api_key = AsyncMock(
|
||||
return_value=mock_validation_result
|
||||
)
|
||||
mock_api_key_store_cls.get_instance.return_value = mock_api_key_store
|
||||
|
||||
mock_token_manager.load_offline_token = AsyncMock(return_value=offline_token)
|
||||
@@ -485,6 +498,9 @@ async def test_saas_user_auth_from_bearer_success():
|
||||
|
||||
assert isinstance(result, SaasUserAuth)
|
||||
assert result.user_id == 'test_user_id'
|
||||
assert result.api_key_org_id == mock_org_id
|
||||
assert result.api_key_id == 42
|
||||
assert result.api_key_name == 'Test Key'
|
||||
mock_api_key_store.validate_api_key.assert_called_once_with('test_api_key')
|
||||
mock_token_manager.load_offline_token.assert_called_once_with('test_user_id')
|
||||
mock_token_manager.refresh.assert_called_once_with(offline_token)
|
||||
|
||||
@@ -8,6 +8,9 @@ import os
|
||||
from unittest.mock import patch
|
||||
|
||||
from server.sharing.aws_shared_event_service import AwsSharedEventServiceInjector
|
||||
from server.sharing.filesystem_shared_event_service import (
|
||||
FilesystemSharedEventServiceInjector,
|
||||
)
|
||||
from server.sharing.google_cloud_shared_event_service import (
|
||||
GoogleCloudSharedEventServiceInjector,
|
||||
)
|
||||
@@ -17,8 +20,8 @@ from server.sharing.shared_event_router import get_shared_event_service_injector
|
||||
class TestGetSharedEventServiceInjector:
|
||||
"""Test cases for get_shared_event_service_injector function."""
|
||||
|
||||
def test_defaults_to_google_cloud_when_no_env_set(self):
|
||||
"""Test that GoogleCloudSharedEventServiceInjector is used when no env is set."""
|
||||
def test_defaults_to_filesystem_when_no_env_set(self):
|
||||
"""Test that FilesystemSharedEventServiceInjector is used when no env is set."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{},
|
||||
@@ -29,7 +32,8 @@ class TestGetSharedEventServiceInjector:
|
||||
|
||||
injector = get_shared_event_service_injector()
|
||||
|
||||
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
|
||||
# Default behavior is filesystem storage when nothing is configured
|
||||
assert isinstance(injector, FilesystemSharedEventServiceInjector)
|
||||
|
||||
def test_uses_google_cloud_when_file_store_google_cloud(self):
|
||||
"""Test that GoogleCloudSharedEventServiceInjector is used when FILE_STORE=google_cloud."""
|
||||
@@ -141,8 +145,8 @@ class TestGetSharedEventServiceInjector:
|
||||
|
||||
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
|
||||
|
||||
def test_unknown_provider_defaults_to_google_cloud(self):
|
||||
"""Test that unknown provider defaults to GoogleCloudSharedEventServiceInjector."""
|
||||
def test_unknown_provider_defaults_to_filesystem(self):
|
||||
"""Test that unknown provider defaults to FilesystemSharedEventServiceInjector."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
@@ -152,11 +156,11 @@ class TestGetSharedEventServiceInjector:
|
||||
):
|
||||
injector = get_shared_event_service_injector()
|
||||
|
||||
# Should default to GCP for unknown providers
|
||||
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
|
||||
# Should default to filesystem for unknown providers
|
||||
assert isinstance(injector, FilesystemSharedEventServiceInjector)
|
||||
|
||||
def test_empty_provider_falls_back_to_file_store(self):
|
||||
"""Test that empty SHARED_EVENT_STORAGE_PROVIDER falls back to FILE_STORE."""
|
||||
def test_empty_provider_falls_back_to_file_store_gcp(self):
|
||||
"""Test that empty SHARED_EVENT_STORAGE_PROVIDER falls back to FILE_STORE=google_cloud."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
@@ -167,5 +171,35 @@ class TestGetSharedEventServiceInjector:
|
||||
):
|
||||
injector = get_shared_event_service_injector()
|
||||
|
||||
# Should default to GCP for unknown providers
|
||||
# Should use GCP when FILE_STORE=google_cloud
|
||||
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
|
||||
|
||||
def test_empty_provider_falls_back_to_file_store_s3(self):
|
||||
"""Test that empty SHARED_EVENT_STORAGE_PROVIDER falls back to FILE_STORE=s3."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'SHARED_EVENT_STORAGE_PROVIDER': '',
|
||||
'FILE_STORE': 's3',
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
injector = get_shared_event_service_injector()
|
||||
|
||||
# Should use AWS when FILE_STORE=s3
|
||||
assert isinstance(injector, AwsSharedEventServiceInjector)
|
||||
|
||||
def test_empty_provider_falls_back_to_file_store_filesystem(self):
|
||||
"""Test that empty SHARED_EVENT_STORAGE_PROVIDER falls back to FILE_STORE=filesystem."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'SHARED_EVENT_STORAGE_PROVIDER': '',
|
||||
'FILE_STORE': 'filesystem',
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
injector = get_shared_event_service_injector()
|
||||
|
||||
# Should use filesystem when FILE_STORE=filesystem
|
||||
assert isinstance(injector, FilesystemSharedEventServiceInjector)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { CopyableContentWrapper } from "#/components/shared/buttons/copyable-content-wrapper";
|
||||
|
||||
describe("CopyableContentWrapper", () => {
|
||||
it("should hide the copy button by default", () => {
|
||||
render(
|
||||
<CopyableContentWrapper text="hello">
|
||||
<p>content</p>
|
||||
</CopyableContentWrapper>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("copy-to-clipboard")).not.toBeVisible();
|
||||
});
|
||||
|
||||
it("should show the copy button on hover", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<CopyableContentWrapper text="hello">
|
||||
<p>content</p>
|
||||
</CopyableContentWrapper>,
|
||||
);
|
||||
|
||||
await user.hover(screen.getByText("content"));
|
||||
|
||||
expect(screen.getByTestId("copy-to-clipboard")).toBeVisible();
|
||||
});
|
||||
|
||||
it("should copy text to clipboard on click", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<CopyableContentWrapper text="copy me">
|
||||
<p>content</p>
|
||||
</CopyableContentWrapper>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("copy-to-clipboard"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(navigator.clipboard.readText()).resolves.toBe("copy me"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should show copied state after clicking", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<CopyableContentWrapper text="hello">
|
||||
<p>content</p>
|
||||
</CopyableContentWrapper>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("copy-to-clipboard"));
|
||||
|
||||
expect(screen.getByTestId("copy-to-clipboard")).toHaveAttribute(
|
||||
"aria-label",
|
||||
"BUTTON$COPIED",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ContextMenuContainer } from "#/components/features/context-menu/context-menu-container";
|
||||
|
||||
describe("ContextMenuContainer", () => {
|
||||
const user = userEvent.setup();
|
||||
const onCloseMock = vi.fn();
|
||||
|
||||
it("should render children", () => {
|
||||
render(
|
||||
<ContextMenuContainer onClose={onCloseMock}>
|
||||
<div data-testid="child-1">Child 1</div>
|
||||
<div data-testid="child-2">Child 2</div>
|
||||
</ContextMenuContainer>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("child-1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("child-2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should apply consistent base styling", () => {
|
||||
render(
|
||||
<ContextMenuContainer onClose={onCloseMock} testId="test-container">
|
||||
<div>Content</div>
|
||||
</ContextMenuContainer>,
|
||||
);
|
||||
|
||||
const container = screen.getByTestId("test-container");
|
||||
expect(container).toHaveClass("bg-[#050505]");
|
||||
expect(container).toHaveClass("border");
|
||||
expect(container).toHaveClass("border-[#242424]");
|
||||
expect(container).toHaveClass("rounded-[12px]");
|
||||
expect(container).toHaveClass("p-[25px]");
|
||||
expect(container).toHaveClass("context-menu-box-shadow");
|
||||
});
|
||||
|
||||
it("should call onClose when clicking outside", async () => {
|
||||
render(
|
||||
<ContextMenuContainer onClose={onCloseMock} testId="test-container">
|
||||
<div>Content</div>
|
||||
</ContextMenuContainer>,
|
||||
);
|
||||
|
||||
await user.click(document.body);
|
||||
expect(onCloseMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should render children in a flex row layout", () => {
|
||||
render(
|
||||
<ContextMenuContainer onClose={onCloseMock} testId="test-container">
|
||||
<div data-testid="child-1">Child 1</div>
|
||||
<div data-testid="child-2">Child 2</div>
|
||||
</ContextMenuContainer>,
|
||||
);
|
||||
|
||||
const container = screen.getByTestId("test-container");
|
||||
const innerDiv = container.firstChild as HTMLElement;
|
||||
expect(innerDiv).toHaveClass("flex");
|
||||
expect(innerDiv).toHaveClass("flex-row");
|
||||
expect(innerDiv).toHaveClass("gap-4");
|
||||
});
|
||||
|
||||
it("should apply additional className when provided", () => {
|
||||
render(
|
||||
<ContextMenuContainer
|
||||
onClose={onCloseMock}
|
||||
testId="test-container"
|
||||
className="custom-class"
|
||||
>
|
||||
<div>Content</div>
|
||||
</ContextMenuContainer>,
|
||||
);
|
||||
|
||||
const container = screen.getByTestId("test-container");
|
||||
expect(container).toHaveClass("custom-class");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ContextMenuCTA } from "#/components/features/context-menu/context-menu-cta";
|
||||
|
||||
// Mock useTracking hook
|
||||
const mockTrackSaasSelfhostedInquiry = vi.fn();
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackSaasSelfhostedInquiry: mockTrackSaasSelfhostedInquiry,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("ContextMenuCTA", () => {
|
||||
it("should render the CTA component", () => {
|
||||
render(<ContextMenuCTA />);
|
||||
|
||||
expect(screen.getByText("CTA$ENTERPRISE_TITLE")).toBeInTheDocument();
|
||||
expect(screen.getByText("CTA$ENTERPRISE_DESCRIPTION")).toBeInTheDocument();
|
||||
expect(screen.getByText("CTA$LEARN_MORE")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call trackSaasSelfhostedInquiry with location 'context_menu' when Learn More is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ContextMenuCTA />);
|
||||
|
||||
const learnMoreLink = screen.getByRole("link", {
|
||||
name: "CTA$LEARN_MORE",
|
||||
});
|
||||
await user.click(learnMoreLink);
|
||||
|
||||
expect(mockTrackSaasSelfhostedInquiry).toHaveBeenCalledWith({
|
||||
location: "context_menu",
|
||||
});
|
||||
});
|
||||
|
||||
it("should render Learn More as a link with correct href and target", () => {
|
||||
render(<ContextMenuCTA />);
|
||||
|
||||
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 render the stacked icon", () => {
|
||||
render(<ContextMenuCTA />);
|
||||
|
||||
const contentContainer = screen.getByTestId("context-menu-cta-content");
|
||||
const icon = contentContainer.querySelector("svg");
|
||||
expect(icon).toBeInTheDocument();
|
||||
expect(icon).toHaveAttribute("width", "40");
|
||||
expect(icon).toHaveAttribute("height", "40");
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,16 @@
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { AnalyticsConsentFormModal } from "#/components/features/analytics/analytics-consent-form-modal";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
|
||||
describe("AnalyticsConsentFormModal", () => {
|
||||
beforeEach(() => {
|
||||
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
|
||||
});
|
||||
|
||||
it("should call saveUserSettings with consent", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCloseMock = vi.fn();
|
||||
|
||||
@@ -49,9 +49,17 @@ vi.mock("#/utils/custom-toast-handlers", () => ({
|
||||
displayErrorToast: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock feature flags - we'll control the return value in each test
|
||||
const mockEnableProjUserJourney = vi.fn(() => true);
|
||||
vi.mock("#/utils/feature-flags", () => ({
|
||||
ENABLE_PROJ_USER_JOURNEY: () => mockEnableProjUserJourney(),
|
||||
}));
|
||||
|
||||
describe("LoginContent", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("location", { href: "" });
|
||||
// Reset mock to return true by default
|
||||
mockEnableProjUserJourney.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -274,6 +282,65 @@ describe("LoginContent", () => {
|
||||
expect(screen.getByTestId("terms-and-privacy-notice")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display the enterprise LoginCTA component when appMode is saas and feature flag enabled", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginContent
|
||||
githubAuthUrl="https://github.com/oauth/authorize"
|
||||
appMode="saas"
|
||||
providersConfigured={["github"]}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("login-cta")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not display the enterprise LoginCTA component when appMode is oss even with feature flag enabled", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginContent
|
||||
githubAuthUrl="https://github.com/oauth/authorize"
|
||||
appMode="oss"
|
||||
providersConfigured={["github"]}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("login-cta")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not display the enterprise LoginCTA component when appMode is null", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginContent
|
||||
githubAuthUrl="https://github.com/oauth/authorize"
|
||||
appMode={null}
|
||||
providersConfigured={["github"]}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("login-cta")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not display the enterprise LoginCTA component when feature flag is disabled", () => {
|
||||
// Disable the feature flag
|
||||
mockEnableProjUserJourney.mockReturnValue(false);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginContent
|
||||
githubAuthUrl="https://github.com/oauth/authorize"
|
||||
appMode="saas"
|
||||
providersConfigured={["github"]}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("login-cta")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display invitation pending message when hasInvitation is true", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
|
||||
106
frontend/__tests__/components/features/auth/login-cta.test.tsx
Normal file
106
frontend/__tests__/components/features/auth/login-cta.test.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { LoginCTA } from "#/components/features/auth/login-cta";
|
||||
|
||||
// Mock useTracking hook
|
||||
const mockTrackSaasSelfhostedInquiry = vi.fn();
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackSaasSelfhostedInquiry: mockTrackSaasSelfhostedInquiry,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("LoginCTA", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderWithRouter = (source?: "login_page" | "device_verify") => {
|
||||
const Stub = createRoutesStub([
|
||||
{
|
||||
path: "/",
|
||||
Component: () => <LoginCTA source={source} />,
|
||||
},
|
||||
{
|
||||
path: "/information-request",
|
||||
Component: () => <div data-testid="information-request-page" />,
|
||||
},
|
||||
]);
|
||||
|
||||
return render(<Stub initialEntries={["/"]} />);
|
||||
};
|
||||
|
||||
it("should render enterprise CTA with title and description", () => {
|
||||
renderWithRouter();
|
||||
|
||||
expect(screen.getByTestId("login-cta")).toBeInTheDocument();
|
||||
expect(screen.getByText("CTA$ENTERPRISE")).toBeInTheDocument();
|
||||
expect(screen.getByText("CTA$ENTERPRISE_DEPLOY")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render all enterprise feature list items", () => {
|
||||
renderWithRouter();
|
||||
|
||||
expect(screen.getByText("CTA$FEATURE_ON_PREMISES")).toBeInTheDocument();
|
||||
expect(screen.getByText("CTA$FEATURE_DATA_CONTROL")).toBeInTheDocument();
|
||||
expect(screen.getByText("CTA$FEATURE_COMPLIANCE")).toBeInTheDocument();
|
||||
expect(screen.getByText("CTA$FEATURE_SUPPORT")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should track and navigate to information request page when Learn More is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithRouter();
|
||||
|
||||
const learnMoreLink = screen.getByRole("link", {
|
||||
name: "CTA$LEARN_MORE",
|
||||
});
|
||||
await user.click(learnMoreLink);
|
||||
|
||||
expect(mockTrackSaasSelfhostedInquiry).toHaveBeenCalledWith({
|
||||
location: "login_page",
|
||||
});
|
||||
expect(screen.getByTestId("information-request-page")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render Learn More as a link for Open in New Tab support", () => {
|
||||
renderWithRouter();
|
||||
|
||||
const learnMoreLink = screen.getByRole("link", {
|
||||
name: "CTA$LEARN_MORE",
|
||||
});
|
||||
expect(learnMoreLink).toHaveAttribute(
|
||||
"href",
|
||||
"/information-request",
|
||||
);
|
||||
});
|
||||
|
||||
it("should render external enterprise URL in device verify mode", () => {
|
||||
renderWithRouter("device_verify");
|
||||
|
||||
const learnMoreLink = screen.getByRole("link", {
|
||||
name: "CTA$LEARN_MORE",
|
||||
});
|
||||
expect(learnMoreLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://openhands.dev/enterprise",
|
||||
);
|
||||
expect(learnMoreLink).toHaveAttribute("target", "_blank");
|
||||
expect(learnMoreLink).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
|
||||
it("should track device_verify location when Learn More is clicked in device verify mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithRouter("device_verify");
|
||||
|
||||
const learnMoreLink = screen.getByRole("link", {
|
||||
name: "CTA$LEARN_MORE",
|
||||
});
|
||||
await user.click(learnMoreLink);
|
||||
|
||||
expect(mockTrackSaasSelfhostedInquiry).toHaveBeenCalledWith({
|
||||
location: "device_verify",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,9 +10,12 @@ import {
|
||||
import { OpenHandsObservation } from "#/types/core/observations";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import { Conversation } from "#/api/open-hands.types";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
|
||||
vi.mock("react-router", () => ({
|
||||
vi.mock("react-router", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-router")>()),
|
||||
useParams: () => ({ conversationId: "123" }),
|
||||
useRevalidator: () => ({ revalidate: vi.fn() }),
|
||||
}));
|
||||
|
||||
let queryClient: QueryClient;
|
||||
@@ -47,6 +50,7 @@ const renderMessages = ({
|
||||
describe("Messages", () => {
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient();
|
||||
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
|
||||
});
|
||||
|
||||
const assistantMessage: AssistantMessageAction = {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -11,23 +11,23 @@ vi.mock("posthog-js/react", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const { PROJ_USER_JOURNEY_MOCK } = vi.hoisted(() => ({
|
||||
PROJ_USER_JOURNEY_MOCK: vi.fn(() => true),
|
||||
const { ENABLE_PROJ_USER_JOURNEY_MOCK } = vi.hoisted(() => ({
|
||||
ENABLE_PROJ_USER_JOURNEY_MOCK: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("#/utils/feature-flags", () => ({
|
||||
PROJ_USER_JOURNEY: () => PROJ_USER_JOURNEY_MOCK(),
|
||||
ENABLE_PROJ_USER_JOURNEY: () => ENABLE_PROJ_USER_JOURNEY_MOCK(),
|
||||
}));
|
||||
|
||||
describe("EnterpriseBanner", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
|
||||
ENABLE_PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe("Feature Flag", () => {
|
||||
it("should not render when proj_user_journey feature flag is disabled", () => {
|
||||
PROJ_USER_JOURNEY_MOCK.mockReturnValue(false);
|
||||
ENABLE_PROJ_USER_JOURNEY_MOCK.mockReturnValue(false);
|
||||
|
||||
const { container } = renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
@@ -36,7 +36,7 @@ describe("EnterpriseBanner", () => {
|
||||
});
|
||||
|
||||
it("should render when proj_user_journey feature flag is enabled", () => {
|
||||
PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
|
||||
ENABLE_PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
|
||||
|
||||
renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { HomepageCTA } from "#/components/features/home/homepage-cta";
|
||||
|
||||
// Mock the translation function
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual = await vi.importActual("react-i18next");
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"CTA$ENTERPRISE_TITLE": "Get OpenHands for Enterprise",
|
||||
"CTA$ENTERPRISE_DESCRIPTION":
|
||||
"Cloud allows you to access OpenHands anywhere and coordinate with your team like never before",
|
||||
"CTA$LEARN_MORE": "Learn More",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock local storage
|
||||
vi.mock("#/utils/local-storage", () => ({
|
||||
setCTADismissed: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock useTracking hook
|
||||
const mockTrackSaasSelfhostedInquiry = vi.fn();
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackSaasSelfhostedInquiry: mockTrackSaasSelfhostedInquiry,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { setCTADismissed } from "#/utils/local-storage";
|
||||
|
||||
describe("HomepageCTA", () => {
|
||||
const mockSetShouldShowCTA = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderHomepageCTA = () => {
|
||||
return render(<HomepageCTA setShouldShowCTA={mockSetShouldShowCTA} />);
|
||||
};
|
||||
|
||||
describe("rendering", () => {
|
||||
it("renders the enterprise title", () => {
|
||||
renderHomepageCTA();
|
||||
expect(
|
||||
screen.getByText("Get OpenHands for Enterprise"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the enterprise description", () => {
|
||||
renderHomepageCTA();
|
||||
expect(
|
||||
screen.getByText(/Cloud allows you to access OpenHands anywhere/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the Learn More link", () => {
|
||||
renderHomepageCTA();
|
||||
const link = screen.getByRole("link", { name: "Learn More" });
|
||||
expect(link).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the close button with correct aria-label", () => {
|
||||
renderHomepageCTA();
|
||||
expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("close button behavior", () => {
|
||||
it("calls setCTADismissed with 'homepage' when close button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHomepageCTA();
|
||||
|
||||
const closeButton = screen.getByRole("button", { name: "Close" });
|
||||
await user.click(closeButton);
|
||||
|
||||
expect(setCTADismissed).toHaveBeenCalledWith("homepage");
|
||||
});
|
||||
|
||||
it("calls setShouldShowCTA with false when close button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHomepageCTA();
|
||||
|
||||
const closeButton = screen.getByRole("button", { name: "Close" });
|
||||
await user.click(closeButton);
|
||||
|
||||
expect(mockSetShouldShowCTA).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("calls both setCTADismissed and setShouldShowCTA in order", async () => {
|
||||
const user = userEvent.setup();
|
||||
const callOrder: string[] = [];
|
||||
|
||||
vi.mocked(setCTADismissed).mockImplementation(() => {
|
||||
callOrder.push("setCTADismissed");
|
||||
});
|
||||
mockSetShouldShowCTA.mockImplementation(() => {
|
||||
callOrder.push("setShouldShowCTA");
|
||||
});
|
||||
|
||||
renderHomepageCTA();
|
||||
|
||||
const closeButton = screen.getByRole("button", { name: "Close" });
|
||||
await user.click(closeButton);
|
||||
|
||||
expect(callOrder).toEqual(["setCTADismissed", "setShouldShowCTA"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Learn More link behavior", () => {
|
||||
it("calls trackSaasSelfhostedInquiry with location 'home_page' when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHomepageCTA();
|
||||
|
||||
const learnMoreLink = screen.getByRole("link", { name: "Learn More" });
|
||||
await user.click(learnMoreLink);
|
||||
|
||||
expect(mockTrackSaasSelfhostedInquiry).toHaveBeenCalledWith({
|
||||
location: "home_page",
|
||||
});
|
||||
});
|
||||
|
||||
it("has correct href and target attributes", () => {
|
||||
renderHomepageCTA();
|
||||
|
||||
const learnMoreLink = screen.getByRole("link", { name: "Learn More" });
|
||||
expect(learnMoreLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://openhands.dev/enterprise/",
|
||||
);
|
||||
expect(learnMoreLink).toHaveAttribute("target", "_blank");
|
||||
expect(learnMoreLink).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("accessibility", () => {
|
||||
it("close button is focusable", () => {
|
||||
renderHomepageCTA();
|
||||
const closeButton = screen.getByRole("button", { name: "Close" });
|
||||
expect(closeButton).not.toHaveAttribute("tabindex", "-1");
|
||||
});
|
||||
|
||||
it("Learn More link is focusable", () => {
|
||||
renderHomepageCTA();
|
||||
const learnMoreLink = screen.getByRole("link", { name: "Learn More" });
|
||||
expect(learnMoreLink).not.toHaveAttribute("tabindex", "-1");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -4,12 +4,13 @@ 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";
|
||||
import { RepoConnector } from "#/components/features/home/repo-connector";
|
||||
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
|
||||
const renderRepoConnector = () => {
|
||||
const mockRepoSelection = vi.fn();
|
||||
@@ -65,6 +66,7 @@ const MOCK_RESPOSITORIES: GitRepository[] = [
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
@@ -312,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",
|
||||
@@ -388,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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,426 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { PluginLaunchModal } from "#/components/features/launch/plugin-launch-modal";
|
||||
import { PluginSpec } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
|
||||
const mockOnStartConversation = vi.fn();
|
||||
const mockOnClose = vi.fn();
|
||||
|
||||
function renderModal(
|
||||
plugins: PluginSpec[],
|
||||
props: Partial<{
|
||||
message: string;
|
||||
isLoading: boolean;
|
||||
}> = {},
|
||||
) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PluginLaunchModal
|
||||
plugins={plugins}
|
||||
message={props.message}
|
||||
isLoading={props.isLoading ?? false}
|
||||
onStartConversation={mockOnStartConversation}
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("PluginLaunchModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Plugin Display Name Extraction", () => {
|
||||
it("should extract plugin name from repo_path when provided", () => {
|
||||
renderModal([{ source: "github:owner/repo", repo_path: "plugins/my-plugin" }]);
|
||||
|
||||
// Plugin name should be "my-plugin" from the path
|
||||
expect(screen.getByText("my-plugin")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show repo path when no repo_path (repo IS the plugin)", () => {
|
||||
renderModal([{ source: "github:owner/my-plugin" }]);
|
||||
|
||||
// When no repo_path, the whole repo is the plugin, show "owner/my-plugin"
|
||||
const elements = screen.getAllByText("owner/my-plugin");
|
||||
expect(elements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should extract name from git URL", () => {
|
||||
renderModal([
|
||||
{ source: "https://github.com/owner/repo-name.git" },
|
||||
]);
|
||||
|
||||
const elements = screen.getAllByText("repo-name");
|
||||
expect(elements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should display full source when no special format", () => {
|
||||
renderModal([{ source: "local-plugin" }]);
|
||||
|
||||
const elements = screen.getAllByText("local-plugin");
|
||||
expect(elements.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Modal Title", () => {
|
||||
it("should show plugin name in title for single plugin", () => {
|
||||
renderModal([{ source: "github:owner/awesome-plugin" }]);
|
||||
|
||||
// Title should include the plugin name - use getAllBy since text appears in multiple places
|
||||
const elements = screen.getAllByText(/owner\/awesome-plugin/);
|
||||
expect(elements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should show generic title for multiple plugins", () => {
|
||||
renderModal([
|
||||
{ source: "github:owner/plugin1" },
|
||||
{ source: "github:owner/plugin2" },
|
||||
]);
|
||||
|
||||
// The h2 title contains both LAUNCH$MODAL_TITLE and LAUNCH$MODAL_TITLE_GENERIC
|
||||
const title = screen.getByRole("heading", { level: 2 });
|
||||
expect(title.textContent).toContain("LAUNCH$MODAL_TITLE_GENERIC");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Message Display", () => {
|
||||
it("should display message when provided", () => {
|
||||
renderModal([{ source: "github:owner/repo" }], {
|
||||
message: "This is a custom message",
|
||||
});
|
||||
|
||||
expect(screen.getByText("This is a custom message")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render message element when not provided", () => {
|
||||
renderModal([{ source: "github:owner/repo" }]);
|
||||
|
||||
// No message should be present
|
||||
const modal = screen.getByTestId("plugin-launch-modal");
|
||||
expect(modal.querySelector("p.text-neutral-400")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Expandable Sections", () => {
|
||||
it("should expand plugin section by default when it has parameters", () => {
|
||||
renderModal([
|
||||
{
|
||||
source: "github:owner/repo",
|
||||
parameters: { apiKey: "test-key" },
|
||||
},
|
||||
]);
|
||||
|
||||
// Parameter input should be visible (section is expanded)
|
||||
expect(screen.getByTestId("plugin-0-param-apiKey")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should collapse/expand section when clicking header", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal([
|
||||
{
|
||||
source: "github:owner/repo",
|
||||
parameters: { apiKey: "test-key" },
|
||||
},
|
||||
]);
|
||||
|
||||
// Initially expanded - parameter visible
|
||||
expect(screen.getByTestId("plugin-0-param-apiKey")).toBeInTheDocument();
|
||||
|
||||
// Click to collapse
|
||||
await user.click(screen.getByTestId("plugin-section-0"));
|
||||
|
||||
// Parameter should be hidden
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId("plugin-0-param-apiKey"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click to expand again
|
||||
await user.click(screen.getByTestId("plugin-section-0"));
|
||||
|
||||
// Parameter should be visible again
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("plugin-0-param-apiKey")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Parameter Inputs", () => {
|
||||
it("should render text input for string parameters", () => {
|
||||
renderModal([
|
||||
{
|
||||
source: "github:owner/repo",
|
||||
parameters: { name: "default-name" },
|
||||
},
|
||||
]);
|
||||
|
||||
const input = screen.getByTestId("plugin-0-param-name");
|
||||
expect(input).toHaveAttribute("type", "text");
|
||||
expect(input).toHaveValue("default-name");
|
||||
});
|
||||
|
||||
it("should render number input for number parameters", () => {
|
||||
renderModal([
|
||||
{
|
||||
source: "github:owner/repo",
|
||||
parameters: { count: 42 },
|
||||
},
|
||||
]);
|
||||
|
||||
const input = screen.getByTestId("plugin-0-param-count");
|
||||
expect(input).toHaveAttribute("type", "number");
|
||||
expect(input).toHaveValue(42);
|
||||
});
|
||||
|
||||
it("should render checkbox for boolean parameters", () => {
|
||||
renderModal([
|
||||
{
|
||||
source: "github:owner/repo",
|
||||
parameters: { enabled: true },
|
||||
},
|
||||
]);
|
||||
|
||||
const checkbox = screen.getByTestId("plugin-0-param-enabled");
|
||||
expect(checkbox).toHaveAttribute("type", "checkbox");
|
||||
expect(checkbox).toBeChecked();
|
||||
});
|
||||
|
||||
it("should update string parameter value when typing", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal([
|
||||
{
|
||||
source: "github:owner/repo",
|
||||
parameters: { apiKey: "initial" },
|
||||
},
|
||||
]);
|
||||
|
||||
const input = screen.getByTestId("plugin-0-param-apiKey");
|
||||
await user.clear(input);
|
||||
await user.type(input, "new-value");
|
||||
|
||||
expect(input).toHaveValue("new-value");
|
||||
});
|
||||
|
||||
it("should update number parameter value when typing", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal([
|
||||
{
|
||||
source: "github:owner/repo",
|
||||
parameters: { count: 5 },
|
||||
},
|
||||
]);
|
||||
|
||||
const input = screen.getByTestId("plugin-0-param-count");
|
||||
await user.clear(input);
|
||||
await user.type(input, "100");
|
||||
|
||||
expect(input).toHaveValue(100);
|
||||
});
|
||||
|
||||
it("should toggle boolean parameter when clicking checkbox", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal([
|
||||
{
|
||||
source: "github:owner/repo",
|
||||
parameters: { debug: false },
|
||||
},
|
||||
]);
|
||||
|
||||
const checkbox = screen.getByTestId("plugin-0-param-debug");
|
||||
expect(checkbox).not.toBeChecked();
|
||||
|
||||
await user.click(checkbox);
|
||||
|
||||
expect(checkbox).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ref and Path Display", () => {
|
||||
it("should display ref when provided", async () => {
|
||||
renderModal([
|
||||
{
|
||||
source: "github:owner/repo",
|
||||
ref: "v1.2.3",
|
||||
parameters: { key: "value" },
|
||||
},
|
||||
]);
|
||||
|
||||
// Check the expanded section contains the ref value
|
||||
const modal = screen.getByTestId("plugin-launch-modal");
|
||||
expect(modal.textContent).toContain("v1.2.3");
|
||||
});
|
||||
|
||||
it("should display repo_path when provided", () => {
|
||||
renderModal([
|
||||
{
|
||||
source: "github:owner/repo",
|
||||
repo_path: "plugins/my-plugin",
|
||||
parameters: { key: "value" },
|
||||
},
|
||||
]);
|
||||
|
||||
// Check the expanded section contains the path value
|
||||
const modal = screen.getByTestId("plugin-launch-modal");
|
||||
expect(modal.textContent).toContain("plugins/my-plugin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Plugins Without Parameters", () => {
|
||||
it("should show plugins list when all plugins have no parameters", () => {
|
||||
renderModal([
|
||||
{ source: "github:owner/plugin1" },
|
||||
{ source: "github:owner/plugin2" },
|
||||
]);
|
||||
|
||||
expect(screen.getByText("LAUNCH$PLUGINS")).toBeInTheDocument();
|
||||
// When no repo_path, the full repo path is shown (may appear multiple times)
|
||||
expect(screen.getAllByText("owner/plugin1").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("owner/plugin2").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should show 'Additional Plugins' when mixing plugins with and without params", () => {
|
||||
renderModal([
|
||||
{ source: "github:owner/with-params", parameters: { key: "val" } },
|
||||
{ source: "github:owner/without-params" },
|
||||
]);
|
||||
|
||||
expect(screen.getByText("LAUNCH$ADDITIONAL_PLUGINS")).toBeInTheDocument();
|
||||
// When no repo_path, the full repo path is shown
|
||||
expect(screen.getAllByText("owner/without-params").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should show ref in simple plugin list", () => {
|
||||
renderModal([
|
||||
{ source: "github:owner/plugin", ref: "main" },
|
||||
]);
|
||||
|
||||
expect(screen.getByText("@ main")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show repo_path in simple plugin list", () => {
|
||||
renderModal([
|
||||
{ source: "github:owner/repo", repo_path: "plugins/city-weather" },
|
||||
]);
|
||||
|
||||
// Should show the plugin name
|
||||
expect(screen.getByText("city-weather")).toBeInTheDocument();
|
||||
// Should show the source info with path
|
||||
const modal = screen.getByTestId("plugin-launch-modal");
|
||||
expect(modal.textContent).toContain("owner/repo");
|
||||
expect(modal.textContent).toContain("plugins/city-weather");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Action Buttons", () => {
|
||||
it("should call onClose when close button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal([{ source: "github:owner/repo" }]);
|
||||
|
||||
await user.click(screen.getByTestId("close-button"));
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call onStartConversation with updated plugins when start button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal([
|
||||
{
|
||||
source: "github:owner/repo",
|
||||
ref: "main",
|
||||
parameters: { apiKey: "initial" },
|
||||
},
|
||||
]);
|
||||
|
||||
// Update the parameter
|
||||
const input = screen.getByTestId("plugin-0-param-apiKey");
|
||||
await user.clear(input);
|
||||
await user.type(input, "updated-key");
|
||||
|
||||
// Check the trust checkbox first
|
||||
await user.click(screen.getByTestId("trust-checkbox"));
|
||||
|
||||
// Click start
|
||||
await user.click(screen.getByTestId("start-conversation-button"));
|
||||
|
||||
expect(mockOnStartConversation).toHaveBeenCalledTimes(1);
|
||||
const calledWithPlugins = mockOnStartConversation.mock.calls[0][0];
|
||||
const calledWithMessage = mockOnStartConversation.mock.calls[0][1];
|
||||
expect(calledWithPlugins[0].source).toBe("github:owner/repo");
|
||||
expect(calledWithPlugins[0].ref).toBe("main");
|
||||
expect(calledWithPlugins[0].parameters.apiKey).toBe("updated-key");
|
||||
expect(calledWithMessage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should call onStartConversation with message when provided", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal(
|
||||
[{ source: "github:owner/repo" }],
|
||||
{ message: "/city-weather:now Tokyo" },
|
||||
);
|
||||
|
||||
// Check the trust checkbox first
|
||||
await user.click(screen.getByTestId("trust-checkbox"));
|
||||
|
||||
await user.click(screen.getByTestId("start-conversation-button"));
|
||||
|
||||
expect(mockOnStartConversation).toHaveBeenCalledTimes(1);
|
||||
const calledWithPlugins = mockOnStartConversation.mock.calls[0][0];
|
||||
const calledWithMessage = mockOnStartConversation.mock.calls[0][1];
|
||||
expect(calledWithPlugins[0].source).toBe("github:owner/repo");
|
||||
expect(calledWithMessage).toBe("/city-weather:now Tokyo");
|
||||
});
|
||||
|
||||
it("should show 'Starting...' text when loading", () => {
|
||||
renderModal([{ source: "github:owner/repo" }], { isLoading: true });
|
||||
|
||||
expect(screen.getByText("LAUNCH$STARTING")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should disable start button when loading", () => {
|
||||
renderModal([{ source: "github:owner/repo" }], { isLoading: true });
|
||||
|
||||
expect(screen.getByTestId("start-conversation-button")).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Multiple Plugins with Parameters", () => {
|
||||
it("should render multiple expandable sections for plugins with parameters", () => {
|
||||
renderModal([
|
||||
{ source: "github:owner/plugin1", parameters: { key1: "val1" } },
|
||||
{ source: "github:owner/plugin2", parameters: { key2: "val2" } },
|
||||
]);
|
||||
|
||||
expect(screen.getByTestId("plugin-section-0")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("plugin-section-1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should maintain separate state for each plugin's parameters", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal([
|
||||
{ source: "github:owner/plugin1", parameters: { key1: "val1" } },
|
||||
{ source: "github:owner/plugin2", parameters: { key2: "val2" } },
|
||||
]);
|
||||
|
||||
// Update first plugin's parameter
|
||||
const input1 = screen.getByTestId("plugin-0-param-key1");
|
||||
await user.clear(input1);
|
||||
await user.type(input1, "new-val1");
|
||||
|
||||
// Second plugin's parameter should be unchanged
|
||||
const input2 = screen.getByTestId("plugin-1-param-key2");
|
||||
expect(input2).toHaveValue("val2");
|
||||
|
||||
// First plugin should have new value
|
||||
expect(input1).toHaveValue("new-val1");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { code as Code } from "#/components/features/markdown/code";
|
||||
|
||||
describe("code (markdown)", () => {
|
||||
it("should render inline code without a copy button", () => {
|
||||
render(<Code>inline snippet</Code>);
|
||||
|
||||
expect(screen.getByText("inline snippet")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("copy-to-clipboard")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render a multiline code block with a copy button", () => {
|
||||
render(<Code>{"line1\nline2"}</Code>);
|
||||
|
||||
expect(screen.getByText("line1 line2")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("copy-to-clipboard")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render a syntax-highlighted block with a copy button", () => {
|
||||
render(<Code className="language-js">{"console.log('hi')"}</Code>);
|
||||
|
||||
expect(screen.getByTestId("copy-to-clipboard")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should copy code block content to clipboard", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Code>{"line1\nline2"}</Code>);
|
||||
|
||||
await user.click(screen.getByTestId("copy-to-clipboard"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(navigator.clipboard.readText()).resolves.toBe("line1\nline2"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { EnterpriseCard } from "#/components/features/onboarding/enterprise-card";
|
||||
|
||||
describe("EnterpriseCard", () => {
|
||||
const defaultProps = {
|
||||
icon: <svg data-testid="test-icon" />,
|
||||
title: "Test Title",
|
||||
description: "Test description",
|
||||
features: ["Feature 1", "Feature 2"],
|
||||
learnMoreLabel: "Learn More",
|
||||
onLearnMore: vi.fn(),
|
||||
};
|
||||
|
||||
const renderWithRouter = (props = defaultProps) =>
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<EnterpriseCard {...props} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
it("should render the card with title", () => {
|
||||
renderWithRouter();
|
||||
|
||||
expect(screen.getByText("Test Title")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the description", () => {
|
||||
renderWithRouter();
|
||||
|
||||
expect(screen.getByText("Test description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the icon", () => {
|
||||
renderWithRouter();
|
||||
|
||||
expect(screen.getByTestId("test-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the features", () => {
|
||||
renderWithRouter();
|
||||
|
||||
expect(screen.getByText("Feature 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Feature 2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the learn more link with correct label", () => {
|
||||
renderWithRouter();
|
||||
|
||||
const link = screen.getByRole("link", {
|
||||
name: "Learn More Test Title",
|
||||
});
|
||||
expect(link).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have correct href", () => {
|
||||
renderWithRouter();
|
||||
|
||||
const link = screen.getByRole("link", { name: "Learn More Test Title" });
|
||||
expect(link).toHaveAttribute("href", "/information-request");
|
||||
});
|
||||
|
||||
it("should call onLearnMore when link is clicked", async () => {
|
||||
const mockOnLearnMore = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithRouter({ ...defaultProps, onLearnMore: mockOnLearnMore });
|
||||
|
||||
const link = screen.getByRole("link", { name: "Learn More Test Title" });
|
||||
await user.click(link);
|
||||
|
||||
expect(mockOnLearnMore).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should have correct aria-label on link", () => {
|
||||
renderWithRouter();
|
||||
|
||||
const link = screen.getByRole("link");
|
||||
expect(link).toHaveAttribute("aria-label", "Learn More Test Title");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { FeatureList } from "#/components/features/onboarding/feature-list";
|
||||
|
||||
describe("FeatureList", () => {
|
||||
it("should render a list of features", () => {
|
||||
const features = ["Feature 1", "Feature 2", "Feature 3"];
|
||||
render(<FeatureList features={features} />);
|
||||
|
||||
expect(screen.getByText("Feature 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Feature 2")).toBeInTheDocument();
|
||||
expect(screen.getByText("Feature 3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render bullet points for each feature", () => {
|
||||
const features = ["Feature 1", "Feature 2"];
|
||||
render(<FeatureList features={features} />);
|
||||
|
||||
const bullets = screen.getAllByText("•");
|
||||
expect(bullets).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should render an empty list when no features provided", () => {
|
||||
render(<FeatureList features={[]} />);
|
||||
|
||||
const list = screen.getByRole("list");
|
||||
expect(list).toBeInTheDocument();
|
||||
expect(list.children).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should render each feature as a list item", () => {
|
||||
const features = ["Feature 1", "Feature 2"];
|
||||
render(<FeatureList features={features} />);
|
||||
|
||||
const listItems = screen.getAllByRole("listitem");
|
||||
expect(listItems).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { FormInput } from "#/components/features/onboarding/form-input";
|
||||
|
||||
describe("FormInput", () => {
|
||||
const defaultProps = {
|
||||
id: "test-input",
|
||||
label: "Test Label",
|
||||
value: "",
|
||||
onChange: vi.fn(),
|
||||
};
|
||||
|
||||
it("should render with correct test id", () => {
|
||||
render(<FormInput {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("form-input-test-input")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the label", () => {
|
||||
render(<FormInput {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Test Label")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display the provided value", () => {
|
||||
render(<FormInput {...defaultProps} value="Hello World" />);
|
||||
|
||||
const input = screen.getByTestId("form-input-test-input");
|
||||
expect(input).toHaveValue("Hello World");
|
||||
});
|
||||
|
||||
it("should call onChange when user types", async () => {
|
||||
const mockOnChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<FormInput {...defaultProps} onChange={mockOnChange} />);
|
||||
|
||||
const input = screen.getByTestId("form-input-test-input");
|
||||
await user.type(input, "a");
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith("a");
|
||||
});
|
||||
|
||||
it("should render as a text input by default", () => {
|
||||
render(<FormInput {...defaultProps} />);
|
||||
|
||||
const input = screen.getByTestId("form-input-test-input");
|
||||
expect(input).toHaveAttribute("type", "text");
|
||||
});
|
||||
|
||||
it("should render as an email input when type is email", () => {
|
||||
render(<FormInput {...defaultProps} type="email" />);
|
||||
|
||||
const input = screen.getByTestId("form-input-test-input");
|
||||
expect(input).toHaveAttribute("type", "email");
|
||||
});
|
||||
|
||||
it("should render a textarea when rows prop is provided", () => {
|
||||
render(<FormInput {...defaultProps} rows={4} />);
|
||||
|
||||
const textarea = screen.getByTestId("form-input-test-input");
|
||||
expect(textarea.tagName).toBe("TEXTAREA");
|
||||
expect(textarea).toHaveAttribute("rows", "4");
|
||||
});
|
||||
|
||||
it("should render placeholder text", () => {
|
||||
render(<FormInput {...defaultProps} placeholder="Enter text here" />);
|
||||
|
||||
const input = screen.getByTestId("form-input-test-input");
|
||||
expect(input).toHaveAttribute("placeholder", "Enter text here");
|
||||
});
|
||||
|
||||
it("should have aria-required attribute when required", () => {
|
||||
render(<FormInput {...defaultProps} required />);
|
||||
|
||||
const input = screen.getByTestId("form-input-test-input");
|
||||
expect(input).toHaveAttribute("aria-required", "true");
|
||||
});
|
||||
|
||||
it("should have aria-label attribute", () => {
|
||||
render(<FormInput {...defaultProps} />);
|
||||
|
||||
const input = screen.getByTestId("form-input-test-input");
|
||||
expect(input).toHaveAttribute("aria-label", "Test Label");
|
||||
});
|
||||
|
||||
it("should have required attribute on input when required", () => {
|
||||
render(<FormInput {...defaultProps} required />);
|
||||
|
||||
const input = screen.getByTestId("form-input-test-input");
|
||||
expect(input).toBeRequired();
|
||||
});
|
||||
|
||||
it("should have required attribute on textarea when required", () => {
|
||||
render(<FormInput {...defaultProps} rows={4} required />);
|
||||
|
||||
const textarea = screen.getByTestId("form-input-test-input");
|
||||
expect(textarea).toBeRequired();
|
||||
});
|
||||
|
||||
it("should associate label with input via htmlFor", () => {
|
||||
render(<FormInput {...defaultProps} />);
|
||||
|
||||
const label = screen.getByText("Test Label");
|
||||
const input = screen.getByTestId("form-input-test-input");
|
||||
|
||||
expect(label).toHaveAttribute("for", "form-input-test-input");
|
||||
expect(input).toHaveAttribute("id", "form-input-test-input");
|
||||
});
|
||||
|
||||
describe("error state", () => {
|
||||
it("should have aria-invalid true when showing error", () => {
|
||||
render(<FormInput {...defaultProps} required showError />);
|
||||
|
||||
const input = screen.getByTestId("form-input-test-input");
|
||||
expect(input).toHaveAttribute("aria-invalid", "true");
|
||||
});
|
||||
|
||||
it("should have aria-invalid false when not showing error", () => {
|
||||
render(<FormInput {...defaultProps} required showError={false} />);
|
||||
|
||||
const input = screen.getByTestId("form-input-test-input");
|
||||
expect(input).toHaveAttribute("aria-invalid", "false");
|
||||
});
|
||||
|
||||
it("should have aria-invalid false when showError is true but field has value", () => {
|
||||
render(<FormInput {...defaultProps} required showError value="filled" />);
|
||||
|
||||
const input = screen.getByTestId("form-input-test-input");
|
||||
expect(input).toHaveAttribute("aria-invalid", "false");
|
||||
});
|
||||
|
||||
it("should have aria-invalid false when showError is true but field is not required", () => {
|
||||
render(<FormInput {...defaultProps} required={false} showError />);
|
||||
|
||||
const input = screen.getByTestId("form-input-test-input");
|
||||
expect(input).toHaveAttribute("aria-invalid", "false");
|
||||
});
|
||||
|
||||
it("should have aria-invalid true on textarea when showError is true and empty", () => {
|
||||
render(<FormInput {...defaultProps} rows={4} required showError />);
|
||||
|
||||
const textarea = screen.getByTestId("form-input-test-input");
|
||||
expect(textarea).toHaveAttribute("aria-invalid", "true");
|
||||
});
|
||||
|
||||
it("should have aria-invalid true for invalid email when showError is true", () => {
|
||||
render(
|
||||
<FormInput {...defaultProps} type="email" value="invalid" showError />,
|
||||
);
|
||||
|
||||
const input = screen.getByTestId("form-input-test-input");
|
||||
expect(input).toHaveAttribute("aria-invalid", "true");
|
||||
});
|
||||
|
||||
it("should have aria-invalid false for valid email when showError is true", () => {
|
||||
render(
|
||||
<FormInput
|
||||
{...defaultProps}
|
||||
type="email"
|
||||
value="test@example.com"
|
||||
showError
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByTestId("form-input-test-input");
|
||||
expect(input).toHaveAttribute("aria-invalid", "false");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,367 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
InformationRequestForm,
|
||||
RequestType,
|
||||
} from "#/components/features/onboarding/information-request-form";
|
||||
import { EnterpriseFormData } from "#/utils/local-storage";
|
||||
|
||||
// Mock useTracking
|
||||
const mockTrackEnterpriseLeadFormSubmitted = vi.fn();
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackEnterpriseLeadFormSubmitted: mockTrackEnterpriseLeadFormSubmitted,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockOnBack = vi.fn();
|
||||
|
||||
// Wrapper to manage form state (needed since component is controlled)
|
||||
function StatefulForm({ requestType }: { requestType: RequestType }) {
|
||||
const [formData, setFormData] = useState<EnterpriseFormData>({ name: "", company: "", email: "", message: "" });
|
||||
return <InformationRequestForm requestType={requestType} formData={formData} onFormDataChange={setFormData} onBack={mockOnBack} />;
|
||||
}
|
||||
|
||||
describe("InformationRequestForm", () => {
|
||||
const defaultProps = {
|
||||
requestType: "saas" as RequestType,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockOnBack.mockClear();
|
||||
});
|
||||
|
||||
const renderWithRouter = (props = defaultProps) => {
|
||||
const Stub = createRoutesStub([
|
||||
{
|
||||
path: "/",
|
||||
Component: () => <StatefulForm {...props} />,
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
Component: () => <div data-testid="login-page" />,
|
||||
},
|
||||
{
|
||||
path: "/information-request",
|
||||
Component: () => <div data-testid="information-request-page" />,
|
||||
},
|
||||
]);
|
||||
|
||||
return render(<Stub initialEntries={["/"]} />);
|
||||
};
|
||||
|
||||
it("should render the form", () => {
|
||||
renderWithRouter();
|
||||
|
||||
expect(screen.getByTestId("information-request-form")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the logo", () => {
|
||||
renderWithRouter();
|
||||
|
||||
const logo = screen.getByTestId("information-request-form").querySelector("svg");
|
||||
expect(logo).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render all form fields", () => {
|
||||
renderWithRouter();
|
||||
|
||||
expect(screen.getByTestId("form-input-name")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("form-input-company")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("form-input-email")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("form-input-message")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render SaaS-specific title when requestType is saas", () => {
|
||||
renderWithRouter({ ...defaultProps, requestType: "saas" });
|
||||
|
||||
expect(screen.getByText("ENTERPRISE$FORM_SAAS_TITLE")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render Self-hosted-specific title when requestType is self-hosted", () => {
|
||||
renderWithRouter({ ...defaultProps, requestType: "self-hosted" });
|
||||
|
||||
expect(screen.getByText("ENTERPRISE$FORM_SELF_HOSTED_TITLE")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render cloud icon for SaaS request type", () => {
|
||||
renderWithRouter({ ...defaultProps, requestType: "saas" });
|
||||
|
||||
// The card should contain the cloud icon
|
||||
const card = screen.getByText("ENTERPRISE$SAAS_TITLE").closest("div");
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render stacked icon for self-hosted request type", () => {
|
||||
renderWithRouter({ ...defaultProps, requestType: "self-hosted" });
|
||||
|
||||
// The card should contain the stacked icon
|
||||
const card = screen.getByText("ENTERPRISE$SELF_HOSTED_TITLE").closest("div");
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onBack when back button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithRouter();
|
||||
|
||||
const backButton = screen.getByRole("button", { name: "COMMON$BACK" });
|
||||
await user.click(backButton);
|
||||
|
||||
expect(mockOnBack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should update form fields when user types", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithRouter();
|
||||
|
||||
const nameInput = screen.getByTestId("form-input-name");
|
||||
await user.type(nameInput, "John Doe");
|
||||
|
||||
expect(nameInput).toHaveValue("John Doe");
|
||||
});
|
||||
|
||||
it("should update email field when user types", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithRouter();
|
||||
|
||||
const emailInput = screen.getByTestId("form-input-email");
|
||||
await user.type(emailInput, "john@example.com");
|
||||
|
||||
expect(emailInput).toHaveValue("john@example.com");
|
||||
});
|
||||
|
||||
it("should render message as textarea", () => {
|
||||
renderWithRouter();
|
||||
|
||||
const messageInput = screen.getByTestId("form-input-message");
|
||||
expect(messageInput.tagName).toBe("TEXTAREA");
|
||||
});
|
||||
|
||||
it("should have all fields marked as required", () => {
|
||||
renderWithRouter();
|
||||
|
||||
expect(screen.getByTestId("form-input-name")).toBeRequired();
|
||||
expect(screen.getByTestId("form-input-company")).toBeRequired();
|
||||
expect(screen.getByTestId("form-input-email")).toBeRequired();
|
||||
expect(screen.getByTestId("form-input-message")).toBeRequired();
|
||||
});
|
||||
|
||||
it("should render submit button", () => {
|
||||
renderWithRouter();
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: "ENTERPRISE$FORM_SUBMIT" });
|
||||
expect(submitButton).toBeInTheDocument();
|
||||
expect(submitButton).toHaveAttribute("type", "submit");
|
||||
});
|
||||
|
||||
it("should render back button", () => {
|
||||
renderWithRouter();
|
||||
|
||||
const backButton = screen.getByRole("button", { name: "COMMON$BACK" });
|
||||
expect(backButton).toBeInTheDocument();
|
||||
expect(backButton).toHaveAttribute("type", "button");
|
||||
});
|
||||
|
||||
it("should have button group with role and aria-label", () => {
|
||||
renderWithRouter();
|
||||
|
||||
const buttonGroup = screen.getByRole("group", { name: "Form actions" });
|
||||
expect(buttonGroup).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display SaaS card description for saas request type", () => {
|
||||
renderWithRouter({ ...defaultProps, requestType: "saas" });
|
||||
|
||||
expect(screen.getByText("ENTERPRISE$SAAS_DESCRIPTION")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display Self-hosted card description for self-hosted request type", () => {
|
||||
renderWithRouter({ ...defaultProps, requestType: "self-hosted" });
|
||||
|
||||
expect(screen.getByText("ENTERPRISE$SELF_HOSTED_DESCRIPTION")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("form validation", () => {
|
||||
it("should not show error state before form submission", () => {
|
||||
renderWithRouter();
|
||||
|
||||
const nameInput = screen.getByTestId("form-input-name");
|
||||
const companyInput = screen.getByTestId("form-input-company");
|
||||
const emailInput = screen.getByTestId("form-input-email");
|
||||
const messageInput = screen.getByTestId("form-input-message");
|
||||
|
||||
expect(nameInput).toHaveAttribute("aria-invalid", "false");
|
||||
expect(companyInput).toHaveAttribute("aria-invalid", "false");
|
||||
expect(emailInput).toHaveAttribute("aria-invalid", "false");
|
||||
expect(messageInput).toHaveAttribute("aria-invalid", "false");
|
||||
});
|
||||
|
||||
it("should not navigate when form is submitted with empty fields", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithRouter();
|
||||
|
||||
const submitButton = screen.getByRole("button", {
|
||||
name: "ENTERPRISE$FORM_SUBMIT",
|
||||
});
|
||||
await user.click(submitButton);
|
||||
|
||||
// Should stay on form page, not navigate to login
|
||||
expect(screen.getByTestId("information-request-form")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("login-page")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not call tracking when form is submitted with empty fields", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithRouter();
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: "ENTERPRISE$FORM_SUBMIT" });
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(mockTrackEnterpriseLeadFormSubmitted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should navigate to login page when form is submitted with all fields filled", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithRouter();
|
||||
|
||||
await user.type(screen.getByTestId("form-input-name"), "John Doe");
|
||||
await user.type(screen.getByTestId("form-input-company"), "Acme Inc");
|
||||
await user.type(screen.getByTestId("form-input-email"), "john@example.com");
|
||||
await user.type(screen.getByTestId("form-input-message"), "Hello world");
|
||||
|
||||
const submitButton = screen.getByRole("button", {
|
||||
name: "ENTERPRISE$FORM_SUBMIT",
|
||||
});
|
||||
await user.click(submitButton);
|
||||
|
||||
// Should navigate to login page
|
||||
expect(screen.getByTestId("login-page")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call tracking with form data when form is submitted successfully", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithRouter({ ...defaultProps, requestType: "saas" });
|
||||
|
||||
await user.type(screen.getByTestId("form-input-name"), "John Doe");
|
||||
await user.type(screen.getByTestId("form-input-company"), "Acme Inc");
|
||||
await user.type(screen.getByTestId("form-input-email"), "john@example.com");
|
||||
await user.type(screen.getByTestId("form-input-message"), "Hello world");
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: "ENTERPRISE$FORM_SUBMIT" });
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(mockTrackEnterpriseLeadFormSubmitted).toHaveBeenCalledTimes(1);
|
||||
expect(mockTrackEnterpriseLeadFormSubmitted).toHaveBeenCalledWith({
|
||||
requestType: "saas",
|
||||
name: "John Doe",
|
||||
company: "Acme Inc",
|
||||
email: "john@example.com",
|
||||
message: "Hello world",
|
||||
});
|
||||
});
|
||||
|
||||
it("should call tracking with self-hosted request type", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithRouter({ ...defaultProps, requestType: "self-hosted" });
|
||||
|
||||
await user.type(screen.getByTestId("form-input-name"), "Jane Smith");
|
||||
await user.type(screen.getByTestId("form-input-company"), "Tech Corp");
|
||||
await user.type(screen.getByTestId("form-input-email"), "jane@techcorp.com");
|
||||
await user.type(screen.getByTestId("form-input-message"), "Interested in self-hosted");
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: "ENTERPRISE$FORM_SUBMIT" });
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(mockTrackEnterpriseLeadFormSubmitted).toHaveBeenCalledWith({
|
||||
requestType: "self-hosted",
|
||||
name: "Jane Smith",
|
||||
company: "Tech Corp",
|
||||
email: "jane@techcorp.com",
|
||||
message: "Interested in self-hosted",
|
||||
});
|
||||
});
|
||||
|
||||
it("should trim whitespace from form fields before tracking", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithRouter();
|
||||
|
||||
await user.type(screen.getByTestId("form-input-name"), " John Doe ");
|
||||
await user.type(screen.getByTestId("form-input-company"), " Acme Inc ");
|
||||
await user.type(screen.getByTestId("form-input-email"), " john@example.com ");
|
||||
await user.type(screen.getByTestId("form-input-message"), " Hello world ");
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: "ENTERPRISE$FORM_SUBMIT" });
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(mockTrackEnterpriseLeadFormSubmitted).toHaveBeenCalledWith({
|
||||
requestType: "saas",
|
||||
name: "John Doe",
|
||||
company: "Acme Inc",
|
||||
email: "john@example.com",
|
||||
message: "Hello world",
|
||||
});
|
||||
});
|
||||
|
||||
it("should have valid aria-invalid state when field has value", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithRouter();
|
||||
|
||||
const nameInput = screen.getByTestId("form-input-name");
|
||||
await user.type(nameInput, "John Doe");
|
||||
|
||||
// Field with value should not be invalid
|
||||
expect(nameInput).toHaveAttribute("aria-invalid", "false");
|
||||
});
|
||||
|
||||
it("should not navigate when email is invalid", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithRouter();
|
||||
|
||||
await user.type(screen.getByTestId("form-input-name"), "John Doe");
|
||||
await user.type(screen.getByTestId("form-input-company"), "Acme Inc");
|
||||
await user.type(screen.getByTestId("form-input-email"), "invalid-email");
|
||||
await user.type(screen.getByTestId("form-input-message"), "Hello world");
|
||||
|
||||
const submitButton = screen.getByRole("button", {
|
||||
name: "ENTERPRISE$FORM_SUBMIT",
|
||||
});
|
||||
await user.click(submitButton);
|
||||
|
||||
// Should stay on form page, not navigate to login
|
||||
expect(screen.getByTestId("information-request-form")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("login-page")).not.toBeInTheDocument();
|
||||
expect(mockTrackEnterpriseLeadFormSubmitted).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("loading state", () => {
|
||||
it("should prevent double submission", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithRouter();
|
||||
|
||||
await user.type(screen.getByTestId("form-input-name"), "John Doe");
|
||||
await user.type(screen.getByTestId("form-input-company"), "Acme Inc");
|
||||
await user.type(screen.getByTestId("form-input-email"), "john@example.com");
|
||||
await user.type(screen.getByTestId("form-input-message"), "Hello world");
|
||||
|
||||
const submitButton = screen.getByRole("button", {
|
||||
name: "ENTERPRISE$FORM_SUBMIT",
|
||||
});
|
||||
|
||||
// Click multiple times rapidly
|
||||
await user.click(submitButton);
|
||||
await user.click(submitButton);
|
||||
await user.click(submitButton);
|
||||
|
||||
// Should only track once
|
||||
expect(mockTrackEnterpriseLeadFormSubmitted).toHaveBeenCalledTimes(1);
|
||||
// Should navigate to login page
|
||||
expect(screen.getByTestId("login-page")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,8 @@ import OnboardingForm from "#/routes/onboarding-form";
|
||||
|
||||
const mockMutate = vi.fn();
|
||||
const mockNavigate = vi.fn();
|
||||
const mockUseConfig = vi.fn();
|
||||
const mockTrackOnboardingCompleted = vi.fn();
|
||||
|
||||
vi.mock("react-router", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("react-router")>();
|
||||
@@ -22,6 +24,16 @@ vi.mock("#/hooks/mutation/use-submit-onboarding", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-config", () => ({
|
||||
useConfig: () => mockUseConfig(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackOnboardingCompleted: mockTrackOnboardingCompleted,
|
||||
}),
|
||||
}));
|
||||
|
||||
const renderOnboardingForm = () => {
|
||||
return renderWithProviders(
|
||||
<MemoryRouter>
|
||||
@@ -30,10 +42,15 @@ const renderOnboardingForm = () => {
|
||||
);
|
||||
};
|
||||
|
||||
describe("OnboardingForm", () => {
|
||||
describe("OnboardingForm - SaaS Mode", () => {
|
||||
beforeEach(() => {
|
||||
mockMutate.mockClear();
|
||||
mockNavigate.mockClear();
|
||||
mockTrackOnboardingCompleted.mockClear();
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: { app_mode: "saas" },
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should render with the correct test id", () => {
|
||||
@@ -50,7 +67,7 @@ describe("OnboardingForm", () => {
|
||||
expect(screen.getByTestId("step-actions")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display step progress indicator with 3 bars", () => {
|
||||
it("should display step progress indicator with 3 bars for saas mode", () => {
|
||||
renderOnboardingForm();
|
||||
|
||||
const stepHeader = screen.getByTestId("step-header");
|
||||
@@ -69,7 +86,7 @@ describe("OnboardingForm", () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByTestId("step-option-solo"));
|
||||
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
@@ -84,7 +101,7 @@ describe("OnboardingForm", () => {
|
||||
let progressBars = stepHeader.querySelectorAll(".bg-white");
|
||||
expect(progressBars).toHaveLength(1);
|
||||
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByTestId("step-option-solo"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// On step 2, first two progress bars should be filled
|
||||
@@ -96,7 +113,7 @@ describe("OnboardingForm", () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByTestId("step-option-solo"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
@@ -107,29 +124,51 @@ describe("OnboardingForm", () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
// Step 1 - select role
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Step 2 - select org size
|
||||
// Step 1 - select org size (first step in saas mode - single select)
|
||||
await user.click(screen.getByTestId("step-option-org_2_10"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Step 3 - select use case
|
||||
// Step 2 - select use case (multi-select)
|
||||
await user.click(screen.getByTestId("step-option-new_features"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Step 3 - select role (last step in saas mode - single select)
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByRole("button", { name: /finish/i }));
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockMutate).toHaveBeenCalledWith({
|
||||
selections: {
|
||||
step1: "software_engineer",
|
||||
step2: "org_2_10",
|
||||
step3: "new_features",
|
||||
org_size: "org_2_10",
|
||||
use_case: ["new_features"],
|
||||
role: "software_engineer",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should render 6 options on step 1", () => {
|
||||
it("should track onboarding completion to PostHog in SaaS mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
// Complete the full SaaS onboarding flow
|
||||
await user.click(screen.getByTestId("step-option-org_2_10"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
await user.click(screen.getByTestId("step-option-new_features"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByRole("button", { name: /finish/i }));
|
||||
|
||||
expect(mockTrackOnboardingCompleted).toHaveBeenCalledTimes(1);
|
||||
expect(mockTrackOnboardingCompleted).toHaveBeenCalledWith({
|
||||
role: "software_engineer",
|
||||
orgSize: "org_2_10",
|
||||
useCase: ["new_features"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should render 5 options on step 1 (org size question)", () => {
|
||||
renderOnboardingForm();
|
||||
|
||||
const options = screen
|
||||
@@ -137,31 +176,86 @@ describe("OnboardingForm", () => {
|
||||
.filter((btn) =>
|
||||
btn.getAttribute("data-testid")?.startsWith("step-option-"),
|
||||
);
|
||||
expect(options).toHaveLength(6);
|
||||
expect(options).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("should preserve selections when navigating through steps", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
// Select role on step 1
|
||||
await user.click(screen.getByTestId("step-option-cto_founder"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Select org size on step 2
|
||||
// Select org size on step 1 (single select)
|
||||
await user.click(screen.getByTestId("step-option-solo"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Select use case on step 3
|
||||
// Select use case on step 2 (multi-select)
|
||||
await user.click(screen.getByTestId("step-option-fixing_bugs"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Select role on step 3 (single select)
|
||||
await user.click(screen.getByTestId("step-option-cto_founder"));
|
||||
await user.click(screen.getByRole("button", { name: /finish/i }));
|
||||
|
||||
// Verify all selections were preserved
|
||||
expect(mockMutate).toHaveBeenCalledWith({
|
||||
selections: {
|
||||
step1: "cto_founder",
|
||||
step2: "solo",
|
||||
step3: "fixing_bugs",
|
||||
org_size: "solo",
|
||||
use_case: ["fixing_bugs"],
|
||||
role: "cto_founder",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow selecting multiple options on multi-select steps", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
// Step 1 - select org size (single select)
|
||||
await user.click(screen.getByTestId("step-option-solo"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Step 2 - select multiple use cases (multi-select)
|
||||
await user.click(screen.getByTestId("step-option-new_features"));
|
||||
await user.click(screen.getByTestId("step-option-fixing_bugs"));
|
||||
await user.click(screen.getByTestId("step-option-refactoring"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Step 3 - select role (single select)
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByRole("button", { name: /finish/i }));
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith({
|
||||
selections: {
|
||||
org_size: "solo",
|
||||
use_case: ["new_features", "fixing_bugs", "refactoring"],
|
||||
role: "software_engineer",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow deselecting options on multi-select steps", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
// Step 1 - select org size
|
||||
await user.click(screen.getByTestId("step-option-solo"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Step 2 - select and deselect use cases
|
||||
await user.click(screen.getByTestId("step-option-new_features"));
|
||||
await user.click(screen.getByTestId("step-option-fixing_bugs"));
|
||||
await user.click(screen.getByTestId("step-option-new_features")); // Deselect
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Step 3 - select role
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByRole("button", { name: /finish/i }));
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith({
|
||||
selections: {
|
||||
org_size: "solo",
|
||||
use_case: ["fixing_bugs"],
|
||||
role: "software_engineer",
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -171,10 +265,10 @@ describe("OnboardingForm", () => {
|
||||
renderOnboardingForm();
|
||||
|
||||
// Navigate to step 3
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByTestId("step-option-solo"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
await user.click(screen.getByTestId("step-option-solo"));
|
||||
await user.click(screen.getByTestId("step-option-new_features"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// On step 3, all three progress bars should be filled
|
||||
@@ -194,7 +288,7 @@ describe("OnboardingForm", () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByTestId("step-option-solo"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
const backButton = screen.getByRole("button", { name: /back/i });
|
||||
@@ -206,7 +300,7 @@ describe("OnboardingForm", () => {
|
||||
renderOnboardingForm();
|
||||
|
||||
// Navigate to step 2
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByTestId("step-option-solo"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Verify we're on step 2 (2 progress bars filled)
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { RequestSubmittedModal } from "#/components/features/onboarding/request-submitted-modal";
|
||||
|
||||
describe("RequestSubmittedModal", () => {
|
||||
const defaultProps = {
|
||||
onClose: vi.fn(),
|
||||
};
|
||||
|
||||
it("should render the modal", () => {
|
||||
render(<RequestSubmittedModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("request-submitted-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the title", () => {
|
||||
render(<RequestSubmittedModal {...defaultProps} />);
|
||||
|
||||
expect(
|
||||
screen.getByText("ENTERPRISE$REQUEST_SUBMITTED_TITLE"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the description", () => {
|
||||
render(<RequestSubmittedModal {...defaultProps} />);
|
||||
|
||||
expect(
|
||||
screen.getByText("ENTERPRISE$REQUEST_SUBMITTED_DESCRIPTION"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the Done button", () => {
|
||||
render(<RequestSubmittedModal {...defaultProps} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: "ENTERPRISE$DONE_BUTTON" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the close button", () => {
|
||||
render(<RequestSubmittedModal {...defaultProps} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: "MODAL$CLOSE_BUTTON_LABEL" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onClose when Done button is clicked", async () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<RequestSubmittedModal onClose={mockOnClose} />);
|
||||
|
||||
const doneButton = screen.getByRole("button", {
|
||||
name: "ENTERPRISE$DONE_BUTTON",
|
||||
});
|
||||
await user.click(doneButton);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call onClose when close button is clicked", async () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<RequestSubmittedModal onClose={mockOnClose} />);
|
||||
|
||||
const closeButton = screen.getByRole("button", {
|
||||
name: "MODAL$CLOSE_BUTTON_LABEL",
|
||||
});
|
||||
await user.click(closeButton);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call onClose when Escape key is pressed", async () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<RequestSubmittedModal onClose={mockOnClose} />);
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call onClose when backdrop is clicked", async () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<RequestSubmittedModal onClose={mockOnClose} />);
|
||||
|
||||
// Click on the backdrop (the semi-transparent overlay)
|
||||
const backdrop = screen.getByRole("dialog").querySelector(".bg-black");
|
||||
if (backdrop) {
|
||||
await user.click(backdrop);
|
||||
}
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should have proper accessibility attributes", () => {
|
||||
render(<RequestSubmittedModal {...defaultProps} />);
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog).toHaveAttribute("aria-modal", "true");
|
||||
expect(dialog).toHaveAttribute(
|
||||
"aria-label",
|
||||
"ENTERPRISE$REQUEST_SUBMITTED_TITLE",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,7 @@ describe("StepContent", () => {
|
||||
|
||||
const defaultProps = {
|
||||
options: mockOptions,
|
||||
selectedOptionId: null,
|
||||
selectedOptionIds: [],
|
||||
onSelectOption: vi.fn(),
|
||||
};
|
||||
|
||||
@@ -44,7 +44,7 @@ describe("StepContent", () => {
|
||||
});
|
||||
|
||||
it("should mark the selected option as selected", () => {
|
||||
render(<StepContent {...defaultProps} selectedOptionId="option1" />);
|
||||
render(<StepContent {...defaultProps} selectedOptionIds={["option1"]} />);
|
||||
|
||||
const selectedOption = screen.getByTestId("step-option-option1");
|
||||
const unselectedOption = screen.getByTestId("step-option-option2");
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { AddCreditsModal } from "#/components/features/org/add-credits-modal";
|
||||
import BillingService from "#/api/billing-service/billing-service.api";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: {
|
||||
changeLanguage: vi.fn(),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("AddCreditsModal", () => {
|
||||
const onCloseMock = vi.fn();
|
||||
|
||||
const renderModal = () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<AddCreditsModal onClose={onCloseMock} />);
|
||||
return { user };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Rendering", () => {
|
||||
it("should render the form with correct elements", () => {
|
||||
renderModal();
|
||||
|
||||
expect(screen.getByTestId("add-credits-form")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("amount-input")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /ORG\$NEXT/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display the title", () => {
|
||||
renderModal();
|
||||
|
||||
expect(screen.getByText("ORG$ADD_CREDITS")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Button State Management", () => {
|
||||
it("should enable submit button initially when modal opens", () => {
|
||||
renderModal();
|
||||
|
||||
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should enable submit button when input contains invalid value", async () => {
|
||||
const { user } = renderModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||
|
||||
await user.type(amountInput, "-50");
|
||||
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should enable submit button when input contains valid value", async () => {
|
||||
const { user } = renderModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||
|
||||
await user.type(amountInput, "100");
|
||||
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should enable submit button after validation error is shown", async () => {
|
||||
const { user } = renderModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||
|
||||
await user.type(amountInput, "9");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("amount-error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Input Attributes & Placeholder", () => {
|
||||
it("should have min attribute set to 10", () => {
|
||||
renderModal();
|
||||
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
expect(amountInput).toHaveAttribute("min", "10");
|
||||
});
|
||||
|
||||
it("should have max attribute set to 25000", () => {
|
||||
renderModal();
|
||||
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
expect(amountInput).toHaveAttribute("max", "25000");
|
||||
});
|
||||
|
||||
it("should have step attribute set to 1", () => {
|
||||
renderModal();
|
||||
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
expect(amountInput).toHaveAttribute("step", "1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Message Display", () => {
|
||||
it("should not display error message initially when modal opens", () => {
|
||||
renderModal();
|
||||
|
||||
const errorMessage = screen.queryByTestId("amount-error");
|
||||
expect(errorMessage).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display error message after submitting amount above maximum", async () => {
|
||||
const { user } = renderModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||
|
||||
await user.type(amountInput, "25001");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MAXIMUM_AMOUNT");
|
||||
});
|
||||
});
|
||||
|
||||
it("should display error message after submitting decimal value", async () => {
|
||||
const { user } = renderModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||
|
||||
await user.type(amountInput, "50.5");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MUST_BE_WHOLE_NUMBER");
|
||||
});
|
||||
});
|
||||
|
||||
it("should display error message after submitting amount below minimum", async () => {
|
||||
const { user } = renderModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||
|
||||
await user.type(amountInput, "9");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MINIMUM_AMOUNT");
|
||||
});
|
||||
});
|
||||
|
||||
it("should display error message after submitting negative amount", async () => {
|
||||
const { user } = renderModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||
|
||||
await user.type(amountInput, "-50");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_NEGATIVE_AMOUNT");
|
||||
});
|
||||
});
|
||||
|
||||
it("should replace error message when submitting different invalid value", async () => {
|
||||
const { user } = renderModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||
|
||||
await user.type(amountInput, "9");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MINIMUM_AMOUNT");
|
||||
});
|
||||
|
||||
await user.clear(amountInput);
|
||||
await user.type(amountInput, "25001");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MAXIMUM_AMOUNT");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Form Submission Behavior", () => {
|
||||
it("should prevent submission when amount is invalid", async () => {
|
||||
const createCheckoutSessionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"createCheckoutSession",
|
||||
);
|
||||
const { user } = renderModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||
|
||||
await user.type(amountInput, "9");
|
||||
await user.click(nextButton);
|
||||
|
||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MINIMUM_AMOUNT");
|
||||
});
|
||||
});
|
||||
|
||||
it("should call createCheckoutSession with correct amount when valid", async () => {
|
||||
const createCheckoutSessionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"createCheckoutSession",
|
||||
);
|
||||
const { user } = renderModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||
|
||||
await user.type(amountInput, "1000");
|
||||
await user.click(nextButton);
|
||||
|
||||
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(1000);
|
||||
const errorMessage = screen.queryByTestId("amount-error");
|
||||
expect(errorMessage).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not call createCheckoutSession when validation fails", async () => {
|
||||
const createCheckoutSessionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"createCheckoutSession",
|
||||
);
|
||||
const { user } = renderModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||
|
||||
await user.type(amountInput, "-50");
|
||||
await user.click(nextButton);
|
||||
|
||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_NEGATIVE_AMOUNT");
|
||||
});
|
||||
});
|
||||
|
||||
it("should close modal on successful submission", async () => {
|
||||
vi.spyOn(BillingService, "createCheckoutSession").mockResolvedValue(
|
||||
"https://checkout.stripe.com/test-session",
|
||||
);
|
||||
|
||||
const { user } = renderModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||
|
||||
await user.type(amountInput, "1000");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onCloseMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow API call when validation passes and clear any previous errors", async () => {
|
||||
const createCheckoutSessionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"createCheckoutSession",
|
||||
);
|
||||
|
||||
const { user } = renderModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||
|
||||
// First submit invalid value
|
||||
await user.type(amountInput, "9");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("amount-error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Then submit valid value
|
||||
await user.clear(amountInput);
|
||||
await user.type(amountInput, "100");
|
||||
await user.click(nextButton);
|
||||
|
||||
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(100);
|
||||
const errorMessage = screen.queryByTestId("amount-error");
|
||||
expect(errorMessage).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle zero value correctly", async () => {
|
||||
const { user } = renderModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||
|
||||
await user.type(amountInput, "0");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MINIMUM_AMOUNT");
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle whitespace-only input correctly", async () => {
|
||||
const createCheckoutSessionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"createCheckoutSession",
|
||||
);
|
||||
const { user } = renderModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||
|
||||
// Number inputs typically don't accept spaces, but test the behavior
|
||||
await user.type(amountInput, " ");
|
||||
await user.click(nextButton);
|
||||
|
||||
// Should not call API (empty/invalid input)
|
||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Modal Interaction", () => {
|
||||
it("should call onClose when cancel button is clicked", async () => {
|
||||
const { user } = renderModal();
|
||||
|
||||
const cancelButton = screen.getByRole("button", { name: /close/i });
|
||||
await user.click(cancelButton);
|
||||
|
||||
expect(onCloseMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ApiKeysManager } from "#/components/features/settings/api-keys-manager";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
|
||||
// Mock the react-i18next
|
||||
vi.mock("react-i18next", async () => {
|
||||
@@ -37,6 +38,10 @@ vi.mock("#/hooks/query/use-api-keys", () => ({
|
||||
}));
|
||||
|
||||
describe("ApiKeysManager", () => {
|
||||
beforeEach(() => {
|
||||
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
|
||||
});
|
||||
|
||||
const renderComponent = () => {
|
||||
const queryClient = new QueryClient();
|
||||
return render(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user