Compare commits

..

1 Commits

Author SHA1 Message Date
Chuck Butkus
269e27e734 Use absolute paths for git hooks 2026-03-17 13:31:54 -04:00
292 changed files with 3227 additions and 19440 deletions

View File

@@ -1,202 +0,0 @@
---
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. |

View File

@@ -4,7 +4,7 @@ updates:
directory: "/"
schedule:
interval: "daily"
open-pull-requests-limit: 5
open-pull-requests-limit: 1
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: 5
open-pull-requests-limit: 1
groups:
docusaurus:
patterns:
@@ -51,7 +51,7 @@ updates:
schedule:
interval: "weekly"
day: "wednesday"
open-pull-requests-limit: 5
open-pull-requests-limit: 1
groups:
docusaurus:
patterns:
@@ -72,11 +72,9 @@ updates:
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
- package-ecosystem: "docker"
directories:
- "containers/*"
schedule:
interval: "weekly"
open-pull-requests-limit: 5

View File

@@ -10,7 +10,6 @@ on:
branches:
- main
- "saas-rel-*"
- "jl/debug-webhook-auth"
tags:
- "*"
pull_request:
@@ -41,11 +40,11 @@ jobs:
run: |
if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
json=$(jq -n -c '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22-slim", tag: "nikolaik" }
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" }
]')
else
json=$(jq -n -c '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22-slim", tag: "nikolaik" },
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" },
{ image: "ubuntu:24.04", tag: "ubuntu" }
]')
fi
@@ -220,9 +219,11 @@ jobs:
- name: Determine app image tag
shell: bash
run: |
# 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
# 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
- name: Build and push Docker image
uses: useblacksmith/build-push-action@v1
with:

View File

@@ -1,136 +0,0 @@
---
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,
});
}

View File

@@ -36,40 +36,6 @@ 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

View File

@@ -125,17 +125,6 @@ For example, a PR title could be:
- 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
## Becoming a Maintainer
For contributors who have made significant and sustained contributions to the project, there is a possibility of joining the maintainer team.
The process for this is as follows:
1. Any contributor who has made sustained and high-quality contributions 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.
Note that just making many PRs does not immediately imply that you will become a maintainer. We will be looking at sustained high-quality contributions over a period of time, as well as good teamwork and adherence to our [Code of Conduct](./CODE_OF_CONDUCT.md).
## Need Help?
- **Slack**: [Join our community](https://openhands.dev/joinslack)

View File

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

View File

@@ -1,5 +1,5 @@
ARG OPENHANDS_BUILD_VERSION=dev
FROM node:25.8-trixie-slim AS frontend-builder
FROM node:25.2-trixie-slim AS frontend-builder
WORKDIR /app

View File

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

View File

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

View File

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

View File

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

View File

@@ -100,25 +100,27 @@ async def has_payment_method_by_user_id(user_id: str) -> bool:
return bool(payment_methods.data)
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)},
)
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)},
)
logger.info(
'migrated_customer',
extra={
'user_id': user_id,
'org_id': str(org.id),
'stripe_customer_id': customer.id,
},
)
logger.info(
'migrated_customer',
extra={
'user_id': user_id,
'org_id': str(org.id),
'stripe_customer_id': customer.id,
},
)
await session.commit()

View File

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

View File

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

View File

@@ -1,41 +0,0 @@
"""Add mcp_config to org_member for user-specific MCP settings.
Revision ID: 103
Revises: 102
Create Date: 2026-03-26
"""
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': mcp_config, 'org_id': str(org_id)},
)
def downgrade() -> None:
op.drop_column('org_member', 'mcp_config')

49
enterprise/poetry.lock generated
View File

@@ -2598,21 +2598,6 @@ 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"
@@ -5458,14 +5443,14 @@ files = [
[[package]]
name = "mcp"
version = "1.26.0"
version = "1.25.0"
description = "Model Context Protocol SDK"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca"},
{file = "mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66"},
{file = "mcp-1.25.0-py3-none-any.whl", hash = "sha256:b37c38144a666add0862614cc79ec276e97d72aa8ca26d622818d4e278b9721a"},
{file = "mcp-1.25.0.tar.gz", hash = "sha256:56310361ebf0364e2d438e5b45f7668cbb124e158bb358333cd06e49e83a6802"},
]
[package.dependencies]
@@ -7612,14 +7597,14 @@ wrappers-encryption = ["cryptography (>=45.0.0)"]
[[package]]
name = "pyasn1"
version = "0.6.3"
version = "0.6.2"
description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde"},
{file = "pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf"},
{file = "pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf"},
{file = "pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b"},
]
[[package]]
@@ -11602,14 +11587,14 @@ diagrams = ["jinja2", "railroad-diagrams"]
[[package]]
name = "pypdf"
version = "6.9.1"
version = "6.8.0"
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.9.1-py3-none-any.whl", hash = "sha256:f35a6a022348fae47e092a908339a8f3dc993510c026bb39a96718fc7185e89f"},
{file = "pypdf-6.9.1.tar.gz", hash = "sha256:ae052407d33d34de0c86c5c729be6d51010bf36e03035a8f23ab449bca52377d"},
{file = "pypdf-6.8.0-py3-none-any.whl", hash = "sha256:2a025080a8dd73f48123c89c57174a5ff3806c71763ee4e49572dc90454943c7"},
{file = "pypdf-6.8.0.tar.gz", hash = "sha256:cb7eaeaa4133ce76f762184069a854e03f4d9a08568f0e0623f7ea810407833b"},
]
[package.extras]
@@ -13098,24 +13083,24 @@ test = ["pytest (>=8)"]
[[package]]
name = "setuptools"
version = "82.0.1"
description = "Most extensible Python build backend with support for C/C++ extension modules"
version = "80.9.0"
description = "Easily download, build, install, upgrade, and uninstall Python packages"
optional = false
python-versions = ">=3.9"
groups = ["main", "test"]
files = [
{file = "setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb"},
{file = "setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9"},
{file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"},
{file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"},
]
[package.extras]
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)"]
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)"]
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.18.*)", "pytest-mypy"]
type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"]
[[package]]
name = "shap"
@@ -15012,4 +14997,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 = "c468b13e2d26e31e0e8f84518bcb8379234d431ca3819625f49b91aa3589359c"
content-hash = "ef037f6d6085d26166d35c56ce266439f8f1a4fea90bc43ccf15cfeaf116cae5"

View File

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

View File

@@ -46,7 +46,6 @@ 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
@@ -113,7 +112,6 @@ 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

View File

@@ -35,7 +35,7 @@ Usage:
from enum import Enum
from uuid import UUID
from fastapi import Depends, HTTPException, Request, status
from fastapi import Depends, HTTPException, status
from storage.org_member_store import OrgMemberStore
from storage.role import Role
from storage.role_store import RoleStore
@@ -214,19 +214,6 @@ 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.
@@ -234,9 +221,8 @@ 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. 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
3. Checks if the user has the required permission in the organization
4. Returns the user_id if authorized, raises HTTPException otherwise
Usage:
@router.get('/{org_id}/settings')
@@ -254,7 +240,6 @@ 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:
@@ -264,23 +249,6 @@ 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:

View File

@@ -1,7 +1,6 @@
import time
from dataclasses import dataclass
from types import MappingProxyType
from uuid import UUID
import jwt
from fastapi import Request
@@ -60,19 +59,6 @@ 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
@@ -297,19 +283,14 @@ async def saas_user_auth_from_bearer(request: Request) -> SaasUserAuth | None:
return None
api_key_store = ApiKeyStore.get_instance()
validation_result = await api_key_store.validate_api_key(api_key)
if not validation_result:
user_id = await api_key_store.validate_api_key(api_key)
if not user_id:
return None
offline_token = await token_manager.load_offline_token(
validation_result.user_id
)
offline_token = await token_manager.load_offline_token(user_id)
saas_user_auth = SaasUserAuth(
user_id=validation_result.user_id,
user_id=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

View File

@@ -84,8 +84,6 @@ def setup_json_logger(
style='{',
rename_fields={'levelname': 'severity'},
json_serializer=custom_json_serializer,
# Use 'ts' for consistency with LOG_JSON_FOR_CONSOLE mode (skip when console mode to avoid duplicates)
timestamp='ts' if not LOG_JSON_FOR_CONSOLE else False,
)
handler.setFormatter(formatter)

View File

@@ -182,10 +182,6 @@ 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

View File

@@ -1,9 +1,7 @@
from datetime import UTC, datetime
from typing import cast
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi import APIRouter, Depends, HTTPException, 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
@@ -13,8 +11,7 @@ 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_auth, get_user_id
from openhands.server.user_auth.user_auth import AuthType
from openhands.server.user_auth import get_user_id
# Helper functions for BYOR API key management
@@ -153,16 +150,6 @@ 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(
@@ -275,46 +262,6 @@ 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),

View File

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

View File

@@ -241,6 +241,7 @@ 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

View File

@@ -68,7 +68,7 @@ async def list_user_orgs(
] = None,
limit: Annotated[
int,
Query(title='The max number of results in the page', gt=0, le=100),
Query(title='The max number of results in the page', gt=0, lte=100),
] = 100,
user_id: str = Depends(get_user_id),
) -> OrgPage:
@@ -734,7 +734,7 @@ async def get_org_members(
Query(
title='The max number of results in the page',
gt=0,
le=100,
lte=100,
),
] = 10,
email: Annotated[

View File

@@ -1,270 +0,0 @@
"""
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'}

View File

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

View File

@@ -4,7 +4,7 @@ from datetime import datetime
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, 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,
le=100,
lte=100,
),
] = 100,
include_sub_conversations: Annotated[
@@ -72,6 +72,8 @@ 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,
@@ -125,11 +127,7 @@ 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."""
if len(ids) > 100:
raise HTTPException(
status_code=400,
detail=f'Cannot request more than 100 conversations at once, got {len(ids)}',
)
assert len(ids) <= 100
uuids = [UUID(id_) for id_ in ids]
shared_conversation_info = (
await shared_conversation_service.batch_get_shared_conversation_info(uuids)

View File

@@ -4,7 +4,7 @@ from datetime import datetime
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, Query
from server.sharing.shared_event_service import (
SharedEventService,
SharedEventServiceInjector,
@@ -33,12 +33,6 @@ 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 (
@@ -83,11 +77,13 @@ async def search_shared_events(
] = None,
limit: Annotated[
int,
Query(title='The max number of results in the page', gt=0, le=100),
Query(title='The max number of results in the page', gt=0, lte=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,
@@ -138,11 +134,7 @@ 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."""
if len(id) > 100:
raise HTTPException(
status_code=400,
detail=f'Cannot request more than 100 events at once, got {len(id)}',
)
assert len(id) <= 100
event_ids = [UUID(id_) for id_ in id]
events = await shared_event_service.batch_get_shared_events(
UUID(conversation_id), event_ids

View File

@@ -354,15 +354,6 @@ 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)
@@ -371,15 +362,16 @@ 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 == org_id
and existing_saas_metadata.org_id == user.current_org_id
)
if not existing_saas_metadata:
# Create new SAAS metadata with the determined org_id
# Create new SAAS metadata
# Set org_id to user_id as specified in requirements
saas_metadata = StoredConversationMetadataSaas(
conversation_id=str(info.id),
user_id=user_id_uuid,
org_id=org_id,
org_id=user.current_org_id,
)
self.db_session.add(saas_metadata)

View File

@@ -4,7 +4,6 @@ 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
@@ -14,22 +13,9 @@ 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."""
@@ -37,19 +23,6 @@ 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:
@@ -87,120 +60,8 @@ class ApiKeyStore:
return api_key
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.
"""
async def validate_api_key(self, api_key: str) -> str | None:
"""Validate an API key and return the associated user_id if valid."""
now = datetime.now(UTC)
async with a_session_maker() as session:
@@ -228,12 +89,7 @@ class ApiKeyStore:
)
await session.commit()
return ApiKeyValidationResult(
user_id=key_record.user_id,
org_id=key_record.org_id,
key_id=key_record.id,
key_name=key_record.name,
)
return key_record.user_id
async def delete_api_key(self, api_key: str) -> bool:
"""Delete an API key by the key value."""
@@ -249,18 +105,8 @@ class ApiKeyStore:
return True
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 def delete_api_key_by_id(self, key_id: int) -> bool:
"""Delete an API key by its ID."""
async with a_session_maker() as session:
result = await session.execute(select(ApiKey).filter(ApiKey.id == key_id))
key_record = result.scalars().first()
@@ -268,26 +114,13 @@ 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 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
"""
"""List all API keys for a user."""
user = await UserStore.get_user_by_id(user_id)
if user is None:
raise ValueError(f'User not found: {user_id}')
@@ -296,17 +129,11 @@ 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()
# 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)
]
return [key for key in keys if key.name != 'MCP_API_KEY']
async def retrieve_mcp_api_key(self, user_id: str) -> str | None:
user = await UserStore.get_user_by_id(user_id)
@@ -336,44 +163,17 @@ 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,
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 def delete_api_key_by_name(self, user_id: str, name: str) -> bool:
"""Delete an API key by name for a specific user."""
async with a_session_maker() as session:
# 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))
result = await session.execute(
select(ApiKey).filter(ApiKey.user_id == user_id, ApiKey.name == name)
)
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()

View File

@@ -29,37 +29,14 @@ KEY_VERIFICATION_TIMEOUT = 5.0
# A very large number to represent "unlimited" until LiteLLM fixes their unlimited update bug.
UNLIMITED_BUDGET_SETTING = 1000000000.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:
try:
DEFAULT_INITIAL_BUDGET = float(os.environ.get('DEFAULT_INITIAL_BUDGET', 0.0))
if DEFAULT_INITIAL_BUDGET < 0:
raise ValueError(
f'Invalid DEFAULT_INITIAL_BUDGET environment variable: {e}'
) from e
DEFAULT_INITIAL_BUDGET: float | None = _get_default_initial_budget()
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
def get_openhands_cloud_key_alias(keycloak_user_id: str, org_id: str) -> str:
@@ -133,15 +110,12 @@ class LiteLlmManager:
) as client:
# Check if team already exists and get its budget
# New users joining existing orgs should inherit the team's budget
# When billing is disabled, DEFAULT_INITIAL_BUDGET is None
team_budget: float | None = DEFAULT_INITIAL_BUDGET
team_budget: float = DEFAULT_INITIAL_BUDGET
try:
existing_team = await LiteLlmManager._get_team(client, org_id)
if existing_team:
team_info = existing_team.get('team_info', {})
# Preserve None from existing team (no budget enforcement)
existing_budget = team_info.get('max_budget')
team_budget = existing_budget
team_budget = team_info.get('max_budget', 0.0) or 0.0
logger.info(
'LiteLlmManager:create_entries:existing_team_budget',
extra={
@@ -164,33 +138,9 @@ class LiteLlmManager:
)
if create_user:
user_created = await LiteLlmManager._create_user(
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
@@ -575,40 +525,25 @@ class LiteLlmManager:
client: httpx.AsyncClient,
team_alias: str,
team_id: str,
max_budget: float | None,
max_budget: float,
):
"""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=json_data,
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(),
},
},
)
# Team failed to create in litellm - this is an unforseen error state...
if not response.is_success:
if (
@@ -685,48 +620,15 @@ 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 False
return
response = await client.post(
f'{LITE_LLM_API_URL}/user/new',
json={
@@ -779,33 +681,17 @@ class LiteLlmManager:
'user_id': keycloak_user_id,
},
)
# 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
return
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:
@@ -1032,34 +918,19 @@ class LiteLlmManager:
client: httpx.AsyncClient,
keycloak_user_id: str,
team_id: str,
max_budget: float | None,
max_budget: float,
):
"""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=json_data,
json={
'team_id': team_id,
'member': {'user_id': keycloak_user_id, 'role': 'user'},
'max_budget_in_team': max_budget,
},
)
# Failed to add user to team - this is an unforseen error state...
if not response.is_success:
if (
@@ -1127,34 +998,19 @@ class LiteLlmManager:
client: httpx.AsyncClient,
keycloak_user_id: str,
team_id: str,
max_budget: float | None,
max_budget: float,
):
"""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=json_data,
json={
'team_id': team_id,
'user_id': keycloak_user_id,
'max_budget_in_team': max_budget,
},
)
# Failed to update user in team - this is an unforseen error state...
if not response.is_success:
logger.error(
@@ -1531,8 +1387,7 @@ class LiteLlmManager:
@functools.wraps(internal_fn)
async def wrapper(*args, **kwargs):
async with httpx.AsyncClient(
headers={'x-goog-api-key': LITE_LLM_API_KEY},
timeout=httpx.Timeout(30.0),
headers={'x-goog-api-key': LITE_LLM_API_KEY}
) as client:
return await internal_fn(client, *args, **kwargs)
@@ -1542,7 +1397,6 @@ 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))

View File

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

View File

@@ -15,27 +15,25 @@ class SaasConversationValidator(ConversationValidator):
async def _validate_api_key(self, api_key: str) -> str | None:
"""
Validate an API key and return the user_id if valid.
Validate an API key and return the user_id and github_user_id if valid.
Args:
api_key: The API key to validate
Returns:
The user_id if the API key is valid, None otherwise
A tuple of (user_id, github_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()
validation_result = await api_key_store.validate_api_key(api_key)
user_id = await api_key_store.validate_api_key(api_key)
if not validation_result:
if not user_id:
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:

View File

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

View File

@@ -115,9 +115,6 @@ 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
@@ -182,7 +179,7 @@ class SaasSettingsStore(SettingsStore):
return None
# Check if we need to generate an LLM key.
if not item.llm_base_url or item.llm_base_url == LITE_LLM_API_URL:
if 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)
)
@@ -190,9 +187,6 @@ class SaasSettingsStore(SettingsStore):
kwargs = item.model_dump(context={'expose_secrets': True})
for model in (user, org, org_member):
for key, value in kwargs.items():
# Skip mcp_config for org - it should only be stored on org_member (user-specific)
if key == 'mcp_config' and model is org:
continue
if hasattr(model, key):
setattr(model, key, value)

View File

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

View File

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

View File

@@ -1,325 +0,0 @@
"""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

View File

@@ -1,26 +1,19 @@
"""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."""
@@ -519,81 +512,3 @@ 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

View File

@@ -1,314 +0,0 @@
"""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

View File

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

View File

@@ -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, ApiKeyValidationResult
from storage.api_key_store import ApiKeyStore
@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 returns user_id and org_id."""
# Arrange
"""Test validating a valid API key."""
# Setup - create an API key in the database
user_id = str(uuid.uuid4())
org_id = uuid.uuid4()
api_key_value = 'test-api-key'
@@ -126,19 +126,13 @@ 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
# Act
# Execute - patch a_session_maker to use test's async session maker
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 == org_id
assert result.key_id == key_id
assert result.key_name == 'Test Key'
# Verify
assert result == user_id
@pytest.mark.asyncio
@@ -203,7 +197,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."""
# Arrange
# Setup - create a valid API key with timezone-naive datetime (future date)
user_id = str(uuid.uuid4())
org_id = uuid.uuid4()
api_key_value = 'test-valid-naive-key'
@@ -220,44 +214,12 @@ async def test_validate_api_key_valid_timezone_naive(
session.add(key_record)
await session.commit()
# Act
# Execute - patch a_session_maker to use test's async session maker
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.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
# Verify
assert result == user_id
@pytest.mark.asyncio

View File

@@ -846,108 +846,10 @@ async def test_keycloak_callback_duplicate_email_detected(
assert exc_info.value.detail == 'duplicate_email'
@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()
# 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

View File

@@ -13,7 +13,6 @@ 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,
@@ -445,15 +444,6 @@ 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."""
@@ -466,7 +456,6 @@ class TestRequirePermission:
"""
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request()
mock_role = MagicMock()
mock_role.name = 'admin'
@@ -476,9 +465,7 @@ class TestRequirePermission:
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
)
result = await permission_checker(org_id=org_id, user_id=user_id)
assert result == user_id
@pytest.mark.asyncio
@@ -489,11 +476,10 @@ 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(request=mock_request, org_id=org_id, user_id=None)
await permission_checker(org_id=org_id, user_id=None)
assert exc_info.value.status_code == 401
assert 'not authenticated' in exc_info.value.detail.lower()
@@ -507,7 +493,6 @@ class TestRequirePermission:
"""
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request()
with patch(
'server.auth.authorization.get_user_org_role',
@@ -515,9 +500,7 @@ class TestRequirePermission:
):
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
with pytest.raises(HTTPException) as exc_info:
await permission_checker(
request=mock_request, org_id=org_id, user_id=user_id
)
await permission_checker(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()
@@ -531,7 +514,6 @@ class TestRequirePermission:
"""
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request()
mock_role = MagicMock()
mock_role.name = 'member'
@@ -542,9 +524,7 @@ class TestRequirePermission:
):
permission_checker = require_permission(Permission.DELETE_ORGANIZATION)
with pytest.raises(HTTPException) as exc_info:
await permission_checker(
request=mock_request, org_id=org_id, user_id=user_id
)
await permission_checker(org_id=org_id, user_id=user_id)
assert exc_info.value.status_code == 403
assert 'delete_organization' in exc_info.value.detail.lower()
@@ -558,7 +538,6 @@ class TestRequirePermission:
"""
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request()
mock_role = MagicMock()
mock_role.name = 'owner'
@@ -568,9 +547,7 @@ class TestRequirePermission:
AsyncMock(return_value=mock_role),
):
permission_checker = require_permission(Permission.DELETE_ORGANIZATION)
result = await permission_checker(
request=mock_request, org_id=org_id, user_id=user_id
)
result = await permission_checker(org_id=org_id, user_id=user_id)
assert result == user_id
@pytest.mark.asyncio
@@ -582,7 +559,6 @@ class TestRequirePermission:
"""
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request()
mock_role = MagicMock()
mock_role.name = 'admin'
@@ -593,9 +569,7 @@ class TestRequirePermission:
):
permission_checker = require_permission(Permission.DELETE_ORGANIZATION)
with pytest.raises(HTTPException) as exc_info:
await permission_checker(
request=mock_request, org_id=org_id, user_id=user_id
)
await permission_checker(org_id=org_id, user_id=user_id)
assert exc_info.value.status_code == 403
@@ -608,7 +582,6 @@ class TestRequirePermission:
"""
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request()
mock_role = MagicMock()
mock_role.name = 'member'
@@ -622,9 +595,7 @@ class TestRequirePermission:
):
permission_checker = require_permission(Permission.DELETE_ORGANIZATION)
with pytest.raises(HTTPException):
await permission_checker(
request=mock_request, org_id=org_id, user_id=user_id
)
await permission_checker(org_id=org_id, user_id=user_id)
mock_logger.warning.assert_called()
call_args = mock_logger.warning.call_args
@@ -640,7 +611,6 @@ class TestRequirePermission:
THEN: User ID is returned
"""
user_id = str(uuid4())
mock_request = _create_mock_request()
mock_role = MagicMock()
mock_role.name = 'admin'
@@ -650,9 +620,7 @@ class TestRequirePermission:
AsyncMock(return_value=mock_role),
) as mock_get_role:
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
result = await permission_checker(
request=mock_request, org_id=None, user_id=user_id
)
result = await permission_checker(org_id=None, user_id=user_id)
assert result == user_id
mock_get_role.assert_called_once_with(user_id, None)
@@ -664,7 +632,6 @@ 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',
@@ -672,9 +639,7 @@ class TestRequirePermission:
):
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
with pytest.raises(HTTPException) as exc_info:
await permission_checker(
request=mock_request, org_id=None, user_id=user_id
)
await permission_checker(org_id=None, user_id=user_id)
assert exc_info.value.status_code == 403
assert 'not a member' in exc_info.value.detail
@@ -697,7 +662,6 @@ class TestPermissionScenarios:
"""
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request()
mock_role = MagicMock()
mock_role.name = 'member'
@@ -707,9 +671,7 @@ class TestPermissionScenarios:
AsyncMock(return_value=mock_role),
):
permission_checker = require_permission(Permission.MANAGE_SECRETS)
result = await permission_checker(
request=mock_request, org_id=org_id, user_id=user_id
)
result = await permission_checker(org_id=org_id, user_id=user_id)
assert result == user_id
@pytest.mark.asyncio
@@ -721,7 +683,6 @@ class TestPermissionScenarios:
"""
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request()
mock_role = MagicMock()
mock_role.name = 'member'
@@ -734,9 +695,7 @@ class TestPermissionScenarios:
Permission.INVITE_USER_TO_ORGANIZATION
)
with pytest.raises(HTTPException) as exc_info:
await permission_checker(
request=mock_request, org_id=org_id, user_id=user_id
)
await permission_checker(org_id=org_id, user_id=user_id)
assert exc_info.value.status_code == 403
@@ -749,7 +708,6 @@ class TestPermissionScenarios:
"""
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request()
mock_role = MagicMock()
mock_role.name = 'admin'
@@ -761,9 +719,7 @@ class TestPermissionScenarios:
permission_checker = require_permission(
Permission.INVITE_USER_TO_ORGANIZATION
)
result = await permission_checker(
request=mock_request, org_id=org_id, user_id=user_id
)
result = await permission_checker(org_id=org_id, user_id=user_id)
assert result == user_id
@pytest.mark.asyncio
@@ -775,7 +731,6 @@ class TestPermissionScenarios:
"""
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request()
mock_role = MagicMock()
mock_role.name = 'admin'
@@ -786,9 +741,7 @@ class TestPermissionScenarios:
):
permission_checker = require_permission(Permission.CHANGE_USER_ROLE_OWNER)
with pytest.raises(HTTPException) as exc_info:
await permission_checker(
request=mock_request, org_id=org_id, user_id=user_id
)
await permission_checker(org_id=org_id, user_id=user_id)
assert exc_info.value.status_code == 403
@@ -801,7 +754,6 @@ class TestPermissionScenarios:
"""
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request()
mock_role = MagicMock()
mock_role.name = 'owner'
@@ -811,200 +763,5 @@ class TestPermissionScenarios:
AsyncMock(return_value=mock_role),
):
permission_checker = require_permission(Permission.CHANGE_USER_ROLE_OWNER)
result = await permission_checker(
request=mock_request, org_id=org_id, user_id=user_id
)
result = await permission_checker(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

View File

@@ -38,9 +38,8 @@ class TestDefaultInitialBudget:
if 'storage.lite_llm_manager' in sys.modules:
del sys.modules['storage.lite_llm_manager']
# Clear the env vars
# Clear the env var
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:
@@ -48,56 +47,31 @@ class TestDefaultInitialBudget:
else:
importlib.import_module('storage.lite_llm_manager')
def test_default_initial_budget_none_when_billing_disabled(self):
"""Test that DEFAULT_INITIAL_BUDGET is None when billing is disabled."""
def test_default_initial_budget_defaults_to_zero(self):
"""Test that DEFAULT_INITIAL_BUDGET defaults to 0.0 when env var not set."""
# 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']
# 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'
# Clear the env var and reimport
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_when_billing_enabled(self):
"""Test that DEFAULT_INITIAL_BUDGET uses value from environment variable when billing enabled."""
def test_default_initial_budget_uses_env_var(self):
"""Test that DEFAULT_INITIAL_BUDGET uses value from environment variable."""
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')
@@ -108,7 +82,6 @@ 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')
@@ -239,16 +212,6 @@ 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(
@@ -256,8 +219,12 @@ class TestLiteLlmManager:
)
mock_client = AsyncMock()
# 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.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
)
)
mock_client.post.return_value = mock_response
mock_client_class = MagicMock()
@@ -280,8 +247,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 + user_exists + 4 posts)
assert mock_client.get.call_count == 2 # get_team + user_exists
# Verify API calls were made (get_team + 4 posts)
assert mock_client.get.call_count == 1 # get_team
assert (
mock_client.post.call_count == 4
) # create_team, add_user_to_team, delete_key_by_alias, generate_key
@@ -300,21 +267,13 @@ 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()
# 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.get.return_value = mock_team_response
mock_client.post.return_value = mock_response
mock_client_class = MagicMock()
@@ -334,8 +293,8 @@ class TestLiteLlmManager:
assert result is not None
# Verify _get_team was called first
assert mock_client.get.call_count == 2 # get_team + user_exists
get_call_url = mock_client.get.call_args_list[0][0][0]
mock_client.get.assert_called_once()
get_call_url = mock_client.get.call_args[0][0]
assert 'team/info' in get_call_url
assert 'test-org-id' in get_call_url
@@ -357,25 +316,19 @@ 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()
# 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.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
)
)
mock_client.post.return_value = mock_response
mock_client_class = MagicMock()
@@ -413,16 +366,6 @@ 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(
@@ -430,8 +373,12 @@ class TestLiteLlmManager:
)
mock_client = AsyncMock()
# 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.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
)
)
mock_client.post.return_value = mock_response
mock_client_class = MagicMock()
@@ -859,16 +806,15 @@ class TestLiteLlmManager:
@pytest.mark.asyncio
async def test_create_user_success(self, mock_http_client, mock_response):
"""Test successful _create_user operation returns True."""
"""Test successful _create_user operation."""
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'):
result = await LiteLlmManager._create_user(
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]
@@ -877,7 +823,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 returns True."""
"""Test _create_user with duplicate email handling."""
# First call fails with duplicate email
error_response = MagicMock()
error_response.is_success = False
@@ -889,192 +835,15 @@ 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'):
result = await LiteLlmManager._create_user(
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_and_verified(
self, mock_logger, mock_http_client
):
"""Test _create_user returns True when user already exists and is verified."""
# 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'},
)
@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')
@@ -1082,7 +851,7 @@ class TestLiteLlmManager:
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."""
"""Test _create_user handles 409 Conflict when user already exists."""
# Arrange
first_response = MagicMock()
first_response.is_success = False
@@ -1094,22 +863,14 @@ 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
result = await LiteLlmManager._create_user(
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'},
@@ -1122,7 +883,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 and verifies."""
"""Test _create_user handles 400 Bad Request when user already exists."""
# Arrange
first_response = MagicMock()
first_response.is_success = False
@@ -1134,22 +895,14 @@ 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
result = await LiteLlmManager._create_user(
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'},
@@ -2384,195 +2137,3 @@ 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

View File

@@ -5,15 +5,10 @@ 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():
@@ -26,31 +21,20 @@ 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',
'ts': FROZEN_TIMESTAMP,
}
assert output == {'message': 'Test message', 'severity': 'INFO'}
@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',
'ts': FROZEN_TIMESTAMP,
}
assert output == {'message': 'Test message', 'severity': 'ERROR'}
@freeze_time(FROZEN_TIMESTAMP)
def test_extra_fields(self, log_output):
logger, string_io = log_output
@@ -60,7 +44,6 @@ class TestLogOutput:
'key': '..val..',
'message': 'Test message',
'severity': 'INFO',
'ts': FROZEN_TIMESTAMP,
}
def test_format_stack(self):
@@ -274,7 +257,6 @@ class TestLogOutput:
]
assert formatted == expected
@freeze_time(FROZEN_TIMESTAMP)
def test_filtering(self):
# Ensure that secret values are still filtered
string_io = StringIO()
@@ -284,62 +266,4 @@ class TestLogOutput:
):
openhands_logger.info('The secret key was supersecretvalue')
output = json.loads(string_io.getvalue())
assert output == {
'message': 'The secret key was ******',
'severity': 'INFO',
'ts': FROZEN_TIMESTAMP,
}
@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
assert output == {'message': 'The secret key was ******', 'severity': 'INFO'}

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
import time
import uuid
from unittest.mock import AsyncMock, MagicMock, patch
import jwt
@@ -19,7 +18,6 @@ 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
@@ -459,8 +457,7 @@ 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 sets user_id and api_key_org_id."""
# Arrange
"""Test successful authentication from bearer token."""
mock_request = MagicMock()
mock_request.headers = {'Authorization': 'Bearer test_api_key'}
@@ -471,22 +468,12 @@ 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=mock_validation_result
)
mock_api_key_store.validate_api_key = AsyncMock(return_value='test_user_id')
mock_api_key_store_cls.get_instance.return_value = mock_api_key_store
mock_token_manager.load_offline_token = AsyncMock(return_value=offline_token)
@@ -498,9 +485,6 @@ 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)

View File

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

View File

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

View File

@@ -1,78 +0,0 @@
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");
});
});

View File

@@ -1,60 +0,0 @@
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");
});
});

View File

@@ -1,16 +1,11 @@
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { describe, expect, it, vi } 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();

View File

@@ -49,17 +49,9 @@ 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(() => {
@@ -282,65 +274,6 @@ 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>

View File

@@ -1,78 +0,0 @@
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 = () => {
const Stub = createRoutesStub([
{
path: "/",
Component: LoginCTA,
},
{
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",
);
});
});

View File

@@ -10,12 +10,9 @@ 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", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router")>()),
vi.mock("react-router", () => ({
useParams: () => ({ conversationId: "123" }),
useRevalidator: () => ({ revalidate: vi.fn() }),
}));
let queryClient: QueryClient;
@@ -50,7 +47,6 @@ const renderMessages = ({
describe("Messages", () => {
beforeEach(() => {
queryClient = new QueryClient();
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
});
const assistantMessage: AssistantMessageAction = {

View File

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

View File

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

View File

@@ -11,23 +11,23 @@ vi.mock("posthog-js/react", () => ({
}),
}));
const { ENABLE_PROJ_USER_JOURNEY_MOCK } = vi.hoisted(() => ({
ENABLE_PROJ_USER_JOURNEY_MOCK: vi.fn(() => true),
const { PROJ_USER_JOURNEY_MOCK } = vi.hoisted(() => ({
PROJ_USER_JOURNEY_MOCK: vi.fn(() => true),
}));
vi.mock("#/utils/feature-flags", () => ({
ENABLE_PROJ_USER_JOURNEY: () => ENABLE_PROJ_USER_JOURNEY_MOCK(),
PROJ_USER_JOURNEY: () => PROJ_USER_JOURNEY_MOCK(),
}));
describe("EnterpriseBanner", () => {
beforeEach(() => {
vi.clearAllMocks();
ENABLE_PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
});
describe("Feature Flag", () => {
it("should not render when proj_user_journey feature flag is disabled", () => {
ENABLE_PROJ_USER_JOURNEY_MOCK.mockReturnValue(false);
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", () => {
ENABLE_PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
renderWithProviders(<EnterpriseBanner />);

View File

@@ -1,159 +0,0 @@
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");
});
});
});

View File

@@ -10,7 +10,6 @@ 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();
@@ -66,7 +65,6 @@ const MOCK_RESPOSITORIES: GitRepository[] = [
];
beforeEach(() => {
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,8 +7,6 @@ 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")>();
@@ -24,16 +22,6 @@ 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>
@@ -42,15 +30,10 @@ const renderOnboardingForm = () => {
);
};
describe("OnboardingForm - SaaS Mode", () => {
describe("OnboardingForm", () => {
beforeEach(() => {
mockMutate.mockClear();
mockNavigate.mockClear();
mockTrackOnboardingCompleted.mockClear();
mockUseConfig.mockReturnValue({
data: { app_mode: "saas" },
isLoading: false,
});
});
it("should render with the correct test id", () => {
@@ -67,7 +50,7 @@ describe("OnboardingForm - SaaS Mode", () => {
expect(screen.getByTestId("step-actions")).toBeInTheDocument();
});
it("should display step progress indicator with 3 bars for saas mode", () => {
it("should display step progress indicator with 3 bars", () => {
renderOnboardingForm();
const stepHeader = screen.getByTestId("step-header");
@@ -86,7 +69,7 @@ describe("OnboardingForm - SaaS Mode", () => {
const user = userEvent.setup();
renderOnboardingForm();
await user.click(screen.getByTestId("step-option-solo"));
await user.click(screen.getByTestId("step-option-software_engineer"));
const nextButton = screen.getByRole("button", { name: /next/i });
expect(nextButton).not.toBeDisabled();
@@ -101,7 +84,7 @@ describe("OnboardingForm - SaaS Mode", () => {
let progressBars = stepHeader.querySelectorAll(".bg-white");
expect(progressBars).toHaveLength(1);
await user.click(screen.getByTestId("step-option-solo"));
await user.click(screen.getByTestId("step-option-software_engineer"));
await user.click(screen.getByRole("button", { name: /next/i }));
// On step 2, first two progress bars should be filled
@@ -113,7 +96,7 @@ describe("OnboardingForm - SaaS Mode", () => {
const user = userEvent.setup();
renderOnboardingForm();
await user.click(screen.getByTestId("step-option-solo"));
await user.click(screen.getByTestId("step-option-software_engineer"));
await user.click(screen.getByRole("button", { name: /next/i }));
const nextButton = screen.getByRole("button", { name: /next/i });
@@ -124,51 +107,29 @@ describe("OnboardingForm - SaaS Mode", () => {
const user = userEvent.setup();
renderOnboardingForm();
// Step 1 - select org size (first step in saas mode - single select)
// Step 1 - select role
await user.click(screen.getByTestId("step-option-software_engineer"));
await user.click(screen.getByRole("button", { name: /next/i }));
// Step 2 - select org size
await user.click(screen.getByTestId("step-option-org_2_10"));
await user.click(screen.getByRole("button", { name: /next/i }));
// Step 2 - select use case (multi-select)
// Step 3 - select use case
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: {
org_size: "org_2_10",
use_case: ["new_features"],
role: "software_engineer",
step1: "software_engineer",
step2: "org_2_10",
step3: "new_features",
},
});
});
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)", () => {
it("should render 6 options on step 1", () => {
renderOnboardingForm();
const options = screen
@@ -176,86 +137,31 @@ describe("OnboardingForm - SaaS Mode", () => {
.filter((btn) =>
btn.getAttribute("data-testid")?.startsWith("step-option-"),
);
expect(options).toHaveLength(5);
expect(options).toHaveLength(6);
});
it("should preserve selections when navigating through steps", async () => {
const user = userEvent.setup();
renderOnboardingForm();
// Select org size on step 1 (single select)
// Select role on step 1
await user.click(screen.getByTestId("step-option-cto_founder"));
await user.click(screen.getByRole("button", { name: /next/i }));
// Select org size on step 2
await user.click(screen.getByTestId("step-option-solo"));
await user.click(screen.getByRole("button", { name: /next/i }));
// Select use case on step 2 (multi-select)
// Select use case on step 3
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: {
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",
step1: "cto_founder",
step2: "solo",
step3: "fixing_bugs",
},
});
});
@@ -265,10 +171,10 @@ describe("OnboardingForm - SaaS Mode", () => {
renderOnboardingForm();
// Navigate to step 3
await user.click(screen.getByTestId("step-option-solo"));
await user.click(screen.getByTestId("step-option-software_engineer"));
await user.click(screen.getByRole("button", { name: /next/i }));
await user.click(screen.getByTestId("step-option-new_features"));
await user.click(screen.getByTestId("step-option-solo"));
await user.click(screen.getByRole("button", { name: /next/i }));
// On step 3, all three progress bars should be filled
@@ -288,7 +194,7 @@ describe("OnboardingForm - SaaS Mode", () => {
const user = userEvent.setup();
renderOnboardingForm();
await user.click(screen.getByTestId("step-option-solo"));
await user.click(screen.getByTestId("step-option-software_engineer"));
await user.click(screen.getByRole("button", { name: /next/i }));
const backButton = screen.getByRole("button", { name: /back/i });
@@ -300,7 +206,7 @@ describe("OnboardingForm - SaaS Mode", () => {
renderOnboardingForm();
// Navigate to step 2
await user.click(screen.getByTestId("step-option-solo"));
await user.click(screen.getByTestId("step-option-software_engineer"));
await user.click(screen.getByRole("button", { name: /next/i }));
// Verify we're on step 2 (2 progress bars filled)

View File

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

View File

@@ -12,7 +12,7 @@ describe("StepContent", () => {
const defaultProps = {
options: mockOptions,
selectedOptionIds: [],
selectedOptionId: null,
onSelectOption: vi.fn(),
};
@@ -44,7 +44,7 @@ describe("StepContent", () => {
});
it("should mark the selected option as selected", () => {
render(<StepContent {...defaultProps} selectedOptionIds={["option1"]} />);
render(<StepContent {...defaultProps} selectedOptionId="option1" />);
const selectedOption = screen.getByTestId("step-option-option1");
const unselectedOption = screen.getByTestId("step-option-option2");

View File

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

View File

@@ -1,8 +1,7 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { describe, expect, it, vi } 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 () => {
@@ -38,10 +37,6 @@ vi.mock("#/hooks/query/use-api-keys", () => ({
}));
describe("ApiKeysManager", () => {
beforeEach(() => {
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
});
const renderComponent = () => {
const queryClient = new QueryClient();
return render(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
renderWithProviders,
createAxiosNotFoundErrorObject,
@@ -10,7 +10,6 @@ import SettingsService from "#/api/settings-service/settings-service.api";
import OptionService from "#/api/option-service/option-service.api";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { WebClientConfig } from "#/api/option-service/option.types";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
// Helper to create mock config with sensible defaults
const createMockConfig = (
@@ -77,10 +76,6 @@ describe("Sidebar", () => {
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
beforeEach(() => {
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
});
afterEach(() => {
vi.clearAllMocks();
});

View File

@@ -18,27 +18,6 @@ import { OrganizationMember } from "#/types/org";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
import { createMockWebClientConfig } from "#/mocks/settings-handlers";
// Mock useBreakpoint hook
vi.mock("#/hooks/use-breakpoint", () => ({
useBreakpoint: vi.fn(() => false), // Default to desktop (not mobile)
}));
// Mock feature flags
const mockEnableProjUserJourney = vi.fn(() => true);
vi.mock("#/utils/feature-flags", () => ({
ENABLE_PROJ_USER_JOURNEY: () => mockEnableProjUserJourney(),
}));
// Mock useTracking hook for CTA
vi.mock("#/hooks/use-tracking", () => ({
useTracking: () => ({
trackSaasSelfhostedInquiry: vi.fn(),
}),
}));
// Import the mocked modules
import * as breakpoint from "#/hooks/use-breakpoint";
type UserContextMenuProps = GetComponentPropTypes<typeof UserContextMenu>;
function UserContextMenuWithRootOutlet({
@@ -144,9 +123,6 @@ describe("UserContextMenu", () => {
// Ensure clean state at the start of each test
vi.restoreAllMocks();
useSelectedOrganizationStore.setState({ organizationId: null });
// Reset feature flag and breakpoint mocks to defaults
mockEnableProjUserJourney.mockReturnValue(true);
vi.mocked(breakpoint.useBreakpoint).mockReturnValue(false); // Desktop by default
});
afterEach(() => {
@@ -156,19 +132,11 @@ describe("UserContextMenu", () => {
useSelectedOrganizationStore.setState({ organizationId: null });
});
it("should render the default context items for a user", async () => {
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
createMockWebClientConfig({ app_mode: "saas" }),
);
it("should render the default context items for a user", () => {
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
screen.getByTestId("org-selector");
// Wait for config to load so logout button appears
await waitFor(() => {
expect(screen.getByText("ACCOUNT_SETTINGS$LOGOUT")).toBeInTheDocument();
});
screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
expect(
screen.queryByText("ORG$INVITE_ORG_MEMBERS"),
@@ -312,20 +280,6 @@ describe("UserContextMenu", () => {
screen.queryByText("Organization Members"),
).not.toBeInTheDocument();
});
it("should not display logout button in OSS mode", async () => {
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
// Wait for the config to load
await waitFor(() => {
expect(screen.getByText("SETTINGS$NAV_LLM")).toBeInTheDocument();
});
// Verify logout button is NOT rendered in OSS mode
expect(
screen.queryByText("ACCOUNT_SETTINGS$LOGOUT"),
).not.toBeInTheDocument();
});
});
describe("HIDE_LLM_SETTINGS feature flag", () => {
@@ -385,70 +339,29 @@ describe("UserContextMenu", () => {
});
});
it("should render additional context items when user is an admin", async () => {
// Mock SaaS mode and a team org so org management items are visible
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
createMockWebClientConfig({ app_mode: "saas" }),
);
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
items: [MOCK_TEAM_ORG_ACME],
currentOrgId: MOCK_TEAM_ORG_ACME.id,
});
useSelectedOrganizationStore.setState({
organizationId: MOCK_TEAM_ORG_ACME.id,
});
vi.spyOn(organizationService, "getMe").mockResolvedValue(
createMockUser({ role: "admin", org_id: MOCK_TEAM_ORG_ACME.id }),
);
it("should render additional context items when user is an admin", () => {
renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn });
screen.getByTestId("org-selector");
// Wait for orgs to load so org management items appear
await waitFor(() => {
expect(screen.getByText("ORG$INVITE_ORG_MEMBERS")).toBeInTheDocument();
});
// Note: Organization and Org Members links may or may not appear depending on
// permission checks in useSettingsNavItems. The key test is that Invite button appears.
screen.getByText("ORG$INVITE_ORG_MEMBERS");
screen.getByText("ORG$ORGANIZATION_MEMBERS");
screen.getByText("COMMON$ORGANIZATION");
});
it("should render additional context items when user is an owner", async () => {
// Mock SaaS mode and a team org so org management items are visible
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
createMockWebClientConfig({ app_mode: "saas" }),
);
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
items: [MOCK_TEAM_ORG_ACME],
currentOrgId: MOCK_TEAM_ORG_ACME.id,
});
useSelectedOrganizationStore.setState({
organizationId: MOCK_TEAM_ORG_ACME.id,
});
vi.spyOn(organizationService, "getMe").mockResolvedValue(
createMockUser({ role: "owner", org_id: MOCK_TEAM_ORG_ACME.id }),
);
it("should render additional context items when user is an owner", () => {
renderUserContextMenu({ type: "owner", onClose: vi.fn, onOpenInviteModal: vi.fn });
screen.getByTestId("org-selector");
// Wait for orgs to load so org management items appear
await waitFor(() => {
expect(screen.getByText("ORG$INVITE_ORG_MEMBERS")).toBeInTheDocument();
});
// Note: Organization and Org Members links may or may not appear depending on
// permission checks in useSettingsNavItems. The key test is that Invite button appears.
screen.getByText("ORG$INVITE_ORG_MEMBERS");
screen.getByText("ORG$ORGANIZATION_MEMBERS");
screen.getByText("COMMON$ORGANIZATION");
});
it("should call the logout handler when Logout is clicked", async () => {
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
createMockWebClientConfig({ app_mode: "saas" }),
);
const logoutSpy = vi.spyOn(AuthService, "logout");
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
// Wait for config to load so logout button appears
const logoutButton = await screen.findByText("ACCOUNT_SETTINGS$LOGOUT");
const logoutButton = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
await userEvent.click(logoutButton);
expect(logoutSpy).toHaveBeenCalledOnce();
@@ -497,61 +410,42 @@ describe("UserContextMenu", () => {
});
});
it("should have correct link for Organization Members nav item when visible", async () => {
// Mock SaaS mode and a team org so org management items are visible
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
createMockWebClientConfig({ app_mode: "saas" }),
);
it("should navigate to /settings/org-members when Manage Organization Members is clicked", async () => {
// Mock a team org so org management buttons are visible (not personal org)
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
items: [MOCK_TEAM_ORG_ACME],
currentOrgId: MOCK_TEAM_ORG_ACME.id,
});
useSelectedOrganizationStore.setState({
organizationId: MOCK_TEAM_ORG_ACME.id,
});
vi.spyOn(organizationService, "getMe").mockResolvedValue(
createMockUser({ role: "admin", org_id: MOCK_TEAM_ORG_ACME.id }),
);
renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn });
// Wait for nav items to load. The Org Members link may appear if permissions are met.
await waitFor(() => {
const orgMembersLink = screen.queryByText("SETTINGS$NAV_ORG_MEMBERS");
if (orgMembersLink) {
expect(orgMembersLink.closest("a")).toHaveAttribute(
"href",
"/settings/org-members",
);
}
});
// Wait for orgs to load so org management buttons are visible
const manageOrganizationMembersButton = await screen.findByText(
"ORG$ORGANIZATION_MEMBERS",
);
await userEvent.click(manageOrganizationMembersButton);
expect(navigateMock).toHaveBeenCalledExactlyOnceWith(
"/settings/org-members",
);
});
it("should have correct link for Organization nav item when visible", async () => {
// Mock SaaS mode and a team org so org management items are visible
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
createMockWebClientConfig({ app_mode: "saas" }),
);
it("should navigate to /settings/org when Manage Account is clicked", async () => {
// Mock a team org so org management buttons are visible (not personal org)
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
items: [MOCK_TEAM_ORG_ACME],
currentOrgId: MOCK_TEAM_ORG_ACME.id,
});
useSelectedOrganizationStore.setState({
organizationId: MOCK_TEAM_ORG_ACME.id,
});
vi.spyOn(organizationService, "getMe").mockResolvedValue(
createMockUser({ role: "admin", org_id: MOCK_TEAM_ORG_ACME.id }),
);
renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn });
// Wait for nav items to load. The Organization link may appear if permissions are met.
await waitFor(() => {
const orgLink = screen.queryByText("SETTINGS$NAV_ORGANIZATION");
if (orgLink) {
expect(orgLink.closest("a")).toHaveAttribute("href", "/settings/org");
}
});
// Wait for orgs to load so org management buttons are visible
const manageAccountButton = await screen.findByText(
"COMMON$ORGANIZATION",
);
await userEvent.click(manageAccountButton);
expect(navigateMock).toHaveBeenCalledExactlyOnceWith("/settings/org");
});
it("should call the onClose handler when clicking outside the context menu", async () => {
@@ -570,34 +464,28 @@ describe("UserContextMenu", () => {
});
it("should call the onClose handler after each action", async () => {
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
createMockWebClientConfig({ app_mode: "saas" }),
);
// Mock a team org so org management items are visible
// Mock a team org so org management buttons are visible
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
items: [MOCK_TEAM_ORG_ACME],
currentOrgId: MOCK_TEAM_ORG_ACME.id,
});
seedActiveUser({ role: "owner" });
const onCloseMock = vi.fn();
renderUserContextMenu({ type: "owner", onClose: onCloseMock, onOpenInviteModal: vi.fn });
// Wait for config to load so logout button appears
const logoutButton = await screen.findByText("ACCOUNT_SETTINGS$LOGOUT");
const logoutButton = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
await userEvent.click(logoutButton);
expect(onCloseMock).toHaveBeenCalledTimes(1);
// Wait for orgs to load so org management items are visible
// Click on Organization Members link (now it's a Link, not a button)
const orgMembersLink = await screen.findByText("SETTINGS$NAV_ORG_MEMBERS");
await userEvent.click(orgMembersLink);
// Wait for orgs to load so org management buttons are visible
const manageOrganizationMembersButton = await screen.findByText(
"ORG$ORGANIZATION_MEMBERS",
);
await userEvent.click(manageOrganizationMembersButton);
expect(onCloseMock).toHaveBeenCalledTimes(2);
// Click on Organization link
const orgLink = screen.getByText("SETTINGS$NAV_ORGANIZATION");
await userEvent.click(orgLink);
const manageAccountButton = screen.getByText("COMMON$ORGANIZATION");
await userEvent.click(manageAccountButton);
expect(onCloseMock).toHaveBeenCalledTimes(3);
});
@@ -669,17 +557,11 @@ describe("UserContextMenu", () => {
});
it("should call onOpenInviteModal and onClose when Invite Organization Member is clicked", async () => {
// Mock a team org so org management items are visible (not personal org)
// Mock a team org so org management buttons are visible (not personal org)
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
items: [MOCK_TEAM_ORG_ACME],
currentOrgId: MOCK_TEAM_ORG_ACME.id,
});
useSelectedOrganizationStore.setState({
organizationId: MOCK_TEAM_ORG_ACME.id,
});
vi.spyOn(organizationService, "getMe").mockResolvedValue(
createMockUser({ role: "admin", org_id: MOCK_TEAM_ORG_ACME.id }),
);
const onCloseMock = vi.fn();
const onOpenInviteModalMock = vi.fn();
@@ -689,7 +571,7 @@ describe("UserContextMenu", () => {
onOpenInviteModal: onOpenInviteModalMock,
});
// Wait for orgs to load so org management items are visible
// Wait for orgs to load so org management buttons are visible
const inviteButton = await screen.findByText("ORG$INVITE_ORG_MEMBERS");
await userEvent.click(inviteButton);
@@ -748,77 +630,4 @@ describe("UserContextMenu", () => {
// Verify that the dropdown shows the selected organization
expect(screen.getByRole("combobox")).toHaveValue(INITIAL_MOCK_ORGS[1].name);
});
describe("Context Menu CTA", () => {
it("should render the CTA component in SaaS mode on desktop with feature flag enabled", async () => {
// Set SaaS mode
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
createMockWebClientConfig({ app_mode: "saas" }),
);
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
// Wait for config to load
await waitFor(() => {
expect(screen.getByTestId("context-menu-cta")).toBeInTheDocument();
});
expect(screen.getByText("CTA$ENTERPRISE_TITLE")).toBeInTheDocument();
expect(screen.getByText("CTA$LEARN_MORE")).toBeInTheDocument();
});
it("should not render the CTA component in OSS mode even with feature flag enabled", async () => {
// Set OSS mode
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
createMockWebClientConfig({ app_mode: "oss" }),
);
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
// Wait for config to load
await waitFor(() => {
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
});
expect(screen.queryByTestId("context-menu-cta")).not.toBeInTheDocument();
expect(screen.queryByText("CTA$ENTERPRISE_TITLE")).not.toBeInTheDocument();
});
it("should not render the CTA component on mobile even in SaaS mode with feature flag enabled", async () => {
// Set SaaS mode
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
createMockWebClientConfig({ app_mode: "saas" }),
);
// Set mobile mode
vi.mocked(breakpoint.useBreakpoint).mockReturnValue(true);
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
// Wait for config to load
await waitFor(() => {
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
});
expect(screen.queryByTestId("context-menu-cta")).not.toBeInTheDocument();
expect(screen.queryByText("CTA$ENTERPRISE_TITLE")).not.toBeInTheDocument();
});
it("should not render the CTA component when feature flag is disabled in SaaS mode", async () => {
// Set SaaS mode
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
createMockWebClientConfig({ app_mode: "saas" }),
);
// Disable the feature flag
mockEnableProjUserJourney.mockReturnValue(false);
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
// Wait for config to load
await waitFor(() => {
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
});
expect(screen.queryByTestId("context-menu-cta")).not.toBeInTheDocument();
expect(screen.queryByText("CTA$ENTERPRISE_TITLE")).not.toBeInTheDocument();
});
});
});

View File

@@ -1,25 +1,26 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { MemoryRouter } from "react-router";
import { InteractiveChatBox } from "#/components/features/chat/interactive-chat-box";
import { renderWithProviders } from "../../test-utils";
import { AgentState } from "#/types/agent-state";
import { useAgentState } from "#/hooks/use-agent-state";
import { useConversationStore } from "#/stores/conversation-store";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
vi.mock("#/hooks/use-agent-state", () => ({
useAgentState: vi.fn(),
}));
// Mock React Router hooks
vi.mock("react-router", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router")>()),
useNavigate: () => vi.fn(),
useParams: () => ({ conversationId: "test-conversation-id" }),
useRevalidator: () => ({ revalidate: vi.fn() }),
}));
vi.mock("react-router", async () => {
const actual = await vi.importActual("react-router");
return {
...actual,
useNavigate: () => vi.fn(),
useParams: () => ({ conversationId: "test-conversation-id" }),
};
});
// Mock the useActiveConversation hook
vi.mock("#/hooks/query/use-active-conversation", () => ({
@@ -51,10 +52,6 @@ vi.mock("#/hooks/use-conversation-name-context-menu", () => ({
describe("InteractiveChatBox", () => {
const onSubmitMock = vi.fn();
beforeEach(() => {
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
});
const mockStores = (agentState: AgentState = AgentState.INIT) => {
vi.mocked(useAgentState).mockReturnValue({
curAgentState: agentState,
@@ -216,36 +213,6 @@ describe("InteractiveChatBox", () => {
expect(onSubmitMock).not.toHaveBeenCalled();
});
it("should lock the text input field when disabled prop is true (isNewConversationPending)", () => {
mockStores(AgentState.INIT);
renderInteractiveChatBox({
onSubmit: onSubmitMock,
disabled: true,
});
const chatInput = screen.getByTestId("chat-input");
// When disabled=true, the text field should not be editable
expect(chatInput).toHaveAttribute("contenteditable", "false");
// Should show visual disabled state
expect(chatInput.className).toContain("cursor-not-allowed");
expect(chatInput.className).toContain("opacity-50");
});
it("should keep the text input field editable when disabled prop is false", () => {
mockStores(AgentState.INIT);
renderInteractiveChatBox({
onSubmit: onSubmitMock,
disabled: false,
});
const chatInput = screen.getByTestId("chat-input");
expect(chatInput).toHaveAttribute("contenteditable", "true");
expect(chatInput.className).not.toContain("cursor-not-allowed");
expect(chatInput.className).not.toContain("opacity-50");
});
it("should handle image upload and message submission correctly", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();

View File

@@ -1,16 +1,13 @@
import { render, screen, waitFor, fireEvent, act } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi, afterEach, beforeEach, test } from "vitest";
import userEvent from "@testing-library/user-event";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { MemoryRouter, createRoutesStub } from "react-router";
import { MemoryRouter } from "react-router";
import { ReactElement } from "react";
import { http, HttpResponse } from "msw";
import { UserActions } from "#/components/features/sidebar/user-actions";
import { organizationService } from "#/api/organization-service/organization-service.api";
import { MOCK_PERSONAL_ORG, MOCK_TEAM_ORG_ACME } from "#/mocks/org-handlers";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
import { server } from "#/mocks/node";
import { createMockWebClientConfig } from "#/mocks/settings-handlers";
import { renderWithProviders } from "../../test-utils";
vi.mock("react-router", async (importActual) => ({
@@ -62,20 +59,6 @@ const renderUserActions = (props = { hasAvatar: true }) => {
);
};
// RouterStub and render helper for menu close delay tests
const RouterStubForMenuCloseDelay = createRoutesStub([
{
path: "/",
Component: () => (
<UserActions user={{ avatar_url: "https://example.com/avatar.png" }} />
),
},
]);
const renderUserActionsForMenuCloseDelay = () => {
return renderWithProviders(<RouterStubForMenuCloseDelay initialEntries={["/"]} />);
};
// Create mocks for all the hooks we need
const useIsAuthedMock = vi
.fn()
@@ -364,7 +347,7 @@ describe("UserActions", () => {
expect(contextMenu).toBeVisible();
});
it("should use state-based visibility for hover behavior instead of CSS pseudo-element", async () => {
it("should have pointer-events-none on hover bridge pseudo-element to allow menu item clicks", async () => {
renderUserActions();
const userActions = screen.getByTestId("user-actions");
@@ -373,17 +356,19 @@ describe("UserActions", () => {
const contextMenu = screen.getByTestId("user-context-menu");
const hoverBridgeContainer = contextMenu.parentElement;
// The component uses state-based visibility with a 500ms delay for diagonal mouse movement
// When visible, the container should have opacity-100 and pointer-events-auto
expect(hoverBridgeContainer?.className).toContain("opacity-100");
expect(hoverBridgeContainer?.className).toContain("pointer-events-auto");
// The hover bridge uses a ::before pseudo-element for diagonal mouse movement
// This pseudo-element MUST have pointer-events-none to allow clicks through to menu items
// The class should include "before:pointer-events-none" to prevent the hover bridge from blocking clicks
expect(hoverBridgeContainer?.className).toContain(
"before:pointer-events-none",
);
});
describe("Org selector dropdown state reset when context menu hides", () => {
// These tests verify that the org selector dropdown resets its internal
// state (search text, open/closed) when the context menu hides and
// reappears. The component uses a 500ms delay before hiding (to support
// diagonal mouse movement).
// reappears. Without this, stale state persists because the context
// menu is hidden via CSS (opacity/pointer-events) rather than unmounted.
beforeEach(() => {
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
@@ -415,22 +400,8 @@ describe("UserActions", () => {
await user.type(input, "search text");
expect(input).toHaveValue("search text");
// Unhover to trigger hide timeout, then wait for the 500ms delay to complete
// Unhover to hide context menu, then hover again
await user.unhover(userActions);
// Wait for the 500ms hide delay to complete and menu to actually hide
await waitFor(
() => {
// The menu resets when it actually hides (after 500ms delay)
// After hiding, hovering again should show a fresh menu
},
{ timeout: 600 },
);
// Wait a bit more for the timeout to fire
await new Promise((resolve) => setTimeout(resolve, 550));
// Now hover again to show the menu
await user.hover(userActions);
// Org selector should be reset — showing selected org name, not search text
@@ -463,13 +434,8 @@ describe("UserActions", () => {
await user.type(input, "Acme");
expect(input).toHaveValue("Acme");
// Unhover to trigger hide timeout
// Unhover to hide context menu, then hover again
await user.unhover(userActions);
// Wait for the 500ms hide delay to complete
await new Promise((resolve) => setTimeout(resolve, 550));
// Now hover again to show the menu
await user.hover(userActions);
// Wait for fresh component with org data
@@ -488,83 +454,4 @@ describe("UserActions", () => {
expect(screen.queryAllByRole("option")).toHaveLength(0);
});
});
describe("menu close delay", () => {
beforeEach(() => {
vi.useFakeTimers();
useSelectedOrganizationStore.setState({ organizationId: "1" });
// Mock config to return SaaS mode so useShouldShowUserFeatures returns true
server.use(
http.get("/api/v1/web-client/config", () =>
HttpResponse.json(createMockWebClientConfig({ app_mode: "saas" })),
),
);
});
afterEach(() => {
vi.useRealTimers();
server.resetHandlers();
});
it("should keep menu visible when mouse leaves and re-enters within 500ms", async () => {
// Arrange - render and wait for queries to settle
renderUserActionsForMenuCloseDelay();
await act(async () => {
await vi.runAllTimersAsync();
});
const userActions = screen.getByTestId("user-actions");
// Act - open menu
await act(async () => {
fireEvent.mouseEnter(userActions);
});
// Assert - menu is visible
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
// Act - leave and re-enter within 500ms
await act(async () => {
fireEvent.mouseLeave(userActions);
await vi.advanceTimersByTimeAsync(200);
fireEvent.mouseEnter(userActions);
});
// Assert - menu should still be visible after waiting (pending close was cancelled)
await act(async () => {
await vi.advanceTimersByTimeAsync(500);
});
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
});
it("should not close menu before 500ms delay when mouse leaves", async () => {
// Arrange - render and wait for queries to settle
renderUserActionsForMenuCloseDelay();
await act(async () => {
await vi.runAllTimersAsync();
});
const userActions = screen.getByTestId("user-actions");
// Act - open menu
await act(async () => {
fireEvent.mouseEnter(userActions);
});
// Assert - menu is visible
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
// Act - leave without re-entering, but check before timeout expires
await act(async () => {
fireEvent.mouseLeave(userActions);
await vi.advanceTimersByTimeAsync(400); // Before the 500ms delay
});
// Assert - menu should still be visible (delay hasn't expired yet)
// Note: The menu is always in DOM but with opacity-0 when closed.
// This test verifies the state hasn't changed yet (delay is working).
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
});
});
});

View File

@@ -1,95 +0,0 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { getEventContent } from "#/components/v1/chat";
import { ActionEvent, ObservationEvent, SecurityRisk } from "#/types/v1/core";
const terminalActionEvent: ActionEvent = {
id: "action-1",
timestamp: new Date().toISOString(),
source: "agent",
thought: [{ type: "text", text: "Checking repository status." }],
thinking_blocks: [],
action: {
kind: "TerminalAction",
command: "git status",
is_input: false,
timeout: null,
reset: false,
},
tool_name: "terminal",
tool_call_id: "tool-1",
tool_call: {
id: "tool-1",
type: "function",
function: {
name: "terminal",
arguments: '{"command":"git status"}',
},
},
llm_response_id: "response-1",
security_risk: SecurityRisk.LOW,
summary: "Check repository status",
};
const terminalObservationEvent: ObservationEvent = {
id: "obs-1",
timestamp: new Date().toISOString(),
source: "environment",
tool_name: "terminal",
tool_call_id: "tool-1",
action_id: "action-1",
observation: {
kind: "TerminalObservation",
content: [{ type: "text", text: "On branch main" }],
command: "git status",
exit_code: 0,
is_error: false,
timeout: false,
metadata: {
exit_code: 0,
pid: 1,
username: "openhands",
hostname: "sandbox",
prefix: "",
suffix: "",
working_dir: "/workspace/project/OpenHands",
py_interpreter_path: null,
},
},
};
describe("getEventContent", () => {
it("uses the action summary as the full action title", () => {
const { title } = getEventContent(terminalActionEvent);
render(<>{title}</>);
expect(screen.getByText("Check repository status")).toBeInTheDocument();
expect(screen.queryByText("$ git status")).not.toBeInTheDocument();
});
it("falls back to command-based title when summary is missing", () => {
const actionWithoutSummary = { ...terminalActionEvent, summary: undefined };
const { title } = getEventContent(actionWithoutSummary);
render(<>{title}</>);
// Without i18n loaded, the translation key renders as the raw key
expect(screen.getByText("ACTION_MESSAGE$RUN")).toBeInTheDocument();
expect(
screen.queryByText("Check repository status"),
).not.toBeInTheDocument();
});
it("reuses the action summary as the full paired observation title", () => {
const { title } = getEventContent(
terminalObservationEvent,
terminalActionEvent,
);
render(<>{title}</>);
expect(screen.getByText("Check repository status")).toBeInTheDocument();
expect(screen.queryByText("$ git status")).not.toBeInTheDocument();
});
});

View File

@@ -7,7 +7,6 @@ import {
WsClientProvider,
useWsClient,
} from "#/context/ws-client-provider";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
describe("Propagate error message", () => {
it("should do nothing when no message was passed from server", () => {
@@ -57,7 +56,6 @@ function TestComponent() {
describe("WsClientProvider", () => {
beforeEach(() => {
vi.clearAllMocks();
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
vi.mock("#/hooks/query/use-active-conversation", () => ({
useActiveConversation: () => {
return { data: {

View File

@@ -40,7 +40,6 @@ import {
import { conversationWebSocketTestSetup } from "./helpers/msw-websocket-setup";
import { useEventStore } from "#/stores/use-event-store";
import { isV1Event } from "#/types/v1/type-guards";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
// Mock useUserConversation to return V1 conversation data
vi.mock("#/hooks/query/use-user-conversation", () => ({
@@ -63,10 +62,6 @@ beforeAll(() => {
mswServer.listen({ onUnhandledRequest: "bypass" });
});
beforeEach(() => {
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
});
afterEach(() => {
mswServer.resetHandlers();
// Clean up any React components
@@ -691,11 +686,17 @@ describe("Conversation WebSocket Handler", () => {
it("should send user actions through WebSocket when connected", async () => {
// Arrange
const conversationId = "test-conversation-send";
let receivedMessage: unknown = null;
// Set up MSW to connect WebSocket
// Set up MSW to capture sent messages
mswServer.use(
wsLink.addEventListener("connection", ({ server }) => {
wsLink.addEventListener("connection", ({ client, server }) => {
server.connect();
// Capture messages sent from client
client.addEventListener("message", (event) => {
receivedMessage = JSON.parse(event.data as string);
});
}),
);
@@ -743,15 +744,20 @@ describe("Conversation WebSocket Handler", () => {
expect(sendMessageFn).not.toBeNull();
});
// sendMessage delivers via WebSocket when connected, or falls back to
// REST API (PendingMessageService) due to React useCallback timing.
// Either path is valid — we just verify it completes without error.
await act(async () => {
await sendMessageFn!({
role: "user",
content: [{ type: "text", text: "Hello from test" }],
});
});
// Assert - message should have been received by mock server
await waitFor(() => {
expect(receivedMessage).toEqual({
role: "user",
content: [{ type: "text", text: "Hello from test" }],
});
});
});
it("should not throw error when sendMessage is called with WebSocket connected", async () => {

View File

@@ -98,7 +98,6 @@ describe("useCreateConversation", () => {
undefined,
undefined,
undefined,
undefined, // plugins
);
});
});

View File

@@ -1,301 +0,0 @@
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it, vi, beforeEach } from "vitest";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import { useNewConversationCommand } from "#/hooks/mutation/use-new-conversation-command";
const mockNavigate = vi.fn();
vi.mock("react-router", () => ({
useNavigate: () => mockNavigate,
useParams: () => ({ conversationId: "conv-123" }),
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
const { mockToast } = vi.hoisted(() => {
const mockToast = Object.assign(vi.fn(), {
loading: vi.fn(),
dismiss: vi.fn(),
});
return { mockToast };
});
vi.mock("react-hot-toast", () => ({
default: mockToast,
}));
vi.mock("#/utils/custom-toast-handlers", () => ({
displaySuccessToast: vi.fn(),
displayErrorToast: vi.fn(),
TOAST_OPTIONS: { position: "top-right" },
}));
const mockConversation = {
conversation_id: "conv-123",
sandbox_id: "sandbox-456",
title: "Test Conversation",
selected_repository: null,
selected_branch: null,
git_provider: null,
last_updated_at: new Date().toISOString(),
created_at: new Date().toISOString(),
status: "RUNNING" as const,
runtime_status: null,
url: null,
session_api_key: null,
conversation_version: "V1" as const,
};
vi.mock("#/hooks/query/use-active-conversation", () => ({
useActiveConversation: () => ({
data: mockConversation,
}),
}));
function makeStartTask(overrides: Record<string, unknown> = {}) {
return {
id: "task-789",
created_by_user_id: null,
status: "READY",
detail: null,
app_conversation_id: "new-conv-999",
sandbox_id: "sandbox-456",
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: null,
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(),
...overrides,
};
}
describe("useNewConversationCommand", () => {
let queryClient: QueryClient;
beforeEach(() => {
vi.clearAllMocks();
queryClient = new QueryClient({
defaultOptions: { mutations: { retry: false } },
});
// Mock batchGetAppConversations to return V1 data with llm_model
vi.spyOn(
V1ConversationService,
"batchGetAppConversations",
).mockResolvedValue([
{
id: "conv-123",
title: "Test Conversation",
sandbox_id: "sandbox-456",
sandbox_status: "RUNNING",
execution_status: "IDLE",
conversation_url: null,
session_api_key: null,
selected_repository: null,
selected_branch: null,
git_provider: null,
trigger: null,
pr_number: [],
llm_model: "gpt-4o",
metrics: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
sub_conversation_ids: [],
public: false,
} as never,
]);
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
it("calls createConversation with sandbox_id and navigates on success", async () => {
const readyTask = makeStartTask();
const createSpy = vi
.spyOn(V1ConversationService, "createConversation")
.mockResolvedValue(readyTask as never);
const getStartTaskSpy = vi
.spyOn(V1ConversationService, "getStartTask")
.mockResolvedValue(readyTask as never);
const { result } = renderHook(() => useNewConversationCommand(), { wrapper });
await result.current.mutateAsync();
await waitFor(() => {
expect(createSpy).toHaveBeenCalledWith(
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined, // plugins
"sandbox-456",
"gpt-4o",
);
expect(getStartTaskSpy).toHaveBeenCalledWith("task-789");
expect(mockNavigate).toHaveBeenCalledWith(
"/conversations/new-conv-999",
);
});
});
it("polls getStartTask until status is READY", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
const workingTask = makeStartTask({
status: "WORKING",
app_conversation_id: null,
});
const readyTask = makeStartTask({ status: "READY" });
vi.spyOn(V1ConversationService, "createConversation").mockResolvedValue(
workingTask as never,
);
const getStartTaskSpy = vi
.spyOn(V1ConversationService, "getStartTask")
.mockResolvedValueOnce(workingTask as never)
.mockResolvedValueOnce(readyTask as never);
const { result } = renderHook(() => useNewConversationCommand(), { wrapper });
const mutatePromise = result.current.mutateAsync();
await vi.advanceTimersByTimeAsync(2000);
await mutatePromise;
await waitFor(() => {
expect(getStartTaskSpy).toHaveBeenCalledTimes(2);
expect(mockNavigate).toHaveBeenCalledWith(
"/conversations/new-conv-999",
);
});
vi.useRealTimers();
});
it("throws when task status is ERROR", async () => {
const errorTask = makeStartTask({
status: "ERROR",
detail: "Sandbox crashed",
app_conversation_id: null,
});
vi.spyOn(V1ConversationService, "createConversation").mockResolvedValue(
errorTask as never,
);
vi.spyOn(V1ConversationService, "getStartTask").mockResolvedValue(
errorTask as never,
);
const { result } = renderHook(() => useNewConversationCommand(), { wrapper });
await expect(result.current.mutateAsync()).rejects.toThrow(
"Sandbox crashed",
);
});
it("invalidates conversation list queries on success", async () => {
const readyTask = makeStartTask();
vi.spyOn(V1ConversationService, "createConversation").mockResolvedValue(
readyTask as never,
);
vi.spyOn(V1ConversationService, "getStartTask").mockResolvedValue(
readyTask as never,
);
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
const { result } = renderHook(() => useNewConversationCommand(), { wrapper });
await result.current.mutateAsync();
await waitFor(() => {
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: ["user", "conversations"],
});
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: ["v1-batch-get-app-conversations"],
});
});
});
it("creates a standalone conversation (not a sub-conversation) so it appears in the list", async () => {
const readyTask = makeStartTask();
const createSpy = vi
.spyOn(V1ConversationService, "createConversation")
.mockResolvedValue(readyTask as never);
vi.spyOn(V1ConversationService, "getStartTask").mockResolvedValue(
readyTask as never,
);
const { result } = renderHook(() => useNewConversationCommand(), { wrapper });
await result.current.mutateAsync();
await waitFor(() => {
// parent_conversation_id should be undefined so the new conversation
// is NOT a sub-conversation and will appear in the conversation list.
expect(createSpy).toHaveBeenCalledWith(
undefined, // selectedRepository (null from mock)
undefined, // git_provider (null from mock)
undefined, // initialUserMsg
undefined, // selected_branch (null from mock)
undefined, // conversationInstructions
undefined, // suggestedTask
undefined, // trigger
undefined, // parent_conversation_id is NOT set
undefined, // agent_type
undefined, // plugins
"sandbox-456", // sandbox_id IS set to reuse the sandbox
"gpt-4o", // llm_model IS inherited from the original conversation
);
});
});
it("shows a loading toast immediately and dismisses it on success", async () => {
const readyTask = makeStartTask();
vi.spyOn(V1ConversationService, "createConversation").mockResolvedValue(
readyTask as never,
);
vi.spyOn(V1ConversationService, "getStartTask").mockResolvedValue(
readyTask as never,
);
const { result } = renderHook(() => useNewConversationCommand(), { wrapper });
await result.current.mutateAsync();
await waitFor(() => {
expect(mockToast.loading).toHaveBeenCalledWith(
"CONVERSATION$CLEARING",
expect.objectContaining({ id: "clear-conversation" }),
);
expect(mockToast.dismiss).toHaveBeenCalledWith("clear-conversation");
});
});
});

View File

@@ -1,15 +1,10 @@
import { renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import SettingsService from "#/api/settings-service/settings-service.api";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
describe("useSaveSettings", () => {
beforeEach(() => {
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
});
it("should send an empty string for llm_api_key if an empty string is passed, otherwise undefined", async () => {
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
const { result } = renderHook(() => useSaveSettings(), {

View File

@@ -1,225 +0,0 @@
import { renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react";
import { useSettings } from "#/hooks/query/use-settings";
import { useGetSecrets } from "#/hooks/query/use-get-secrets";
import { useApiKeys } from "#/hooks/query/use-api-keys";
import SettingsService from "#/api/settings-service/settings-service.api";
import { SecretsService } from "#/api/secrets-service";
import ApiKeysClient from "#/api/api-keys";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
vi.mock("#/hooks/query/use-config", () => ({
useConfig: () => ({
data: { app_mode: "saas" },
}),
}));
vi.mock("#/hooks/query/use-is-authed", () => ({
useIsAuthed: () => ({
data: true,
}),
}));
vi.mock("#/hooks/use-is-on-intermediate-page", () => ({
useIsOnIntermediatePage: () => false,
}));
describe("Organization-scoped query hooks", () => {
let queryClient: QueryClient;
const createWrapper = () => {
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
useSelectedOrganizationStore.setState({ organizationId: "org-1" });
vi.clearAllMocks();
});
describe("useSettings", () => {
it("should include organizationId in query key for proper cache isolation", async () => {
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
const { result } = renderHook(() => useSettings(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isFetched).toBe(true));
// Verify the query was cached with the org-specific key
const cachedData = queryClient.getQueryData(["settings", "org-1"]);
expect(cachedData).toBeDefined();
// Verify no data is cached under the old key without org ID
const oldKeyData = queryClient.getQueryData(["settings"]);
expect(oldKeyData).toBeUndefined();
});
it("should refetch when organization changes", async () => {
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
language: "en",
});
// First render with org-1
const { result, rerender } = renderHook(() => useSettings(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isFetched).toBe(true));
expect(getSettingsSpy).toHaveBeenCalledTimes(1);
// Change organization
useSelectedOrganizationStore.setState({ organizationId: "org-2" });
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
language: "es",
});
// Rerender to pick up the new org ID
rerender();
await waitFor(() => {
// Should have fetched again for the new org
expect(getSettingsSpy).toHaveBeenCalledTimes(2);
});
// Verify both org caches exist independently
const org1Data = queryClient.getQueryData(["settings", "org-1"]);
const org2Data = queryClient.getQueryData(["settings", "org-2"]);
expect(org1Data).toBeDefined();
expect(org2Data).toBeDefined();
});
});
describe("useGetSecrets", () => {
it("should include organizationId in query key for proper cache isolation", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
getSecretsSpy.mockResolvedValue([]);
const { result } = renderHook(() => useGetSecrets(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isFetched).toBe(true));
// Verify the query was cached with the org-specific key
const cachedData = queryClient.getQueryData(["secrets", "org-1"]);
expect(cachedData).toBeDefined();
// Verify no data is cached under the old key without org ID
const oldKeyData = queryClient.getQueryData(["secrets"]);
expect(oldKeyData).toBeUndefined();
});
it("should fetch different data when organization changes", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
// Mock different secrets for different orgs
getSecretsSpy.mockResolvedValueOnce([
{ name: "SECRET_ORG_1", description: "Org 1 secret" },
]);
const { result, rerender } = renderHook(() => useGetSecrets(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isFetched).toBe(true));
expect(result.current.data).toHaveLength(1);
expect(result.current.data?.[0].name).toBe("SECRET_ORG_1");
// Change organization
useSelectedOrganizationStore.setState({ organizationId: "org-2" });
getSecretsSpy.mockResolvedValueOnce([
{ name: "SECRET_ORG_2", description: "Org 2 secret" },
]);
rerender();
await waitFor(() => {
expect(result.current.data?.[0]?.name).toBe("SECRET_ORG_2");
});
});
});
describe("useApiKeys", () => {
it("should include organizationId in query key for proper cache isolation", async () => {
const getApiKeysSpy = vi.spyOn(ApiKeysClient, "getApiKeys");
getApiKeysSpy.mockResolvedValue([]);
const { result } = renderHook(() => useApiKeys(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isFetched).toBe(true));
// Verify the query was cached with the org-specific key
const cachedData = queryClient.getQueryData(["api-keys", "org-1"]);
expect(cachedData).toBeDefined();
// Verify no data is cached under the old key without org ID
const oldKeyData = queryClient.getQueryData(["api-keys"]);
expect(oldKeyData).toBeUndefined();
});
});
describe("Cache isolation between organizations", () => {
it("should maintain separate caches for each organization", async () => {
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
// Simulate fetching for org-1
getSettingsSpy.mockResolvedValueOnce({
...MOCK_DEFAULT_USER_SETTINGS,
language: "en",
});
useSelectedOrganizationStore.setState({ organizationId: "org-1" });
const { rerender } = renderHook(() => useSettings(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(queryClient.getQueryData(["settings", "org-1"])).toBeDefined();
});
// Switch to org-2
getSettingsSpy.mockResolvedValueOnce({
...MOCK_DEFAULT_USER_SETTINGS,
language: "fr",
});
useSelectedOrganizationStore.setState({ organizationId: "org-2" });
rerender();
await waitFor(() => {
expect(queryClient.getQueryData(["settings", "org-2"])).toBeDefined();
});
// Switch back to org-1 - should use cached data, not refetch
useSelectedOrganizationStore.setState({ organizationId: "org-1" });
rerender();
// org-1 data should still be in cache
const org1Cache = queryClient.getQueryData(["settings", "org-1"]) as any;
expect(org1Cache?.language).toBe("en");
// org-2 data should also still be in cache
const org2Cache = queryClient.getQueryData(["settings", "org-2"]) as any;
expect(org2Cache?.language).toBe("fr");
});
});
});

View File

@@ -21,10 +21,6 @@ vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
initReactI18next: {
type: "3rdParty",
init: () => {},
},
}));
const mockSetConversationMode = vi.fn();

View File

@@ -32,12 +32,6 @@ describe("useIsOnIntermediatePage", () => {
const { result } = renderHook(() => useIsOnIntermediatePage());
expect(result.current).toBe(true);
});
it("should return true when on /information-request page", () => {
useLocationMock.mockReturnValue({ pathname: "/information-request" });
const { result } = renderHook(() => useIsOnIntermediatePage());
expect(result.current).toBe(true);
});
});
describe("returns false for non-intermediate pages", () => {

View File

@@ -1,64 +0,0 @@
import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Conversation } from "#/api/open-hands.types";
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
import { useAgentState } from "#/hooks/use-agent-state";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { AgentState } from "#/types/agent-state";
vi.mock("#/hooks/use-agent-state");
vi.mock("#/hooks/query/use-active-conversation");
function asMockReturnValue<T>(value: Partial<T>): T {
return value as T;
}
function makeConversation(): Conversation {
return {
conversation_id: "conv-123",
title: "Test Conversation",
selected_repository: null,
selected_branch: null,
git_provider: null,
last_updated_at: new Date().toISOString(),
created_at: new Date().toISOString(),
status: "RUNNING",
runtime_status: null,
url: null,
session_api_key: null,
};
}
describe("useRuntimeIsReady", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useActiveConversation).mockReturnValue(
asMockReturnValue<ReturnType<typeof useActiveConversation>>({
data: makeConversation(),
}),
);
});
it("treats agent errors as not ready by default", () => {
vi.mocked(useAgentState).mockReturnValue({
curAgentState: AgentState.ERROR,
});
const { result } = renderHook(() => useRuntimeIsReady());
expect(result.current).toBe(false);
});
it("allows runtime-backed tabs to stay ready when the agent errors", () => {
vi.mocked(useAgentState).mockReturnValue({
curAgentState: AgentState.ERROR,
});
const { result } = renderHook(() =>
useRuntimeIsReady({ allowAgentError: true }),
);
expect(result.current).toBe(true);
});
});

View File

@@ -10,10 +10,6 @@ vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
initReactI18next: {
type: "3rdParty",
init: () => {},
},
}));
vi.mock("#/hooks/use-user-providers", () => ({

View File

@@ -3,23 +3,9 @@ import { renderHook, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav";
import OptionService from "#/api/option-service/option-service.api";
import {
useSettingsNavItems,
SettingsNavRenderedItem,
} from "#/hooks/use-settings-nav-items";
import { useSettingsNavItems } from "#/hooks/use-settings-nav-items";
import { WebClientFeatureFlags } from "#/api/option-service/option.types";
// Helper to find an item by path in rendered items
const findItemByPath = (
items: SettingsNavRenderedItem[],
path: string,
): SettingsNavRenderedItem | undefined =>
items.find((item) => item.type === "item" && item.item.to === path);
// Helper to get only nav items (excluding headers and dividers)
const getNavItems = (items: SettingsNavRenderedItem[]) =>
items.filter((item) => item.type === "item");
// Mock useOrgTypeAndAccess
const mockOrgTypeAndAccess = vi.hoisted(() => ({
isPersonalOrg: false,
@@ -109,13 +95,14 @@ describe("useSettingsNavItems", () => {
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
await waitFor(() => {
// Members should not see billing, org, or org-members routes
expect(findItemByPath(result.current, "/settings/billing")).toBeUndefined();
expect(findItemByPath(result.current, "/settings/org")).toBeUndefined();
expect(findItemByPath(result.current, "/settings/org-members")).toBeUndefined();
// But should see other items
expect(findItemByPath(result.current, "/settings")).toBeDefined();
expect(findItemByPath(result.current, "/settings/user")).toBeDefined();
expect(result.current).toEqual(
SAAS_NAV_ITEMS.filter(
(item) =>
item.to !== "/settings/billing" &&
item.to !== "/settings/org" &&
item.to !== "/settings/org-members",
),
);
});
});
@@ -125,13 +112,7 @@ describe("useSettingsNavItems", () => {
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
await waitFor(() => {
// OSS mode should return items matching OSS_NAV_ITEMS paths
const navItems = getNavItems(result.current);
const ossPaths = OSS_NAV_ITEMS.map((item) => item.to);
const resultPaths = navItems.map((item) =>
item.type === "item" ? item.item.to : null,
);
expect(resultPaths).toEqual(ossPaths);
expect(result.current).toEqual(OSS_NAV_ITEMS);
});
});
@@ -142,7 +123,9 @@ describe("useSettingsNavItems", () => {
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
await waitFor(() => {
expect(findItemByPath(result.current, "/settings")).toBeUndefined();
expect(
result.current.find((item) => item.to === "/settings"),
).toBeUndefined();
});
});
@@ -159,16 +142,16 @@ describe("useSettingsNavItems", () => {
await waitFor(() => {
expect(result.current.length).toBeGreaterThan(0);
expect(
findItemByPath(result.current, "/settings/user"),
result.current.find((item) => item.to === "/settings/user"),
).toBeDefined();
});
// Org routes should be included for team org admin
expect(
findItemByPath(result.current, "/settings/org"),
result.current.find((item) => item.to === "/settings/org"),
).toBeDefined();
expect(
findItemByPath(result.current, "/settings/org-members"),
result.current.find((item) => item.to === "/settings/org-members"),
).toBeDefined();
});
@@ -184,16 +167,16 @@ describe("useSettingsNavItems", () => {
await waitFor(() => {
expect(result.current.length).toBeGreaterThan(0);
expect(
findItemByPath(result.current, "/settings/user"),
result.current.find((item) => item.to === "/settings/user"),
).toBeDefined();
});
// Org routes should be filtered out for personal orgs
expect(
findItemByPath(result.current, "/settings/org"),
result.current.find((item) => item.to === "/settings/org"),
).toBeUndefined();
expect(
findItemByPath(result.current, "/settings/org-members"),
result.current.find((item) => item.to === "/settings/org-members"),
).toBeUndefined();
});
@@ -209,16 +192,16 @@ describe("useSettingsNavItems", () => {
await waitFor(() => {
expect(result.current.length).toBeGreaterThan(0);
expect(
findItemByPath(result.current, "/settings/user"),
result.current.find((item) => item.to === "/settings/user"),
).toBeDefined();
});
// Org routes should be hidden for members
expect(
findItemByPath(result.current, "/settings/org"),
result.current.find((item) => item.to === "/settings/org"),
).toBeUndefined();
expect(
findItemByPath(result.current, "/settings/org-members"),
result.current.find((item) => item.to === "/settings/org-members"),
).toBeUndefined();
});
@@ -235,16 +218,16 @@ describe("useSettingsNavItems", () => {
await waitFor(() => {
expect(result.current.length).toBeGreaterThan(0);
expect(
findItemByPath(result.current, "/settings/user"),
result.current.find((item) => item.to === "/settings/user"),
).toBeDefined();
});
// Org routes should be hidden when no org is selected
expect(
findItemByPath(result.current, "/settings/org"),
result.current.find((item) => item.to === "/settings/org"),
).toBeUndefined();
expect(
findItemByPath(result.current, "/settings/org-members"),
result.current.find((item) => item.to === "/settings/org-members"),
).toBeUndefined();
});
@@ -260,13 +243,13 @@ describe("useSettingsNavItems", () => {
await waitFor(() => {
expect(result.current.length).toBeGreaterThan(0);
expect(
findItemByPath(result.current, "/settings/user"),
result.current.find((item) => item.to === "/settings/user"),
).toBeDefined();
});
// Billing should be hidden for team orgs
expect(
findItemByPath(result.current, "/settings/billing"),
result.current.find((item) => item.to === "/settings/billing"),
).toBeUndefined();
});
@@ -283,13 +266,13 @@ describe("useSettingsNavItems", () => {
await waitFor(() => {
expect(result.current.length).toBeGreaterThan(0);
expect(
findItemByPath(result.current, "/settings/user"),
result.current.find((item) => item.to === "/settings/user"),
).toBeDefined();
});
// Billing should be visible for personal orgs
expect(
findItemByPath(result.current, "/settings/billing"),
result.current.find((item) => item.to === "/settings/billing"),
).toBeDefined();
});
});
@@ -309,14 +292,14 @@ describe("useSettingsNavItems", () => {
await waitFor(() => {
expect(
findItemByPath(result.current, "/settings/user"),
result.current.find((item) => item.to === "/settings/user"),
).toBeUndefined();
// Other pages should still be present
expect(
findItemByPath(result.current, "/settings/integrations"),
result.current.find((item) => item.to === "/settings/integrations"),
).toBeDefined();
expect(
findItemByPath(result.current, "/settings/billing"),
result.current.find((item) => item.to === "/settings/billing"),
).toBeDefined();
});
});
@@ -327,14 +310,14 @@ describe("useSettingsNavItems", () => {
await waitFor(() => {
expect(
findItemByPath(result.current, "/settings/billing"),
result.current.find((item) => item.to === "/settings/billing"),
).toBeUndefined();
// Other pages should still be present
expect(
findItemByPath(result.current, "/settings/user"),
result.current.find((item) => item.to === "/settings/user"),
).toBeDefined();
expect(
findItemByPath(result.current, "/settings/integrations"),
result.current.find((item) => item.to === "/settings/integrations"),
).toBeDefined();
});
});
@@ -345,14 +328,14 @@ describe("useSettingsNavItems", () => {
await waitFor(() => {
expect(
findItemByPath(result.current, "/settings/integrations"),
result.current.find((item) => item.to === "/settings/integrations"),
).toBeUndefined();
// Other pages should still be present
expect(
findItemByPath(result.current, "/settings/user"),
result.current.find((item) => item.to === "/settings/user"),
).toBeDefined();
expect(
findItemByPath(result.current, "/settings/billing"),
result.current.find((item) => item.to === "/settings/billing"),
).toBeDefined();
});
});
@@ -367,26 +350,26 @@ describe("useSettingsNavItems", () => {
await waitFor(() => {
expect(
findItemByPath(result.current, "/settings/user"),
result.current.find((item) => item.to === "/settings/user"),
).toBeUndefined();
expect(
findItemByPath(result.current, "/settings/billing"),
result.current.find((item) => item.to === "/settings/billing"),
).toBeUndefined();
expect(
findItemByPath(result.current, "/settings/integrations"),
result.current.find((item) => item.to === "/settings/integrations"),
).toBeUndefined();
// Non-hidden pages should still be present
expect(
findItemByPath(result.current, "/settings"),
result.current.find((item) => item.to === "/settings"),
).toBeDefined();
expect(
findItemByPath(result.current, "/settings/app"),
result.current.find((item) => item.to === "/settings/app"),
).toBeDefined();
expect(
findItemByPath(result.current, "/settings/secrets"),
result.current.find((item) => item.to === "/settings/secrets"),
).toBeDefined();
expect(
findItemByPath(result.current, "/settings/mcp"),
result.current.find((item) => item.to === "/settings/mcp"),
).toBeDefined();
});
});
@@ -398,19 +381,19 @@ describe("useSettingsNavItems", () => {
await waitFor(() => {
// All SAAS pages should be present
expect(
findItemByPath(result.current, "/settings/user"),
result.current.find((item) => item.to === "/settings/user"),
).toBeDefined();
expect(
findItemByPath(result.current, "/settings/billing"),
result.current.find((item) => item.to === "/settings/billing"),
).toBeDefined();
expect(
findItemByPath(result.current, "/settings/integrations"),
result.current.find((item) => item.to === "/settings/integrations"),
).toBeDefined();
expect(
findItemByPath(result.current, "/settings"),
result.current.find((item) => item.to === "/settings"),
).toBeDefined();
expect(
findItemByPath(result.current, "/settings/app"),
result.current.find((item) => item.to === "/settings/app"),
).toBeDefined();
});
});
@@ -421,17 +404,17 @@ describe("useSettingsNavItems", () => {
await waitFor(() => {
expect(
findItemByPath(result.current, "/settings/integrations"),
result.current.find((item) => item.to === "/settings/integrations"),
).toBeUndefined();
// Other OSS pages should still be present
expect(
findItemByPath(result.current, "/settings"),
result.current.find((item) => item.to === "/settings"),
).toBeDefined();
expect(
findItemByPath(result.current, "/settings/mcp"),
result.current.find((item) => item.to === "/settings/mcp"),
).toBeDefined();
expect(
findItemByPath(result.current, "/settings/app"),
result.current.find((item) => item.to === "/settings/app"),
).toBeDefined();
});
});
@@ -445,20 +428,20 @@ describe("useSettingsNavItems", () => {
await waitFor(() => {
expect(
findItemByPath(result.current, "/settings"),
result.current.find((item) => item.to === "/settings"),
).toBeUndefined();
expect(
findItemByPath(result.current, "/settings/integrations"),
result.current.find((item) => item.to === "/settings/integrations"),
).toBeUndefined();
// Other OSS pages should still be present
expect(
findItemByPath(result.current, "/settings/mcp"),
result.current.find((item) => item.to === "/settings/mcp"),
).toBeDefined();
expect(
findItemByPath(result.current, "/settings/app"),
result.current.find((item) => item.to === "/settings/app"),
).toBeDefined();
expect(
findItemByPath(result.current, "/settings/secrets"),
result.current.find((item) => item.to === "/settings/secrets"),
).toBeDefined();
});
});

View File

@@ -205,9 +205,7 @@ describe("useWebSocket", () => {
expect(result.current.isConnected).toBe(true);
});
// Reset spy after connection is established to ignore any spurious
// close events fired by the MSW mock during the handshake.
onCloseSpy.mockClear();
expect(onCloseSpy).not.toHaveBeenCalled();
// Unmount to trigger close
unmount();

View File

@@ -5,11 +5,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import AcceptTOS from "#/routes/accept-tos";
import * as CaptureConsent from "#/utils/handle-capture-consent";
import { openHands } from "#/api/open-hands-axios";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
// Mock the react-router hooks
vi.mock("react-router", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router")>()),
vi.mock("react-router", () => ({
useNavigate: () => vi.fn(),
useSearchParams: () => [
{
@@ -21,7 +19,6 @@ vi.mock("react-router", async (importOriginal) => ({
},
},
],
useRevalidator: () => ({ revalidate: vi.fn() }),
}));
// Mock the axios instance
@@ -57,7 +54,6 @@ const createWrapper = () => {
describe("AcceptTOS", () => {
beforeEach(() => {
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
vi.stubGlobal("location", { href: "" });
});

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