mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
68 Commits
fix-setup-
...
fix/git-ap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4f7f07d5d | ||
|
|
a34dc949ce | ||
|
|
d6b8d80026 | ||
|
|
1e6a92b454 | ||
|
|
b4a3e5db2f | ||
|
|
80e4fe1226 | ||
|
|
f9d553d0bb | ||
|
|
f6f6c1ab25 | ||
|
|
c511a89426 | ||
|
|
1f82ff04d9 | ||
|
|
eec17311c7 | ||
|
|
c34fdf4b37 | ||
|
|
25076ee44c | ||
|
|
baaec8473a | ||
|
|
402fa47422 | ||
|
|
8dde385843 | ||
|
|
a905e35531 | ||
|
|
1f185173b7 | ||
|
|
ddc7a78723 | ||
|
|
a29ed4d926 | ||
|
|
b8ab4bb44e | ||
|
|
ddd544f8d6 | ||
|
|
3804b66e32 | ||
|
|
b97adf392a | ||
|
|
dcb584913a | ||
|
|
d2fd54a083 | ||
|
|
112d863287 | ||
|
|
c8680caec3 | ||
|
|
d4b9fb1d03 | ||
|
|
409df1287d | ||
|
|
a92bfe6cc0 | ||
|
|
f93e3254d3 | ||
|
|
0476d57451 | ||
|
|
a4cd21e155 | ||
|
|
7f3af371d1 | ||
|
|
1421794c1b | ||
|
|
2fc689457c | ||
|
|
3161b365a8 | ||
|
|
18ab56ef4e | ||
|
|
a9c0df778c | ||
|
|
51b989b5f8 | ||
|
|
dc039d81d6 | ||
|
|
8e4559b14a | ||
|
|
b84f352b63 | ||
|
|
a0dba6124a | ||
|
|
951739f3eb | ||
|
|
0f1ad46a47 | ||
|
|
5367bef43a | ||
|
|
3afeccfe7f | ||
|
|
0677c035ff | ||
|
|
68165b52d9 | ||
|
|
dcc8217317 | ||
|
|
d1410949ff | ||
|
|
a6c0d80fe1 | ||
|
|
0efb1db85d | ||
|
|
8e0f74c92c | ||
|
|
6e1ba3d836 | ||
|
|
0ec97893d1 | ||
|
|
ddb809bc43 | ||
|
|
872f2b87f2 | ||
|
|
ee86005a3a | ||
|
|
d4aa30580b | ||
|
|
2f0e879129 | ||
|
|
3bc2ef954e | ||
|
|
32ab2a24c6 | ||
|
|
a6e148d1e6 | ||
|
|
3fc977eddd | ||
|
|
89a6890269 |
2
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
2
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# disable blank issue creation
|
||||
blank_issues_enabled: false
|
||||
29
.github/workflows/enterprise-preview.yml
vendored
29
.github/workflows/enterprise-preview.yml
vendored
@@ -1,29 +0,0 @@
|
||||
# Feature branch preview for enterprise code
|
||||
name: Enterprise Preview
|
||||
|
||||
# Run on PRs labeled
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
|
||||
# Match ghcr-build.yml, but don't interrupt it.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# This must happen for the PR Docker workflow when the label is present,
|
||||
# and also if it's added after the fact. Thus, it exists in both places.
|
||||
enterprise-preview:
|
||||
name: Enterprise preview
|
||||
if: github.event.label.name == 'deploy'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
# This should match the version in ghcr-build.yml
|
||||
- name: Trigger remote job
|
||||
run: |
|
||||
curl --fail-with-body -sS -X POST \
|
||||
-H "Authorization: Bearer ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-d "{\"ref\": \"main\", \"inputs\": {\"openhandsPrNumber\": \"${{ github.event.pull_request.number }}\", \"deployEnvironment\": \"feature\", \"enterpriseImageTag\": \"pr-${{ github.event.pull_request.number }}\" }}" \
|
||||
https://api.github.com/repos/OpenHands/deploy/actions/workflows/deploy.yaml/dispatches
|
||||
15
.github/workflows/ghcr-build.yml
vendored
15
.github/workflows/ghcr-build.yml
vendored
@@ -240,21 +240,6 @@ jobs:
|
||||
# Add build attestations for better security
|
||||
sbom: true
|
||||
|
||||
enterprise-preview:
|
||||
name: Enterprise preview
|
||||
if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'deploy')
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
needs: [ghcr_build_enterprise]
|
||||
steps:
|
||||
# This should match the version in enterprise-preview.yml
|
||||
- name: Trigger remote job
|
||||
run: |
|
||||
curl --fail-with-body -sS -X POST \
|
||||
-H "Authorization: Bearer ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-d "{\"ref\": \"main\", \"inputs\": {\"openhandsPrNumber\": \"${{ github.event.pull_request.number }}\", \"deployEnvironment\": \"feature\", \"enterpriseImageTag\": \"pr-${{ github.event.pull_request.number }}\" }}" \
|
||||
https://api.github.com/repos/OpenHands/deploy/actions/workflows/deploy.yaml/dispatches
|
||||
|
||||
# "All Runtime Tests Passed" is a required job for PRs to merge
|
||||
# We can remove this once the config changes
|
||||
runtime_tests_check_success:
|
||||
|
||||
137
.github/workflows/pr-review-by-openhands.yml
vendored
137
.github/workflows/pr-review-by-openhands.yml
vendored
@@ -2,16 +2,11 @@
|
||||
name: PR Review by OpenHands
|
||||
|
||||
on:
|
||||
# Use pull_request_target to allow fork PRs to access secrets when triggered by maintainers
|
||||
# Security: This workflow runs when:
|
||||
# 1. A new PR is opened (non-draft), OR
|
||||
# 2. A draft PR is marked as ready for review, OR
|
||||
# 3. A maintainer adds the 'review-this' label, OR
|
||||
# 4. A maintainer requests openhands-agent or all-hands-bot as a reviewer
|
||||
# Only users with write access can add labels or request reviews, ensuring security.
|
||||
# The PR code is explicitly checked out for review, but secrets are only accessible
|
||||
# because the workflow runs in the base repository context
|
||||
pull_request_target:
|
||||
# TEMPORARY MITIGATION (Clinejection hardening)
|
||||
#
|
||||
# We temporarily avoid `pull_request_target` here. We'll restore it after the PR review
|
||||
# workflow is fully hardened for untrusted execution.
|
||||
pull_request:
|
||||
types: [opened, ready_for_review, labeled, review_requested]
|
||||
|
||||
permissions:
|
||||
@@ -21,107 +16,33 @@ permissions:
|
||||
|
||||
jobs:
|
||||
pr-review:
|
||||
# Run when one of the following conditions is met:
|
||||
# 1. A new non-draft PR is opened by a trusted contributor, OR
|
||||
# 2. A draft PR is converted to ready for review by a trusted contributor, OR
|
||||
# 3. 'review-this' label is added, OR
|
||||
# 4. openhands-agent or all-hands-bot is requested as a reviewer
|
||||
# Note: FIRST_TIME_CONTRIBUTOR PRs require manual trigger via label/reviewer request
|
||||
# Note: fork PRs will not have access to repository secrets under `pull_request`.
|
||||
# Skip forks to avoid noisy failures until we restore a hardened `pull_request_target` flow.
|
||||
if: |
|
||||
(github.event.action == 'opened' && github.event.pull_request.draft == false && github.event.pull_request.author_association != 'FIRST_TIME_CONTRIBUTOR') ||
|
||||
(github.event.action == 'ready_for_review' && github.event.pull_request.author_association != 'FIRST_TIME_CONTRIBUTOR') ||
|
||||
github.event.label.name == 'review-this' ||
|
||||
github.event.requested_reviewer.login == 'openhands-agent' ||
|
||||
github.event.requested_reviewer.login == 'all-hands-bot'
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
(
|
||||
(github.event.action == 'opened' && github.event.pull_request.draft == false) ||
|
||||
github.event.action == 'ready_for_review' ||
|
||||
(github.event.action == 'labeled' && github.event.label.name == 'review-this') ||
|
||||
(
|
||||
github.event.action == 'review_requested' &&
|
||||
(
|
||||
github.event.requested_reviewer.login == 'openhands-agent' ||
|
||||
github.event.requested_reviewer.login == 'all-hands-bot'
|
||||
)
|
||||
)
|
||||
)
|
||||
concurrency:
|
||||
group: pr-review-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
env:
|
||||
LLM_MODEL: litellm_proxy/claude-sonnet-4-5-20250929
|
||||
LLM_BASE_URL: https://llm-proxy.app.all-hands.dev
|
||||
# PR context will be automatically provided by the agent script
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
PR_BASE_BRANCH: ${{ github.event.pull_request.base.ref }}
|
||||
PR_HEAD_BRANCH: ${{ github.event.pull_request.head.ref }}
|
||||
REPO_NAME: ${{ github.repository }}
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout software-agent-sdk repository
|
||||
uses: actions/checkout@v5
|
||||
- name: Run PR Review
|
||||
uses: OpenHands/extensions/plugins/pr-review@main
|
||||
with:
|
||||
repository: OpenHands/software-agent-sdk
|
||||
path: software-agent-sdk
|
||||
|
||||
- name: Checkout PR repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
# When using pull_request_target, explicitly checkout the PR branch
|
||||
# This ensures we review the actual PR code (including fork PRs)
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
fetch-depth: 0
|
||||
# Security: Don't persist credentials to prevent untrusted PR code from using them
|
||||
persist-credentials: false
|
||||
path: pr-repo
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install GitHub CLI
|
||||
run: |
|
||||
# Install GitHub CLI for posting review comments
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gh
|
||||
|
||||
- name: Install OpenHands dependencies
|
||||
run: |
|
||||
# Install OpenHands SDK and tools from local checkout
|
||||
uv pip install --system ./software-agent-sdk/openhands-sdk ./software-agent-sdk/openhands-tools
|
||||
|
||||
- name: Check required configuration
|
||||
env:
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
run: |
|
||||
if [ -z "$LLM_API_KEY" ]; then
|
||||
echo "Error: LLM_API_KEY secret is not set."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "PR Number: $PR_NUMBER"
|
||||
echo "PR Title: $PR_TITLE"
|
||||
echo "Repository: $REPO_NAME"
|
||||
echo "LLM model: $LLM_MODEL"
|
||||
if [ -n "$LLM_BASE_URL" ]; then
|
||||
echo "LLM base URL: $LLM_BASE_URL"
|
||||
fi
|
||||
|
||||
- name: Run PR review
|
||||
env:
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}
|
||||
LMNR_PROJECT_API_KEY: ${{ secrets.LMNR_SKILLS_API_KEY }}
|
||||
run: |
|
||||
# Change to the PR repository directory so agent can analyze the code
|
||||
cd pr-repo
|
||||
|
||||
# Run the PR review script from the software-agent-sdk checkout
|
||||
uv run python ../software-agent-sdk/examples/03_github_workflows/02_pr_review/agent_script.py
|
||||
|
||||
- name: Upload logs as artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
if: always()
|
||||
with:
|
||||
name: openhands-pr-review-logs
|
||||
path: |
|
||||
*.log
|
||||
output/
|
||||
retention-days: 7
|
||||
llm-model: litellm_proxy/claude-sonnet-4-5-20250929
|
||||
llm-base-url: https://llm-proxy.app.all-hands.dev
|
||||
review-style: roasted
|
||||
llm-api-key: ${{ secrets.LLM_API_KEY }}
|
||||
github-token: ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}
|
||||
lmnr-api-key: ${{ secrets.LMNR_SKILLS_API_KEY }}
|
||||
|
||||
85
.github/workflows/pr-review-evaluation.yml
vendored
Normal file
85
.github/workflows/pr-review-evaluation.yml
vendored
Normal file
@@ -0,0 +1,85 @@
|
||||
---
|
||||
name: PR Review Evaluation
|
||||
|
||||
# This workflow evaluates how well PR review comments were addressed.
|
||||
# It runs when a PR is closed to assess review effectiveness.
|
||||
#
|
||||
# Security note: pull_request_target is safe here because:
|
||||
# 1. Only triggers on PR close (not on code changes)
|
||||
# 2. Does not checkout PR code - only downloads artifacts from trusted workflow runs
|
||||
# 3. Runs evaluation scripts from the extensions repo, not from the PR
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [closed]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
evaluate:
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPO_NAME: ${{ github.repository }}
|
||||
PR_MERGED: ${{ github.event.pull_request.merged }}
|
||||
|
||||
steps:
|
||||
- name: Download review trace artifact
|
||||
id: download-trace
|
||||
uses: dawidd6/action-download-artifact@v6
|
||||
continue-on-error: true
|
||||
with:
|
||||
workflow: pr-review-by-openhands.yml
|
||||
name: pr-review-trace-${{ github.event.pull_request.number }}
|
||||
path: trace-info
|
||||
search_artifacts: true
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Check if trace file exists
|
||||
id: check-trace
|
||||
run: |
|
||||
if [ -f "trace-info/laminar_trace_info.json" ]; then
|
||||
echo "trace_exists=true" >> $GITHUB_OUTPUT
|
||||
echo "Found trace file for PR #$PR_NUMBER"
|
||||
else
|
||||
echo "trace_exists=false" >> $GITHUB_OUTPUT
|
||||
echo "No trace file found for PR #$PR_NUMBER - skipping evaluation"
|
||||
fi
|
||||
|
||||
# Always checkout main branch for security - cannot test script changes in PRs
|
||||
- name: Checkout extensions repository
|
||||
if: steps.check-trace.outputs.trace_exists == 'true'
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: OpenHands/extensions
|
||||
path: extensions
|
||||
|
||||
- name: Set up Python
|
||||
if: steps.check-trace.outputs.trace_exists == 'true'
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.check-trace.outputs.trace_exists == 'true'
|
||||
run: pip install lmnr
|
||||
|
||||
- name: Run evaluation
|
||||
if: steps.check-trace.outputs.trace_exists == 'true'
|
||||
env:
|
||||
# Script expects LMNR_PROJECT_API_KEY; org secret is named LMNR_SKILLS_API_KEY
|
||||
LMNR_PROJECT_API_KEY: ${{ secrets.LMNR_SKILLS_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
python extensions/plugins/pr-review/scripts/evaluate_review.py \
|
||||
--trace-file trace-info/laminar_trace_info.json
|
||||
|
||||
- name: Upload evaluation logs
|
||||
uses: actions/upload-artifact@v5
|
||||
if: always() && steps.check-trace.outputs.trace_exists == 'true'
|
||||
with:
|
||||
name: pr-review-evaluation-${{ github.event.pull_request.number }}
|
||||
path: '*.log'
|
||||
retention-days: 30
|
||||
131
enterprise/doc/design-doc/plugin-launch-flow.md
Normal file
131
enterprise/doc/design-doc/plugin-launch-flow.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# Plugin Launch Flow
|
||||
|
||||
This document describes how plugins are launched in OpenHands Saas / Enterprise, from the plugin directory through to agent execution.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
Plugin Directory ──▶ Frontend /launch ──▶ App Server ──▶ Agent Server ──▶ SDK
|
||||
(external) (modal) (API) (in sandbox) (plugin loading)
|
||||
```
|
||||
|
||||
| Component | Responsibility |
|
||||
|-----------|---------------|
|
||||
| **Plugin Directory** | Index plugins, present to user, construct launch URLs |
|
||||
| **Frontend** | Display confirmation modal, collect parameters, call API |
|
||||
| **App Server** | Validate request, pass plugin specs to agent server |
|
||||
| **Agent Server** | Run inside sandbox, delegate plugin loading to SDK |
|
||||
| **SDK** | Fetch plugins, load contents, merge skills/hooks/MCP into agent |
|
||||
|
||||
## User Experience
|
||||
|
||||
### Plugin Directory
|
||||
|
||||
The plugin directory presents users with a catalog of available plugins. For each plugin, users see:
|
||||
- Plugin name and description (from `plugin.json`)
|
||||
- Author and version information
|
||||
- A "Launch" button
|
||||
|
||||
When a user clicks "Launch", the plugin directory:
|
||||
1. Reads the plugin's `entry_command` to know which slash command to invoke
|
||||
2. Determines what parameters the plugin accepts (if any)
|
||||
3. Redirects to OpenHands with this information encoded in the URL
|
||||
|
||||
### Parameter Collection
|
||||
|
||||
If a plugin requires user input (API keys, configuration values, etc.), the frontend displays a form modal before starting the conversation. Parameters are passed in the launch URL and rendered as form fields based on their type:
|
||||
|
||||
- **String values** → Text input
|
||||
- **Number values** → Number input
|
||||
- **Boolean values** → Checkbox
|
||||
|
||||
Only primitive types are supported. Complex types (arrays, objects) are not currently supported for parameter input.
|
||||
|
||||
The user fills in required values, then clicks "Start Conversation" to proceed.
|
||||
|
||||
## Launch Flow
|
||||
|
||||
1. **Plugin Directory** (external) constructs a launch URL to the OpenHands app server when user clicks "Launch":
|
||||
```
|
||||
/launch?plugins=BASE64_JSON&message=/city-weather:now%20Tokyo
|
||||
```
|
||||
|
||||
The `plugins` parameter includes any parameter definitions with default values:
|
||||
```json
|
||||
[{
|
||||
"source": "github:owner/repo",
|
||||
"repo_path": "plugins/my-plugin",
|
||||
"parameters": {"api_key": "", "timeout": 30, "debug": false}
|
||||
}]
|
||||
```
|
||||
|
||||
2. **OpenHands Frontend** (`/launch` route, [PR #12699](https://github.com/OpenHands/OpenHands/pull/12699)) displays modal with parameter form, collects user input
|
||||
|
||||
3. **OpenHands App Server** ([PR #12338](https://github.com/OpenHands/OpenHands/pull/12338)) receives the API call:
|
||||
```
|
||||
POST /api/v1/app-conversations
|
||||
{
|
||||
"plugins": [{"source": "github:owner/repo", "repo_path": "plugins/city-weather"}],
|
||||
"initial_message": {"content": [{"type": "text", "text": "/city-weather:now Tokyo"}]}
|
||||
}
|
||||
```
|
||||
|
||||
Call stack:
|
||||
- `AppConversationRouter` receives request with `PluginSpec` list
|
||||
- `LiveStatusAppConversationService._finalize_conversation_request()` converts `PluginSpec` → `PluginSource`
|
||||
- Creates `StartConversationRequest(plugins=sdk_plugins, ...)` and sends to agent server
|
||||
|
||||
4. **Agent Server** (inside sandbox, [SDK PR #1651](https://github.com/OpenHands/software-agent-sdk/pull/1651)) stores specs, defers loading:
|
||||
|
||||
Call stack:
|
||||
- `ConversationService.start_conversation()` receives `StartConversationRequest`
|
||||
- Creates `StoredConversation` with plugin specs
|
||||
- Creates `LocalConversation(plugins=request.plugins, ...)`
|
||||
- Plugin loading deferred until first `run()` or `send_message()`
|
||||
|
||||
5. **SDK** fetches and loads plugins on first use:
|
||||
|
||||
Call stack:
|
||||
- `LocalConversation._ensure_plugins_loaded()` triggered by first message
|
||||
- For each plugin spec:
|
||||
- `Plugin.fetch(source, ref, repo_path)` → clones/caches git repo
|
||||
- `Plugin.load(path)` → parses `plugin.json`, loads commands/skills/hooks
|
||||
- `plugin.add_skills_to(context)` → merges skills into agent
|
||||
- `plugin.add_mcp_config_to(config)` → merges MCP servers
|
||||
|
||||
6. **Agent** receives message, `/city-weather:now` triggers the skill
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### Plugin Loading in Sandbox
|
||||
|
||||
Plugins load **inside the sandbox** because:
|
||||
- Plugin hooks and scripts need isolated execution
|
||||
- MCP servers run inside the sandbox
|
||||
- Skills may reference sandbox filesystem
|
||||
|
||||
### Entry Command Handling
|
||||
|
||||
The `entry_command` field in `plugin.json` allows plugin authors to declare a default command:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "city-weather",
|
||||
"entry_command": "now"
|
||||
}
|
||||
```
|
||||
|
||||
This flows through the system:
|
||||
1. Plugin author declares `entry_command` in plugin.json
|
||||
2. Plugin directory reads it when indexing
|
||||
3. Plugin directory includes `/city-weather:now` in the launch URL's `message` parameter
|
||||
4. Message passes through to agent as `initial_message`
|
||||
|
||||
The SDK exposes this field but does not auto-invoke it—callers control the initial message.
|
||||
|
||||
## Related
|
||||
|
||||
- [OpenHands PR #12338](https://github.com/OpenHands/OpenHands/pull/12338) - App server plugin support
|
||||
- [OpenHands PR #12699](https://github.com/OpenHands/OpenHands/pull/12699) - Frontend `/launch` route
|
||||
- [SDK PR #1651](https://github.com/OpenHands/software-agent-sdk/pull/1651) - Agent server plugin loading
|
||||
- [SDK PR #1647](https://github.com/OpenHands/software-agent-sdk/pull/1647) - Plugin.fetch() for remote plugin fetching
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Add session_api_key_hash to v1_remote_sandbox table
|
||||
|
||||
Revision ID: 097
|
||||
Revises: 096
|
||||
Create Date: 2025-02-24 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '097'
|
||||
down_revision: Union[str, None] = '096'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add session_api_key_hash column to v1_remote_sandbox table."""
|
||||
op.add_column(
|
||||
'v1_remote_sandbox',
|
||||
sa.Column('session_api_key_hash', sa.String(), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
op.f('ix_v1_remote_sandbox_session_api_key_hash'),
|
||||
'v1_remote_sandbox',
|
||||
['session_api_key_hash'],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove session_api_key_hash column from v1_remote_sandbox table."""
|
||||
op.drop_index(
|
||||
op.f('ix_v1_remote_sandbox_session_api_key_hash'),
|
||||
table_name='v1_remote_sandbox',
|
||||
)
|
||||
op.drop_column('v1_remote_sandbox', 'session_api_key_hash')
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Create verified_models table.
|
||||
|
||||
Revision ID: 098
|
||||
Revises: 097
|
||||
Create Date: 2026-02-26 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '098'
|
||||
down_revision: Union[str, None] = '097'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create verified_models table and seed with current model list."""
|
||||
op.create_table(
|
||||
'verified_models',
|
||||
sa.Column('id', sa.Integer, sa.Identity(), primary_key=True),
|
||||
sa.Column('model_name', sa.String(255), nullable=False),
|
||||
sa.Column('provider', sa.String(100), nullable=False),
|
||||
sa.Column(
|
||||
'is_enabled',
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text('true'),
|
||||
),
|
||||
sa.Column(
|
||||
'created_at',
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.text('CURRENT_TIMESTAMP'),
|
||||
),
|
||||
sa.Column(
|
||||
'updated_at',
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.text('CURRENT_TIMESTAMP'),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
'model_name', 'provider', name='uq_verified_model_provider'
|
||||
),
|
||||
)
|
||||
|
||||
op.create_index(
|
||||
'ix_verified_models_provider',
|
||||
'verified_models',
|
||||
['provider'],
|
||||
)
|
||||
op.create_index(
|
||||
'ix_verified_models_is_enabled',
|
||||
'verified_models',
|
||||
['is_enabled'],
|
||||
)
|
||||
|
||||
# Seed with current openhands provider models
|
||||
models = [
|
||||
('claude-opus-4-5-20251101', 'openhands'),
|
||||
('claude-sonnet-4-5-20250929', 'openhands'),
|
||||
('gpt-5.2-codex', 'openhands'),
|
||||
('gpt-5.2', 'openhands'),
|
||||
('minimax-m2.5', 'openhands'),
|
||||
('gemini-3-pro-preview', 'openhands'),
|
||||
('gemini-3-flash-preview', 'openhands'),
|
||||
('deepseek-chat', 'openhands'),
|
||||
('devstral-medium-2512', 'openhands'),
|
||||
('kimi-k2-0711-preview', 'openhands'),
|
||||
('qwen3-coder-480b', 'openhands'),
|
||||
]
|
||||
|
||||
for model_name, provider in models:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO verified_models (model_name, provider)
|
||||
VALUES (:model_name, :provider)
|
||||
"""
|
||||
).bindparams(model_name=model_name, provider=provider)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop verified_models table."""
|
||||
op.drop_index('ix_verified_models_is_enabled', table_name='verified_models')
|
||||
op.drop_index('ix_verified_models_provider', table_name='verified_models')
|
||||
op.drop_table('verified_models')
|
||||
198
enterprise/poetry.lock
generated
198
enterprise/poetry.lock
generated
@@ -1540,66 +1540,58 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.3"
|
||||
version = "46.0.5"
|
||||
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
|
||||
optional = false
|
||||
python-versions = "!=3.9.0,!=3.9.1,>=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372"},
|
||||
{file = "cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32"},
|
||||
{file = "cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c"},
|
||||
{file = "cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea"},
|
||||
{file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b"},
|
||||
{file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb"},
|
||||
{file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717"},
|
||||
{file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9"},
|
||||
{file = "cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c"},
|
||||
{file = "cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1"},
|
||||
{file = "cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad"},
|
||||
{file = "cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b"},
|
||||
{file = "cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b"},
|
||||
{file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263"},
|
||||
{file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d"},
|
||||
{file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed"},
|
||||
{file = "cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2"},
|
||||
{file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2"},
|
||||
{file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0"},
|
||||
{file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731"},
|
||||
{file = "cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82"},
|
||||
{file = "cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1"},
|
||||
{file = "cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48"},
|
||||
{file = "cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4"},
|
||||
{file = "cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2"},
|
||||
{file = "cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678"},
|
||||
{file = "cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87"},
|
||||
{file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee"},
|
||||
{file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981"},
|
||||
{file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9"},
|
||||
{file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648"},
|
||||
{file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4"},
|
||||
{file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0"},
|
||||
{file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663"},
|
||||
{file = "cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826"},
|
||||
{file = "cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d"},
|
||||
{file = "cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a"},
|
||||
{file = "cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4"},
|
||||
{file = "cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31"},
|
||||
{file = "cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18"},
|
||||
{file = "cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235"},
|
||||
{file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a"},
|
||||
{file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76"},
|
||||
{file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614"},
|
||||
{file = "cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229"},
|
||||
{file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1"},
|
||||
{file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d"},
|
||||
{file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c"},
|
||||
{file = "cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4"},
|
||||
{file = "cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9"},
|
||||
{file = "cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72"},
|
||||
{file = "cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595"},
|
||||
{file = "cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c"},
|
||||
{file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a"},
|
||||
{file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356"},
|
||||
{file = "cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -1612,7 +1604,7 @@ nox = ["nox[uv] (>=2024.4.15)"]
|
||||
pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"]
|
||||
sdist = ["build (>=1.0.0)"]
|
||||
ssh = ["bcrypt (>=3.1.5)"]
|
||||
test = ["certifi (>=2024)", "cryptography-vectors (==46.0.3)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
|
||||
test = ["certifi (>=2024)", "cryptography-vectors (==46.0.5)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
|
||||
test-randomorder = ["pytest-randomly"]
|
||||
|
||||
[[package]]
|
||||
@@ -5754,14 +5746,14 @@ test = ["flaky", "ipykernel (>=6.19.3)", "ipython", "ipywidgets", "nbconvert (>=
|
||||
|
||||
[[package]]
|
||||
name = "nbconvert"
|
||||
version = "7.16.6"
|
||||
description = "Converting Jupyter Notebooks (.ipynb files) to other formats. Output formats include asciidoc, html, latex, markdown, pdf, py, rst, script. nbconvert can be used both as a Python library (`import nbconvert`) or as a command line tool (invoked as `jupyter nbconvert ...`)."
|
||||
version = "7.17.0"
|
||||
description = "Convert Jupyter Notebooks (.ipynb files) to other formats."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "nbconvert-7.16.6-py3-none-any.whl", hash = "sha256:1375a7b67e0c2883678c48e506dc320febb57685e5ee67faa51b18a90f3a712b"},
|
||||
{file = "nbconvert-7.16.6.tar.gz", hash = "sha256:576a7e37c6480da7b8465eefa66c17844243816ce1ccc372633c6b71c3c0f582"},
|
||||
{file = "nbconvert-7.17.0-py3-none-any.whl", hash = "sha256:4f99a63b337b9a23504347afdab24a11faa7d86b405e5c8f9881cd313336d518"},
|
||||
{file = "nbconvert-7.17.0.tar.gz", hash = "sha256:1b2696f1b5be12309f6c7d707c24af604b87dfaf6d950794c7b07acab96dda78"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -5781,8 +5773,8 @@ pygments = ">=2.4.1"
|
||||
traitlets = ">=5.1"
|
||||
|
||||
[package.extras]
|
||||
all = ["flaky", "ipykernel", "ipython", "ipywidgets (>=7.5)", "myst-parser", "nbsphinx (>=0.2.12)", "playwright", "pydata-sphinx-theme", "pyqtwebengine (>=5.15)", "pytest (>=7)", "sphinx (==5.0.2)", "sphinxcontrib-spelling", "tornado (>=6.1)"]
|
||||
docs = ["ipykernel", "ipython", "myst-parser", "nbsphinx (>=0.2.12)", "pydata-sphinx-theme", "sphinx (==5.0.2)", "sphinxcontrib-spelling"]
|
||||
all = ["flaky", "intersphinx-registry", "ipykernel", "ipython", "ipywidgets (>=7.5)", "myst-parser", "nbsphinx (>=0.2.12)", "playwright", "pydata-sphinx-theme", "pyqtwebengine (>=5.15)", "pytest (>=7)", "sphinx (>=5.0.2)", "sphinxcontrib-spelling", "tornado (>=6.1)"]
|
||||
docs = ["intersphinx-registry", "ipykernel", "ipython", "myst-parser", "nbsphinx (>=0.2.12)", "pydata-sphinx-theme", "sphinx (>=5.0.2)", "sphinxcontrib-spelling"]
|
||||
qtpdf = ["pyqtwebengine (>=5.15)"]
|
||||
qtpng = ["pyqtwebengine (>=5.15)"]
|
||||
serve = ["tornado (>=6.1)"]
|
||||
@@ -6102,14 +6094,14 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
|
||||
|
||||
[[package]]
|
||||
name = "openhands-agent-server"
|
||||
version = "1.11.4"
|
||||
version = "1.11.5"
|
||||
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_agent_server-1.11.4-py3-none-any.whl", hash = "sha256:739bdb774dbfcd23d6e87ee6ee32bc0999f22300037506b6dd33e9ea67fa5c2a"},
|
||||
{file = "openhands_agent_server-1.11.4.tar.gz", hash = "sha256:41247f7022a046eb50ca3b552bc6d12bfa9776e1bd27d0989da91b9f7ac77ca2"},
|
||||
{file = "openhands_agent_server-1.11.5-py3-none-any.whl", hash = "sha256:8bae7063f232791d58a5c31919f58b557f7cce60e6295773985c7dadc556cb9e"},
|
||||
{file = "openhands_agent_server-1.11.5.tar.gz", hash = "sha256:b61366d727c61ab9b7fcd66faab53f230f8ef0928c1177a388d2c5c4be6ebbd0"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6126,7 +6118,7 @@ wsproto = ">=1.2.0"
|
||||
|
||||
[[package]]
|
||||
name = "openhands-ai"
|
||||
version = "1.3.0"
|
||||
version = "1.4.0"
|
||||
description = "OpenHands: Code Less, Make More"
|
||||
optional = false
|
||||
python-versions = "^3.12,<3.14"
|
||||
@@ -6168,9 +6160,9 @@ memory-profiler = ">=0.61"
|
||||
numpy = "*"
|
||||
openai = "2.8"
|
||||
openhands-aci = "0.3.2"
|
||||
openhands-agent-server = "1.11.4"
|
||||
openhands-sdk = "1.11.4"
|
||||
openhands-tools = "1.11.4"
|
||||
openhands-agent-server = "1.11.5"
|
||||
openhands-sdk = "1.11.5"
|
||||
openhands-tools = "1.11.5"
|
||||
opentelemetry-api = ">=1.33.1"
|
||||
opentelemetry-exporter-otlp-proto-grpc = ">=1.33.1"
|
||||
pathspec = ">=0.12.1"
|
||||
@@ -6194,7 +6186,7 @@ python-jose = {version = ">=3.3", extras = ["cryptography"]}
|
||||
python-json-logger = ">=3.2.1"
|
||||
python-multipart = "*"
|
||||
python-pptx = "*"
|
||||
python-socketio = "5.13"
|
||||
python-socketio = "5.14"
|
||||
pythonnet = "*"
|
||||
pyyaml = ">=6.0.2"
|
||||
qtconsole = ">=5.6.1"
|
||||
@@ -6225,14 +6217,14 @@ url = ".."
|
||||
|
||||
[[package]]
|
||||
name = "openhands-sdk"
|
||||
version = "1.11.4"
|
||||
version = "1.11.5"
|
||||
description = "OpenHands SDK - Core functionality for building AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_sdk-1.11.4-py3-none-any.whl", hash = "sha256:9f4607c5d94b56fbcd533207026ee892779dd50e29bce79277ff82454a4f76d5"},
|
||||
{file = "openhands_sdk-1.11.4.tar.gz", hash = "sha256:4088744f6b8856eeab22d3bc17e47d1736ea7ced945c2fa126bd7d48c14bb313"},
|
||||
{file = "openhands_sdk-1.11.5-py3-none-any.whl", hash = "sha256:f949cd540cbecc339d90fb0cca2a5f29e1b62566b82b5aee82ef40f259d14e60"},
|
||||
{file = "openhands_sdk-1.11.5.tar.gz", hash = "sha256:dd6225876b7b8dbb6c608559f2718c3d0bf44d0bb741e990b185c6cdc5150c5a"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6253,14 +6245,14 @@ boto3 = ["boto3 (>=1.35.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "openhands-tools"
|
||||
version = "1.11.4"
|
||||
version = "1.11.5"
|
||||
description = "OpenHands Tools - Runtime tools for AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_tools-1.11.4-py3-none-any.whl", hash = "sha256:efd721b73e87a0dac69171a76931363fa59fcde98107ca86081ee7bf0253673a"},
|
||||
{file = "openhands_tools-1.11.4.tar.gz", hash = "sha256:80671b1ea8c85a5247a75ea2340ae31d76363e9c723b104699a9a77e66d2043c"},
|
||||
{file = "openhands_tools-1.11.5-py3-none-any.whl", hash = "sha256:1e981e1e7f3544184fe946cee8eb6bd287010cdef77d83ebac945c9f42df3baf"},
|
||||
{file = "openhands_tools-1.11.5.tar.gz", hash = "sha256:d7b1163f6505a51b07147e7d8972062c129ecc46571a71f28d5470355e06650e"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -7323,23 +7315,23 @@ testing = ["google-api-core (>=1.31.5)"]
|
||||
|
||||
[[package]]
|
||||
name = "protobuf"
|
||||
version = "5.29.5"
|
||||
version = "5.29.6"
|
||||
description = ""
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "protobuf-5.29.5-cp310-abi3-win32.whl", hash = "sha256:3f1c6468a2cfd102ff4703976138844f78ebd1fb45f49011afc5139e9e283079"},
|
||||
{file = "protobuf-5.29.5-cp310-abi3-win_amd64.whl", hash = "sha256:3f76e3a3675b4a4d867b52e4a5f5b78a2ef9565549d4037e06cf7b0942b1d3fc"},
|
||||
{file = "protobuf-5.29.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e38c5add5a311f2a6eb0340716ef9b039c1dfa428b28f25a7838ac329204a671"},
|
||||
{file = "protobuf-5.29.5-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:fa18533a299d7ab6c55a238bf8629311439995f2e7eca5caaff08663606e9015"},
|
||||
{file = "protobuf-5.29.5-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:63848923da3325e1bf7e9003d680ce6e14b07e55d0473253a690c3a8b8fd6e61"},
|
||||
{file = "protobuf-5.29.5-cp38-cp38-win32.whl", hash = "sha256:ef91363ad4faba7b25d844ef1ada59ff1604184c0bcd8b39b8a6bef15e1af238"},
|
||||
{file = "protobuf-5.29.5-cp38-cp38-win_amd64.whl", hash = "sha256:7318608d56b6402d2ea7704ff1e1e4597bee46d760e7e4dd42a3d45e24b87f2e"},
|
||||
{file = "protobuf-5.29.5-cp39-cp39-win32.whl", hash = "sha256:6f642dc9a61782fa72b90878af134c5afe1917c89a568cd3476d758d3c3a0736"},
|
||||
{file = "protobuf-5.29.5-cp39-cp39-win_amd64.whl", hash = "sha256:470f3af547ef17847a28e1f47200a1cbf0ba3ff57b7de50d22776607cd2ea353"},
|
||||
{file = "protobuf-5.29.5-py3-none-any.whl", hash = "sha256:6cf42630262c59b2d8de33954443d94b746c952b01434fc58a417fdbd2e84bd5"},
|
||||
{file = "protobuf-5.29.5.tar.gz", hash = "sha256:bc1463bafd4b0929216c35f437a8e28731a2b7fe3d98bb77a600efced5a15c84"},
|
||||
{file = "protobuf-5.29.6-cp310-abi3-win32.whl", hash = "sha256:62e8a3114992c7c647bce37dcc93647575fc52d50e48de30c6fcb28a6a291eb1"},
|
||||
{file = "protobuf-5.29.6-cp310-abi3-win_amd64.whl", hash = "sha256:7e6ad413275be172f67fdee0f43484b6de5a904cc1c3ea9804cb6fe2ff366eda"},
|
||||
{file = "protobuf-5.29.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:b5a169e664b4057183a34bdc424540e86eea47560f3c123a0d64de4e137f9269"},
|
||||
{file = "protobuf-5.29.6-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:a8866b2cff111f0f863c1b3b9e7572dc7eaea23a7fae27f6fc613304046483e6"},
|
||||
{file = "protobuf-5.29.6-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:e3387f44798ac1106af0233c04fb8abf543772ff241169946f698b3a9a3d3ab9"},
|
||||
{file = "protobuf-5.29.6-cp38-cp38-win32.whl", hash = "sha256:36ade6ff88212e91aef4e687a971a11d7d24d6948a66751abc1b3238648f5d05"},
|
||||
{file = "protobuf-5.29.6-cp38-cp38-win_amd64.whl", hash = "sha256:831e2da16b6cc9d8f1654c041dd594eda43391affd3c03a91bea7f7f6da106d6"},
|
||||
{file = "protobuf-5.29.6-cp39-cp39-win32.whl", hash = "sha256:cb4c86de9cd8a7f3a256b9744220d87b847371c6b2f10bde87768918ef33ba49"},
|
||||
{file = "protobuf-5.29.6-cp39-cp39-win_amd64.whl", hash = "sha256:76e07e6567f8baf827137e8d5b8204b6c7b6488bbbff1bf0a72b383f77999c18"},
|
||||
{file = "protobuf-5.29.6-py3-none-any.whl", hash = "sha256:6b9edb641441b2da9fa8f428760fc136a49cf97a52076010cf22a2ff73438a86"},
|
||||
{file = "protobuf-5.29.6.tar.gz", hash = "sha256:da9ee6a5424b6b30fd5e45c5ea663aef540ca95f9ad99d1e887e819cdf9b8723"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7562,14 +7554,14 @@ typing-extensions = ">=4.15.0"
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1"
|
||||
version = "0.6.1"
|
||||
version = "0.6.2"
|
||||
description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"},
|
||||
{file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"},
|
||||
{file = "pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf"},
|
||||
{file = "pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11578,20 +11570,20 @@ diagrams = ["jinja2", "railroad-diagrams"]
|
||||
|
||||
[[package]]
|
||||
name = "pypdf"
|
||||
version = "6.6.0"
|
||||
version = "6.7.3"
|
||||
description = "A pure-python PDF library capable of splitting, merging, cropping, and transforming PDF files"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pypdf-6.6.0-py3-none-any.whl", hash = "sha256:bca9091ef6de36c7b1a81e09327c554b7ce51e88dad68f5890c2b4a4417f1fd7"},
|
||||
{file = "pypdf-6.6.0.tar.gz", hash = "sha256:4c887ef2ea38d86faded61141995a3c7d068c9d6ae8477be7ae5de8a8e16592f"},
|
||||
{file = "pypdf-6.7.3-py3-none-any.whl", hash = "sha256:cd25ac508f20b554a9fafd825186e3ba29591a69b78c156783c5d8a2d63a1c0a"},
|
||||
{file = "pypdf-6.7.3.tar.gz", hash = "sha256:eca55c78d0ec7baa06f9288e2be5c4e8242d5cbb62c7a4b94f2716f8e50076d2"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
crypto = ["cryptography"]
|
||||
cryptodome = ["PyCryptodome"]
|
||||
dev = ["black", "flit", "pip-tools", "pre-commit", "pytest-cov", "pytest-socket", "pytest-timeout", "pytest-xdist", "wheel"]
|
||||
dev = ["flit", "pip-tools", "pre-commit", "pytest-cov", "pytest-socket", "pytest-timeout", "pytest-xdist", "wheel"]
|
||||
docs = ["myst_parser", "sphinx", "sphinx_rtd_theme"]
|
||||
full = ["Pillow (>=8.0.0)", "cryptography"]
|
||||
image = ["Pillow (>=8.0.0)"]
|
||||
@@ -11886,14 +11878,14 @@ requests-toolbelt = ">=0.6.0"
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.21"
|
||||
version = "0.0.22"
|
||||
description = "A streaming multipart parser for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "python_multipart-0.0.21-py3-none-any.whl", hash = "sha256:cf7a6713e01c87aa35387f4774e812c4361150938d20d232800f75ffcf266090"},
|
||||
{file = "python_multipart-0.0.21.tar.gz", hash = "sha256:7137ebd4d3bbf70ea1622998f902b97a29434a9e8dc40eb203bbcf7c2a2cba92"},
|
||||
{file = "python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155"},
|
||||
{file = "python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11916,14 +11908,14 @@ XlsxWriter = ">=0.5.7"
|
||||
|
||||
[[package]]
|
||||
name = "python-socketio"
|
||||
version = "5.13.0"
|
||||
version = "5.14.0"
|
||||
description = "Socket.IO server and client for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "python_socketio-5.13.0-py3-none-any.whl", hash = "sha256:51f68d6499f2df8524668c24bcec13ba1414117cfb3a90115c559b601ab10caf"},
|
||||
{file = "python_socketio-5.13.0.tar.gz", hash = "sha256:ac4e19a0302ae812e23b712ec8b6427ca0521f7c582d6abb096e36e24a263029"},
|
||||
{file = "python_socketio-5.14.0-py3-none-any.whl", hash = "sha256:7de5ad8a55efc33e17897f6cf91d20168d3d259f98c38d38e2940af83136d6f8"},
|
||||
{file = "python_socketio-5.14.0.tar.gz", hash = "sha256:d057737f658b3948392ff452a5c865c5ccc969859c37cf095a73393ce755f98e"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -11969,7 +11961,7 @@ description = "Python for Window Extensions"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
markers = "sys_platform == \"win32\" or platform_system == \"Windows\""
|
||||
markers = "platform_system == \"Windows\" or sys_platform == \"win32\""
|
||||
files = [
|
||||
{file = "pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3"},
|
||||
{file = "pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b"},
|
||||
|
||||
@@ -47,12 +47,19 @@ from server.routes.org_invitations import ( # noqa: E402
|
||||
from server.routes.orgs import org_router # noqa: E402
|
||||
from server.routes.readiness import readiness_router # noqa: E402
|
||||
from server.routes.user import saas_user_router # noqa: E402
|
||||
from server.routes.user_app_settings import user_app_settings_router # noqa: E402
|
||||
from server.sharing.shared_conversation_router import ( # noqa: E402
|
||||
router as shared_conversation_router,
|
||||
)
|
||||
from server.sharing.shared_event_router import ( # noqa: E402
|
||||
router as shared_event_router,
|
||||
)
|
||||
from server.verified_models.verified_model_router import ( # noqa: E402
|
||||
api_router as verified_models_router,
|
||||
)
|
||||
from server.verified_models.verified_model_router import ( # noqa: E402
|
||||
override_llm_models_dependency,
|
||||
)
|
||||
|
||||
from openhands.server.app import app as base_app # noqa: E402
|
||||
from openhands.server.listen_socket import sio # noqa: E402
|
||||
@@ -76,6 +83,7 @@ base_app.include_router(api_router) # Add additional route for github auth
|
||||
base_app.include_router(oauth_router) # Add additional route for oauth callback
|
||||
base_app.include_router(oauth_device_router) # Add OAuth 2.0 Device Flow routes
|
||||
base_app.include_router(saas_user_router) # Add additional route SAAS user calls
|
||||
base_app.include_router(user_app_settings_router) # Add routes for user app settings
|
||||
base_app.include_router(
|
||||
billing_router
|
||||
) # Add routes for credit management and Stripe payment integration
|
||||
@@ -105,6 +113,14 @@ if GITLAB_APP_CLIENT_ID:
|
||||
|
||||
base_app.include_router(api_keys_router) # Add routes for API key management
|
||||
base_app.include_router(org_router) # Add routes for organization management
|
||||
base_app.include_router(
|
||||
verified_models_router
|
||||
) # Add routes for verified models management
|
||||
|
||||
# Override the default LLM models implementation with SaaS version
|
||||
# This must happen after all routers are included
|
||||
override_llm_models_dependency(base_app)
|
||||
|
||||
base_app.include_router(invitation_router) # Add routes for org invitation management
|
||||
base_app.include_router(invitation_accept_router) # Add route for accepting invitations
|
||||
add_github_proxy_routes(base_app)
|
||||
|
||||
@@ -38,3 +38,9 @@ class ExpiredError(AuthError):
|
||||
"""Error when a token has expired (Usually the refresh token)"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TokenRefreshError(AuthError):
|
||||
"""Error when token refresh fails due to timeout or lock contention"""
|
||||
|
||||
pass
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import os
|
||||
|
||||
from server.auth.sheets_client import GoogleSheetsClient
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
@@ -9,12 +7,9 @@ class UserVerifier:
|
||||
def __init__(self) -> None:
|
||||
logger.debug('Initializing UserVerifier')
|
||||
self.file_users: list[str] | None = None
|
||||
self.sheets_client: GoogleSheetsClient | None = None
|
||||
self.spreadsheet_id: str | None = None
|
||||
|
||||
# Initialize from environment variables
|
||||
self._init_file_users()
|
||||
self._init_sheets_client()
|
||||
|
||||
def _init_file_users(self) -> None:
|
||||
"""Load users from text file if configured."""
|
||||
@@ -36,23 +31,11 @@ class UserVerifier:
|
||||
except Exception:
|
||||
logger.exception(f'Error reading user list file {waitlist}')
|
||||
|
||||
def _init_sheets_client(self) -> None:
|
||||
"""Initialize Google Sheets client if configured."""
|
||||
sheet_id = os.getenv('GITHUB_USERS_SHEET_ID')
|
||||
|
||||
if not sheet_id:
|
||||
logger.debug('GITHUB_USERS_SHEET_ID not configured')
|
||||
return
|
||||
|
||||
logger.debug('Initializing Google Sheets integration')
|
||||
self.sheets_client = GoogleSheetsClient()
|
||||
self.spreadsheet_id = sheet_id
|
||||
|
||||
def is_active(self) -> bool:
|
||||
if os.getenv('DISABLE_WAITLIST', '').lower() == 'true':
|
||||
logger.info('Waitlist disabled via DISABLE_WAITLIST env var')
|
||||
return False
|
||||
return bool(self.file_users or (self.sheets_client and self.spreadsheet_id))
|
||||
return bool(self.file_users)
|
||||
|
||||
def is_user_allowed(self, username: str) -> bool:
|
||||
"""Check if user is allowed based on file and/or sheet configuration."""
|
||||
@@ -63,15 +46,6 @@ class UserVerifier:
|
||||
return True
|
||||
logger.debug(f'User {username} not found in text file allowlist')
|
||||
|
||||
if self.sheets_client and self.spreadsheet_id:
|
||||
sheet_users = [
|
||||
u.lower() for u in self.sheets_client.get_usernames(self.spreadsheet_id)
|
||||
]
|
||||
if username.lower() in sheet_users:
|
||||
logger.debug(f'User {username} found in Google Sheets allowlist')
|
||||
return True
|
||||
logger.debug(f'User {username} not found in Google Sheets allowlist')
|
||||
|
||||
logger.debug(f'User {username} not found in any allowlist')
|
||||
return False
|
||||
|
||||
|
||||
@@ -1,87 +1,11 @@
|
||||
import os
|
||||
|
||||
from integrations.github.github_service import SaaSGitHubService
|
||||
from pydantic import SecretStr
|
||||
from server.auth.sheets_client import GoogleSheetsClient
|
||||
|
||||
from enterprise.server.auth.auth_utils import user_verifier
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.github.github_types import GitHubUser
|
||||
|
||||
|
||||
class UserVerifier:
|
||||
def __init__(self) -> None:
|
||||
logger.debug('Initializing UserVerifier')
|
||||
self.file_users: list[str] | None = None
|
||||
self.sheets_client: GoogleSheetsClient | None = None
|
||||
self.spreadsheet_id: str | None = None
|
||||
|
||||
# Initialize from environment variables
|
||||
self._init_file_users()
|
||||
self._init_sheets_client()
|
||||
|
||||
def _init_file_users(self) -> None:
|
||||
"""Load users from text file if configured"""
|
||||
waitlist = os.getenv('GITHUB_USER_LIST_FILE')
|
||||
if not waitlist:
|
||||
logger.debug('GITHUB_USER_LIST_FILE not configured')
|
||||
return
|
||||
|
||||
if not os.path.exists(waitlist):
|
||||
logger.error(f'User list file not found: {waitlist}')
|
||||
raise FileNotFoundError(f'User list file not found: {waitlist}')
|
||||
|
||||
try:
|
||||
with open(waitlist, 'r') as f:
|
||||
self.file_users = [line.strip().lower() for line in f if line.strip()]
|
||||
logger.info(
|
||||
f'Successfully loaded {len(self.file_users)} users from {waitlist}'
|
||||
)
|
||||
except Exception:
|
||||
logger.error(f'Error reading user list file {waitlist}', exc_info=True)
|
||||
|
||||
def _init_sheets_client(self) -> None:
|
||||
"""Initialize Google Sheets client if configured"""
|
||||
sheet_id = os.getenv('GITHUB_USERS_SHEET_ID')
|
||||
|
||||
if not sheet_id:
|
||||
logger.debug('GITHUB_USERS_SHEET_ID not configured')
|
||||
return
|
||||
|
||||
logger.debug('Initializing Google Sheets integration')
|
||||
self.sheets_client = GoogleSheetsClient()
|
||||
self.spreadsheet_id = sheet_id
|
||||
|
||||
def is_active(self) -> bool:
|
||||
if os.getenv('DISABLE_WAITLIST', '').lower() == 'true':
|
||||
logger.info('Waitlist disabled via DISABLE_WAITLIST env var')
|
||||
return False
|
||||
return bool(self.file_users or (self.sheets_client and self.spreadsheet_id))
|
||||
|
||||
def is_user_allowed(self, username: str) -> bool:
|
||||
"""Check if user is allowed based on file and/or sheet configuration"""
|
||||
logger.debug(f'Checking if GitHub user {username} is allowed')
|
||||
if self.file_users:
|
||||
if username.lower() in self.file_users:
|
||||
logger.debug(f'User {username} found in text file allowlist')
|
||||
return True
|
||||
logger.debug(f'User {username} not found in text file allowlist')
|
||||
|
||||
if self.sheets_client and self.spreadsheet_id:
|
||||
sheet_users = [
|
||||
u.lower() for u in self.sheets_client.get_usernames(self.spreadsheet_id)
|
||||
]
|
||||
if username.lower() in sheet_users:
|
||||
logger.debug(f'User {username} found in Google Sheets allowlist')
|
||||
return True
|
||||
logger.debug(f'User {username} not found in Google Sheets allowlist')
|
||||
|
||||
logger.debug(f'User {username} not found in any allowlist')
|
||||
return False
|
||||
|
||||
|
||||
user_verifier = UserVerifier()
|
||||
|
||||
|
||||
def is_user_allowed(user_login: str):
|
||||
if user_verifier.is_active() and not user_verifier.is_user_allowed(user_login):
|
||||
logger.warning(f'GitHub user {user_login} not in allow list')
|
||||
|
||||
@@ -49,6 +49,10 @@ from openhands.integrations.service_types import ProviderType
|
||||
from openhands.server.types import SessionExpiredError
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
# HTTP timeout for external IDP calls (in seconds)
|
||||
# This prevents indefinite blocking if an IDP is slow or unresponsive
|
||||
IDP_HTTP_TIMEOUT = 15.0
|
||||
|
||||
|
||||
def _before_sleep_callback(retry_state: RetryCallState) -> None:
|
||||
logger.info(f'Retry attempt {retry_state.attempt_number} for Keycloak operation')
|
||||
@@ -202,7 +206,9 @@ class TokenManager:
|
||||
access_token: str,
|
||||
idp: ProviderType,
|
||||
) -> dict[str, str | int]:
|
||||
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
|
||||
async with httpx.AsyncClient(
|
||||
verify=httpx_verify_option(), timeout=IDP_HTTP_TIMEOUT
|
||||
) as client:
|
||||
base_url = KEYCLOAK_SERVER_URL_EXT if self.external else KEYCLOAK_SERVER_URL
|
||||
url = f'{base_url}/realms/{KEYCLOAK_REALM_NAME}/broker/{idp.value}/token'
|
||||
headers = {
|
||||
@@ -361,7 +367,9 @@ class TokenManager:
|
||||
'refresh_token': refresh_token,
|
||||
'grant_type': 'refresh_token',
|
||||
}
|
||||
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
|
||||
async with httpx.AsyncClient(
|
||||
verify=httpx_verify_option(), timeout=IDP_HTTP_TIMEOUT
|
||||
) as client:
|
||||
response = await client.post(url, data=payload)
|
||||
response.raise_for_status()
|
||||
logger.info('Successfully refreshed GitHub token')
|
||||
@@ -387,7 +395,9 @@ class TokenManager:
|
||||
'refresh_token': refresh_token,
|
||||
'grant_type': 'refresh_token',
|
||||
}
|
||||
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
|
||||
async with httpx.AsyncClient(
|
||||
verify=httpx_verify_option(), timeout=IDP_HTTP_TIMEOUT
|
||||
) as client:
|
||||
response = await client.post(url, data=payload)
|
||||
response.raise_for_status()
|
||||
logger.info('Successfully refreshed GitLab token')
|
||||
@@ -415,7 +425,9 @@ class TokenManager:
|
||||
'refresh_token': refresh_token,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
|
||||
async with httpx.AsyncClient(
|
||||
verify=httpx_verify_option(), timeout=IDP_HTTP_TIMEOUT
|
||||
) as client:
|
||||
response = await client.post(url, data=data, headers=headers)
|
||||
response.raise_for_status()
|
||||
logger.info('Successfully refreshed Bitbucket token')
|
||||
|
||||
@@ -103,11 +103,13 @@ class SetAuthCookieMiddleware:
|
||||
keycloak_auth_cookie = request.cookies.get('keycloak_auth')
|
||||
auth_header = request.headers.get('Authorization')
|
||||
mcp_auth_header = request.headers.get('X-Session-API-Key')
|
||||
api_auth_header = request.headers.get('X-Access-Token')
|
||||
accepted_tos: bool | None = False
|
||||
if (
|
||||
keycloak_auth_cookie is None
|
||||
and (auth_header is None or not auth_header.startswith('Bearer '))
|
||||
and mcp_auth_header is None
|
||||
and api_auth_header is None
|
||||
):
|
||||
raise NoCredentialsError
|
||||
|
||||
@@ -164,7 +166,6 @@ class SetAuthCookieMiddleware:
|
||||
'/oauth/device/authorize',
|
||||
'/oauth/device/token',
|
||||
'/api/v1/web-client/config',
|
||||
'/api/v1/webhooks/secrets',
|
||||
)
|
||||
if path in ignore_paths:
|
||||
return False
|
||||
@@ -175,6 +176,10 @@ class SetAuthCookieMiddleware:
|
||||
):
|
||||
return False
|
||||
|
||||
# Webhooks access is controlled using separate API keys
|
||||
if path.startswith('/api/v1/webhooks/'):
|
||||
return False
|
||||
|
||||
is_mcp = path.startswith('/mcp')
|
||||
is_api_route = path.startswith('/api')
|
||||
return is_api_route or is_mcp
|
||||
|
||||
@@ -208,6 +208,7 @@ async def keycloak_callback(
|
||||
else:
|
||||
# Existing user — gradually backfill contact_name if it still has a username-style value
|
||||
await UserStore.backfill_contact_name(user_id, user_info)
|
||||
await UserStore.backfill_user_email(user_id, user_info)
|
||||
|
||||
if not user:
|
||||
logger.error(f'Failed to authenticate user {user_info["preferred_username"]}')
|
||||
@@ -549,7 +550,10 @@ async def keycloak_offline_callback(code: str, state: str, request: Request):
|
||||
user_id=user_info['sub'], offline_token=keycloak_refresh_token
|
||||
)
|
||||
|
||||
return RedirectResponse(state if state else request.base_url, status_code=302)
|
||||
redirect_url, _, _ = _extract_oauth_state(state)
|
||||
return RedirectResponse(
|
||||
redirect_url if redirect_url else request.base_url, status_code=302
|
||||
)
|
||||
|
||||
|
||||
@oauth_router.get('/github/callback')
|
||||
|
||||
@@ -23,7 +23,7 @@ from openhands.app_server.config import get_global_config
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
stripe.api_key = STRIPE_API_KEY
|
||||
billing_router = APIRouter(prefix='/api/billing')
|
||||
billing_router = APIRouter(prefix='/api/billing', tags=['Billing'])
|
||||
|
||||
|
||||
async def validate_billing_enabled() -> None:
|
||||
|
||||
@@ -8,6 +8,7 @@ from server.auth.keycloak_manager import get_keycloak_admin
|
||||
from server.auth.saas_user_auth import SaasUserAuth
|
||||
from server.routes.auth import set_response_cookie
|
||||
from server.utils.rate_limit_utils import check_rate_limit_by_user_id
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth import get_user_id
|
||||
@@ -62,6 +63,10 @@ async def update_email(
|
||||
},
|
||||
)
|
||||
|
||||
await UserStore.update_user_email(
|
||||
user_id=user_id, email=email, email_verified=False
|
||||
)
|
||||
|
||||
user_auth: SaasUserAuth = await get_user_auth(request)
|
||||
await user_auth.refresh() # refresh so access token has updated email
|
||||
user_auth.email = email
|
||||
@@ -144,6 +149,7 @@ async def verified_email(request: Request):
|
||||
user_auth: SaasUserAuth = await get_user_auth(request)
|
||||
await user_auth.refresh() # refresh so access token has updated email
|
||||
user_auth.email_verified = True
|
||||
await UserStore.update_user_email(user_id=user_auth.user_id, email_verified=True)
|
||||
scheme = 'http' if request.url.hostname == 'localhost' else 'https'
|
||||
redirect_uri = f'{scheme}://{request.url.netloc}/settings/user'
|
||||
response = RedirectResponse(redirect_uri, status_code=302)
|
||||
|
||||
@@ -8,11 +8,18 @@ from storage.feedback import ConversationFeedback
|
||||
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
|
||||
|
||||
from openhands.events.event_store import EventStore
|
||||
from openhands.server.dependencies import get_dependencies
|
||||
from openhands.server.shared import file_store
|
||||
from openhands.server.user_auth import get_user_id
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
router = APIRouter(prefix='/feedback', tags=['feedback'])
|
||||
# We use the get_dependencies method here to signal to the OpenAPI docs that this endpoint
|
||||
# is protected. The actual protection is provided by SetAuthCookieMiddleware
|
||||
# TODO: It may be an error by you can actually post feedback to a conversation you don't
|
||||
# own right now - maybe this is useful in the context of public shared conversations?
|
||||
router = APIRouter(
|
||||
prefix='/feedback', tags=['feedback'], dependencies=get_dependencies()
|
||||
)
|
||||
|
||||
|
||||
async def get_event_ids(conversation_id: str, user_id: str) -> List[int]:
|
||||
|
||||
@@ -4,7 +4,7 @@ import json
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
import requests
|
||||
from fastapi import APIRouter, BackgroundTasks, Header, HTTPException, Request, status
|
||||
@@ -371,9 +371,7 @@ async def create_jira_workspace(request: Request, workspace_data: JiraWorkspaceC
|
||||
'prompt': 'consent',
|
||||
}
|
||||
|
||||
auth_url = (
|
||||
f"{JIRA_AUTH_URL}?{'&'.join([f'{k}={v}' for k, v in auth_params.items()])}"
|
||||
)
|
||||
auth_url = f'{JIRA_AUTH_URL}?{urlencode(auth_params)}'
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
@@ -432,9 +430,7 @@ async def create_workspace_link(request: Request, link_data: JiraLinkCreate):
|
||||
'response_type': 'code',
|
||||
'prompt': 'consent',
|
||||
}
|
||||
auth_url = (
|
||||
f"{JIRA_AUTH_URL}?{'&'.join([f'{k}={v}' for k, v in auth_params.items()])}"
|
||||
)
|
||||
auth_url = f'{JIRA_AUTH_URL}?{urlencode(auth_params)}'
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
|
||||
@@ -2,7 +2,7 @@ import json
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
import requests
|
||||
from fastapi import (
|
||||
@@ -316,7 +316,7 @@ async def create_jira_dc_workspace(
|
||||
'response_type': 'code',
|
||||
}
|
||||
|
||||
auth_url = f"{JIRA_DC_AUTH_URL}?{'&'.join([f'{k}={v}' for k, v in auth_params.items()])}"
|
||||
auth_url = f'{JIRA_DC_AUTH_URL}?{urlencode(auth_params)}'
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
@@ -436,7 +436,7 @@ async def create_workspace_link(request: Request, link_data: JiraDcLinkCreate):
|
||||
'state': state,
|
||||
'response_type': 'code',
|
||||
}
|
||||
auth_url = f"{JIRA_DC_AUTH_URL}?{'&'.join([f'{k}={v}' for k, v in auth_params.items()])}"
|
||||
auth_url = f'{JIRA_DC_AUTH_URL}?{urlencode(auth_params)}'
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, SecretStr, StringConstraints
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
EmailStr,
|
||||
Field,
|
||||
SecretStr,
|
||||
StringConstraints,
|
||||
field_validator,
|
||||
)
|
||||
from storage.org import Org
|
||||
from storage.org_member import OrgMember
|
||||
from storage.role import Role
|
||||
@@ -252,6 +259,115 @@ class OrgUpdate(BaseModel):
|
||||
condenser_max_size: int | None = Field(default=None, ge=20)
|
||||
|
||||
|
||||
class OrgLLMSettingsResponse(BaseModel):
|
||||
"""Response model for organization LLM settings."""
|
||||
|
||||
default_llm_model: str | None = None
|
||||
default_llm_base_url: str | None = None
|
||||
search_api_key: str | None = None # Masked in response
|
||||
agent: str | None = None
|
||||
confirmation_mode: bool | None = None
|
||||
security_analyzer: str | None = None
|
||||
enable_default_condenser: bool = True
|
||||
condenser_max_size: int | None = None
|
||||
default_max_iterations: int | None = None
|
||||
|
||||
@staticmethod
|
||||
def _mask_key(secret: SecretStr | None) -> str | None:
|
||||
"""Mask an API key, showing only last 4 characters."""
|
||||
if secret is None:
|
||||
return None
|
||||
raw = secret.get_secret_value()
|
||||
if not raw:
|
||||
return None
|
||||
if len(raw) <= 4:
|
||||
return '****'
|
||||
return '****' + raw[-4:]
|
||||
|
||||
@classmethod
|
||||
def from_org(cls, org: Org) -> 'OrgLLMSettingsResponse':
|
||||
"""Create response from Org entity."""
|
||||
return cls(
|
||||
default_llm_model=org.default_llm_model,
|
||||
default_llm_base_url=org.default_llm_base_url,
|
||||
search_api_key=cls._mask_key(org.search_api_key),
|
||||
agent=org.agent,
|
||||
confirmation_mode=org.confirmation_mode,
|
||||
security_analyzer=org.security_analyzer,
|
||||
enable_default_condenser=org.enable_default_condenser
|
||||
if org.enable_default_condenser is not None
|
||||
else True,
|
||||
condenser_max_size=org.condenser_max_size,
|
||||
default_max_iterations=org.default_max_iterations,
|
||||
)
|
||||
|
||||
|
||||
class OrgMemberLLMSettings(BaseModel):
|
||||
"""LLM settings to propagate to organization members.
|
||||
|
||||
Field names match OrgMember DB columns.
|
||||
"""
|
||||
|
||||
llm_model: str | None = None
|
||||
llm_base_url: str | None = None
|
||||
max_iterations: int | None = None
|
||||
llm_api_key: str | None = None
|
||||
|
||||
def has_updates(self) -> bool:
|
||||
"""Check if any field is set (not None)."""
|
||||
return any(getattr(self, field) is not None for field in self.model_fields)
|
||||
|
||||
|
||||
class OrgLLMSettingsUpdate(BaseModel):
|
||||
"""Request model for updating organization LLM settings.
|
||||
|
||||
Field names match Org DB columns exactly.
|
||||
"""
|
||||
|
||||
default_llm_model: str | None = None
|
||||
default_llm_base_url: str | None = None
|
||||
search_api_key: str | None = None
|
||||
agent: str | None = None
|
||||
confirmation_mode: bool | None = None
|
||||
security_analyzer: str | None = None
|
||||
enable_default_condenser: bool | None = None
|
||||
condenser_max_size: int | None = Field(default=None, ge=20)
|
||||
default_max_iterations: int | None = Field(default=None, gt=0)
|
||||
llm_api_key: str | None = None
|
||||
|
||||
def has_updates(self) -> bool:
|
||||
"""Check if any field is set (not None)."""
|
||||
return any(getattr(self, field) is not None for field in self.model_fields)
|
||||
|
||||
def apply_to_org(self, org: Org) -> None:
|
||||
"""Apply non-None settings to the organization model.
|
||||
|
||||
Args:
|
||||
org: Organization entity to update in place
|
||||
"""
|
||||
for field_name in self.model_fields:
|
||||
value = getattr(self, field_name)
|
||||
# Skip llm_api_key - it's only for member propagation, not org-level
|
||||
if value is not None and field_name != 'llm_api_key':
|
||||
setattr(org, field_name, value)
|
||||
|
||||
def get_member_updates(self) -> OrgMemberLLMSettings | None:
|
||||
"""Get updates that need to be propagated to org members.
|
||||
|
||||
Returns:
|
||||
OrgMemberLLMSettings with mapped field values, or None if no member updates needed.
|
||||
Maps: default_llm_model → llm_model, default_llm_base_url → llm_base_url,
|
||||
default_max_iterations → max_iterations, llm_api_key → llm_api_key
|
||||
"""
|
||||
member_settings = OrgMemberLLMSettings(
|
||||
llm_model=self.default_llm_model,
|
||||
llm_base_url=self.default_llm_base_url,
|
||||
max_iterations=self.default_max_iterations,
|
||||
llm_api_key=self.llm_api_key,
|
||||
)
|
||||
return member_settings if member_settings.has_updates() else None
|
||||
|
||||
|
||||
class OrgMemberResponse(BaseModel):
|
||||
"""Response model for a single organization member."""
|
||||
|
||||
@@ -267,7 +383,8 @@ class OrgMemberPage(BaseModel):
|
||||
"""Paginated response for organization members."""
|
||||
|
||||
items: list[OrgMemberResponse]
|
||||
next_page_id: str | None = None
|
||||
current_page: int = 1
|
||||
per_page: int = 10
|
||||
|
||||
|
||||
class OrgMemberUpdate(BaseModel):
|
||||
@@ -326,3 +443,44 @@ class MeResponse(BaseModel):
|
||||
llm_base_url=member.llm_base_url,
|
||||
status=member.status,
|
||||
)
|
||||
|
||||
|
||||
class OrgAppSettingsResponse(BaseModel):
|
||||
"""Response model for organization app settings."""
|
||||
|
||||
enable_proactive_conversation_starters: bool = True
|
||||
enable_solvability_analysis: bool | None = None
|
||||
max_budget_per_task: float | None = None
|
||||
|
||||
@classmethod
|
||||
def from_org(cls, org: Org) -> 'OrgAppSettingsResponse':
|
||||
"""Create an OrgAppSettingsResponse from an Org entity.
|
||||
|
||||
Args:
|
||||
org: The organization entity
|
||||
|
||||
Returns:
|
||||
OrgAppSettingsResponse with app settings
|
||||
"""
|
||||
return cls(
|
||||
enable_proactive_conversation_starters=org.enable_proactive_conversation_starters
|
||||
if org.enable_proactive_conversation_starters is not None
|
||||
else True,
|
||||
enable_solvability_analysis=org.enable_solvability_analysis,
|
||||
max_budget_per_task=org.max_budget_per_task,
|
||||
)
|
||||
|
||||
|
||||
class OrgAppSettingsUpdate(BaseModel):
|
||||
"""Request model for updating organization app settings."""
|
||||
|
||||
enable_proactive_conversation_starters: bool | None = None
|
||||
enable_solvability_analysis: bool | None = None
|
||||
max_budget_per_task: float | None = None
|
||||
|
||||
@field_validator('max_budget_per_task')
|
||||
@classmethod
|
||||
def validate_max_budget_per_task(cls, v: float | None) -> float | None:
|
||||
if v is not None and v <= 0:
|
||||
raise ValueError('max_budget_per_task must be greater than 0')
|
||||
return v
|
||||
|
||||
@@ -15,9 +15,13 @@ from server.routes.org_models import (
|
||||
LiteLLMIntegrationError,
|
||||
MemberUpdateError,
|
||||
MeResponse,
|
||||
OrgAppSettingsResponse,
|
||||
OrgAppSettingsUpdate,
|
||||
OrgAuthorizationError,
|
||||
OrgCreate,
|
||||
OrgDatabaseError,
|
||||
OrgLLMSettingsResponse,
|
||||
OrgLLMSettingsUpdate,
|
||||
OrgMemberNotFoundError,
|
||||
OrgMemberPage,
|
||||
OrgMemberResponse,
|
||||
@@ -30,6 +34,14 @@ from server.routes.org_models import (
|
||||
OrphanedUserError,
|
||||
RoleNotFoundError,
|
||||
)
|
||||
from server.services.org_app_settings_service import (
|
||||
OrgAppSettingsService,
|
||||
OrgAppSettingsServiceInjector,
|
||||
)
|
||||
from server.services.org_llm_settings_service import (
|
||||
OrgLLMSettingsService,
|
||||
OrgLLMSettingsServiceInjector,
|
||||
)
|
||||
from server.services.org_member_service import OrgMemberService
|
||||
from storage.org_service import OrgService
|
||||
from storage.user_store import UserStore
|
||||
@@ -38,7 +50,14 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
# Initialize API router
|
||||
org_router = APIRouter(prefix='/api/organizations')
|
||||
org_router = APIRouter(prefix='/api/organizations', tags=['Orgs'])
|
||||
|
||||
# Create injector instance and dependency for LLM settings
|
||||
_org_llm_settings_injector = OrgLLMSettingsServiceInjector()
|
||||
org_llm_settings_service_dependency = Depends(_org_llm_settings_injector.depends)
|
||||
# Create injector instance and dependency at module level
|
||||
_org_app_settings_injector = OrgAppSettingsServiceInjector()
|
||||
org_app_settings_service_dependency = Depends(_org_app_settings_injector.depends)
|
||||
|
||||
|
||||
@org_router.get('', response_model=OrgPage)
|
||||
@@ -201,6 +220,195 @@ async def create_org(
|
||||
)
|
||||
|
||||
|
||||
@org_router.get(
|
||||
'/llm',
|
||||
response_model=OrgLLMSettingsResponse,
|
||||
dependencies=[Depends(require_permission(Permission.VIEW_LLM_SETTINGS))],
|
||||
)
|
||||
async def get_org_llm_settings(
|
||||
service: OrgLLMSettingsService = org_llm_settings_service_dependency,
|
||||
) -> OrgLLMSettingsResponse:
|
||||
"""Get LLM settings for the user's current organization.
|
||||
|
||||
This endpoint retrieves the LLM configuration settings for the
|
||||
authenticated user's current organization. All organization members
|
||||
can view these settings.
|
||||
|
||||
Args:
|
||||
service: OrgLLMSettingsService (injected by dependency)
|
||||
|
||||
Returns:
|
||||
OrgLLMSettingsResponse: The organization's LLM settings
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if not authenticated
|
||||
HTTPException: 403 if not a member of any organization
|
||||
HTTPException: 404 if current organization not found
|
||||
HTTPException: 500 if retrieval fails
|
||||
"""
|
||||
try:
|
||||
return await service.get_org_llm_settings()
|
||||
except OrgNotFoundError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Error getting organization LLM settings',
|
||||
extra={'error': str(e)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to retrieve LLM settings',
|
||||
)
|
||||
|
||||
|
||||
@org_router.post(
|
||||
'/llm',
|
||||
response_model=OrgLLMSettingsResponse,
|
||||
dependencies=[Depends(require_permission(Permission.EDIT_LLM_SETTINGS))],
|
||||
)
|
||||
async def update_org_llm_settings(
|
||||
settings: OrgLLMSettingsUpdate,
|
||||
service: OrgLLMSettingsService = org_llm_settings_service_dependency,
|
||||
) -> OrgLLMSettingsResponse:
|
||||
"""Update LLM settings for the user's current organization.
|
||||
|
||||
This endpoint updates the LLM configuration settings for the
|
||||
authenticated user's current organization. Only admins and owners
|
||||
can update these settings.
|
||||
|
||||
Args:
|
||||
settings: The LLM settings to update (only non-None fields are updated)
|
||||
service: OrgLLMSettingsService (injected by dependency)
|
||||
|
||||
Returns:
|
||||
OrgLLMSettingsResponse: The updated organization's LLM settings
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if not authenticated
|
||||
HTTPException: 403 if user lacks EDIT_LLM_SETTINGS permission
|
||||
HTTPException: 404 if current organization not found
|
||||
HTTPException: 500 if update fails
|
||||
"""
|
||||
try:
|
||||
return await service.update_org_llm_settings(settings)
|
||||
except OrgNotFoundError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
except OrgDatabaseError as e:
|
||||
logger.error(
|
||||
'Database error updating LLM settings',
|
||||
extra={'error': str(e)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to update LLM settings',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Error updating organization LLM settings',
|
||||
extra={'error': str(e)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to update LLM settings',
|
||||
)
|
||||
|
||||
|
||||
@org_router.get(
|
||||
'/app',
|
||||
response_model=OrgAppSettingsResponse,
|
||||
dependencies=[Depends(require_permission(Permission.MANAGE_APPLICATION_SETTINGS))],
|
||||
)
|
||||
async def get_org_app_settings(
|
||||
service: OrgAppSettingsService = org_app_settings_service_dependency,
|
||||
) -> OrgAppSettingsResponse:
|
||||
"""Get organization app settings for the user's current organization.
|
||||
|
||||
This endpoint retrieves application settings for the authenticated user's
|
||||
current organization. Access requires the MANAGE_APPLICATION_SETTINGS permission,
|
||||
which is granted to all organization members (member, admin, and owner roles).
|
||||
|
||||
Args:
|
||||
service: OrgAppSettingsService (injected by dependency)
|
||||
|
||||
Returns:
|
||||
OrgAppSettingsResponse: The organization app settings
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if user is not authenticated
|
||||
HTTPException: 403 if user lacks MANAGE_APPLICATION_SETTINGS permission
|
||||
HTTPException: 404 if current organization not found
|
||||
"""
|
||||
try:
|
||||
return await service.get_org_app_settings()
|
||||
except OrgNotFoundError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Current organization not found',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Unexpected error retrieving organization app settings',
|
||||
extra={'error': str(e)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='An unexpected error occurred',
|
||||
)
|
||||
|
||||
|
||||
@org_router.post(
|
||||
'/app',
|
||||
response_model=OrgAppSettingsResponse,
|
||||
dependencies=[Depends(require_permission(Permission.MANAGE_APPLICATION_SETTINGS))],
|
||||
)
|
||||
async def update_org_app_settings(
|
||||
update_data: OrgAppSettingsUpdate,
|
||||
service: OrgAppSettingsService = org_app_settings_service_dependency,
|
||||
) -> OrgAppSettingsResponse:
|
||||
"""Update organization app settings for the user's current organization.
|
||||
|
||||
This endpoint updates application settings for the authenticated user's
|
||||
current organization. Access requires the MANAGE_APPLICATION_SETTINGS permission,
|
||||
which is granted to all organization members (member, admin, and owner roles).
|
||||
|
||||
Args:
|
||||
update_data: App settings update data
|
||||
service: OrgAppSettingsService (injected by dependency)
|
||||
|
||||
Returns:
|
||||
OrgAppSettingsResponse: The updated organization app settings
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if user is not authenticated
|
||||
HTTPException: 403 if user lacks MANAGE_APPLICATION_SETTINGS permission
|
||||
HTTPException: 404 if current organization not found
|
||||
HTTPException: 422 if validation errors occur (handled by FastAPI)
|
||||
HTTPException: 500 if update fails
|
||||
"""
|
||||
try:
|
||||
return await service.update_org_app_settings(update_data)
|
||||
except OrgNotFoundError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Current organization not found',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Unexpected error updating organization app settings',
|
||||
extra={'error': str(e)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='An unexpected error occurred',
|
||||
)
|
||||
|
||||
|
||||
@org_router.get('/{org_id}', response_model=OrgResponse, status_code=status.HTTP_200_OK)
|
||||
async def get_org(
|
||||
org_id: UUID,
|
||||
@@ -519,7 +727,7 @@ async def get_org_members(
|
||||
org_id: UUID,
|
||||
page_id: Annotated[
|
||||
str | None,
|
||||
Query(title='Optional next_page_id from the previously returned page'),
|
||||
Query(title='Optional page offset for pagination'),
|
||||
] = None,
|
||||
limit: Annotated[
|
||||
int,
|
||||
@@ -528,10 +736,18 @@ async def get_org_members(
|
||||
gt=0,
|
||||
lte=100,
|
||||
),
|
||||
] = 100,
|
||||
] = 10,
|
||||
email: Annotated[
|
||||
str | None,
|
||||
Query(
|
||||
title='Filter members by email (case-insensitive partial match)',
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
),
|
||||
] = None,
|
||||
user_id: str = Depends(require_permission(Permission.VIEW_ORG_SETTINGS)),
|
||||
) -> OrgMemberPage:
|
||||
"""Get all members of an organization with cursor-based pagination.
|
||||
"""Get all members of an organization with pagination and optional email filter.
|
||||
|
||||
This endpoint retrieves a paginated list of organization members. Access requires
|
||||
the VIEW_ORG_SETTINGS permission, which is granted to all organization members
|
||||
@@ -539,12 +755,15 @@ async def get_org_members(
|
||||
|
||||
Args:
|
||||
org_id: Organization ID (UUID)
|
||||
page_id: Optional page ID (offset) for pagination
|
||||
limit: Maximum number of members to return (1-100, default 100)
|
||||
page_id: Optional page offset for pagination
|
||||
limit: Maximum number of members to return (1-100, default 10)
|
||||
email: Optional email filter (case-insensitive partial match)
|
||||
user_id: Authenticated user ID (injected by require_permission dependency)
|
||||
|
||||
Returns:
|
||||
OrgMemberPage: Paginated list of organization members
|
||||
OrgMemberPage: Paginated list of organization members with
|
||||
current_page and per_page metadata. Use the /count endpoint
|
||||
to get the total count separately.
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if user is not authenticated
|
||||
@@ -558,6 +777,7 @@ async def get_org_members(
|
||||
current_user_id=UUID(user_id),
|
||||
page_id=page_id,
|
||||
limit=limit,
|
||||
email_filter=email,
|
||||
)
|
||||
|
||||
if not success:
|
||||
@@ -600,6 +820,64 @@ async def get_org_members(
|
||||
)
|
||||
|
||||
|
||||
@org_router.get('/{org_id}/members/count')
|
||||
async def get_org_members_count(
|
||||
org_id: UUID,
|
||||
email: Annotated[
|
||||
str | None,
|
||||
Query(
|
||||
title='Filter members by email (case-insensitive partial match)',
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
),
|
||||
] = None,
|
||||
user_id: str = Depends(require_permission(Permission.VIEW_ORG_SETTINGS)),
|
||||
) -> int:
|
||||
"""Get count of organization members with optional email filter.
|
||||
|
||||
This endpoint returns the total count of organization members matching
|
||||
the filter criteria. Access requires the VIEW_ORG_SETTINGS permission,
|
||||
which is granted to all organization members (member, admin, and owner roles).
|
||||
|
||||
Args:
|
||||
org_id: Organization ID (UUID)
|
||||
email: Optional email filter (case-insensitive partial match)
|
||||
user_id: Authenticated user ID (injected by require_permission dependency)
|
||||
|
||||
Returns:
|
||||
int: Total count of organization members matching the filter
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if user is not authenticated
|
||||
HTTPException: 403 if user lacks VIEW_ORG_SETTINGS permission or is not a member
|
||||
HTTPException: 400 if org_id format is invalid
|
||||
HTTPException: 500 if retrieval fails
|
||||
"""
|
||||
try:
|
||||
return await OrgMemberService.get_org_members_count(
|
||||
org_id=org_id,
|
||||
current_user_id=UUID(user_id),
|
||||
email_filter=email,
|
||||
)
|
||||
except OrgMemberNotFoundError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='You are not a member of this organization',
|
||||
)
|
||||
except ValueError:
|
||||
logger.exception('Invalid UUID format')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid organization ID format',
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Error retrieving organization member count')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to retrieve member count',
|
||||
)
|
||||
|
||||
|
||||
@org_router.delete('/{org_id}/members/{user_id}')
|
||||
async def remove_org_member(
|
||||
org_id: UUID,
|
||||
|
||||
@@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, Query, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import SecretStr
|
||||
from server.auth.token_manager import TokenManager
|
||||
from storage.user_store import UserStore
|
||||
from utils.identity import resolve_display_name
|
||||
|
||||
from openhands.integrations.provider import (
|
||||
@@ -115,13 +116,21 @@ async def saas_get_user(
|
||||
content='Failed to retrieve user_info.',
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
# Prefer email from DB; fall back to Keycloak if not yet persisted
|
||||
email = user_info.get('email') if user_info else None
|
||||
sub = user_info.get('sub') if user_info else ''
|
||||
if sub:
|
||||
db_user = await UserStore.get_user_by_id_async(sub)
|
||||
if db_user and db_user.email is not None:
|
||||
email = db_user.email
|
||||
|
||||
retval = await _check_idp(
|
||||
access_token=access_token,
|
||||
default_value=User(
|
||||
id=(user_info.get('sub') if user_info else '') or '',
|
||||
id=sub,
|
||||
login=(user_info.get('preferred_username') if user_info else '') or '',
|
||||
avatar_url='',
|
||||
email=user_info.get('email') if user_info else None,
|
||||
email=email,
|
||||
name=resolve_display_name(user_info) if user_info else None,
|
||||
company=user_info.get('company') if user_info else None,
|
||||
),
|
||||
|
||||
115
enterprise/server/routes/user_app_settings.py
Normal file
115
enterprise/server/routes/user_app_settings.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Routes for user app settings API.
|
||||
|
||||
Provides endpoints for managing user-level app preferences:
|
||||
- GET /api/users/app - Retrieve current user's app settings
|
||||
- POST /api/users/app - Update current user's app settings
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from server.routes.user_app_settings_models import (
|
||||
UserAppSettingsResponse,
|
||||
UserAppSettingsUpdate,
|
||||
UserNotFoundError,
|
||||
)
|
||||
from server.services.user_app_settings_service import (
|
||||
UserAppSettingsService,
|
||||
UserAppSettingsServiceInjector,
|
||||
)
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
user_app_settings_router = APIRouter(prefix='/api/users')
|
||||
|
||||
# Create injector instance and dependency at module level
|
||||
_injector = UserAppSettingsServiceInjector()
|
||||
user_app_settings_service_dependency = Depends(_injector.depends)
|
||||
|
||||
|
||||
@user_app_settings_router.get('/app', response_model=UserAppSettingsResponse)
|
||||
async def get_user_app_settings(
|
||||
service: UserAppSettingsService = user_app_settings_service_dependency,
|
||||
) -> UserAppSettingsResponse:
|
||||
"""Get the current user's app settings.
|
||||
|
||||
Returns language, analytics consent, sound notifications, and git config.
|
||||
|
||||
Args:
|
||||
service: UserAppSettingsService (injected by dependency)
|
||||
|
||||
Returns:
|
||||
UserAppSettingsResponse: The user's app settings
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if user is not authenticated
|
||||
HTTPException: 404 if user not found
|
||||
HTTPException: 500 if retrieval fails
|
||||
"""
|
||||
try:
|
||||
return await service.get_user_app_settings()
|
||||
|
||||
except ValueError as e:
|
||||
# User not authenticated
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=str(e),
|
||||
)
|
||||
except UserNotFoundError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Unexpected error retrieving user app settings',
|
||||
extra={'error': str(e)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to retrieve user app settings',
|
||||
)
|
||||
|
||||
|
||||
@user_app_settings_router.post('/app', response_model=UserAppSettingsResponse)
|
||||
async def update_user_app_settings(
|
||||
update_data: UserAppSettingsUpdate,
|
||||
service: UserAppSettingsService = user_app_settings_service_dependency,
|
||||
) -> UserAppSettingsResponse:
|
||||
"""Update the current user's app settings (partial update).
|
||||
|
||||
Only provided fields will be updated. Pass null to clear a field.
|
||||
|
||||
Args:
|
||||
update_data: Fields to update
|
||||
service: UserAppSettingsService (injected by dependency)
|
||||
|
||||
Returns:
|
||||
UserAppSettingsResponse: The updated user's app settings
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if user is not authenticated
|
||||
HTTPException: 404 if user not found
|
||||
HTTPException: 500 if update fails
|
||||
"""
|
||||
try:
|
||||
return await service.update_user_app_settings(update_data)
|
||||
|
||||
except ValueError as e:
|
||||
# User not authenticated
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=str(e),
|
||||
)
|
||||
except UserNotFoundError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Failed to update user app settings',
|
||||
extra={'error': str(e)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to update user app settings',
|
||||
)
|
||||
57
enterprise/server/routes/user_app_settings_models.py
Normal file
57
enterprise/server/routes/user_app_settings_models.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
Pydantic models for user app settings API.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from storage.user import User
|
||||
|
||||
|
||||
class UserAppSettingsError(Exception):
|
||||
"""Base exception for user app settings errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class UserNotFoundError(UserAppSettingsError):
|
||||
"""Raised when user is not found."""
|
||||
|
||||
def __init__(self, user_id: str):
|
||||
self.user_id = user_id
|
||||
super().__init__(f'User with id "{user_id}" not found')
|
||||
|
||||
|
||||
class UserAppSettingsUpdateError(UserAppSettingsError):
|
||||
"""Raised when user app settings update fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class UserAppSettingsResponse(BaseModel):
|
||||
"""Response model for user app settings."""
|
||||
|
||||
language: str | None = None
|
||||
user_consents_to_analytics: bool | None = None
|
||||
enable_sound_notifications: bool | None = None
|
||||
git_user_name: str | None = None
|
||||
git_user_email: EmailStr | None = None
|
||||
|
||||
@classmethod
|
||||
def from_user(cls, user: User) -> 'UserAppSettingsResponse':
|
||||
"""Create response from User entity."""
|
||||
return cls(
|
||||
language=user.language,
|
||||
user_consents_to_analytics=user.user_consents_to_analytics,
|
||||
enable_sound_notifications=user.enable_sound_notifications,
|
||||
git_user_name=user.git_user_name,
|
||||
git_user_email=user.git_user_email,
|
||||
)
|
||||
|
||||
|
||||
class UserAppSettingsUpdate(BaseModel):
|
||||
"""Request model for updating user app settings (partial update)."""
|
||||
|
||||
language: str | None = None
|
||||
user_consents_to_analytics: bool | None = None
|
||||
enable_sound_notifications: bool | None = None
|
||||
git_user_name: str | None = None
|
||||
git_user_email: EmailStr | None = None
|
||||
130
enterprise/server/services/org_app_settings_service.py
Normal file
130
enterprise/server/services/org_app_settings_service.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""Service class for managing organization app settings.
|
||||
|
||||
Separates business logic from route handlers.
|
||||
Uses dependency injection for db_session and user_context.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import Request
|
||||
from server.routes.org_models import (
|
||||
OrgAppSettingsResponse,
|
||||
OrgAppSettingsUpdate,
|
||||
OrgNotFoundError,
|
||||
)
|
||||
from storage.org_app_settings_store import OrgAppSettingsStore
|
||||
|
||||
from openhands.app_server.services.injector import Injector, InjectorState
|
||||
from openhands.app_server.user.user_context import UserContext
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrgAppSettingsService:
|
||||
"""Service for organization app settings with injected dependencies."""
|
||||
|
||||
store: OrgAppSettingsStore
|
||||
user_context: UserContext
|
||||
|
||||
async def get_org_app_settings(self) -> OrgAppSettingsResponse:
|
||||
"""Get organization app settings.
|
||||
|
||||
User ID is obtained from the injected user_context.
|
||||
|
||||
Returns:
|
||||
OrgAppSettingsResponse: The organization's app settings
|
||||
|
||||
Raises:
|
||||
OrgNotFoundError: If current organization is not found
|
||||
"""
|
||||
user_id = await self.user_context.get_user_id()
|
||||
|
||||
logger.info(
|
||||
'Getting organization app settings',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
|
||||
org = await self.store.get_current_org_by_user_id(user_id)
|
||||
|
||||
if not org:
|
||||
raise OrgNotFoundError('current')
|
||||
|
||||
return OrgAppSettingsResponse.from_org(org)
|
||||
|
||||
async def update_org_app_settings(
|
||||
self,
|
||||
update_data: OrgAppSettingsUpdate,
|
||||
) -> OrgAppSettingsResponse:
|
||||
"""Update organization app settings.
|
||||
|
||||
Only updates fields that are explicitly provided in update_data.
|
||||
User ID is obtained from the injected user_context.
|
||||
Session auto-commits at request end via DbSessionInjector.
|
||||
|
||||
Args:
|
||||
update_data: The update data from the request
|
||||
|
||||
Returns:
|
||||
OrgAppSettingsResponse: The updated organization's app settings
|
||||
|
||||
Raises:
|
||||
OrgNotFoundError: If current organization is not found
|
||||
"""
|
||||
user_id = await self.user_context.get_user_id()
|
||||
|
||||
logger.info(
|
||||
'Updating organization app settings',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
|
||||
# Get current org first
|
||||
org = await self.store.get_current_org_by_user_id(user_id)
|
||||
|
||||
if not org:
|
||||
raise OrgNotFoundError('current')
|
||||
|
||||
# Check if any fields are provided
|
||||
update_dict = update_data.model_dump(exclude_unset=True)
|
||||
|
||||
if not update_dict:
|
||||
# No fields to update, just return current settings
|
||||
logger.info(
|
||||
'No fields to update in app settings',
|
||||
extra={'user_id': user_id, 'org_id': str(org.id)},
|
||||
)
|
||||
return OrgAppSettingsResponse.from_org(org)
|
||||
|
||||
updated_org = await self.store.update_org_app_settings(
|
||||
org_id=org.id,
|
||||
update_data=update_data,
|
||||
)
|
||||
|
||||
if not updated_org:
|
||||
raise OrgNotFoundError('current')
|
||||
|
||||
logger.info(
|
||||
'Organization app settings updated successfully',
|
||||
extra={'user_id': user_id, 'updated_fields': list(update_dict.keys())},
|
||||
)
|
||||
|
||||
return OrgAppSettingsResponse.from_org(updated_org)
|
||||
|
||||
|
||||
class OrgAppSettingsServiceInjector(Injector[OrgAppSettingsService]):
|
||||
"""Injector that composes store and user_context for OrgAppSettingsService."""
|
||||
|
||||
async def inject(
|
||||
self, state: InjectorState, request: Request | None = None
|
||||
) -> AsyncGenerator[OrgAppSettingsService, None]:
|
||||
# Local imports to avoid circular dependencies
|
||||
from openhands.app_server.config import get_db_session, get_user_context
|
||||
|
||||
async with (
|
||||
get_user_context(state, request) as user_context,
|
||||
get_db_session(state, request) as db_session,
|
||||
):
|
||||
store = OrgAppSettingsStore(db_session=db_session)
|
||||
yield OrgAppSettingsService(store=store, user_context=user_context)
|
||||
130
enterprise/server/services/org_llm_settings_service.py
Normal file
130
enterprise/server/services/org_llm_settings_service.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""Service class for managing organization LLM settings.
|
||||
|
||||
Separates business logic from route handlers.
|
||||
Uses dependency injection for db_session and user_context.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import Request
|
||||
from server.routes.org_models import (
|
||||
OrgLLMSettingsResponse,
|
||||
OrgLLMSettingsUpdate,
|
||||
OrgNotFoundError,
|
||||
)
|
||||
from storage.org_llm_settings_store import OrgLLMSettingsStore
|
||||
|
||||
from openhands.app_server.services.injector import Injector, InjectorState
|
||||
from openhands.app_server.user.user_context import UserContext
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrgLLMSettingsService:
|
||||
"""Service for org LLM settings with injected dependencies."""
|
||||
|
||||
store: OrgLLMSettingsStore
|
||||
user_context: UserContext
|
||||
|
||||
async def get_org_llm_settings(self) -> OrgLLMSettingsResponse:
|
||||
"""Get LLM settings for user's current organization.
|
||||
|
||||
User ID is obtained from the injected user_context.
|
||||
|
||||
Returns:
|
||||
OrgLLMSettingsResponse: The organization's LLM settings
|
||||
|
||||
Raises:
|
||||
ValueError: If user is not authenticated
|
||||
OrgNotFoundError: If current organization not found
|
||||
"""
|
||||
user_id = await self.user_context.get_user_id()
|
||||
if not user_id:
|
||||
raise ValueError('User is not authenticated')
|
||||
|
||||
logger.info(
|
||||
'Getting organization LLM settings',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
|
||||
org = await self.store.get_current_org_by_user_id(user_id)
|
||||
|
||||
if not org:
|
||||
raise OrgNotFoundError('No current organization')
|
||||
|
||||
return OrgLLMSettingsResponse.from_org(org)
|
||||
|
||||
async def update_org_llm_settings(
|
||||
self,
|
||||
update_data: OrgLLMSettingsUpdate,
|
||||
) -> OrgLLMSettingsResponse:
|
||||
"""Update LLM settings for user's current organization.
|
||||
|
||||
Only updates fields that are explicitly provided in update_data.
|
||||
User ID is obtained from the injected user_context.
|
||||
Session auto-commits at request end via DbSessionInjector.
|
||||
|
||||
Args:
|
||||
update_data: The update data from the request
|
||||
|
||||
Returns:
|
||||
OrgLLMSettingsResponse: The updated organization's LLM settings
|
||||
|
||||
Raises:
|
||||
ValueError: If user is not authenticated
|
||||
OrgNotFoundError: If current organization not found
|
||||
"""
|
||||
user_id = await self.user_context.get_user_id()
|
||||
if not user_id:
|
||||
raise ValueError('User is not authenticated')
|
||||
|
||||
logger.info(
|
||||
'Updating organization LLM settings',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
|
||||
# Check if any fields are provided
|
||||
if not update_data.has_updates():
|
||||
# No fields to update, just return current settings
|
||||
return await self.get_org_llm_settings()
|
||||
|
||||
# Get user's current org first
|
||||
org = await self.store.get_current_org_by_user_id(user_id)
|
||||
if not org:
|
||||
raise OrgNotFoundError('No current organization')
|
||||
|
||||
# Update the org LLM settings
|
||||
updated_org = await self.store.update_org_llm_settings(
|
||||
org_id=org.id,
|
||||
update_data=update_data,
|
||||
)
|
||||
|
||||
if not updated_org:
|
||||
raise OrgNotFoundError(str(org.id))
|
||||
|
||||
logger.info(
|
||||
'Organization LLM settings updated successfully',
|
||||
extra={'user_id': user_id, 'org_id': str(org.id)},
|
||||
)
|
||||
|
||||
return OrgLLMSettingsResponse.from_org(updated_org)
|
||||
|
||||
|
||||
class OrgLLMSettingsServiceInjector(Injector[OrgLLMSettingsService]):
|
||||
"""Injector that composes store and user_context for OrgLLMSettingsService."""
|
||||
|
||||
async def inject(
|
||||
self, state: InjectorState, request: Request | None = None
|
||||
) -> AsyncGenerator[OrgLLMSettingsService, None]:
|
||||
# Local imports to avoid circular dependencies
|
||||
from openhands.app_server.config import get_db_session, get_user_context
|
||||
|
||||
async with (
|
||||
get_user_context(state, request) as user_context,
|
||||
get_db_session(state, request) as db_session,
|
||||
):
|
||||
store = OrgLLMSettingsStore(db_session=db_session)
|
||||
yield OrgLLMSettingsService(store=store, user_context=user_context)
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from server.constants import ROLE_ADMIN, ROLE_MEMBER, ROLE_OWNER
|
||||
from server.constants import ROLE_ADMIN, ROLE_OWNER
|
||||
from server.routes.org_models import (
|
||||
CannotModifySelfError,
|
||||
InsufficientPermissionError,
|
||||
@@ -16,10 +16,12 @@ from server.routes.org_models import (
|
||||
OrgMemberUpdate,
|
||||
RoleNotFoundError,
|
||||
)
|
||||
from storage.lite_llm_manager import LiteLlmManager
|
||||
from storage.org_member_store import OrgMemberStore
|
||||
from storage.role_store import RoleStore
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
|
||||
@@ -65,10 +67,18 @@ class OrgMemberService:
|
||||
org_id: UUID,
|
||||
current_user_id: UUID,
|
||||
page_id: str | None = None,
|
||||
limit: int = 100,
|
||||
limit: int = 10,
|
||||
email_filter: str | None = None,
|
||||
) -> tuple[bool, str | None, OrgMemberPage | None]:
|
||||
"""Get organization members with authorization check.
|
||||
|
||||
Args:
|
||||
org_id: Organization UUID.
|
||||
current_user_id: Requesting user's UUID.
|
||||
page_id: Offset encoded as string (e.g., "0", "10", "20").
|
||||
limit: Items per page (default 10).
|
||||
email_filter: Optional case-insensitive partial email match.
|
||||
|
||||
Returns:
|
||||
Tuple of (success, error_code, data). If success is True, error_code is None.
|
||||
"""
|
||||
@@ -88,8 +98,11 @@ class OrgMemberService:
|
||||
return False, 'invalid_page_id', None
|
||||
|
||||
# Call store to get paginated members
|
||||
members, has_more = await OrgMemberStore.get_org_members_paginated(
|
||||
org_id=org_id, offset=offset, limit=limit
|
||||
members, _ = await OrgMemberStore.get_org_members_paginated(
|
||||
org_id=org_id,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
email_filter=email_filter,
|
||||
)
|
||||
|
||||
# Transform data to response format
|
||||
@@ -110,12 +123,47 @@ class OrgMemberService:
|
||||
)
|
||||
)
|
||||
|
||||
# Calculate next_page_id
|
||||
next_page_id = None
|
||||
if has_more:
|
||||
next_page_id = str(offset + limit)
|
||||
# Calculate current page (1-indexed)
|
||||
current_page = (offset // limit) + 1
|
||||
|
||||
return True, None, OrgMemberPage(items=items, next_page_id=next_page_id)
|
||||
return (
|
||||
True,
|
||||
None,
|
||||
OrgMemberPage(
|
||||
items=items,
|
||||
current_page=current_page,
|
||||
per_page=limit,
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def get_org_members_count(
|
||||
org_id: UUID,
|
||||
current_user_id: UUID,
|
||||
email_filter: str | None = None,
|
||||
) -> int:
|
||||
"""Get count of organization members with authorization check.
|
||||
|
||||
Args:
|
||||
org_id: Organization UUID.
|
||||
current_user_id: Requesting user's UUID.
|
||||
email_filter: Optional case-insensitive partial email match.
|
||||
|
||||
Returns:
|
||||
int: Count of organization members matching the filter.
|
||||
|
||||
Raises:
|
||||
OrgMemberNotFoundError: If requesting user is not a member of the organization.
|
||||
"""
|
||||
# Verify current user is a member of the organization
|
||||
requester_membership = OrgMemberStore.get_org_member(org_id, current_user_id)
|
||||
if not requester_membership:
|
||||
raise OrgMemberNotFoundError(str(org_id), str(current_user_id))
|
||||
|
||||
return await OrgMemberStore.get_org_members_count(
|
||||
org_id=org_id,
|
||||
email_filter=email_filter,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def remove_org_member(
|
||||
@@ -168,9 +216,42 @@ class OrgMemberService:
|
||||
if not success:
|
||||
return False, 'removal_failed'
|
||||
|
||||
# Update user's current_org_id if it points to the org they were removed from
|
||||
user = UserStore.get_user_by_id(str(target_user_id))
|
||||
if user and user.current_org_id == org_id:
|
||||
# Set current_org_id to personal workspace (org.id == user.id)
|
||||
UserStore.update_current_org(str(target_user_id), target_user_id)
|
||||
|
||||
return True, None
|
||||
|
||||
return await call_sync_from_async(_remove_member)
|
||||
success, error = await call_sync_from_async(_remove_member)
|
||||
|
||||
# If database removal succeeded, also remove from LiteLLM team
|
||||
if success:
|
||||
try:
|
||||
await LiteLlmManager.remove_user_from_team(
|
||||
str(target_user_id), str(org_id)
|
||||
)
|
||||
logger.info(
|
||||
'Successfully removed user from LiteLLM team',
|
||||
extra={
|
||||
'user_id': str(target_user_id),
|
||||
'org_id': str(org_id),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
# Log but don't fail the operation - database removal already succeeded
|
||||
# LiteLLM state will be eventually consistent
|
||||
logger.warning(
|
||||
'Failed to remove user from LiteLLM team',
|
||||
extra={
|
||||
'user_id': str(target_user_id),
|
||||
'org_id': str(org_id),
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
|
||||
return success, error
|
||||
|
||||
@staticmethod
|
||||
async def update_org_member(
|
||||
@@ -182,10 +263,9 @@ class OrgMemberService:
|
||||
"""Update a member's role in an organization.
|
||||
|
||||
Permission rules:
|
||||
- Admins can change roles of users (rank > ADMIN_RANK) to Admin or User
|
||||
- Admins cannot modify other Admins or Owners
|
||||
- Owners can change roles of non-owners (rank > OWNER_RANK) to any role
|
||||
- Owners cannot modify other Owners
|
||||
- Owners can modify anyone (including other owners), can set any role
|
||||
- Admins can modify other admins and users
|
||||
- Admins can only set admin or user roles (not owner)
|
||||
|
||||
Args:
|
||||
org_id: Organization ID
|
||||
@@ -294,26 +374,21 @@ class OrgMemberService:
|
||||
"""Check if requester can change target's role to new_role.
|
||||
|
||||
Permission rules:
|
||||
- Owners can modify admins and users, can set any role
|
||||
- Owners cannot modify other owners
|
||||
- Admins can only modify users
|
||||
- Owners can modify anyone (including other owners), can set any role
|
||||
- Admins can modify other admins and users
|
||||
- Admins can only set admin or user roles (not owner)
|
||||
"""
|
||||
is_requester_owner = requester_role_name == ROLE_OWNER
|
||||
is_requester_admin = requester_role_name == ROLE_ADMIN
|
||||
is_target_owner = target_role_name == ROLE_OWNER
|
||||
is_target_admin = target_role_name == ROLE_ADMIN
|
||||
is_new_role_owner = new_role_name == ROLE_OWNER
|
||||
|
||||
if is_requester_owner:
|
||||
# Owners cannot modify other owners
|
||||
if is_target_owner:
|
||||
return False
|
||||
# Owners can set any role (owner, admin, user)
|
||||
# Owners can modify anyone (including other owners)
|
||||
return True
|
||||
elif is_requester_admin:
|
||||
# Admins cannot modify owners or other admins
|
||||
if is_target_owner or is_target_admin:
|
||||
# Admins cannot modify owners
|
||||
if is_target_owner:
|
||||
return False
|
||||
# Admins can only set admin or user roles (not owner)
|
||||
return not is_new_role_owner
|
||||
@@ -325,8 +400,8 @@ class OrgMemberService:
|
||||
if requester_role_name == ROLE_OWNER:
|
||||
return True
|
||||
elif requester_role_name == ROLE_ADMIN:
|
||||
# Admins can only remove members (not owners or other admins)
|
||||
return target_role_name == ROLE_MEMBER
|
||||
# Admins can remove admins and members (not owners)
|
||||
return target_role_name != ROLE_OWNER
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
|
||||
126
enterprise/server/services/user_app_settings_service.py
Normal file
126
enterprise/server/services/user_app_settings_service.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Service class for managing user app settings.
|
||||
|
||||
Separates business logic from route handlers.
|
||||
Uses dependency injection for db_session and user_context.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import Request
|
||||
from server.routes.user_app_settings_models import (
|
||||
UserAppSettingsResponse,
|
||||
UserAppSettingsUpdate,
|
||||
UserNotFoundError,
|
||||
)
|
||||
from storage.user_app_settings_store import UserAppSettingsStore
|
||||
|
||||
from openhands.app_server.services.injector import Injector, InjectorState
|
||||
from openhands.app_server.user.user_context import UserContext
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserAppSettingsService:
|
||||
"""Service for user app settings with injected dependencies."""
|
||||
|
||||
store: UserAppSettingsStore
|
||||
user_context: UserContext
|
||||
|
||||
async def get_user_app_settings(self) -> UserAppSettingsResponse:
|
||||
"""Get user app settings.
|
||||
|
||||
User ID is obtained from the injected user_context.
|
||||
|
||||
Returns:
|
||||
UserAppSettingsResponse: The user's app settings
|
||||
|
||||
Raises:
|
||||
ValueError: If user is not authenticated
|
||||
UserNotFoundError: If user is not found
|
||||
"""
|
||||
user_id = await self.user_context.get_user_id()
|
||||
if not user_id:
|
||||
raise ValueError('User is not authenticated')
|
||||
|
||||
logger.info(
|
||||
'Getting user app settings',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
|
||||
user = await self.store.get_user_by_id(user_id)
|
||||
|
||||
if not user:
|
||||
raise UserNotFoundError(user_id)
|
||||
|
||||
return UserAppSettingsResponse.from_user(user)
|
||||
|
||||
async def update_user_app_settings(
|
||||
self,
|
||||
update_data: UserAppSettingsUpdate,
|
||||
) -> UserAppSettingsResponse:
|
||||
"""Update user app settings.
|
||||
|
||||
Only updates fields that are explicitly provided in update_data.
|
||||
User ID is obtained from the injected user_context.
|
||||
Session auto-commits at request end via DbSessionInjector.
|
||||
|
||||
Args:
|
||||
update_data: The update data from the request
|
||||
|
||||
Returns:
|
||||
UserAppSettingsResponse: The updated user's app settings
|
||||
|
||||
Raises:
|
||||
ValueError: If user is not authenticated
|
||||
UserNotFoundError: If user is not found
|
||||
"""
|
||||
user_id = await self.user_context.get_user_id()
|
||||
if not user_id:
|
||||
raise ValueError('User is not authenticated')
|
||||
|
||||
logger.info(
|
||||
'Updating user app settings',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
|
||||
# Check if any fields are provided
|
||||
update_dict = update_data.model_dump(exclude_unset=True)
|
||||
|
||||
if not update_dict:
|
||||
# No fields to update, just return current settings
|
||||
return await self.get_user_app_settings()
|
||||
|
||||
user = await self.store.update_user_app_settings(
|
||||
user_id=user_id,
|
||||
update_data=update_data,
|
||||
)
|
||||
|
||||
if not user:
|
||||
raise UserNotFoundError(user_id)
|
||||
|
||||
logger.info(
|
||||
'User app settings updated successfully',
|
||||
extra={'user_id': user_id, 'updated_fields': list(update_dict.keys())},
|
||||
)
|
||||
|
||||
return UserAppSettingsResponse.from_user(user)
|
||||
|
||||
|
||||
class UserAppSettingsServiceInjector(Injector[UserAppSettingsService]):
|
||||
"""Injector that composes store and user_context for UserAppSettingsService."""
|
||||
|
||||
async def inject(
|
||||
self, state: InjectorState, request: Request | None = None
|
||||
) -> AsyncGenerator[UserAppSettingsService, None]:
|
||||
# Local imports to avoid circular dependencies
|
||||
from openhands.app_server.config import get_db_session, get_user_context
|
||||
|
||||
async with (
|
||||
get_user_context(state, request) as user_context,
|
||||
get_db_session(state, request) as db_session,
|
||||
):
|
||||
store = UserAppSettingsStore(db_session=db_session)
|
||||
yield UserAppSettingsService(store=store, user_context=user_context)
|
||||
@@ -24,6 +24,7 @@ from openhands.app_server.app_conversation.sql_app_conversation_info_service imp
|
||||
)
|
||||
from openhands.app_server.errors import AuthError
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import ADMIN
|
||||
|
||||
|
||||
class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
@@ -63,6 +64,12 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
Raises:
|
||||
AuthError: If no user_id is available (secure default: deny access)
|
||||
"""
|
||||
# For internal operations such as getting a conversation by session_api_key
|
||||
# we need a mode that does not have filtering. The dependency `as_admin()`
|
||||
# is used to enable it
|
||||
if self.user_context == ADMIN:
|
||||
return query
|
||||
|
||||
user_id_str = await self.user_context.get_user_id()
|
||||
if not user_id_str:
|
||||
# Secure default: no user means no access, not "show everything"
|
||||
|
||||
33
enterprise/server/verified_models/verified_model_models.py
Normal file
33
enterprise/server/verified_models/verified_model_models.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import BaseModel, StringConstraints
|
||||
|
||||
|
||||
class VerifiedModelCreate(BaseModel):
|
||||
model_name: Annotated[
|
||||
str,
|
||||
StringConstraints(max_length=255),
|
||||
]
|
||||
provider: Annotated[
|
||||
str,
|
||||
StringConstraints(max_length=100),
|
||||
]
|
||||
is_enabled: bool = True
|
||||
|
||||
|
||||
class VerifiedModel(VerifiedModelCreate):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class VerifiedModelUpdate(BaseModel):
|
||||
is_enabled: bool | None = None
|
||||
|
||||
|
||||
class VerifiedModelPage(BaseModel):
|
||||
"""Paginated response model for verified model list."""
|
||||
|
||||
items: list[VerifiedModel]
|
||||
next_page_id: str | None = None
|
||||
143
enterprise/server/verified_models/verified_model_router.py
Normal file
143
enterprise/server/verified_models/verified_model_router.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""API routes for managing verified LLM models (admin only)."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from server.email_validation import get_admin_user_id
|
||||
from server.verified_models.verified_model_models import (
|
||||
VerifiedModel,
|
||||
VerifiedModelCreate,
|
||||
VerifiedModelPage,
|
||||
VerifiedModelUpdate,
|
||||
)
|
||||
from server.verified_models.verified_model_service import (
|
||||
VerifiedModelService,
|
||||
verified_model_store_dependency,
|
||||
)
|
||||
|
||||
from openhands.app_server.config import get_db_session
|
||||
from openhands.server.routes import public
|
||||
from openhands.utils.llm import get_supported_llm_models
|
||||
|
||||
api_router = APIRouter(prefix='/api/admin/verified-models', tags=['Verified Models'])
|
||||
|
||||
|
||||
@api_router.get('')
|
||||
async def search_verified_models(
|
||||
provider: str | None = None,
|
||||
page_id: Annotated[
|
||||
str | None,
|
||||
Query(title='Optional next_page_id from the previously returned page'),
|
||||
] = None,
|
||||
limit: Annotated[
|
||||
int, Query(title='The max number of results in the page', gt=0, le=100)
|
||||
] = 100,
|
||||
user_id: str = Depends(get_admin_user_id),
|
||||
verified_model_service: VerifiedModelService = Depends(
|
||||
verified_model_store_dependency
|
||||
),
|
||||
) -> VerifiedModelPage:
|
||||
"""List all verified models, optionally filtered by provider."""
|
||||
# Use SQL-level filtering and pagination
|
||||
result = await verified_model_service.search_verified_models(
|
||||
provider=provider,
|
||||
enabled_only=False, # Admin sees all models including disabled
|
||||
page_id=page_id,
|
||||
limit=limit,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@api_router.post('', status_code=201)
|
||||
async def create_verified_model(
|
||||
data: VerifiedModelCreate,
|
||||
user_id: str = Depends(get_admin_user_id),
|
||||
verified_model_service: VerifiedModelService = Depends(
|
||||
verified_model_store_dependency
|
||||
),
|
||||
) -> VerifiedModel:
|
||||
"""Create a new verified model."""
|
||||
try:
|
||||
model = await verified_model_service.create_verified_model(
|
||||
model_name=data.model_name,
|
||||
provider=data.provider,
|
||||
is_enabled=data.is_enabled,
|
||||
)
|
||||
return model
|
||||
except ValueError as ex:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(ex),
|
||||
)
|
||||
|
||||
|
||||
@api_router.put('/{provider}/{model_name:path}')
|
||||
async def update_verified_model(
|
||||
provider: str,
|
||||
model_name: str,
|
||||
data: VerifiedModelUpdate,
|
||||
user_id: str = Depends(get_admin_user_id),
|
||||
verified_model_service: VerifiedModelService = Depends(
|
||||
verified_model_store_dependency
|
||||
),
|
||||
) -> VerifiedModel:
|
||||
"""Update a verified model by provider and model name."""
|
||||
model = await verified_model_service.update_verified_model(
|
||||
model_name=model_name,
|
||||
provider=provider,
|
||||
is_enabled=data.is_enabled,
|
||||
)
|
||||
if not model:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f'Model {provider}/{model_name} not found',
|
||||
)
|
||||
return model
|
||||
|
||||
|
||||
@api_router.delete('/{provider}/{model_name:path}')
|
||||
async def delete_verified_model(
|
||||
provider: str,
|
||||
model_name: str,
|
||||
user_id: str = Depends(get_admin_user_id),
|
||||
verified_model_service: VerifiedModelService = Depends(
|
||||
verified_model_store_dependency
|
||||
),
|
||||
) -> bool:
|
||||
"""Delete a verified model by provider and model name."""
|
||||
try:
|
||||
await verified_model_service.delete_verified_model(
|
||||
model_name=model_name, provider=provider
|
||||
)
|
||||
return True
|
||||
except ValueError as ex:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(ex),
|
||||
)
|
||||
|
||||
|
||||
async def get_saas_llm_models_dependency(request: Request) -> list[str]:
|
||||
"""SaaS implementation for the LLM models endpoint."""
|
||||
async with get_db_session(request.state, request) as db_session:
|
||||
# Prevent circular import
|
||||
from openhands.server.shared import config
|
||||
|
||||
verified_model_service = VerifiedModelService(db_session)
|
||||
page = await verified_model_service.search_verified_models(enabled_only=True)
|
||||
if page.next_page_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Too many models defined in database',
|
||||
)
|
||||
verified_models = [f'{m.provider}/{m.model_name}' for m in page.items]
|
||||
return get_supported_llm_models(config, verified_models)
|
||||
|
||||
|
||||
# Override the default implementation with SaaS implementation
|
||||
# This must be called after the app is created in saas_server.py
|
||||
def override_llm_models_dependency(app):
|
||||
"""Override the default LLM models implementation with SaaS version."""
|
||||
app.dependency_overrides[public.get_llm_models_dependency] = (
|
||||
get_saas_llm_models_dependency
|
||||
)
|
||||
242
enterprise/server/verified_models/verified_model_service.py
Normal file
242
enterprise/server/verified_models/verified_model_service.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""Store for managing verified LLM models in the database."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
Identity,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
and_,
|
||||
func,
|
||||
select,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from storage.base import Base
|
||||
|
||||
from enterprise.server.verified_models.verified_model_models import (
|
||||
VerifiedModel,
|
||||
VerifiedModelPage,
|
||||
)
|
||||
from openhands.app_server.config import depends_db_session
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
class StoredVerifiedModel(Base): # type: ignore
|
||||
"""A verified LLM model available in the model selector.
|
||||
|
||||
The composite unique constraint on (model_name, provider) allows the same
|
||||
model name to exist under different providers (e.g. 'claude-sonnet' under
|
||||
both 'openhands' and 'anthropic').
|
||||
"""
|
||||
|
||||
__tablename__ = 'verified_models'
|
||||
__table_args__ = (
|
||||
UniqueConstraint('model_name', 'provider', name='uq_verified_model_provider'),
|
||||
)
|
||||
|
||||
id = Column(Integer, Identity(), primary_key=True)
|
||||
model_name = Column(String(255), nullable=False)
|
||||
provider = Column(String(100), nullable=False, index=True)
|
||||
is_enabled = Column(
|
||||
Boolean, nullable=False, default=True, server_default=text('true')
|
||||
)
|
||||
created_at = Column(DateTime, nullable=False, server_default=func.now())
|
||||
updated_at = Column(
|
||||
DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
def verified_model(result: StoredVerifiedModel) -> VerifiedModel:
|
||||
return VerifiedModel(
|
||||
id=result.id,
|
||||
model_name=result.model_name,
|
||||
provider=result.provider,
|
||||
is_enabled=result.is_enabled,
|
||||
created_at=result.created_at,
|
||||
updated_at=result.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VerifiedModelService:
|
||||
"""Store for CRUD operations on verified models.
|
||||
|
||||
Follows the async pattern with db_session as an attribute.
|
||||
"""
|
||||
|
||||
db_session: AsyncSession
|
||||
|
||||
async def search_verified_models(
|
||||
self,
|
||||
provider: str | None = None,
|
||||
enabled_only: bool = True,
|
||||
page_id: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> VerifiedModelPage:
|
||||
"""Search for verified models with optional filtering and pagination.
|
||||
|
||||
Args:
|
||||
provider: Optional provider name to filter by (e.g., 'openhands', 'anthropic')
|
||||
enabled_only: If True, only return enabled models (default: True)
|
||||
page_id: Page id for pagination
|
||||
limit: Maximum number of records to return
|
||||
|
||||
Returns:
|
||||
SearchModelsResult containing items list and has_more flag
|
||||
"""
|
||||
query = select(StoredVerifiedModel)
|
||||
|
||||
# Build filters
|
||||
filters = []
|
||||
if provider:
|
||||
filters.append(StoredVerifiedModel.provider == provider)
|
||||
if enabled_only:
|
||||
filters.append(StoredVerifiedModel.is_enabled.is_(True))
|
||||
|
||||
if filters:
|
||||
query = query.where(and_(*filters))
|
||||
|
||||
# Order by provider, then model_name
|
||||
query = query.order_by(
|
||||
StoredVerifiedModel.provider, StoredVerifiedModel.model_name
|
||||
)
|
||||
|
||||
# Fetch limit + 1 to check if there are more results
|
||||
offset = int(page_id or '0')
|
||||
query = query.offset(offset).limit(limit + 1)
|
||||
|
||||
result = await self.db_session.execute(query)
|
||||
results = list(result.scalars().all())
|
||||
has_more = len(results) > limit
|
||||
next_page_id = None
|
||||
|
||||
# Return only the requested number of results
|
||||
if has_more:
|
||||
next_page_id = str(offset + limit)
|
||||
results.pop()
|
||||
|
||||
items = [verified_model(result) for result in results]
|
||||
return VerifiedModelPage(items=items, next_page_id=next_page_id)
|
||||
|
||||
async def get_model(self, model_name: str, provider: str) -> VerifiedModel | None:
|
||||
"""Get a model by its composite key (model_name, provider).
|
||||
|
||||
Args:
|
||||
model_name: The model identifier
|
||||
provider: The provider name
|
||||
"""
|
||||
query = select(StoredVerifiedModel).where(
|
||||
and_(
|
||||
StoredVerifiedModel.model_name == model_name,
|
||||
StoredVerifiedModel.provider == provider,
|
||||
)
|
||||
)
|
||||
result = await self.db_session.execute(query)
|
||||
return result.scalars().first()
|
||||
|
||||
async def create_verified_model(
|
||||
self,
|
||||
model_name: str,
|
||||
provider: str,
|
||||
is_enabled: bool = True,
|
||||
) -> VerifiedModel:
|
||||
"""Create a new verified model.
|
||||
|
||||
Args:
|
||||
model_name: The model identifier
|
||||
provider: The provider name
|
||||
is_enabled: Whether the model is enabled (default True)
|
||||
|
||||
Raises:
|
||||
ValueError: If a model with the same (model_name, provider) already exists
|
||||
"""
|
||||
existing_query = select(StoredVerifiedModel).where(
|
||||
and_(
|
||||
StoredVerifiedModel.model_name == model_name,
|
||||
StoredVerifiedModel.provider == provider,
|
||||
)
|
||||
)
|
||||
result = await self.db_session.execute(existing_query)
|
||||
existing = result.scalars().first()
|
||||
if existing:
|
||||
raise ValueError(f'Model {provider}/{model_name} already exists')
|
||||
|
||||
model = StoredVerifiedModel(
|
||||
model_name=model_name,
|
||||
provider=provider,
|
||||
is_enabled=is_enabled,
|
||||
)
|
||||
self.db_session.add(model)
|
||||
await self.db_session.commit()
|
||||
await self.db_session.refresh(model)
|
||||
logger.info(f'Created verified model: {provider}/{model_name}')
|
||||
return verified_model(model)
|
||||
|
||||
async def update_verified_model(
|
||||
self,
|
||||
model_name: str,
|
||||
provider: str,
|
||||
is_enabled: bool | None = None,
|
||||
) -> VerifiedModel | None:
|
||||
"""Update an existing verified model.
|
||||
|
||||
Args:
|
||||
model_name: The model name to update
|
||||
provider: The provider name
|
||||
is_enabled: New enabled state (optional)
|
||||
|
||||
Returns:
|
||||
The updated model if found, None otherwise
|
||||
"""
|
||||
query = select(StoredVerifiedModel).where(
|
||||
and_(
|
||||
StoredVerifiedModel.model_name == model_name,
|
||||
StoredVerifiedModel.provider == provider,
|
||||
)
|
||||
)
|
||||
result = await self.db_session.execute(query)
|
||||
model = result.scalars().first()
|
||||
if not model:
|
||||
return None
|
||||
|
||||
if is_enabled is not None:
|
||||
model.is_enabled = is_enabled
|
||||
|
||||
await self.db_session.commit()
|
||||
await self.db_session.refresh(model)
|
||||
logger.info(f'Updated verified model: {provider}/{model_name}')
|
||||
return verified_model(model)
|
||||
|
||||
async def delete_verified_model(self, model_name: str, provider: str):
|
||||
"""Delete a verified model.
|
||||
|
||||
Args:
|
||||
model_name: The model name to delete
|
||||
provider: The provider name
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
query = select(StoredVerifiedModel).where(
|
||||
and_(
|
||||
StoredVerifiedModel.model_name == model_name,
|
||||
StoredVerifiedModel.provider == provider,
|
||||
)
|
||||
)
|
||||
result = await self.db_session.execute(query)
|
||||
model = result.scalars().first()
|
||||
if not model:
|
||||
raise ValueError('Unknown model')
|
||||
|
||||
await self.db_session.delete(model)
|
||||
await self.db_session.commit()
|
||||
logger.info(f'Deleted verified model: {provider}/{model_name}')
|
||||
|
||||
|
||||
def verified_model_store_dependency(db_session: AsyncSession = depends_db_session()):
|
||||
return VerifiedModelService(db_session)
|
||||
@@ -4,7 +4,9 @@ import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Awaitable, Callable, Dict
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from server.auth.auth_error import TokenRefreshError
|
||||
from sqlalchemy import select, text, update
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from storage.auth_tokens import AuthTokens
|
||||
from storage.database import a_session_maker
|
||||
@@ -12,6 +14,14 @@ from storage.database import a_session_maker
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
|
||||
# Time buffer (in seconds) before actual expiration to consider token expired
|
||||
# This ensures tokens are refreshed before they actually expire. The
|
||||
# github default is 8 hours, so 15 minutes leeway is ~3% of this.
|
||||
ACCESS_TOKEN_EXPIRY_BUFFER = 900 # 15 minutes
|
||||
|
||||
# Database lock timeout to prevent indefinite blocking
|
||||
LOCK_TIMEOUT_SECONDS = 5
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthTokenStore:
|
||||
@@ -23,6 +33,31 @@ class AuthTokenStore:
|
||||
def identity_provider_value(self) -> str:
|
||||
return self.idp.value
|
||||
|
||||
def _is_token_expired(
|
||||
self, access_token_expires_at: int, refresh_token_expires_at: int
|
||||
) -> tuple[bool, bool]:
|
||||
"""Check if access and refresh tokens are expired.
|
||||
|
||||
Args:
|
||||
access_token_expires_at: Expiration time for access token (seconds since epoch)
|
||||
refresh_token_expires_at: Expiration time for refresh token (seconds since epoch)
|
||||
|
||||
Returns:
|
||||
Tuple of (access_expired, refresh_expired)
|
||||
"""
|
||||
current_time = int(time.time())
|
||||
access_expired = (
|
||||
False
|
||||
if access_token_expires_at == 0
|
||||
else access_token_expires_at < current_time + ACCESS_TOKEN_EXPIRY_BUFFER
|
||||
)
|
||||
refresh_expired = (
|
||||
False
|
||||
if refresh_token_expires_at == 0
|
||||
else refresh_token_expires_at < current_time
|
||||
)
|
||||
return access_expired, refresh_expired
|
||||
|
||||
async def store_tokens(
|
||||
self,
|
||||
access_token: str,
|
||||
@@ -73,87 +108,149 @@ class AuthTokenStore:
|
||||
]
|
||||
| None = None,
|
||||
) -> Dict[str, str | int] | None:
|
||||
"""
|
||||
Load authentication tokens from the database and refresh them if necessary.
|
||||
"""Load authentication tokens from the database and refresh them if necessary.
|
||||
|
||||
This method retrieves the current authentication tokens for the user and checks if they have expired.
|
||||
It uses the provided `check_expiration_and_refresh` function to determine if the tokens need
|
||||
to be refreshed and to refresh the tokens if needed.
|
||||
This method uses a double-checked locking pattern to minimize lock contention:
|
||||
1. First, check if the token is valid WITHOUT acquiring a lock (fast path)
|
||||
2. If refresh is needed, acquire a lock with a timeout
|
||||
3. Double-check if refresh is still needed (another request may have refreshed)
|
||||
4. Perform the refresh if still needed
|
||||
|
||||
The method ensures that only one refresh operation is performed per refresh token by using a
|
||||
row-level lock on the token record.
|
||||
|
||||
The method is designed to handle race conditions where multiple requests might attempt to refresh
|
||||
the same token simultaneously, ensuring that only one refresh call occurs per refresh token.
|
||||
The row-level lock ensures that only one refresh operation is performed per
|
||||
refresh token, which is important because most IDPs invalidate the old refresh
|
||||
token after it's used once.
|
||||
|
||||
Args:
|
||||
check_expiration_and_refresh (Callable, optional): A function that checks if the tokens have expired
|
||||
and attempts to refresh them. It should return a dictionary containing the new access_token, refresh_token,
|
||||
and their respective expiration timestamps. If no refresh is needed, it should return `None`.
|
||||
check_expiration_and_refresh: A function that checks if the tokens have
|
||||
expired and attempts to refresh them. It should return a dictionary
|
||||
containing the new access_token, refresh_token, and their respective
|
||||
expiration timestamps. If no refresh is needed, it should return None.
|
||||
|
||||
Returns:
|
||||
Dict[str, str | int] | None:
|
||||
A dictionary containing the access_token, refresh_token, access_token_expires_at,
|
||||
and refresh_token_expires_at. If no token record is found, returns `None`.
|
||||
A dictionary containing the access_token, refresh_token,
|
||||
access_token_expires_at, and refresh_token_expires_at.
|
||||
If no token record is found, returns None.
|
||||
|
||||
Raises:
|
||||
TokenRefreshError: If the lock cannot be acquired within the timeout
|
||||
period. This typically means another request is holding the lock
|
||||
for an extended period. Callers should handle this by returning
|
||||
a 401 response to prompt the user to re-authenticate.
|
||||
"""
|
||||
# FAST PATH: Check without lock first to avoid unnecessary lock contention
|
||||
async with self.a_session_maker() as session:
|
||||
async with session.begin(): # Ensures transaction management
|
||||
# Lock the row while we check if we need to refresh the tokens.
|
||||
# There is a race condition where 2 or more calls can load tokens simultaneously.
|
||||
# If it turns out the loaded tokens are expired, then there will be multiple
|
||||
# refresh token calls with the same refresh token. Most IDPs only allow one refresh
|
||||
# per refresh token. This lock ensure that only one refresh call occurs per refresh token
|
||||
result = await session.execute(
|
||||
select(AuthTokens)
|
||||
.filter(
|
||||
AuthTokens.keycloak_user_id == self.keycloak_user_id,
|
||||
AuthTokens.identity_provider == self.identity_provider_value,
|
||||
)
|
||||
.with_for_update()
|
||||
result = await session.execute(
|
||||
select(AuthTokens).filter(
|
||||
AuthTokens.keycloak_user_id == self.keycloak_user_id,
|
||||
AuthTokens.identity_provider == self.identity_provider_value,
|
||||
)
|
||||
token_record = result.scalars().one_or_none()
|
||||
)
|
||||
token_record = result.scalars().one_or_none()
|
||||
|
||||
if not token_record:
|
||||
return None
|
||||
if not token_record:
|
||||
return None
|
||||
|
||||
token_refresh = (
|
||||
await check_expiration_and_refresh(
|
||||
# Check if token needs refresh
|
||||
access_expired, _ = self._is_token_expired(
|
||||
token_record.access_token_expires_at,
|
||||
token_record.refresh_token_expires_at,
|
||||
)
|
||||
|
||||
# If token is still valid, return it without acquiring a lock
|
||||
if not access_expired or check_expiration_and_refresh is None:
|
||||
return {
|
||||
'access_token': token_record.access_token,
|
||||
'refresh_token': token_record.refresh_token,
|
||||
'access_token_expires_at': token_record.access_token_expires_at,
|
||||
'refresh_token_expires_at': token_record.refresh_token_expires_at,
|
||||
}
|
||||
|
||||
# SLOW PATH: Token needs refresh, acquire lock
|
||||
try:
|
||||
async with self.a_session_maker() as session:
|
||||
async with session.begin():
|
||||
# Set a lock timeout to prevent indefinite blocking
|
||||
# This ensures we don't hold connections forever if something goes wrong
|
||||
await session.execute(
|
||||
text(f"SET LOCAL lock_timeout = '{LOCK_TIMEOUT_SECONDS}s'")
|
||||
)
|
||||
|
||||
# Acquire row-level lock to prevent concurrent refresh attempts
|
||||
result = await session.execute(
|
||||
select(AuthTokens)
|
||||
.filter(
|
||||
AuthTokens.keycloak_user_id == self.keycloak_user_id,
|
||||
AuthTokens.identity_provider
|
||||
== self.identity_provider_value,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
token_record = result.scalars().one_or_none()
|
||||
|
||||
if not token_record:
|
||||
return None
|
||||
|
||||
# Double-check: another request may have refreshed while we waited for the lock
|
||||
access_expired, _ = self._is_token_expired(
|
||||
token_record.access_token_expires_at,
|
||||
token_record.refresh_token_expires_at,
|
||||
)
|
||||
|
||||
if not access_expired:
|
||||
# Token was refreshed by another request while we waited
|
||||
logger.debug(
|
||||
'Token was refreshed by another request while waiting for lock'
|
||||
)
|
||||
return {
|
||||
'access_token': token_record.access_token,
|
||||
'refresh_token': token_record.refresh_token,
|
||||
'access_token_expires_at': token_record.access_token_expires_at,
|
||||
'refresh_token_expires_at': token_record.refresh_token_expires_at,
|
||||
}
|
||||
|
||||
# We're the one doing the refresh
|
||||
token_refresh = await check_expiration_and_refresh(
|
||||
self.idp,
|
||||
token_record.refresh_token,
|
||||
token_record.access_token_expires_at,
|
||||
token_record.refresh_token_expires_at,
|
||||
)
|
||||
if check_expiration_and_refresh
|
||||
else None
|
||||
)
|
||||
|
||||
if token_refresh:
|
||||
await session.execute(
|
||||
update(AuthTokens)
|
||||
.where(AuthTokens.id == token_record.id)
|
||||
.values(
|
||||
access_token=token_refresh['access_token'],
|
||||
refresh_token=token_refresh['refresh_token'],
|
||||
access_token_expires_at=token_refresh[
|
||||
'access_token_expires_at'
|
||||
],
|
||||
refresh_token_expires_at=token_refresh[
|
||||
'refresh_token_expires_at'
|
||||
],
|
||||
if token_refresh:
|
||||
await session.execute(
|
||||
update(AuthTokens)
|
||||
.where(AuthTokens.id == token_record.id)
|
||||
.values(
|
||||
access_token=token_refresh['access_token'],
|
||||
refresh_token=token_refresh['refresh_token'],
|
||||
access_token_expires_at=token_refresh[
|
||||
'access_token_expires_at'
|
||||
],
|
||||
refresh_token_expires_at=token_refresh[
|
||||
'refresh_token_expires_at'
|
||||
],
|
||||
)
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
await session.commit()
|
||||
|
||||
return (
|
||||
token_refresh
|
||||
if token_refresh
|
||||
else {
|
||||
'access_token': token_record.access_token,
|
||||
'refresh_token': token_record.refresh_token,
|
||||
'access_token_expires_at': token_record.access_token_expires_at,
|
||||
'refresh_token_expires_at': token_record.refresh_token_expires_at,
|
||||
}
|
||||
)
|
||||
return (
|
||||
token_refresh
|
||||
if token_refresh
|
||||
else {
|
||||
'access_token': token_record.access_token,
|
||||
'refresh_token': token_record.refresh_token,
|
||||
'access_token_expires_at': token_record.access_token_expires_at,
|
||||
'refresh_token_expires_at': token_record.refresh_token_expires_at,
|
||||
}
|
||||
)
|
||||
except OperationalError as e:
|
||||
# Lock timeout - another request is holding the lock for too long
|
||||
logger.warning(
|
||||
f'Token refresh lock timeout for user {self.keycloak_user_id}: {e}'
|
||||
)
|
||||
raise TokenRefreshError(
|
||||
'Unable to refresh token due to lock timeout. Please try again.'
|
||||
) from e
|
||||
|
||||
async def is_access_token_valid(self) -> bool:
|
||||
"""Check if the access token is still valid.
|
||||
@@ -194,8 +291,8 @@ class AuthTokenStore:
|
||||
"""Get an instance of the AuthTokenStore.
|
||||
|
||||
Args:
|
||||
config: The application configuration
|
||||
keycloak_user_id: The Keycloak user ID
|
||||
idp: The identity provider type
|
||||
|
||||
Returns:
|
||||
An instance of AuthTokenStore
|
||||
|
||||
@@ -18,17 +18,17 @@ def _get_db_session_injector():
|
||||
return _config.db_session
|
||||
|
||||
|
||||
def session_maker():
|
||||
def session_maker(**kwargs):
|
||||
db_session_injector = _get_db_session_injector()
|
||||
session_maker = db_session_injector.get_session_maker()
|
||||
return session_maker()
|
||||
factory = db_session_injector.get_session_maker()
|
||||
return factory(**kwargs)
|
||||
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def a_session_maker():
|
||||
async def a_session_maker(**kwargs):
|
||||
db_session_injector = _get_db_session_injector()
|
||||
a_session_maker = await db_session_injector.get_async_session_maker()
|
||||
async with a_session_maker() as session:
|
||||
factory = await db_session_injector.get_async_session_maker()
|
||||
async with factory(**kwargs) as session:
|
||||
yield session
|
||||
|
||||
|
||||
|
||||
@@ -51,7 +51,9 @@ class Org(Base): # type: ignore
|
||||
# Relationships
|
||||
org_members = relationship('OrgMember', back_populates='org')
|
||||
current_users = relationship('User', back_populates='current_org')
|
||||
invitations = relationship('OrgInvitation', back_populates='org')
|
||||
invitations = relationship(
|
||||
'OrgInvitation', back_populates='org', passive_deletes=True
|
||||
)
|
||||
billing_sessions = relationship('BillingSession', back_populates='org')
|
||||
stored_conversation_metadata_saas = relationship(
|
||||
'StoredConversationMetadataSaas', back_populates='org'
|
||||
|
||||
105
enterprise/storage/org_app_settings_store.py
Normal file
105
enterprise/storage/org_app_settings_store.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Store class for managing organization app settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID
|
||||
|
||||
from server.constants import (
|
||||
LITE_LLM_API_URL,
|
||||
ORG_SETTINGS_VERSION,
|
||||
get_default_litellm_model,
|
||||
)
|
||||
from server.routes.org_models import OrgAppSettingsUpdate
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from storage.org import Org
|
||||
from storage.user import User
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrgAppSettingsStore:
|
||||
"""Store for organization app settings with injected db_session."""
|
||||
|
||||
db_session: AsyncSession
|
||||
|
||||
async def get_current_org_by_user_id(self, user_id: str) -> Org | None:
|
||||
"""Get the current organization for a user.
|
||||
|
||||
Args:
|
||||
user_id: The user's ID (Keycloak user ID)
|
||||
|
||||
Returns:
|
||||
Org: The organization object, or None if not found
|
||||
"""
|
||||
# Get user with their current_org_id
|
||||
result = await self.db_session.execute(
|
||||
select(User).filter(User.id == UUID(user_id))
|
||||
)
|
||||
user = result.scalars().first()
|
||||
|
||||
if not user:
|
||||
return None
|
||||
|
||||
org_id = user.current_org_id
|
||||
if not org_id:
|
||||
return None
|
||||
|
||||
# Get the organization
|
||||
result = await self.db_session.execute(select(Org).filter(Org.id == org_id))
|
||||
org = result.scalars().first()
|
||||
|
||||
if not org:
|
||||
return None
|
||||
|
||||
return await self._validate_org_version(org)
|
||||
|
||||
async def _validate_org_version(self, org: Org) -> Org:
|
||||
"""Check if we need to update org version.
|
||||
|
||||
Args:
|
||||
org: The organization to validate
|
||||
|
||||
Returns:
|
||||
Org: The validated (and potentially updated) organization
|
||||
"""
|
||||
if org.org_version < ORG_SETTINGS_VERSION:
|
||||
org.org_version = ORG_SETTINGS_VERSION
|
||||
org.default_llm_model = get_default_litellm_model()
|
||||
org.llm_base_url = LITE_LLM_API_URL
|
||||
await self.db_session.flush()
|
||||
await self.db_session.refresh(org)
|
||||
|
||||
return org
|
||||
|
||||
async def update_org_app_settings(
|
||||
self, org_id: UUID, update_data: OrgAppSettingsUpdate
|
||||
) -> Org | None:
|
||||
"""Update organization app settings.
|
||||
|
||||
Only updates fields that are explicitly provided in update_data.
|
||||
Uses flush() - commit happens at request end via DbSessionInjector.
|
||||
|
||||
Args:
|
||||
org_id: The organization's ID
|
||||
update_data: Pydantic model with fields to update
|
||||
|
||||
Returns:
|
||||
Org: The updated organization object, or None if not found
|
||||
"""
|
||||
result = await self.db_session.execute(
|
||||
select(Org).filter(Org.id == org_id).with_for_update()
|
||||
)
|
||||
org = result.scalars().first()
|
||||
|
||||
if not org:
|
||||
return None
|
||||
|
||||
# Update only explicitly provided fields
|
||||
for field, value in update_data.model_dump(exclude_unset=True).items():
|
||||
setattr(org, field, value)
|
||||
|
||||
# flush instead of commit - DbSessionInjector auto-commits at request end
|
||||
await self.db_session.flush()
|
||||
await self.db_session.refresh(org)
|
||||
return org
|
||||
83
enterprise/storage/org_llm_settings_store.py
Normal file
83
enterprise/storage/org_llm_settings_store.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Store class for managing organization LLM settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID
|
||||
|
||||
from server.routes.org_models import OrgLLMSettingsUpdate
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from storage.org import Org
|
||||
from storage.org_member_store import OrgMemberStore
|
||||
from storage.user import User
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrgLLMSettingsStore:
|
||||
"""Store for org LLM settings with injected db_session."""
|
||||
|
||||
db_session: AsyncSession
|
||||
|
||||
async def get_current_org_by_user_id(self, user_id: str) -> Org | None:
|
||||
"""Get the user's current organization.
|
||||
|
||||
Args:
|
||||
user_id: The user's ID (Keycloak user ID)
|
||||
|
||||
Returns:
|
||||
Org: The user's current organization, or None if not found
|
||||
"""
|
||||
# First get the user to find their current_org_id
|
||||
result = await self.db_session.execute(
|
||||
select(User).filter(User.id == uuid.UUID(user_id))
|
||||
)
|
||||
user = result.scalars().first()
|
||||
|
||||
if not user or not user.current_org_id:
|
||||
return None
|
||||
|
||||
# Then get the org
|
||||
result = await self.db_session.execute(
|
||||
select(Org).filter(Org.id == user.current_org_id)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def update_org_llm_settings(
|
||||
self, org_id: UUID, update_data: OrgLLMSettingsUpdate
|
||||
) -> Org | None:
|
||||
"""Update organization LLM settings.
|
||||
|
||||
Also propagates relevant settings to all org members.
|
||||
Uses flush() - commit happens at request end via DbSessionInjector.
|
||||
|
||||
Args:
|
||||
org_id: The organization's ID
|
||||
update_data: Pydantic model with fields to update
|
||||
|
||||
Returns:
|
||||
Org: The updated organization, or None if org not found
|
||||
"""
|
||||
result = await self.db_session.execute(
|
||||
select(Org).filter(Org.id == org_id).with_for_update()
|
||||
)
|
||||
org = result.scalars().first()
|
||||
|
||||
if not org:
|
||||
return None
|
||||
|
||||
# Apply updates to org (excludes llm_api_key which is member-only)
|
||||
update_data.apply_to_org(org)
|
||||
|
||||
# Propagate relevant settings to all org members
|
||||
member_updates = update_data.get_member_updates()
|
||||
if member_updates:
|
||||
await OrgMemberStore.update_all_members_llm_settings_async(
|
||||
self.db_session, org_id, member_updates
|
||||
)
|
||||
|
||||
# flush instead of commit - DbSessionInjector auto-commits at request end
|
||||
await self.db_session.flush()
|
||||
await self.db_session.refresh(org)
|
||||
return org
|
||||
@@ -5,9 +5,12 @@ Store class for managing organization-member relationships.
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from server.routes.org_models import OrgMemberLLMSettings
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
from storage.database import a_session_maker, session_maker
|
||||
from storage.encrypt_utils import encrypt_value
|
||||
from storage.org_member import OrgMember
|
||||
from storage.user import User
|
||||
from storage.user_settings import UserSettings
|
||||
@@ -183,14 +186,48 @@ class OrgMemberStore:
|
||||
}
|
||||
return kwargs
|
||||
|
||||
@staticmethod
|
||||
async def get_org_members_count(
|
||||
org_id: UUID,
|
||||
email_filter: str | None = None,
|
||||
) -> int:
|
||||
"""Get total count of organization members, optionally filtered by email.
|
||||
|
||||
Args:
|
||||
org_id: Organization UUID.
|
||||
email_filter: Optional case-insensitive partial email match.
|
||||
|
||||
Returns:
|
||||
Total count of matching members.
|
||||
"""
|
||||
async with a_session_maker() as session:
|
||||
query = select(func.count(OrgMember.user_id)).filter(
|
||||
OrgMember.org_id == org_id
|
||||
)
|
||||
|
||||
if email_filter:
|
||||
query = query.join(User, User.id == OrgMember.user_id).filter(
|
||||
User.email.ilike(f'%{email_filter}%')
|
||||
)
|
||||
|
||||
result = await session.execute(query)
|
||||
return result.scalar() or 0
|
||||
|
||||
@staticmethod
|
||||
async def get_org_members_paginated(
|
||||
org_id: UUID,
|
||||
offset: int = 0,
|
||||
limit: int = 100,
|
||||
email_filter: str | None = None,
|
||||
) -> tuple[list[OrgMember], bool]:
|
||||
"""Get paginated list of organization members with user and role info.
|
||||
|
||||
Args:
|
||||
org_id: Organization UUID.
|
||||
offset: Number of records to skip.
|
||||
limit: Maximum number of records to return.
|
||||
email_filter: Optional case-insensitive partial email match.
|
||||
|
||||
Returns:
|
||||
Tuple of (members_list, has_more) where has_more indicates if there are more results.
|
||||
"""
|
||||
@@ -200,13 +237,18 @@ class OrgMemberStore:
|
||||
query = (
|
||||
select(OrgMember)
|
||||
.options(joinedload(OrgMember.user), joinedload(OrgMember.role))
|
||||
.join(User, User.id == OrgMember.user_id)
|
||||
.filter(OrgMember.org_id == org_id)
|
||||
.order_by(OrgMember.user_id)
|
||||
.offset(offset)
|
||||
.limit(limit + 1)
|
||||
)
|
||||
|
||||
# Apply email filter if provided
|
||||
if email_filter:
|
||||
query = query.filter(User.email.ilike(f'%{email_filter}%'))
|
||||
|
||||
query = query.order_by(OrgMember.user_id).offset(offset).limit(limit + 1)
|
||||
|
||||
result = await session.execute(query)
|
||||
members = list(result.scalars().all())
|
||||
members = list(result.unique().scalars().all())
|
||||
|
||||
# Check if there are more results
|
||||
has_more = len(members) > limit
|
||||
@@ -215,3 +257,28 @@ class OrgMemberStore:
|
||||
members = members[:limit]
|
||||
|
||||
return members, has_more
|
||||
|
||||
@staticmethod
|
||||
async def update_all_members_llm_settings_async(
|
||||
session: AsyncSession,
|
||||
org_id: UUID,
|
||||
member_settings: OrgMemberLLMSettings,
|
||||
) -> None:
|
||||
"""Update LLM settings for all members of an organization.
|
||||
|
||||
Args:
|
||||
session: Database session (passed from caller for transaction)
|
||||
org_id: Organization ID
|
||||
member_settings: Typed LLM settings to apply to all members
|
||||
"""
|
||||
# Build update values from non-None fields
|
||||
values = member_settings.model_dump(exclude_none=True)
|
||||
|
||||
# Handle encrypted llm_api_key field - map to _llm_api_key column with encryption
|
||||
if 'llm_api_key' in values:
|
||||
raw_key = values.pop('llm_api_key')
|
||||
values['_llm_api_key'] = encrypt_value(raw_key)
|
||||
|
||||
if values:
|
||||
stmt = update(OrgMember).where(OrgMember.org_id == org_id).values(**values)
|
||||
await session.execute(stmt)
|
||||
|
||||
@@ -10,10 +10,10 @@ from server.constants import (
|
||||
ORG_SETTINGS_VERSION,
|
||||
get_default_litellm_model,
|
||||
)
|
||||
from server.routes.org_models import OrphanedUserError
|
||||
from sqlalchemy import text
|
||||
from server.routes.org_models import OrgLLMSettingsUpdate, OrphanedUserError
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.orm import joinedload
|
||||
from storage.database import session_maker
|
||||
from storage.database import a_session_maker, session_maker
|
||||
from storage.lite_llm_manager import LiteLlmManager
|
||||
from storage.org import Org
|
||||
from storage.org_member import OrgMember
|
||||
@@ -386,3 +386,47 @@ class OrgStore:
|
||||
extra={'org_id': str(org_id), 'error': str(e)},
|
||||
)
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
async def get_org_by_id_async(org_id: UUID) -> Org | None:
|
||||
"""Get organization by ID (async version)."""
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(select(Org).filter(Org.id == org_id))
|
||||
org = result.scalars().first()
|
||||
return OrgStore._validate_org_version(org) if org else None
|
||||
|
||||
@staticmethod
|
||||
async def update_org_llm_settings_async(
|
||||
org_id: UUID,
|
||||
llm_settings: OrgLLMSettingsUpdate,
|
||||
) -> Org | None:
|
||||
"""Update organization LLM settings and propagate to members (async version).
|
||||
|
||||
Args:
|
||||
org_id: Organization ID
|
||||
llm_settings: Typed LLM settings update model
|
||||
|
||||
Returns:
|
||||
Updated Org or None if not found
|
||||
"""
|
||||
from storage.org_member_store import OrgMemberStore
|
||||
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(select(Org).filter(Org.id == org_id))
|
||||
org = result.scalars().first()
|
||||
if not org:
|
||||
return None
|
||||
|
||||
# Apply updates to org
|
||||
llm_settings.apply_to_org(org)
|
||||
|
||||
# Propagate relevant settings to all org members
|
||||
member_updates = llm_settings.get_member_updates()
|
||||
if member_updates:
|
||||
await OrgMemberStore.update_all_members_llm_settings_async(
|
||||
session, org_id, member_updates
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(org)
|
||||
return org
|
||||
|
||||
64
enterprise/storage/user_app_settings_store.py
Normal file
64
enterprise/storage/user_app_settings_store.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Store class for managing user app settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
from server.routes.user_app_settings_models import UserAppSettingsUpdate
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from storage.user import User
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserAppSettingsStore:
|
||||
"""Store for user app settings with injected db_session."""
|
||||
|
||||
db_session: AsyncSession
|
||||
|
||||
async def get_user_by_id(self, user_id: str) -> User | None:
|
||||
"""Get user by ID.
|
||||
|
||||
Args:
|
||||
user_id: The user's ID (Keycloak user ID)
|
||||
|
||||
Returns:
|
||||
User: The user object, or None if not found
|
||||
"""
|
||||
result = await self.db_session.execute(
|
||||
select(User).filter(User.id == uuid.UUID(user_id))
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def update_user_app_settings(
|
||||
self, user_id: str, update_data: UserAppSettingsUpdate
|
||||
) -> User | None:
|
||||
"""Update user app settings.
|
||||
|
||||
Only updates fields that are explicitly provided in update_data.
|
||||
Uses flush() - commit happens at request end via DbSessionInjector.
|
||||
|
||||
Args:
|
||||
user_id: The user's ID (Keycloak user ID)
|
||||
update_data: Pydantic model with fields to update
|
||||
|
||||
Returns:
|
||||
User: The updated user object, or None if user not found
|
||||
"""
|
||||
result = await self.db_session.execute(
|
||||
select(User).filter(User.id == uuid.UUID(user_id)).with_for_update()
|
||||
)
|
||||
user = result.scalars().first()
|
||||
|
||||
if not user:
|
||||
return None
|
||||
|
||||
# Update only explicitly provided fields
|
||||
for field, value in update_data.model_dump(exclude_unset=True).items():
|
||||
setattr(user, field, value)
|
||||
|
||||
# flush instead of commit - DbSessionInjector auto-commits at request end
|
||||
await self.db_session.flush()
|
||||
await self.db_session.refresh(user)
|
||||
return user
|
||||
@@ -869,6 +869,88 @@ class UserStore:
|
||||
org.contact_name = real_name
|
||||
await session.commit()
|
||||
|
||||
@staticmethod
|
||||
async def update_user_email(
|
||||
user_id: str,
|
||||
email: str | None = None,
|
||||
email_verified: bool | None = None,
|
||||
) -> None:
|
||||
"""Unconditionally update User.email and/or email_verified.
|
||||
|
||||
Unlike backfill_user_email(), this overwrites existing values.
|
||||
No-op when both arguments are None.
|
||||
Missing user is logged as a warning and ignored.
|
||||
"""
|
||||
if email is None and email_verified is None:
|
||||
return
|
||||
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(User).filter(User.id == uuid.UUID(user_id))
|
||||
)
|
||||
user = result.scalars().first()
|
||||
if not user:
|
||||
logger.warning(
|
||||
'update_user_email:user_not_found',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
return
|
||||
|
||||
if email is not None:
|
||||
user.email = email
|
||||
if email_verified is not None:
|
||||
user.email_verified = email_verified
|
||||
|
||||
logger.info(
|
||||
'update_user_email:updated',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'email_set': email is not None,
|
||||
'email_verified_set': email_verified is not None,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
@staticmethod
|
||||
async def backfill_user_email(user_id: str, user_info: dict) -> None:
|
||||
"""Set User.email and email_verified from IDP if they are still NULL.
|
||||
|
||||
Called during login to gradually fix existing users whose email
|
||||
was never persisted on the User record. Preserves non-NULL values
|
||||
(e.g. if a user manually changed their email).
|
||||
"""
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(User).filter(User.id == uuid.UUID(user_id))
|
||||
)
|
||||
user = result.scalars().first()
|
||||
if not user:
|
||||
logger.debug(
|
||||
'backfill_user_email:user_not_found',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
return
|
||||
|
||||
updated = False
|
||||
if user.email is None:
|
||||
user.email = user_info.get('email')
|
||||
updated = True
|
||||
|
||||
if user.email_verified is None:
|
||||
user.email_verified = user_info.get('email_verified', False)
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
logger.info(
|
||||
'backfill_user_email:updated',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'email_set': user.email is not None,
|
||||
'email_verified_set': user.email_verified is not None,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
# Prevent circular imports
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ class ResendAPIError(ResendSyncError):
|
||||
EMAIL_REGEX = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
|
||||
|
||||
|
||||
def is_valid_email(email: str) -> bool:
|
||||
def is_valid_email(email: Optional[str]) -> bool:
|
||||
"""Validate an email address format.
|
||||
|
||||
This uses a regex pattern that matches most valid email addresses
|
||||
@@ -104,10 +104,10 @@ def is_valid_email(email: str) -> bool:
|
||||
does not accept (e.g., exclamation marks).
|
||||
|
||||
Args:
|
||||
email: The email address to validate.
|
||||
email: The email address to validate, or None.
|
||||
|
||||
Returns:
|
||||
True if the email is valid, False otherwise.
|
||||
True if the email is valid, False otherwise (including for None).
|
||||
"""
|
||||
if not email:
|
||||
return False
|
||||
@@ -251,6 +251,15 @@ def add_contact_to_resend(
|
||||
raise
|
||||
|
||||
|
||||
@retry(
|
||||
stop=stop_after_attempt(MAX_RETRIES),
|
||||
wait=wait_exponential(
|
||||
multiplier=INITIAL_BACKOFF_SECONDS,
|
||||
max=MAX_BACKOFF_SECONDS,
|
||||
exp_base=BACKOFF_FACTOR,
|
||||
),
|
||||
retry=retry_if_exception_type(ResendError),
|
||||
)
|
||||
def send_welcome_email(
|
||||
email: str,
|
||||
first_name: Optional[str] = None,
|
||||
@@ -267,7 +276,7 @@ def send_welcome_email(
|
||||
The API response.
|
||||
|
||||
Raises:
|
||||
ResendError: If the API call fails.
|
||||
ResendError: If the API call fails after retries.
|
||||
"""
|
||||
try:
|
||||
# Prepare the recipient name
|
||||
|
||||
@@ -4,6 +4,9 @@ from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from server.constants import ORG_SETTINGS_VERSION
|
||||
from server.verified_models.verified_model_service import (
|
||||
StoredVerifiedModel, # noqa: F401
|
||||
)
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from storage.base import Base
|
||||
@@ -15,6 +18,7 @@ from storage.device_code import DeviceCode # noqa: F401
|
||||
from storage.feedback import Feedback
|
||||
from storage.github_app_installation import GithubAppInstallation
|
||||
from storage.org import Org
|
||||
from storage.org_invitation import OrgInvitation # noqa: F401
|
||||
from storage.org_member import OrgMember
|
||||
from storage.role import Role
|
||||
from storage.stored_conversation_metadata import StoredConversationMetadata
|
||||
|
||||
@@ -6,8 +6,10 @@ from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from pydantic import SecretStr
|
||||
from server.auth.saas_user_auth import SaasUserAuth
|
||||
from server.routes.email import (
|
||||
EmailUpdate,
|
||||
ResendEmailVerificationRequest,
|
||||
resend_email_verification,
|
||||
update_email,
|
||||
verified_email,
|
||||
verify_email,
|
||||
)
|
||||
@@ -116,12 +118,15 @@ async def test_verified_email_default_redirect(mock_request, mock_user_auth):
|
||||
"""Test verified_email redirects to /settings/user by default."""
|
||||
# Arrange
|
||||
mock_request.query_params.get.return_value = None
|
||||
mock_user_auth.user_id = 'test-user-id'
|
||||
|
||||
# Act
|
||||
with (
|
||||
patch('server.routes.email.get_user_auth', return_value=mock_user_auth),
|
||||
patch('server.routes.email.set_response_cookie') as mock_set_cookie,
|
||||
patch('server.routes.email.UserStore') as mock_user_store,
|
||||
):
|
||||
mock_user_store.update_user_email = AsyncMock()
|
||||
result = await verified_email(mock_request)
|
||||
|
||||
# Assert
|
||||
@@ -140,12 +145,15 @@ async def test_verified_email_https_scheme(mock_request, mock_user_auth):
|
||||
mock_request.url.hostname = 'example.com'
|
||||
mock_request.url.netloc = 'example.com'
|
||||
mock_request.query_params.get.return_value = None
|
||||
mock_user_auth.user_id = 'test-user-id'
|
||||
|
||||
# Act
|
||||
with (
|
||||
patch('server.routes.email.get_user_auth', return_value=mock_user_auth),
|
||||
patch('server.routes.email.set_response_cookie') as mock_set_cookie,
|
||||
patch('server.routes.email.UserStore') as mock_user_store,
|
||||
):
|
||||
mock_user_store.update_user_email = AsyncMock()
|
||||
result = await verified_email(mock_request)
|
||||
|
||||
# Assert
|
||||
@@ -327,6 +335,62 @@ async def test_resend_email_verification_with_is_auth_flow_false(mock_request):
|
||||
assert '/api/email/verified' in call_args.kwargs['redirect_uri']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_email_calls_update_user_email(mock_request, mock_user_auth):
|
||||
"""POST /api/email should call UserStore.update_user_email with new email and email_verified=False."""
|
||||
user_id = 'test-user-id'
|
||||
new_email = 'new@example.com'
|
||||
email_data = EmailUpdate(email=new_email)
|
||||
|
||||
mock_keycloak_admin = MagicMock()
|
||||
mock_keycloak_admin.get_user.return_value = {
|
||||
'enabled': True,
|
||||
'username': 'testuser',
|
||||
}
|
||||
mock_keycloak_admin.a_update_user = AsyncMock()
|
||||
mock_user_store = MagicMock()
|
||||
mock_user_store.update_user_email = AsyncMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.routes.email.get_keycloak_admin', return_value=mock_keycloak_admin
|
||||
),
|
||||
patch('server.routes.email.get_user_auth', return_value=mock_user_auth),
|
||||
patch('server.routes.email.set_response_cookie'),
|
||||
patch('server.routes.email.verify_email', new_callable=AsyncMock),
|
||||
patch('server.routes.email.UserStore', mock_user_store),
|
||||
):
|
||||
result = await update_email(
|
||||
email_data=email_data, request=mock_request, user_id=user_id
|
||||
)
|
||||
|
||||
assert result.status_code == status.HTTP_200_OK
|
||||
mock_user_store.update_user_email.assert_awaited_once_with(
|
||||
user_id=user_id, email=new_email, email_verified=False
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verified_email_calls_update_user_email(mock_request, mock_user_auth):
|
||||
"""GET /api/email/verified should call UserStore.update_user_email with email_verified=True."""
|
||||
mock_user_auth.user_id = 'test-user-id'
|
||||
|
||||
mock_user_store = MagicMock()
|
||||
mock_user_store.update_user_email = AsyncMock()
|
||||
|
||||
with (
|
||||
patch('server.routes.email.get_user_auth', return_value=mock_user_auth),
|
||||
patch('server.routes.email.set_response_cookie'),
|
||||
patch('server.routes.email.UserStore', mock_user_store),
|
||||
):
|
||||
result = await verified_email(mock_request)
|
||||
|
||||
assert result.status_code == 302
|
||||
mock_user_store.update_user_email.assert_awaited_once_with(
|
||||
user_id='test-user-id', email_verified=True
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resend_email_verification_body_none_uses_auth(mock_request):
|
||||
"""Test resend_email_verification uses auth when body is None."""
|
||||
|
||||
@@ -1220,3 +1220,60 @@ async def test_validate_workspace_update_permissions_no_current_link(mock_manage
|
||||
|
||||
result = await _validate_workspace_update_permissions('user1', 'test-workspace')
|
||||
assert result == mock_workspace
|
||||
|
||||
|
||||
# Tests for OAuth URL encoding
|
||||
class TestJiraDcOAuthUrlEncoding:
|
||||
"""Tests to verify OAuth authorization URLs are properly URL-encoded."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.jira_dc.get_user_auth')
|
||||
@patch('server.routes.integration.jira_dc.redis_client')
|
||||
@patch('server.routes.integration.jira_dc.JIRA_DC_ENABLE_OAUTH', True)
|
||||
async def test_create_jira_dc_workspace_url_encoding(
|
||||
self, mock_redis, mock_get_auth, mock_request, mock_user_auth
|
||||
):
|
||||
"""Test that create_jira_dc_workspace properly URL-encodes the authorization URL."""
|
||||
mock_get_auth.return_value = mock_user_auth
|
||||
mock_redis.setex.return_value = True
|
||||
workspace_data = JiraDcWorkspaceCreate(
|
||||
workspace_name='test-workspace',
|
||||
webhook_secret='secret',
|
||||
svc_acc_email='svc@test.com',
|
||||
svc_acc_api_key='key',
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
response = await create_jira_dc_workspace(mock_request, workspace_data)
|
||||
content = json.loads(response.body)
|
||||
|
||||
auth_url = content['authorizationUrl']
|
||||
# Verify no raw spaces in the URL (spaces should be encoded as + or %20)
|
||||
assert ' ' not in auth_url
|
||||
# Verify scope parameter contains encoded scopes (+ is valid URL encoding for space)
|
||||
assert 'scope=read%3Ame+read%3Ajira-user+read%3Ajira-work' in auth_url
|
||||
# Verify redirect_uri is properly encoded
|
||||
assert 'redirect_uri=https%3A%2F%2F' in auth_url
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.jira_dc.get_user_auth')
|
||||
@patch('server.routes.integration.jira_dc.redis_client')
|
||||
@patch('server.routes.integration.jira_dc.JIRA_DC_ENABLE_OAUTH', True)
|
||||
async def test_create_workspace_link_url_encoding(
|
||||
self, mock_redis, mock_get_auth, mock_request, mock_user_auth
|
||||
):
|
||||
"""Test that create_workspace_link properly URL-encodes the authorization URL."""
|
||||
mock_get_auth.return_value = mock_user_auth
|
||||
mock_redis.setex.return_value = True
|
||||
link_data = JiraDcLinkCreate(workspace_name='test-workspace')
|
||||
|
||||
response = await create_workspace_link(mock_request, link_data)
|
||||
content = json.loads(response.body)
|
||||
|
||||
auth_url = content['authorizationUrl']
|
||||
# Verify no raw spaces in the URL (spaces should be encoded as + or %20)
|
||||
assert ' ' not in auth_url
|
||||
# Verify scope parameter contains encoded scopes (+ is valid URL encoding for space)
|
||||
assert 'scope=read%3Ame+read%3Ajira-user+read%3Ajira-work' in auth_url
|
||||
# Verify redirect_uri is properly encoded
|
||||
assert 'redirect_uri=https%3A%2F%2F' in auth_url
|
||||
|
||||
@@ -1323,3 +1323,58 @@ async def test_validate_workspace_update_permissions_no_current_link(mock_manage
|
||||
|
||||
result = await _validate_workspace_update_permissions('user1', 'test-workspace')
|
||||
assert result == mock_workspace
|
||||
|
||||
|
||||
# Tests for OAuth URL encoding
|
||||
class TestJiraOAuthUrlEncoding:
|
||||
"""Tests to verify OAuth authorization URLs are properly URL-encoded."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.jira.get_user_auth')
|
||||
@patch('server.routes.integration.jira.redis_client')
|
||||
async def test_create_jira_workspace_url_encoding(
|
||||
self, mock_redis, mock_get_auth, mock_request, mock_user_auth
|
||||
):
|
||||
"""Test that create_jira_workspace properly URL-encodes the authorization URL."""
|
||||
mock_get_auth.return_value = mock_user_auth
|
||||
mock_redis.setex.return_value = True
|
||||
workspace_data = JiraWorkspaceCreate(
|
||||
workspace_name='test-workspace',
|
||||
webhook_secret='secret',
|
||||
svc_acc_email='svc@test.com',
|
||||
svc_acc_api_key='key',
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
response = await create_jira_workspace(mock_request, workspace_data)
|
||||
content = json.loads(response.body)
|
||||
|
||||
auth_url = content['authorizationUrl']
|
||||
# Verify no raw spaces in the URL (spaces should be encoded as + or %20)
|
||||
assert ' ' not in auth_url
|
||||
# Verify scope parameter contains encoded scopes (+ is valid URL encoding for space)
|
||||
assert 'scope=read%3Ame+read%3Ajira-user+read%3Ajira-work' in auth_url
|
||||
# Verify redirect_uri is properly encoded
|
||||
assert 'redirect_uri=https%3A%2F%2F' in auth_url
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.jira.get_user_auth')
|
||||
@patch('server.routes.integration.jira.redis_client')
|
||||
async def test_create_workspace_link_url_encoding(
|
||||
self, mock_redis, mock_get_auth, mock_request, mock_user_auth
|
||||
):
|
||||
"""Test that create_workspace_link properly URL-encodes the authorization URL."""
|
||||
mock_get_auth.return_value = mock_user_auth
|
||||
mock_redis.setex.return_value = True
|
||||
link_data = JiraLinkCreate(workspace_name='test-workspace')
|
||||
|
||||
response = await create_workspace_link(mock_request, link_data)
|
||||
content = json.loads(response.body)
|
||||
|
||||
auth_url = content['authorizationUrl']
|
||||
# Verify no raw spaces in the URL (spaces should be encoded as + or %20)
|
||||
assert ' ' not in auth_url
|
||||
# Verify scope parameter contains encoded scopes (+ is valid URL encoding for space)
|
||||
assert 'scope=read%3Ame+read%3Ajira-user+read%3Ajira-work' in auth_url
|
||||
# Verify redirect_uri is properly encoded
|
||||
assert 'redirect_uri=https%3A%2F%2F' in auth_url
|
||||
|
||||
@@ -24,6 +24,8 @@ with patch('storage.database.engine', create=True), patch(
|
||||
LastOwnerError,
|
||||
LiteLLMIntegrationError,
|
||||
MeResponse,
|
||||
OrgAppSettingsResponse,
|
||||
OrgAppSettingsUpdate,
|
||||
OrgAuthorizationError,
|
||||
OrgDatabaseError,
|
||||
OrgMemberNotFoundError,
|
||||
@@ -2132,7 +2134,8 @@ class TestGetOrgMembersEndpoint:
|
||||
status='active',
|
||||
)
|
||||
],
|
||||
next_page_id=None,
|
||||
current_page=1,
|
||||
per_page=100,
|
||||
)
|
||||
|
||||
with patch(
|
||||
@@ -2150,7 +2153,7 @@ class TestGetOrgMembersEndpoint:
|
||||
# Assert
|
||||
assert isinstance(result, OrgMemberPage)
|
||||
assert len(result.items) == 1
|
||||
assert result.next_page_id is None
|
||||
assert result.current_page == 1
|
||||
mock_get.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -2326,7 +2329,8 @@ class TestGetOrgMembersEndpoint:
|
||||
status='active',
|
||||
)
|
||||
],
|
||||
next_page_id='200',
|
||||
current_page=2,
|
||||
per_page=100,
|
||||
)
|
||||
|
||||
with patch(
|
||||
@@ -2343,15 +2347,132 @@ class TestGetOrgMembersEndpoint:
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, OrgMemberPage)
|
||||
assert result.next_page_id == '200'
|
||||
assert result.current_page == 2
|
||||
mock_get.assert_called_once_with(
|
||||
org_id=uuid.UUID(org_id),
|
||||
current_user_id=uuid.UUID(current_user_id),
|
||||
page_id='100',
|
||||
limit=100,
|
||||
email_filter=None,
|
||||
)
|
||||
|
||||
|
||||
class TestGetOrgMembersCountEndpoint:
|
||||
"""Test cases for GET /api/organizations/{org_id}/members/count endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_succeeds_returns_int(self, org_id, current_user_id):
|
||||
"""Test that successful count returns an integer."""
|
||||
# Arrange
|
||||
with patch(
|
||||
'server.routes.orgs.OrgMemberService.get_org_members_count',
|
||||
AsyncMock(return_value=42),
|
||||
) as mock_get_count:
|
||||
# Import here to avoid circular import issues
|
||||
from server.routes.orgs import get_org_members_count
|
||||
|
||||
# Act
|
||||
result = await get_org_members_count(
|
||||
org_id=uuid.UUID(org_id),
|
||||
email=None,
|
||||
user_id=current_user_id,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == 42
|
||||
mock_get_count.assert_called_once_with(
|
||||
org_id=uuid.UUID(org_id),
|
||||
current_user_id=uuid.UUID(current_user_id),
|
||||
email_filter=None,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_with_email_filter(self, org_id, current_user_id):
|
||||
"""Test that email filter is passed to service."""
|
||||
# Arrange
|
||||
with patch(
|
||||
'server.routes.orgs.OrgMemberService.get_org_members_count',
|
||||
AsyncMock(return_value=5),
|
||||
) as mock_get_count:
|
||||
from server.routes.orgs import get_org_members_count
|
||||
|
||||
# Act
|
||||
result = await get_org_members_count(
|
||||
org_id=uuid.UUID(org_id),
|
||||
email='alice',
|
||||
user_id=current_user_id,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == 5
|
||||
mock_get_count.assert_called_once_with(
|
||||
org_id=uuid.UUID(org_id),
|
||||
current_user_id=uuid.UUID(current_user_id),
|
||||
email_filter='alice',
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_not_a_member_returns_403(self, org_id, current_user_id):
|
||||
"""Test that OrgMemberNotFoundError returns 403 Forbidden."""
|
||||
# Arrange
|
||||
with patch(
|
||||
'server.routes.orgs.OrgMemberService.get_org_members_count',
|
||||
AsyncMock(side_effect=OrgMemberNotFoundError(org_id, current_user_id)),
|
||||
):
|
||||
from server.routes.orgs import get_org_members_count
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_org_members_count(
|
||||
org_id=uuid.UUID(org_id),
|
||||
email=None,
|
||||
user_id=current_user_id,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert 'not a member of this organization' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_uuid_returns_400(self, org_id):
|
||||
"""Test that invalid user_id UUID format returns 400 Bad Request."""
|
||||
# Arrange
|
||||
invalid_user_id = 'not-a-uuid'
|
||||
|
||||
from server.routes.orgs import get_org_members_count
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_org_members_count(
|
||||
org_id=uuid.UUID(org_id),
|
||||
email=None,
|
||||
user_id=invalid_user_id,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert 'Invalid organization ID format' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_exception_returns_500(self, org_id, current_user_id):
|
||||
"""Test that generic exception returns 500 Internal Server Error."""
|
||||
# Arrange
|
||||
with patch(
|
||||
'server.routes.orgs.OrgMemberService.get_org_members_count',
|
||||
AsyncMock(side_effect=Exception('Database error')),
|
||||
):
|
||||
from server.routes.orgs import get_org_members_count
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_org_members_count(
|
||||
org_id=uuid.UUID(org_id),
|
||||
email=None,
|
||||
user_id=current_user_id,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
assert 'Failed to retrieve member count' in exc_info.value.detail
|
||||
|
||||
|
||||
class TestRemoveOrgMemberEndpoint:
|
||||
"""Test cases for DELETE /api/organizations/{org_id}/members/{user_id} endpoint."""
|
||||
|
||||
@@ -3305,3 +3426,421 @@ async def test_switch_org_database_error(mock_app_with_get_user_id):
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
assert 'Failed to switch organization' in response.json()['detail']
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for App Settings Endpoints
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_member_role():
|
||||
"""Create a mock member role for authorization tests."""
|
||||
mock_role = MagicMock()
|
||||
mock_role.name = 'member'
|
||||
return mock_role
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_org_app_settings_success(
|
||||
mock_app_with_get_user_id, mock_member_role
|
||||
):
|
||||
"""
|
||||
GIVEN: Authenticated user with MANAGE_APPLICATION_SETTINGS permission
|
||||
WHEN: GET /api/organizations/app is called
|
||||
THEN: App settings are returned with 200 status
|
||||
"""
|
||||
# Arrange
|
||||
mock_response = OrgAppSettingsResponse(
|
||||
enable_proactive_conversation_starters=True,
|
||||
enable_solvability_analysis=False,
|
||||
max_budget_per_task=10.0,
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.auth.authorization.get_user_org_role_async',
|
||||
AsyncMock(return_value=mock_member_role),
|
||||
),
|
||||
patch(
|
||||
'server.routes.orgs.OrgAppSettingsService.get_org_app_settings',
|
||||
AsyncMock(return_value=mock_response),
|
||||
),
|
||||
):
|
||||
client = TestClient(mock_app_with_get_user_id)
|
||||
|
||||
# Act
|
||||
response = client.get('/api/organizations/app')
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
response_data = response.json()
|
||||
assert response_data['enable_proactive_conversation_starters'] is True
|
||||
assert response_data['enable_solvability_analysis'] is False
|
||||
assert response_data['max_budget_per_task'] == 10.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_org_app_settings_with_null_values(
|
||||
mock_app_with_get_user_id, mock_member_role
|
||||
):
|
||||
"""
|
||||
GIVEN: Organization has null app settings values
|
||||
WHEN: GET /api/organizations/app is called
|
||||
THEN: Default values are returned where applicable
|
||||
"""
|
||||
# Arrange
|
||||
# OrgAppSettingsResponse.from_org() handles defaults, so we test the response model
|
||||
mock_response = OrgAppSettingsResponse(
|
||||
enable_proactive_conversation_starters=True, # Default when None in Org
|
||||
enable_solvability_analysis=None,
|
||||
max_budget_per_task=None,
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.auth.authorization.get_user_org_role_async',
|
||||
AsyncMock(return_value=mock_member_role),
|
||||
),
|
||||
patch(
|
||||
'server.routes.orgs.OrgAppSettingsService.get_org_app_settings',
|
||||
AsyncMock(return_value=mock_response),
|
||||
),
|
||||
):
|
||||
client = TestClient(mock_app_with_get_user_id)
|
||||
|
||||
# Act
|
||||
response = client.get('/api/organizations/app')
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
response_data = response.json()
|
||||
# enable_proactive_conversation_starters defaults to True when None
|
||||
assert response_data['enable_proactive_conversation_starters'] is True
|
||||
assert response_data['enable_solvability_analysis'] is None
|
||||
assert response_data['max_budget_per_task'] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_org_app_settings_not_found(
|
||||
mock_app_with_get_user_id, mock_member_role
|
||||
):
|
||||
"""
|
||||
GIVEN: User has no current organization
|
||||
WHEN: GET /api/organizations/app is called
|
||||
THEN: 404 Not Found error is returned
|
||||
"""
|
||||
# Arrange
|
||||
with (
|
||||
patch(
|
||||
'server.auth.authorization.get_user_org_role_async',
|
||||
AsyncMock(return_value=mock_member_role),
|
||||
),
|
||||
patch(
|
||||
'server.routes.orgs.OrgAppSettingsService.get_org_app_settings',
|
||||
AsyncMock(side_effect=OrgNotFoundError('current')),
|
||||
),
|
||||
):
|
||||
client = TestClient(mock_app_with_get_user_id)
|
||||
|
||||
# Act
|
||||
response = client.get('/api/organizations/app')
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert 'not found' in response.json()['detail'].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_org_app_settings_user_not_member(mock_app_with_get_user_id):
|
||||
"""
|
||||
GIVEN: User is not a member of any organization
|
||||
WHEN: GET /api/organizations/app is called
|
||||
THEN: 403 Forbidden error is returned
|
||||
"""
|
||||
# Arrange - user has no role (not a member)
|
||||
with patch(
|
||||
'server.auth.authorization.get_user_org_role_async',
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
client = TestClient(mock_app_with_get_user_id)
|
||||
|
||||
# Act
|
||||
response = client.get('/api/organizations/app')
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert 'not a member' in response.json()['detail'].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_app_settings_success(
|
||||
mock_app_with_get_user_id, mock_member_role
|
||||
):
|
||||
"""
|
||||
GIVEN: Valid update data and authenticated user
|
||||
WHEN: POST /api/organizations/app is called
|
||||
THEN: Updated app settings are returned with 200 status
|
||||
"""
|
||||
# Arrange
|
||||
mock_response = OrgAppSettingsResponse(
|
||||
enable_proactive_conversation_starters=False,
|
||||
enable_solvability_analysis=True,
|
||||
max_budget_per_task=25.0,
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.auth.authorization.get_user_org_role_async',
|
||||
AsyncMock(return_value=mock_member_role),
|
||||
),
|
||||
patch(
|
||||
'server.routes.orgs.OrgAppSettingsService.update_org_app_settings',
|
||||
AsyncMock(return_value=mock_response),
|
||||
) as mock_update,
|
||||
):
|
||||
client = TestClient(mock_app_with_get_user_id)
|
||||
|
||||
# Act
|
||||
response = client.post(
|
||||
'/api/organizations/app',
|
||||
json={
|
||||
'enable_proactive_conversation_starters': False,
|
||||
'enable_solvability_analysis': True,
|
||||
'max_budget_per_task': 25.0,
|
||||
},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
response_data = response.json()
|
||||
assert response_data['enable_proactive_conversation_starters'] is False
|
||||
assert response_data['enable_solvability_analysis'] is True
|
||||
assert response_data['max_budget_per_task'] == 25.0
|
||||
mock_update.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_app_settings_partial_update(
|
||||
mock_app_with_get_user_id, mock_member_role
|
||||
):
|
||||
"""
|
||||
GIVEN: Partial update data (only some fields)
|
||||
WHEN: POST /api/organizations/app is called
|
||||
THEN: Only specified fields are updated
|
||||
"""
|
||||
# Arrange
|
||||
mock_response = OrgAppSettingsResponse(
|
||||
enable_proactive_conversation_starters=False,
|
||||
enable_solvability_analysis=True,
|
||||
max_budget_per_task=10.0, # Unchanged
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.auth.authorization.get_user_org_role_async',
|
||||
AsyncMock(return_value=mock_member_role),
|
||||
),
|
||||
patch(
|
||||
'server.routes.orgs.OrgAppSettingsService.update_org_app_settings',
|
||||
AsyncMock(return_value=mock_response),
|
||||
) as mock_update,
|
||||
):
|
||||
client = TestClient(mock_app_with_get_user_id)
|
||||
|
||||
# Act - only updating one field
|
||||
response = client.post(
|
||||
'/api/organizations/app',
|
||||
json={'enable_proactive_conversation_starters': False},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
mock_update.assert_called_once()
|
||||
# Verify the update data only contains the specified field
|
||||
call_args = mock_update.call_args
|
||||
update_data = call_args[0][0] # First positional argument (update_data)
|
||||
assert isinstance(update_data, OrgAppSettingsUpdate)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_app_settings_set_null(
|
||||
mock_app_with_get_user_id, mock_member_role
|
||||
):
|
||||
"""
|
||||
GIVEN: Request to set max_budget_per_task to null
|
||||
WHEN: POST /api/organizations/app is called
|
||||
THEN: The field is set to null successfully
|
||||
"""
|
||||
# Arrange
|
||||
mock_response = OrgAppSettingsResponse(
|
||||
enable_proactive_conversation_starters=True,
|
||||
enable_solvability_analysis=True,
|
||||
max_budget_per_task=None,
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.auth.authorization.get_user_org_role_async',
|
||||
AsyncMock(return_value=mock_member_role),
|
||||
),
|
||||
patch(
|
||||
'server.routes.orgs.OrgAppSettingsService.update_org_app_settings',
|
||||
AsyncMock(return_value=mock_response),
|
||||
),
|
||||
):
|
||||
client = TestClient(mock_app_with_get_user_id)
|
||||
|
||||
# Act - explicitly setting max_budget_per_task to null
|
||||
response = client.post(
|
||||
'/api/organizations/app',
|
||||
json={'max_budget_per_task': None},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
response_data = response.json()
|
||||
assert response_data['max_budget_per_task'] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_app_settings_invalid_max_budget(
|
||||
mock_app_with_get_user_id, mock_member_role
|
||||
):
|
||||
"""
|
||||
GIVEN: Invalid max_budget_per_task value (zero or negative)
|
||||
WHEN: POST /api/organizations/app is called
|
||||
THEN: 422 Validation error is returned
|
||||
"""
|
||||
# Arrange
|
||||
with patch(
|
||||
'server.auth.authorization.get_user_org_role_async',
|
||||
AsyncMock(return_value=mock_member_role),
|
||||
):
|
||||
client = TestClient(mock_app_with_get_user_id)
|
||||
|
||||
# Act - negative value
|
||||
response = client.post(
|
||||
'/api/organizations/app',
|
||||
json={'max_budget_per_task': -5.0},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_app_settings_zero_max_budget(
|
||||
mock_app_with_get_user_id, mock_member_role
|
||||
):
|
||||
"""
|
||||
GIVEN: max_budget_per_task is set to zero
|
||||
WHEN: POST /api/organizations/app is called
|
||||
THEN: 422 Validation error is returned (must be greater than 0)
|
||||
"""
|
||||
# Arrange
|
||||
with patch(
|
||||
'server.auth.authorization.get_user_org_role_async',
|
||||
AsyncMock(return_value=mock_member_role),
|
||||
):
|
||||
client = TestClient(mock_app_with_get_user_id)
|
||||
|
||||
# Act - zero value
|
||||
response = client.post(
|
||||
'/api/organizations/app',
|
||||
json={'max_budget_per_task': 0},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_app_settings_not_found(
|
||||
mock_app_with_get_user_id, mock_member_role
|
||||
):
|
||||
"""
|
||||
GIVEN: User has no current organization
|
||||
WHEN: POST /api/organizations/app is called
|
||||
THEN: 404 Not Found error is returned
|
||||
"""
|
||||
# Arrange
|
||||
with (
|
||||
patch(
|
||||
'server.auth.authorization.get_user_org_role_async',
|
||||
AsyncMock(return_value=mock_member_role),
|
||||
),
|
||||
patch(
|
||||
'server.routes.orgs.OrgAppSettingsService.update_org_app_settings',
|
||||
AsyncMock(side_effect=OrgNotFoundError('current')),
|
||||
),
|
||||
):
|
||||
client = TestClient(mock_app_with_get_user_id)
|
||||
|
||||
# Act
|
||||
response = client.post(
|
||||
'/api/organizations/app',
|
||||
json={'enable_proactive_conversation_starters': False},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert 'not found' in response.json()['detail'].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_app_settings_database_error(
|
||||
mock_app_with_get_user_id, mock_member_role
|
||||
):
|
||||
"""
|
||||
GIVEN: Database update fails
|
||||
WHEN: POST /api/organizations/app is called
|
||||
THEN: 500 Internal Server Error is returned
|
||||
"""
|
||||
# Arrange
|
||||
with (
|
||||
patch(
|
||||
'server.auth.authorization.get_user_org_role_async',
|
||||
AsyncMock(return_value=mock_member_role),
|
||||
),
|
||||
patch(
|
||||
'server.routes.orgs.OrgAppSettingsService.update_org_app_settings',
|
||||
AsyncMock(side_effect=Exception('Database connection failed')),
|
||||
),
|
||||
):
|
||||
client = TestClient(mock_app_with_get_user_id)
|
||||
|
||||
# Act
|
||||
response = client.post(
|
||||
'/api/organizations/app',
|
||||
json={'enable_proactive_conversation_starters': False},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
assert 'unexpected error' in response.json()['detail'].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_app_settings_user_not_member(mock_app_with_get_user_id):
|
||||
"""
|
||||
GIVEN: User is not a member of any organization
|
||||
WHEN: POST /api/organizations/app is called
|
||||
THEN: 403 Forbidden error is returned
|
||||
"""
|
||||
# Arrange - user has no role (not a member)
|
||||
with patch(
|
||||
'server.auth.authorization.get_user_org_role_async',
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
client = TestClient(mock_app_with_get_user_id)
|
||||
|
||||
# Act
|
||||
response = client.post(
|
||||
'/api/organizations/app',
|
||||
json={'enable_proactive_conversation_starters': False},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert 'not a member' in response.json()['detail'].lower()
|
||||
|
||||
207
enterprise/tests/unit/server/routes/test_user_app_settings.py
Normal file
207
enterprise/tests/unit/server/routes/test_user_app_settings.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""
|
||||
Unit tests for user app settings API routes.
|
||||
|
||||
Tests the GET and POST /api/users/app endpoints.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, status
|
||||
from fastapi.testclient import TestClient
|
||||
from server.routes.user_app_settings import user_app_settings_router
|
||||
from server.routes.user_app_settings_models import (
|
||||
UserAppSettingsResponse,
|
||||
UserNotFoundError,
|
||||
)
|
||||
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
TEST_USER_ID = str(uuid.uuid4())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app():
|
||||
"""Create a test FastAPI app with user app settings routes and mocked auth."""
|
||||
app = FastAPI()
|
||||
app.include_router(user_app_settings_router)
|
||||
|
||||
def mock_get_user_id():
|
||||
return TEST_USER_ID
|
||||
|
||||
app.dependency_overrides[get_user_id] = mock_get_user_id
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app_unauthenticated():
|
||||
"""Create a test FastAPI app with no authenticated user."""
|
||||
app = FastAPI()
|
||||
app.include_router(user_app_settings_router)
|
||||
|
||||
def mock_get_user_id():
|
||||
return None
|
||||
|
||||
app.dependency_overrides[get_user_id] = mock_get_user_id
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_settings_response():
|
||||
"""Create a mock user app settings response."""
|
||||
return UserAppSettingsResponse(
|
||||
language='en',
|
||||
user_consents_to_analytics=True,
|
||||
enable_sound_notifications=False,
|
||||
git_user_name='testuser',
|
||||
git_user_email='test@example.com',
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_app_settings_success(mock_app, mock_settings_response):
|
||||
"""
|
||||
GIVEN: An authenticated user with app settings
|
||||
WHEN: GET /api/users/app is called
|
||||
THEN: User's app settings are returned with 200 status
|
||||
"""
|
||||
# Arrange
|
||||
with patch(
|
||||
'server.routes.user_app_settings.UserAppSettingsService.get_user_app_settings',
|
||||
AsyncMock(return_value=mock_settings_response),
|
||||
):
|
||||
client = TestClient(mock_app)
|
||||
|
||||
# Act
|
||||
response = client.get('/api/users/app')
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert data['language'] == 'en'
|
||||
assert data['user_consents_to_analytics'] is True
|
||||
assert data['enable_sound_notifications'] is False
|
||||
assert data['git_user_name'] == 'testuser'
|
||||
assert data['git_user_email'] == 'test@example.com'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_app_settings_not_authenticated(mock_app_unauthenticated):
|
||||
"""
|
||||
GIVEN: An unauthenticated request
|
||||
WHEN: GET /api/users/app is called
|
||||
THEN: 401 Unauthorized is returned
|
||||
"""
|
||||
# Arrange
|
||||
client = TestClient(mock_app_unauthenticated)
|
||||
|
||||
# Act
|
||||
response = client.get('/api/users/app')
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert 'not authenticated' in response.json()['detail'].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_app_settings_user_not_found(mock_app):
|
||||
"""
|
||||
GIVEN: An authenticated user that doesn't exist in the database
|
||||
WHEN: GET /api/users/app is called
|
||||
THEN: 404 Not Found is returned
|
||||
"""
|
||||
# Arrange
|
||||
with patch(
|
||||
'server.routes.user_app_settings.UserAppSettingsService.get_user_app_settings',
|
||||
AsyncMock(side_effect=UserNotFoundError(TEST_USER_ID)),
|
||||
):
|
||||
client = TestClient(mock_app)
|
||||
|
||||
# Act
|
||||
response = client.get('/api/users/app')
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert 'not found' in response.json()['detail'].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_app_settings_success(mock_app):
|
||||
"""
|
||||
GIVEN: An authenticated user
|
||||
WHEN: POST /api/users/app is called with update data
|
||||
THEN: Updated settings are returned with 200 status
|
||||
"""
|
||||
# Arrange
|
||||
updated_response = UserAppSettingsResponse(
|
||||
language='es',
|
||||
user_consents_to_analytics=False,
|
||||
enable_sound_notifications=True,
|
||||
git_user_name='newuser',
|
||||
git_user_email='new@example.com',
|
||||
)
|
||||
request_data = {
|
||||
'language': 'es',
|
||||
'user_consents_to_analytics': False,
|
||||
}
|
||||
|
||||
with patch(
|
||||
'server.routes.user_app_settings.UserAppSettingsService.update_user_app_settings',
|
||||
AsyncMock(return_value=updated_response),
|
||||
):
|
||||
client = TestClient(mock_app)
|
||||
|
||||
# Act
|
||||
response = client.post('/api/users/app', json=request_data)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert data['language'] == 'es'
|
||||
assert data['user_consents_to_analytics'] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_app_settings_not_authenticated(mock_app_unauthenticated):
|
||||
"""
|
||||
GIVEN: An unauthenticated request
|
||||
WHEN: POST /api/users/app is called
|
||||
THEN: 401 Unauthorized is returned
|
||||
"""
|
||||
# Arrange
|
||||
request_data = {'language': 'en'}
|
||||
client = TestClient(mock_app_unauthenticated)
|
||||
|
||||
# Act
|
||||
response = client.post('/api/users/app', json=request_data)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert 'not authenticated' in response.json()['detail'].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_app_settings_user_not_found(mock_app):
|
||||
"""
|
||||
GIVEN: An authenticated user that doesn't exist in the database
|
||||
WHEN: POST /api/users/app is called
|
||||
THEN: 404 Not Found is returned
|
||||
"""
|
||||
# Arrange
|
||||
request_data = {'language': 'en'}
|
||||
|
||||
with patch(
|
||||
'server.routes.user_app_settings.UserAppSettingsService.update_user_app_settings',
|
||||
AsyncMock(side_effect=UserNotFoundError(TEST_USER_ID)),
|
||||
):
|
||||
client = TestClient(mock_app)
|
||||
|
||||
# Act
|
||||
response = client.post('/api/users/app', json=request_data)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert 'not found' in response.json()['detail'].lower()
|
||||
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
Unit tests for OrgAppSettingsService.
|
||||
|
||||
Tests the service layer for organization app settings operations.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from server.routes.org_models import (
|
||||
OrgAppSettingsResponse,
|
||||
OrgAppSettingsUpdate,
|
||||
OrgNotFoundError,
|
||||
)
|
||||
from server.services.org_app_settings_service import OrgAppSettingsService
|
||||
from storage.org import Org
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_id():
|
||||
"""Create a test user ID."""
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_org():
|
||||
"""Create a mock organization with app settings."""
|
||||
org = MagicMock(spec=Org)
|
||||
org.id = uuid.uuid4()
|
||||
org.enable_proactive_conversation_starters = True
|
||||
org.enable_solvability_analysis = False
|
||||
org.max_budget_per_task = 25.0
|
||||
return org
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_store():
|
||||
"""Create a mock OrgAppSettingsStore."""
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user_context(user_id):
|
||||
"""Create a mock UserContext that returns the user_id."""
|
||||
context = MagicMock()
|
||||
context.get_user_id = AsyncMock(return_value=user_id)
|
||||
return context
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_org_app_settings_success(
|
||||
user_id, mock_org, mock_store, mock_user_context
|
||||
):
|
||||
"""
|
||||
GIVEN: A user's current organization exists
|
||||
WHEN: get_org_app_settings is called
|
||||
THEN: OrgAppSettingsResponse is returned with correct data
|
||||
"""
|
||||
# Arrange
|
||||
mock_store.get_current_org_by_user_id = AsyncMock(return_value=mock_org)
|
||||
service = OrgAppSettingsService(store=mock_store, user_context=mock_user_context)
|
||||
|
||||
# Act
|
||||
result = await service.get_org_app_settings()
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, OrgAppSettingsResponse)
|
||||
assert result.enable_proactive_conversation_starters is True
|
||||
assert result.enable_solvability_analysis is False
|
||||
assert result.max_budget_per_task == 25.0
|
||||
mock_store.get_current_org_by_user_id.assert_called_once_with(user_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_org_app_settings_org_not_found(
|
||||
user_id, mock_store, mock_user_context
|
||||
):
|
||||
"""
|
||||
GIVEN: A user has no current organization
|
||||
WHEN: get_org_app_settings is called
|
||||
THEN: OrgNotFoundError is raised
|
||||
"""
|
||||
# Arrange
|
||||
mock_store.get_current_org_by_user_id = AsyncMock(return_value=None)
|
||||
service = OrgAppSettingsService(store=mock_store, user_context=mock_user_context)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(OrgNotFoundError) as exc_info:
|
||||
await service.get_org_app_settings()
|
||||
|
||||
assert 'current' in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_app_settings_success(
|
||||
user_id, mock_org, mock_store, mock_user_context
|
||||
):
|
||||
"""
|
||||
GIVEN: A user's current organization exists
|
||||
WHEN: update_org_app_settings is called with new values
|
||||
THEN: OrgAppSettingsResponse is returned with updated data
|
||||
"""
|
||||
# Arrange
|
||||
mock_org.enable_proactive_conversation_starters = False
|
||||
mock_org.max_budget_per_task = 50.0
|
||||
|
||||
update_data = OrgAppSettingsUpdate(
|
||||
enable_proactive_conversation_starters=False,
|
||||
max_budget_per_task=50.0,
|
||||
)
|
||||
|
||||
mock_store.get_current_org_by_user_id = AsyncMock(return_value=mock_org)
|
||||
mock_store.update_org_app_settings = AsyncMock(return_value=mock_org)
|
||||
service = OrgAppSettingsService(store=mock_store, user_context=mock_user_context)
|
||||
|
||||
# Act
|
||||
result = await service.update_org_app_settings(update_data)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, OrgAppSettingsResponse)
|
||||
assert result.enable_proactive_conversation_starters is False
|
||||
assert result.max_budget_per_task == 50.0
|
||||
mock_store.update_org_app_settings.assert_called_once_with(
|
||||
org_id=mock_org.id, update_data=update_data
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_app_settings_no_changes(
|
||||
user_id, mock_org, mock_store, mock_user_context
|
||||
):
|
||||
"""
|
||||
GIVEN: A user's current organization exists
|
||||
WHEN: update_org_app_settings is called with no fields
|
||||
THEN: Current settings are returned without calling update
|
||||
"""
|
||||
# Arrange
|
||||
update_data = OrgAppSettingsUpdate() # No fields set
|
||||
|
||||
mock_store.get_current_org_by_user_id = AsyncMock(return_value=mock_org)
|
||||
mock_store.update_org_app_settings = AsyncMock()
|
||||
service = OrgAppSettingsService(store=mock_store, user_context=mock_user_context)
|
||||
|
||||
# Act
|
||||
result = await service.update_org_app_settings(update_data)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, OrgAppSettingsResponse)
|
||||
mock_store.get_current_org_by_user_id.assert_called_once_with(user_id)
|
||||
mock_store.update_org_app_settings.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_app_settings_org_not_found(
|
||||
user_id, mock_store, mock_user_context
|
||||
):
|
||||
"""
|
||||
GIVEN: A user has no current organization
|
||||
WHEN: update_org_app_settings is called
|
||||
THEN: OrgNotFoundError is raised
|
||||
"""
|
||||
# Arrange
|
||||
update_data = OrgAppSettingsUpdate(enable_proactive_conversation_starters=False)
|
||||
|
||||
mock_store.get_current_org_by_user_id = AsyncMock(return_value=None)
|
||||
service = OrgAppSettingsService(store=mock_store, user_context=mock_user_context)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(OrgNotFoundError) as exc_info:
|
||||
await service.update_org_app_settings(update_data)
|
||||
|
||||
assert 'current' in str(exc_info.value)
|
||||
@@ -0,0 +1,215 @@
|
||||
"""
|
||||
Unit tests for OrgLLMSettingsService.
|
||||
|
||||
Tests the service layer for organization LLM settings operations.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from server.routes.org_models import (
|
||||
OrgLLMSettingsResponse,
|
||||
OrgLLMSettingsUpdate,
|
||||
OrgNotFoundError,
|
||||
)
|
||||
from server.services.org_llm_settings_service import OrgLLMSettingsService
|
||||
from storage.org import Org
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_id():
|
||||
"""Create a test user ID."""
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def org_id():
|
||||
"""Create a test org ID."""
|
||||
return uuid.uuid4()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_org(org_id):
|
||||
"""Create a mock organization with LLM settings."""
|
||||
org = MagicMock(spec=Org)
|
||||
org.id = org_id
|
||||
org.default_llm_model = 'claude-3'
|
||||
org.default_llm_base_url = 'https://api.anthropic.com'
|
||||
org.search_api_key = None
|
||||
org.agent = 'CodeActAgent'
|
||||
org.confirmation_mode = True
|
||||
org.security_analyzer = None
|
||||
org.enable_default_condenser = True
|
||||
org.condenser_max_size = None
|
||||
org.default_max_iterations = 50
|
||||
return org
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_store():
|
||||
"""Create a mock OrgLLMSettingsStore."""
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user_context(user_id):
|
||||
"""Create a mock UserContext that returns the user_id."""
|
||||
context = MagicMock()
|
||||
context.get_user_id = AsyncMock(return_value=user_id)
|
||||
return context
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_org_llm_settings_success(
|
||||
user_id, mock_org, mock_store, mock_user_context
|
||||
):
|
||||
"""
|
||||
GIVEN: A user with a current organization
|
||||
WHEN: get_org_llm_settings is called
|
||||
THEN: OrgLLMSettingsResponse is returned with correct data
|
||||
"""
|
||||
# Arrange
|
||||
mock_store.get_current_org_by_user_id = AsyncMock(return_value=mock_org)
|
||||
service = OrgLLMSettingsService(store=mock_store, user_context=mock_user_context)
|
||||
|
||||
# Act
|
||||
result = await service.get_org_llm_settings()
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, OrgLLMSettingsResponse)
|
||||
assert result.default_llm_model == 'claude-3'
|
||||
assert result.agent == 'CodeActAgent'
|
||||
mock_store.get_current_org_by_user_id.assert_called_once_with(user_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_org_llm_settings_user_not_authenticated(mock_store):
|
||||
"""
|
||||
GIVEN: A user is not authenticated
|
||||
WHEN: get_org_llm_settings is called
|
||||
THEN: ValueError is raised
|
||||
"""
|
||||
# Arrange
|
||||
mock_user_context = MagicMock()
|
||||
mock_user_context.get_user_id = AsyncMock(return_value=None)
|
||||
service = OrgLLMSettingsService(store=mock_store, user_context=mock_user_context)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
await service.get_org_llm_settings()
|
||||
|
||||
assert 'not authenticated' in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_org_llm_settings_org_not_found(
|
||||
user_id, mock_store, mock_user_context
|
||||
):
|
||||
"""
|
||||
GIVEN: A user has no current organization
|
||||
WHEN: get_org_llm_settings is called
|
||||
THEN: OrgNotFoundError is raised
|
||||
"""
|
||||
# Arrange
|
||||
mock_store.get_current_org_by_user_id = AsyncMock(return_value=None)
|
||||
service = OrgLLMSettingsService(store=mock_store, user_context=mock_user_context)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(OrgNotFoundError) as exc_info:
|
||||
await service.get_org_llm_settings()
|
||||
|
||||
assert 'No current organization' in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_llm_settings_success(
|
||||
user_id, mock_org, mock_store, mock_user_context
|
||||
):
|
||||
"""
|
||||
GIVEN: A user with a current organization
|
||||
WHEN: update_org_llm_settings is called with new values
|
||||
THEN: OrgLLMSettingsResponse is returned with updated data
|
||||
"""
|
||||
# Arrange
|
||||
updated_org = MagicMock(spec=Org)
|
||||
updated_org.id = mock_org.id
|
||||
updated_org.default_llm_model = 'new-model'
|
||||
updated_org.default_llm_base_url = None
|
||||
updated_org.search_api_key = None
|
||||
updated_org.agent = 'CodeActAgent'
|
||||
updated_org.confirmation_mode = False
|
||||
updated_org.security_analyzer = None
|
||||
updated_org.enable_default_condenser = True
|
||||
updated_org.condenser_max_size = None
|
||||
updated_org.default_max_iterations = 100
|
||||
|
||||
update_data = OrgLLMSettingsUpdate(
|
||||
default_llm_model='new-model',
|
||||
confirmation_mode=False,
|
||||
default_max_iterations=100,
|
||||
)
|
||||
|
||||
mock_store.get_current_org_by_user_id = AsyncMock(return_value=mock_org)
|
||||
mock_store.update_org_llm_settings = AsyncMock(return_value=updated_org)
|
||||
service = OrgLLMSettingsService(store=mock_store, user_context=mock_user_context)
|
||||
|
||||
# Act
|
||||
result = await service.update_org_llm_settings(update_data)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, OrgLLMSettingsResponse)
|
||||
assert result.default_llm_model == 'new-model'
|
||||
assert result.confirmation_mode is False
|
||||
assert result.default_max_iterations == 100
|
||||
mock_store.update_org_llm_settings.assert_called_once_with(
|
||||
org_id=mock_org.id,
|
||||
update_data=update_data,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_llm_settings_no_changes(
|
||||
user_id, mock_org, mock_store, mock_user_context
|
||||
):
|
||||
"""
|
||||
GIVEN: A user with a current organization
|
||||
WHEN: update_org_llm_settings is called with no fields
|
||||
THEN: Current settings are returned without calling update
|
||||
"""
|
||||
# Arrange
|
||||
update_data = OrgLLMSettingsUpdate() # No fields set
|
||||
|
||||
mock_store.get_current_org_by_user_id = AsyncMock(return_value=mock_org)
|
||||
mock_store.update_org_llm_settings = AsyncMock()
|
||||
service = OrgLLMSettingsService(store=mock_store, user_context=mock_user_context)
|
||||
|
||||
# Act
|
||||
result = await service.update_org_llm_settings(update_data)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, OrgLLMSettingsResponse)
|
||||
assert result.default_llm_model == 'claude-3'
|
||||
mock_store.update_org_llm_settings.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_llm_settings_org_not_found(
|
||||
user_id, mock_store, mock_user_context
|
||||
):
|
||||
"""
|
||||
GIVEN: A user has no current organization
|
||||
WHEN: update_org_llm_settings is called
|
||||
THEN: OrgNotFoundError is raised
|
||||
"""
|
||||
# Arrange
|
||||
update_data = OrgLLMSettingsUpdate(default_llm_model='new-model')
|
||||
|
||||
mock_store.get_current_org_by_user_id = AsyncMock(return_value=None)
|
||||
service = OrgLLMSettingsService(store=mock_store, user_context=mock_user_context)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(OrgNotFoundError) as exc_info:
|
||||
await service.update_org_llm_settings(update_data)
|
||||
|
||||
assert 'No current organization' in str(exc_info.value)
|
||||
@@ -7,7 +7,6 @@ import pytest
|
||||
from pydantic import SecretStr
|
||||
from server.routes.org_models import (
|
||||
CannotModifySelfError,
|
||||
InsufficientPermissionError,
|
||||
InvalidRoleError,
|
||||
LastOwnerError,
|
||||
MeResponse,
|
||||
@@ -175,7 +174,8 @@ class TestOrgMemberServiceGetOrgMembers:
|
||||
assert data is not None
|
||||
assert isinstance(data, OrgMemberPage)
|
||||
assert len(data.items) == 1
|
||||
assert data.next_page_id is None
|
||||
assert data.current_page == 1
|
||||
assert data.per_page == 100
|
||||
assert data.items[0].user_id == str(current_user_id)
|
||||
assert data.items[0].email == 'test@example.com'
|
||||
assert data.items[0].role_id == 1
|
||||
@@ -282,9 +282,9 @@ class TestOrgMemberServiceGetOrgMembers:
|
||||
# Assert
|
||||
assert success is True
|
||||
assert data is not None
|
||||
assert data.next_page_id is None
|
||||
assert data.current_page == 1
|
||||
mock_get_paginated.assert_called_once_with(
|
||||
org_id=org_id, offset=0, limit=100
|
||||
org_id=org_id, offset=0, limit=100, email_filter=None
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -316,9 +316,9 @@ class TestOrgMemberServiceGetOrgMembers:
|
||||
# Assert
|
||||
assert success is True
|
||||
assert data is not None
|
||||
assert data.next_page_id == '150' # offset (100) + limit (50)
|
||||
assert data.current_page == 3 # offset (100) / limit (50) + 1
|
||||
mock_get_paginated.assert_called_once_with(
|
||||
org_id=org_id, offset=100, limit=50
|
||||
org_id=org_id, offset=100, limit=50, email_filter=None
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -350,7 +350,7 @@ class TestOrgMemberServiceGetOrgMembers:
|
||||
# Assert
|
||||
assert success is True
|
||||
assert data is not None
|
||||
assert data.next_page_id is None
|
||||
assert data.current_page == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_organization_no_members(
|
||||
@@ -382,7 +382,6 @@ class TestOrgMemberServiceGetOrgMembers:
|
||||
assert success is True
|
||||
assert data is not None
|
||||
assert len(data.items) == 0
|
||||
assert data.next_page_id is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_user_relationship_handles_gracefully(
|
||||
@@ -512,6 +511,156 @@ class TestOrgMemberServiceGetOrgMembers:
|
||||
assert data is not None
|
||||
assert len(data.items) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_email_filter_passed_to_store(
|
||||
self, org_id, current_user_id, mock_org_member, requester_membership_owner
|
||||
):
|
||||
"""Test that email filter is passed to store methods."""
|
||||
# Arrange
|
||||
with (
|
||||
patch(
|
||||
'server.services.org_member_service.OrgMemberStore.get_org_member'
|
||||
) as mock_get_member,
|
||||
patch(
|
||||
'server.services.org_member_service.OrgMemberStore.get_org_members_paginated',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_paginated,
|
||||
):
|
||||
mock_get_member.return_value = requester_membership_owner
|
||||
mock_get_paginated.return_value = ([mock_org_member], False)
|
||||
|
||||
# Act
|
||||
await OrgMemberService.get_org_members(
|
||||
org_id=org_id,
|
||||
current_user_id=current_user_id,
|
||||
page_id=None,
|
||||
limit=10,
|
||||
email_filter='alice',
|
||||
)
|
||||
|
||||
# Assert
|
||||
mock_get_paginated.assert_called_once_with(
|
||||
org_id=org_id, offset=0, limit=10, email_filter='alice'
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pagination_metadata_correct_for_page_2(
|
||||
self, org_id, current_user_id, mock_org_member, requester_membership_owner
|
||||
):
|
||||
"""Test pagination metadata is correct for page 2."""
|
||||
# Arrange
|
||||
with (
|
||||
patch(
|
||||
'server.services.org_member_service.OrgMemberStore.get_org_member'
|
||||
) as mock_get_member,
|
||||
patch(
|
||||
'server.services.org_member_service.OrgMemberStore.get_org_members_paginated',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_paginated,
|
||||
):
|
||||
mock_get_member.return_value = requester_membership_owner
|
||||
mock_get_paginated.return_value = ([mock_org_member], True)
|
||||
|
||||
# Act - Request page 2 (offset 10) with limit 10
|
||||
success, error_code, data = await OrgMemberService.get_org_members(
|
||||
org_id=org_id,
|
||||
current_user_id=current_user_id,
|
||||
page_id='10',
|
||||
limit=10,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert success is True
|
||||
assert data is not None
|
||||
assert data.current_page == 2
|
||||
assert data.per_page == 10
|
||||
|
||||
|
||||
class TestOrgMemberServiceGetOrgMembersCount:
|
||||
"""Test cases for OrgMemberService.get_org_members_count."""
|
||||
|
||||
@pytest.fixture
|
||||
def requester_membership(self, org_id, current_user_id):
|
||||
"""Create a mock requester membership."""
|
||||
membership = MagicMock(spec=OrgMember)
|
||||
membership.org_id = org_id
|
||||
membership.user_id = current_user_id
|
||||
membership.role_id = 1
|
||||
return membership
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_succeeds_returns_count(
|
||||
self, org_id, current_user_id, requester_membership
|
||||
):
|
||||
"""Test that successful count returns the member count."""
|
||||
# Arrange
|
||||
with (
|
||||
patch(
|
||||
'server.services.org_member_service.OrgMemberStore.get_org_member'
|
||||
) as mock_get_member,
|
||||
patch(
|
||||
'server.services.org_member_service.OrgMemberStore.get_org_members_count',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_count,
|
||||
):
|
||||
mock_get_member.return_value = requester_membership
|
||||
mock_get_count.return_value = 42
|
||||
|
||||
# Act
|
||||
count = await OrgMemberService.get_org_members_count(
|
||||
org_id=org_id,
|
||||
current_user_id=current_user_id,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert count == 42
|
||||
mock_get_count.assert_called_once_with(org_id=org_id, email_filter=None)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_with_email_filter(
|
||||
self, org_id, current_user_id, requester_membership
|
||||
):
|
||||
"""Test that email filter is passed to store method."""
|
||||
# Arrange
|
||||
with (
|
||||
patch(
|
||||
'server.services.org_member_service.OrgMemberStore.get_org_member'
|
||||
) as mock_get_member,
|
||||
patch(
|
||||
'server.services.org_member_service.OrgMemberStore.get_org_members_count',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_count,
|
||||
):
|
||||
mock_get_member.return_value = requester_membership
|
||||
mock_get_count.return_value = 5
|
||||
|
||||
# Act
|
||||
count = await OrgMemberService.get_org_members_count(
|
||||
org_id=org_id,
|
||||
current_user_id=current_user_id,
|
||||
email_filter='alice',
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert count == 5
|
||||
mock_get_count.assert_called_once_with(org_id=org_id, email_filter='alice')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_not_a_member_raises_error(self, org_id, current_user_id):
|
||||
"""Test that non-member raises OrgMemberNotFoundError."""
|
||||
# Arrange
|
||||
with patch(
|
||||
'server.services.org_member_service.OrgMemberStore.get_org_member'
|
||||
) as mock_get_member:
|
||||
mock_get_member.return_value = None
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(OrgMemberNotFoundError):
|
||||
await OrgMemberService.get_org_members_count(
|
||||
org_id=org_id,
|
||||
current_user_id=current_user_id,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def target_membership_owner(org_id, target_user_id, owner_role):
|
||||
@@ -549,6 +698,9 @@ class TestOrgMemberServiceRemoveOrgMember:
|
||||
patch(
|
||||
'server.services.org_member_service.OrgMemberStore.remove_user_from_org'
|
||||
) as mock_remove,
|
||||
patch(
|
||||
'server.services.org_member_service.UserStore.get_user_by_id'
|
||||
) as mock_get_user,
|
||||
):
|
||||
mock_get_member.side_effect = [
|
||||
requester_membership_owner,
|
||||
@@ -556,6 +708,7 @@ class TestOrgMemberServiceRemoveOrgMember:
|
||||
]
|
||||
mock_get_role.side_effect = [owner_role, member_role]
|
||||
mock_remove.return_value = True
|
||||
mock_get_user.return_value = None
|
||||
|
||||
# Act
|
||||
success, error = await OrgMemberService.remove_org_member(
|
||||
@@ -590,6 +743,9 @@ class TestOrgMemberServiceRemoveOrgMember:
|
||||
patch(
|
||||
'server.services.org_member_service.OrgMemberStore.remove_user_from_org'
|
||||
) as mock_remove,
|
||||
patch(
|
||||
'server.services.org_member_service.UserStore.get_user_by_id'
|
||||
) as mock_get_user,
|
||||
):
|
||||
mock_get_member.side_effect = [
|
||||
requester_membership_owner,
|
||||
@@ -597,6 +753,7 @@ class TestOrgMemberServiceRemoveOrgMember:
|
||||
]
|
||||
mock_get_role.side_effect = [owner_role, admin_role]
|
||||
mock_remove.return_value = True
|
||||
mock_get_user.return_value = None
|
||||
|
||||
# Act
|
||||
success, error = await OrgMemberService.remove_org_member(
|
||||
@@ -630,6 +787,9 @@ class TestOrgMemberServiceRemoveOrgMember:
|
||||
patch(
|
||||
'server.services.org_member_service.OrgMemberStore.remove_user_from_org'
|
||||
) as mock_remove,
|
||||
patch(
|
||||
'server.services.org_member_service.UserStore.get_user_by_id'
|
||||
) as mock_get_user,
|
||||
):
|
||||
mock_get_member.side_effect = [
|
||||
requester_membership_admin,
|
||||
@@ -637,6 +797,7 @@ class TestOrgMemberServiceRemoveOrgMember:
|
||||
]
|
||||
mock_get_role.side_effect = [admin_role, member_role]
|
||||
mock_remove.return_value = True
|
||||
mock_get_user.return_value = None
|
||||
|
||||
# Act
|
||||
success, error = await OrgMemberService.remove_org_member(
|
||||
@@ -748,7 +909,7 @@ class TestOrgMemberServiceRemoveOrgMember:
|
||||
assert error == 'role_not_found'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_cannot_remove_admin_returns_error(
|
||||
async def test_admin_can_remove_admin_succeeds(
|
||||
self,
|
||||
org_id,
|
||||
current_user_id,
|
||||
@@ -757,7 +918,7 @@ class TestOrgMemberServiceRemoveOrgMember:
|
||||
target_membership_admin,
|
||||
admin_role,
|
||||
):
|
||||
"""Test that an admin cannot remove another admin."""
|
||||
"""Test that an admin can remove another admin."""
|
||||
# Arrange
|
||||
with (
|
||||
patch(
|
||||
@@ -766,12 +927,24 @@ class TestOrgMemberServiceRemoveOrgMember:
|
||||
patch(
|
||||
'server.services.org_member_service.RoleStore.get_role_by_id'
|
||||
) as mock_get_role,
|
||||
patch(
|
||||
'server.services.org_member_service.OrgMemberStore.remove_user_from_org'
|
||||
) as mock_remove,
|
||||
patch(
|
||||
'server.services.org_member_service.UserStore.get_user_by_id'
|
||||
) as mock_get_user,
|
||||
patch(
|
||||
'server.services.org_member_service.LiteLlmManager.remove_user_from_team'
|
||||
) as mock_remove_litellm,
|
||||
):
|
||||
mock_get_member.side_effect = [
|
||||
requester_membership_admin,
|
||||
target_membership_admin,
|
||||
]
|
||||
mock_get_role.side_effect = [admin_role, admin_role]
|
||||
mock_remove.return_value = True
|
||||
mock_get_user.return_value = None
|
||||
mock_remove_litellm.return_value = None
|
||||
|
||||
# Act
|
||||
success, error = await OrgMemberService.remove_org_member(
|
||||
@@ -779,8 +952,8 @@ class TestOrgMemberServiceRemoveOrgMember:
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert success is False
|
||||
assert error == 'insufficient_permission'
|
||||
assert success is True
|
||||
assert error is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_cannot_remove_owner_returns_error(
|
||||
@@ -927,6 +1100,9 @@ class TestOrgMemberServiceRemoveOrgMember:
|
||||
patch(
|
||||
'server.services.org_member_service.OrgMemberStore.remove_user_from_org'
|
||||
) as mock_remove,
|
||||
patch(
|
||||
'server.services.org_member_service.UserStore.get_user_by_id'
|
||||
) as mock_get_user,
|
||||
):
|
||||
mock_get_member.side_effect = [
|
||||
requester_membership_owner,
|
||||
@@ -940,6 +1116,7 @@ class TestOrgMemberServiceRemoveOrgMember:
|
||||
another_owner,
|
||||
]
|
||||
mock_remove.return_value = True
|
||||
mock_get_user.return_value = None
|
||||
|
||||
# Act
|
||||
success, error = await OrgMemberService.remove_org_member(
|
||||
@@ -990,6 +1167,302 @@ class TestOrgMemberServiceRemoveOrgMember:
|
||||
assert success is False
|
||||
assert error == 'removal_failed'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_member_updates_current_org_id_when_matching(
|
||||
self,
|
||||
org_id,
|
||||
current_user_id,
|
||||
target_user_id,
|
||||
requester_membership_owner,
|
||||
target_membership_user,
|
||||
owner_role,
|
||||
member_role,
|
||||
):
|
||||
"""Test that current_org_id is updated to personal workspace when it matches removed org."""
|
||||
# Arrange
|
||||
mock_user = MagicMock(spec=User)
|
||||
mock_user.current_org_id = (
|
||||
org_id # User's current org matches the org being removed
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.services.org_member_service.OrgMemberStore.get_org_member'
|
||||
) as mock_get_member,
|
||||
patch(
|
||||
'server.services.org_member_service.RoleStore.get_role_by_id'
|
||||
) as mock_get_role,
|
||||
patch(
|
||||
'server.services.org_member_service.OrgMemberStore.remove_user_from_org'
|
||||
) as mock_remove,
|
||||
patch(
|
||||
'server.services.org_member_service.UserStore.get_user_by_id'
|
||||
) as mock_get_user,
|
||||
patch(
|
||||
'server.services.org_member_service.UserStore.update_current_org'
|
||||
) as mock_update_org,
|
||||
):
|
||||
mock_get_member.side_effect = [
|
||||
requester_membership_owner,
|
||||
target_membership_user,
|
||||
]
|
||||
mock_get_role.side_effect = [owner_role, member_role]
|
||||
mock_remove.return_value = True
|
||||
mock_get_user.return_value = mock_user
|
||||
|
||||
# Act
|
||||
success, error = await OrgMemberService.remove_org_member(
|
||||
org_id, target_user_id, current_user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert success is True
|
||||
assert error is None
|
||||
mock_update_org.assert_called_once_with(str(target_user_id), target_user_id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_member_does_not_update_current_org_id_when_not_matching(
|
||||
self,
|
||||
org_id,
|
||||
current_user_id,
|
||||
target_user_id,
|
||||
requester_membership_owner,
|
||||
target_membership_user,
|
||||
owner_role,
|
||||
member_role,
|
||||
):
|
||||
"""Test that current_org_id is NOT updated when it differs from removed org."""
|
||||
# Arrange
|
||||
different_org_id = uuid.uuid4()
|
||||
mock_user = MagicMock(spec=User)
|
||||
mock_user.current_org_id = different_org_id # User's current org is different
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.services.org_member_service.OrgMemberStore.get_org_member'
|
||||
) as mock_get_member,
|
||||
patch(
|
||||
'server.services.org_member_service.RoleStore.get_role_by_id'
|
||||
) as mock_get_role,
|
||||
patch(
|
||||
'server.services.org_member_service.OrgMemberStore.remove_user_from_org'
|
||||
) as mock_remove,
|
||||
patch(
|
||||
'server.services.org_member_service.UserStore.get_user_by_id'
|
||||
) as mock_get_user,
|
||||
patch(
|
||||
'server.services.org_member_service.UserStore.update_current_org'
|
||||
) as mock_update_org,
|
||||
):
|
||||
mock_get_member.side_effect = [
|
||||
requester_membership_owner,
|
||||
target_membership_user,
|
||||
]
|
||||
mock_get_role.side_effect = [owner_role, member_role]
|
||||
mock_remove.return_value = True
|
||||
mock_get_user.return_value = mock_user
|
||||
|
||||
# Act
|
||||
success, error = await OrgMemberService.remove_org_member(
|
||||
org_id, target_user_id, current_user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert success is True
|
||||
assert error is None
|
||||
mock_update_org.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_member_succeeds_when_user_not_found_after_removal(
|
||||
self,
|
||||
org_id,
|
||||
current_user_id,
|
||||
target_user_id,
|
||||
requester_membership_owner,
|
||||
target_membership_user,
|
||||
owner_role,
|
||||
member_role,
|
||||
):
|
||||
"""Test that removal succeeds even if user lookup returns None after removal."""
|
||||
# Arrange
|
||||
with (
|
||||
patch(
|
||||
'server.services.org_member_service.OrgMemberStore.get_org_member'
|
||||
) as mock_get_member,
|
||||
patch(
|
||||
'server.services.org_member_service.RoleStore.get_role_by_id'
|
||||
) as mock_get_role,
|
||||
patch(
|
||||
'server.services.org_member_service.OrgMemberStore.remove_user_from_org'
|
||||
) as mock_remove,
|
||||
patch(
|
||||
'server.services.org_member_service.UserStore.get_user_by_id'
|
||||
) as mock_get_user,
|
||||
patch(
|
||||
'server.services.org_member_service.UserStore.update_current_org'
|
||||
) as mock_update_org,
|
||||
):
|
||||
mock_get_member.side_effect = [
|
||||
requester_membership_owner,
|
||||
target_membership_user,
|
||||
]
|
||||
mock_get_role.side_effect = [owner_role, member_role]
|
||||
mock_remove.return_value = True
|
||||
mock_get_user.return_value = None # User not found
|
||||
|
||||
# Act
|
||||
success, error = await OrgMemberService.remove_org_member(
|
||||
org_id, target_user_id, current_user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert success is True
|
||||
assert error is None
|
||||
mock_update_org.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_removal_calls_litellm_remove_user_from_team(
|
||||
self,
|
||||
org_id,
|
||||
current_user_id,
|
||||
target_user_id,
|
||||
requester_membership_owner,
|
||||
target_membership_user,
|
||||
owner_role,
|
||||
member_role,
|
||||
):
|
||||
"""Test that LiteLLM remove_user_from_team is called after successful database removal."""
|
||||
# Arrange
|
||||
with (
|
||||
patch(
|
||||
'server.services.org_member_service.OrgMemberStore.get_org_member'
|
||||
) as mock_get_member,
|
||||
patch(
|
||||
'server.services.org_member_service.RoleStore.get_role_by_id'
|
||||
) as mock_get_role,
|
||||
patch(
|
||||
'server.services.org_member_service.OrgMemberStore.remove_user_from_org'
|
||||
) as mock_remove,
|
||||
patch(
|
||||
'server.services.org_member_service.UserStore.get_user_by_id'
|
||||
) as mock_get_user,
|
||||
patch(
|
||||
'server.services.org_member_service.LiteLlmManager.remove_user_from_team',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_litellm_remove,
|
||||
):
|
||||
mock_get_member.side_effect = [
|
||||
requester_membership_owner,
|
||||
target_membership_user,
|
||||
]
|
||||
mock_get_role.side_effect = [owner_role, member_role]
|
||||
mock_remove.return_value = True
|
||||
mock_get_user.return_value = None
|
||||
|
||||
# Act
|
||||
success, error = await OrgMemberService.remove_org_member(
|
||||
org_id, target_user_id, current_user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert success is True
|
||||
mock_litellm_remove.assert_called_once_with(
|
||||
str(target_user_id), str(org_id)
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_litellm_failure_does_not_fail_removal(
|
||||
self,
|
||||
org_id,
|
||||
current_user_id,
|
||||
target_user_id,
|
||||
requester_membership_owner,
|
||||
target_membership_user,
|
||||
owner_role,
|
||||
member_role,
|
||||
):
|
||||
"""Test that LiteLLM failure doesn't fail the overall removal operation."""
|
||||
# Arrange
|
||||
with (
|
||||
patch(
|
||||
'server.services.org_member_service.OrgMemberStore.get_org_member'
|
||||
) as mock_get_member,
|
||||
patch(
|
||||
'server.services.org_member_service.RoleStore.get_role_by_id'
|
||||
) as mock_get_role,
|
||||
patch(
|
||||
'server.services.org_member_service.OrgMemberStore.remove_user_from_org'
|
||||
) as mock_remove,
|
||||
patch(
|
||||
'server.services.org_member_service.UserStore.get_user_by_id'
|
||||
) as mock_get_user,
|
||||
patch(
|
||||
'server.services.org_member_service.LiteLlmManager.remove_user_from_team',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_litellm_remove,
|
||||
):
|
||||
mock_get_member.side_effect = [
|
||||
requester_membership_owner,
|
||||
target_membership_user,
|
||||
]
|
||||
mock_get_role.side_effect = [owner_role, member_role]
|
||||
mock_remove.return_value = True
|
||||
mock_get_user.return_value = None
|
||||
mock_litellm_remove.side_effect = Exception('LiteLLM API error')
|
||||
|
||||
# Act
|
||||
success, error = await OrgMemberService.remove_org_member(
|
||||
org_id, target_user_id, current_user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert success is True
|
||||
assert error is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_database_failure_skips_litellm_call(
|
||||
self,
|
||||
org_id,
|
||||
current_user_id,
|
||||
target_user_id,
|
||||
requester_membership_owner,
|
||||
target_membership_user,
|
||||
owner_role,
|
||||
member_role,
|
||||
):
|
||||
"""Test that LiteLLM is not called when database removal fails."""
|
||||
# Arrange
|
||||
with (
|
||||
patch(
|
||||
'server.services.org_member_service.OrgMemberStore.get_org_member'
|
||||
) as mock_get_member,
|
||||
patch(
|
||||
'server.services.org_member_service.RoleStore.get_role_by_id'
|
||||
) as mock_get_role,
|
||||
patch(
|
||||
'server.services.org_member_service.OrgMemberStore.remove_user_from_org'
|
||||
) as mock_remove,
|
||||
patch(
|
||||
'server.services.org_member_service.LiteLlmManager.remove_user_from_team',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_litellm_remove,
|
||||
):
|
||||
mock_get_member.side_effect = [
|
||||
requester_membership_owner,
|
||||
target_membership_user,
|
||||
]
|
||||
mock_get_role.side_effect = [owner_role, member_role]
|
||||
mock_remove.return_value = False
|
||||
|
||||
# Act
|
||||
success, error = await OrgMemberService.remove_org_member(
|
||||
org_id, target_user_id, current_user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert success is False
|
||||
mock_litellm_remove.assert_not_called()
|
||||
|
||||
|
||||
class TestOrgMemberServiceCanRemoveMember:
|
||||
"""Test cases for OrgMemberService._can_remove_member."""
|
||||
@@ -1018,13 +1491,13 @@ class TestOrgMemberServiceCanRemoveMember:
|
||||
# Assert
|
||||
assert result is True
|
||||
|
||||
def test_admin_cannot_remove_admin(self):
|
||||
"""Test that admin cannot remove another admin."""
|
||||
def test_admin_can_remove_admin(self):
|
||||
"""Test that admin can remove another admin."""
|
||||
# Act
|
||||
result = OrgMemberService._can_remove_member('admin', 'admin')
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
assert result is True
|
||||
|
||||
def test_admin_cannot_remove_owner(self):
|
||||
"""Test that admin cannot remove owner."""
|
||||
@@ -1158,7 +1631,7 @@ class TestOrgMemberServiceUpdateOrgMember:
|
||||
mock_update.assert_called_once_with(org_id, target_user_id, admin_role.id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_cannot_update_admin_raises_insufficient_permission(
|
||||
async def test_admin_can_update_admin_to_member_succeeds(
|
||||
self,
|
||||
org_id,
|
||||
current_user_id,
|
||||
@@ -1168,8 +1641,14 @@ class TestOrgMemberServiceUpdateOrgMember:
|
||||
admin_role,
|
||||
member_role,
|
||||
):
|
||||
"""GIVEN admin and target admin WHEN admin tries to change target role THEN raises InsufficientPermissionError."""
|
||||
"""GIVEN admin and target admin WHEN admin changes target role to member THEN update succeeds."""
|
||||
# Arrange
|
||||
updated_member = MagicMock(spec=OrgMember)
|
||||
updated_member.user_id = target_user_id
|
||||
updated_member.role_id = member_role.id
|
||||
updated_member.status = 'active'
|
||||
mock_user = MagicMock()
|
||||
mock_user.email = 'target@example.com'
|
||||
with (
|
||||
patch(
|
||||
'server.services.org_member_service.OrgMemberStore.get_org_member'
|
||||
@@ -1180,6 +1659,12 @@ class TestOrgMemberServiceUpdateOrgMember:
|
||||
patch(
|
||||
'server.services.org_member_service.RoleStore.get_role_by_name'
|
||||
) as mock_get_role_by_name,
|
||||
patch(
|
||||
'server.services.org_member_service.OrgMemberStore.update_user_role_in_org'
|
||||
) as mock_update,
|
||||
patch(
|
||||
'server.services.org_member_service.UserStore.get_user_by_id'
|
||||
) as mock_get_user,
|
||||
):
|
||||
mock_get_member.side_effect = [
|
||||
requester_membership_admin,
|
||||
@@ -1187,18 +1672,24 @@ class TestOrgMemberServiceUpdateOrgMember:
|
||||
]
|
||||
mock_get_role.side_effect = [admin_role, admin_role]
|
||||
mock_get_role_by_name.return_value = member_role
|
||||
mock_update.return_value = updated_member
|
||||
mock_get_user.return_value = mock_user
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(InsufficientPermissionError):
|
||||
await OrgMemberService.update_org_member(
|
||||
org_id,
|
||||
target_user_id,
|
||||
current_user_id,
|
||||
OrgMemberUpdate(role='member'),
|
||||
)
|
||||
# Act
|
||||
data = await OrgMemberService.update_org_member(
|
||||
org_id,
|
||||
target_user_id,
|
||||
current_user_id,
|
||||
OrgMemberUpdate(role='member'),
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(data, OrgMemberResponse)
|
||||
assert data.role == 'member'
|
||||
mock_update.assert_called_once_with(org_id, target_user_id, member_role.id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_owner_cannot_update_owner_raises_insufficient_permission(
|
||||
async def test_owner_can_update_owner_to_admin_succeeds(
|
||||
self,
|
||||
org_id,
|
||||
current_user_id,
|
||||
@@ -1208,8 +1699,14 @@ class TestOrgMemberServiceUpdateOrgMember:
|
||||
owner_role,
|
||||
admin_role,
|
||||
):
|
||||
"""GIVEN owner and target owner WHEN owner tries to change target role THEN raises InsufficientPermissionError."""
|
||||
"""GIVEN owner and target owner WHEN owner changes target role to admin THEN update succeeds."""
|
||||
# Arrange
|
||||
updated_member = MagicMock(spec=OrgMember)
|
||||
updated_member.user_id = target_user_id
|
||||
updated_member.role_id = admin_role.id
|
||||
updated_member.status = 'active'
|
||||
mock_user = MagicMock()
|
||||
mock_user.email = 'target@example.com'
|
||||
with (
|
||||
patch(
|
||||
'server.services.org_member_service.OrgMemberStore.get_org_member'
|
||||
@@ -1220,6 +1717,13 @@ class TestOrgMemberServiceUpdateOrgMember:
|
||||
patch(
|
||||
'server.services.org_member_service.RoleStore.get_role_by_name'
|
||||
) as mock_get_role_by_name,
|
||||
patch(
|
||||
'server.services.org_member_service.OrgMemberStore.update_user_role_in_org'
|
||||
) as mock_update,
|
||||
patch(
|
||||
'server.services.org_member_service.UserStore.get_user_by_id'
|
||||
) as mock_get_user,
|
||||
patch.object(OrgMemberService, '_is_last_owner', return_value=False),
|
||||
):
|
||||
mock_get_member.side_effect = [
|
||||
requester_membership_owner,
|
||||
@@ -1227,15 +1731,21 @@ class TestOrgMemberServiceUpdateOrgMember:
|
||||
]
|
||||
mock_get_role.side_effect = [owner_role, owner_role]
|
||||
mock_get_role_by_name.return_value = admin_role
|
||||
mock_update.return_value = updated_member
|
||||
mock_get_user.return_value = mock_user
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(InsufficientPermissionError):
|
||||
await OrgMemberService.update_org_member(
|
||||
org_id,
|
||||
target_user_id,
|
||||
current_user_id,
|
||||
OrgMemberUpdate(role='admin'),
|
||||
)
|
||||
# Act
|
||||
data = await OrgMemberService.update_org_member(
|
||||
org_id,
|
||||
target_user_id,
|
||||
current_user_id,
|
||||
OrgMemberUpdate(role='admin'),
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(data, OrgMemberResponse)
|
||||
assert data.role == 'admin'
|
||||
mock_update.assert_called_once_with(org_id, target_user_id, admin_role.id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_requester_not_a_member_raises_error(
|
||||
@@ -1450,10 +1960,10 @@ class TestOrgMemberServiceCanUpdateMemberRole:
|
||||
OrgMemberService._can_update_member_role('owner', 'member', 'owner') is True
|
||||
)
|
||||
|
||||
def test_owner_cannot_modify_owner(self):
|
||||
"""Owner cannot change another owner's role."""
|
||||
def test_owner_can_modify_owner(self):
|
||||
"""Owner can change another owner's role."""
|
||||
assert (
|
||||
OrgMemberService._can_update_member_role('owner', 'owner', 'admin') is False
|
||||
OrgMemberService._can_update_member_role('owner', 'owner', 'admin') is True
|
||||
)
|
||||
|
||||
def test_admin_can_set_admin_or_member_for_member(self):
|
||||
@@ -1466,12 +1976,14 @@ class TestOrgMemberServiceCanUpdateMemberRole:
|
||||
is True
|
||||
)
|
||||
|
||||
def test_admin_cannot_modify_admin_or_owner(self):
|
||||
"""Admin cannot modify admin or owner targets."""
|
||||
def test_admin_can_modify_admin(self):
|
||||
"""Admin can modify another admin's role to member."""
|
||||
assert (
|
||||
OrgMemberService._can_update_member_role('admin', 'admin', 'member')
|
||||
is False
|
||||
OrgMemberService._can_update_member_role('admin', 'admin', 'member') is True
|
||||
)
|
||||
|
||||
def test_admin_cannot_modify_owner(self):
|
||||
"""Admin cannot modify owner targets."""
|
||||
assert (
|
||||
OrgMemberService._can_update_member_role('admin', 'owner', 'admin') is False
|
||||
)
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
Unit tests for UserAppSettingsService.
|
||||
|
||||
Tests the service layer for user app settings operations.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from server.routes.user_app_settings_models import (
|
||||
UserAppSettingsResponse,
|
||||
UserAppSettingsUpdate,
|
||||
UserNotFoundError,
|
||||
)
|
||||
from server.services.user_app_settings_service import UserAppSettingsService
|
||||
from storage.user import User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_id():
|
||||
"""Create a test user ID."""
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user(user_id):
|
||||
"""Create a mock user with app settings."""
|
||||
user = MagicMock(spec=User)
|
||||
user.id = uuid.UUID(user_id)
|
||||
user.language = 'en'
|
||||
user.user_consents_to_analytics = True
|
||||
user.enable_sound_notifications = False
|
||||
user.git_user_name = 'testuser'
|
||||
user.git_user_email = 'test@example.com'
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_store():
|
||||
"""Create a mock UserAppSettingsStore."""
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user_context(user_id):
|
||||
"""Create a mock UserContext that returns the user_id."""
|
||||
context = MagicMock()
|
||||
context.get_user_id = AsyncMock(return_value=user_id)
|
||||
return context
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_app_settings_success(
|
||||
user_id, mock_user, mock_store, mock_user_context
|
||||
):
|
||||
"""
|
||||
GIVEN: A user exists in the database
|
||||
WHEN: get_user_app_settings is called
|
||||
THEN: UserAppSettingsResponse is returned with correct data
|
||||
"""
|
||||
# Arrange
|
||||
mock_store.get_user_by_id = AsyncMock(return_value=mock_user)
|
||||
service = UserAppSettingsService(store=mock_store, user_context=mock_user_context)
|
||||
|
||||
# Act
|
||||
result = await service.get_user_app_settings()
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, UserAppSettingsResponse)
|
||||
assert result.language == 'en'
|
||||
assert result.user_consents_to_analytics is True
|
||||
assert result.enable_sound_notifications is False
|
||||
assert result.git_user_name == 'testuser'
|
||||
assert result.git_user_email == 'test@example.com'
|
||||
mock_store.get_user_by_id.assert_called_once_with(user_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_app_settings_user_not_found(
|
||||
user_id, mock_store, mock_user_context
|
||||
):
|
||||
"""
|
||||
GIVEN: A user does not exist in the database
|
||||
WHEN: get_user_app_settings is called
|
||||
THEN: UserNotFoundError is raised
|
||||
"""
|
||||
# Arrange
|
||||
mock_store.get_user_by_id = AsyncMock(return_value=None)
|
||||
service = UserAppSettingsService(store=mock_store, user_context=mock_user_context)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(UserNotFoundError) as exc_info:
|
||||
await service.get_user_app_settings()
|
||||
|
||||
assert user_id in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_app_settings_success(
|
||||
user_id, mock_user, mock_store, mock_user_context
|
||||
):
|
||||
"""
|
||||
GIVEN: A user exists in the database
|
||||
WHEN: update_user_app_settings is called with new values
|
||||
THEN: UserAppSettingsResponse is returned with updated data
|
||||
"""
|
||||
# Arrange
|
||||
mock_user.language = 'es'
|
||||
mock_user.user_consents_to_analytics = False
|
||||
|
||||
update_data = UserAppSettingsUpdate(
|
||||
language='es',
|
||||
user_consents_to_analytics=False,
|
||||
)
|
||||
|
||||
mock_store.update_user_app_settings = AsyncMock(return_value=mock_user)
|
||||
service = UserAppSettingsService(store=mock_store, user_context=mock_user_context)
|
||||
|
||||
# Act
|
||||
result = await service.update_user_app_settings(update_data)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, UserAppSettingsResponse)
|
||||
assert result.language == 'es'
|
||||
assert result.user_consents_to_analytics is False
|
||||
mock_store.update_user_app_settings.assert_called_once_with(
|
||||
user_id=user_id, update_data=update_data
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_app_settings_no_changes(
|
||||
user_id, mock_user, mock_store, mock_user_context
|
||||
):
|
||||
"""
|
||||
GIVEN: A user exists in the database
|
||||
WHEN: update_user_app_settings is called with no fields
|
||||
THEN: Current settings are returned without calling update
|
||||
"""
|
||||
# Arrange
|
||||
update_data = UserAppSettingsUpdate() # No fields set
|
||||
|
||||
mock_store.get_user_by_id = AsyncMock(return_value=mock_user)
|
||||
mock_store.update_user_app_settings = AsyncMock()
|
||||
service = UserAppSettingsService(store=mock_store, user_context=mock_user_context)
|
||||
|
||||
# Act
|
||||
result = await service.update_user_app_settings(update_data)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, UserAppSettingsResponse)
|
||||
mock_store.get_user_by_id.assert_called_once_with(user_id)
|
||||
mock_store.update_user_app_settings.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_app_settings_user_not_found(
|
||||
user_id, mock_store, mock_user_context
|
||||
):
|
||||
"""
|
||||
GIVEN: A user does not exist in the database
|
||||
WHEN: update_user_app_settings is called
|
||||
THEN: UserNotFoundError is raised
|
||||
"""
|
||||
# Arrange
|
||||
update_data = UserAppSettingsUpdate(language='en')
|
||||
|
||||
mock_store.update_user_app_settings = AsyncMock(return_value=None)
|
||||
service = UserAppSettingsService(store=mock_store, user_context=mock_user_context)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(UserNotFoundError) as exc_info:
|
||||
await service.update_user_app_settings(update_data)
|
||||
|
||||
assert user_id in str(exc_info.value)
|
||||
661
enterprise/tests/unit/storage/test_auth_token_store.py
Normal file
661
enterprise/tests/unit/storage/test_auth_token_store.py
Normal file
@@ -0,0 +1,661 @@
|
||||
"""Unit tests for AuthTokenStore."""
|
||||
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Dict
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from server.auth.auth_error import TokenRefreshError
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from storage.auth_token_store import (
|
||||
ACCESS_TOKEN_EXPIRY_BUFFER,
|
||||
LOCK_TIMEOUT_SECONDS,
|
||||
AuthTokenStore,
|
||||
)
|
||||
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
|
||||
|
||||
def create_mock_session():
|
||||
"""Create a mock async session with properly configured context managers."""
|
||||
session = AsyncMock()
|
||||
|
||||
# Create async context manager for begin()
|
||||
@asynccontextmanager
|
||||
async def begin_context():
|
||||
yield
|
||||
|
||||
session.begin = begin_context
|
||||
return session
|
||||
|
||||
|
||||
def create_mock_session_maker(mock_session):
|
||||
"""Create a mock async session maker."""
|
||||
|
||||
@asynccontextmanager
|
||||
async def session_context():
|
||||
yield mock_session
|
||||
|
||||
# Return a callable that returns the context manager
|
||||
return lambda: session_context()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session():
|
||||
"""Create mock async session."""
|
||||
return create_mock_session()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session_maker(mock_session):
|
||||
"""Create mock async session maker."""
|
||||
return create_mock_session_maker(mock_session)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_token_store(mock_session_maker):
|
||||
"""Create AuthTokenStore instance with mocked session maker."""
|
||||
return AuthTokenStore(
|
||||
keycloak_user_id='test-user-123',
|
||||
idp=ProviderType.GITHUB,
|
||||
a_session_maker=mock_session_maker,
|
||||
)
|
||||
|
||||
|
||||
class TestIsTokenExpired:
|
||||
"""Tests for _is_token_expired method."""
|
||||
|
||||
def test_both_tokens_valid(self, auth_token_store):
|
||||
"""Test when both tokens are valid (not expired)."""
|
||||
current_time = int(time.time())
|
||||
access_expires = current_time + ACCESS_TOKEN_EXPIRY_BUFFER + 1000
|
||||
refresh_expires = current_time + 1000
|
||||
|
||||
access_expired, refresh_expired = auth_token_store._is_token_expired(
|
||||
access_expires, refresh_expires
|
||||
)
|
||||
|
||||
assert access_expired is False
|
||||
assert refresh_expired is False
|
||||
|
||||
def test_access_token_expired(self, auth_token_store):
|
||||
"""Test when access token is expired but within buffer."""
|
||||
current_time = int(time.time())
|
||||
# Access token expires within buffer period
|
||||
access_expires = current_time + ACCESS_TOKEN_EXPIRY_BUFFER - 100
|
||||
refresh_expires = current_time + 10000
|
||||
|
||||
access_expired, refresh_expired = auth_token_store._is_token_expired(
|
||||
access_expires, refresh_expires
|
||||
)
|
||||
|
||||
assert access_expired is True
|
||||
assert refresh_expired is False
|
||||
|
||||
def test_refresh_token_expired(self, auth_token_store):
|
||||
"""Test when refresh token is expired."""
|
||||
current_time = int(time.time())
|
||||
access_expires = current_time + ACCESS_TOKEN_EXPIRY_BUFFER + 1000
|
||||
refresh_expires = current_time - 100 # Already expired
|
||||
|
||||
access_expired, refresh_expired = auth_token_store._is_token_expired(
|
||||
access_expires, refresh_expires
|
||||
)
|
||||
|
||||
assert access_expired is False
|
||||
assert refresh_expired is True
|
||||
|
||||
def test_both_tokens_expired(self, auth_token_store):
|
||||
"""Test when both tokens are expired."""
|
||||
current_time = int(time.time())
|
||||
access_expires = current_time - 100
|
||||
refresh_expires = current_time - 100
|
||||
|
||||
access_expired, refresh_expired = auth_token_store._is_token_expired(
|
||||
access_expires, refresh_expires
|
||||
)
|
||||
|
||||
assert access_expired is True
|
||||
assert refresh_expired is True
|
||||
|
||||
def test_zero_expiration_treated_as_never_expires(self, auth_token_store):
|
||||
"""Test that 0 expiration time is treated as never expires."""
|
||||
access_expired, refresh_expired = auth_token_store._is_token_expired(0, 0)
|
||||
|
||||
assert access_expired is False
|
||||
assert refresh_expired is False
|
||||
|
||||
|
||||
class TestLoadTokensFastPath:
|
||||
"""Tests for load_tokens fast path (no lock needed)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fast_path_token_not_found(
|
||||
self, auth_token_store, mock_session_maker, mock_session
|
||||
):
|
||||
"""Test fast path returns None when no token record exists."""
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.one_or_none.return_value = None
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
|
||||
result = await auth_token_store.load_tokens()
|
||||
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fast_path_valid_token_no_refresh_needed(
|
||||
self, auth_token_store, mock_session_maker, mock_session
|
||||
):
|
||||
"""Test fast path returns tokens when they are still valid."""
|
||||
current_time = int(time.time())
|
||||
mock_token = MagicMock()
|
||||
mock_token.access_token = 'valid-access-token'
|
||||
mock_token.refresh_token = 'valid-refresh-token'
|
||||
mock_token.access_token_expires_at = (
|
||||
current_time + ACCESS_TOKEN_EXPIRY_BUFFER + 1000
|
||||
)
|
||||
mock_token.refresh_token_expires_at = current_time + 10000
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.one_or_none.return_value = mock_token
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
|
||||
result = await auth_token_store.load_tokens()
|
||||
|
||||
assert result is not None
|
||||
assert result['access_token'] == 'valid-access-token'
|
||||
assert result['refresh_token'] == 'valid-refresh-token'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fast_path_no_refresh_callback_provided(
|
||||
self, auth_token_store, mock_session_maker, mock_session
|
||||
):
|
||||
"""Test fast path returns existing tokens when no refresh callback is provided."""
|
||||
current_time = int(time.time())
|
||||
mock_token = MagicMock()
|
||||
mock_token.access_token = 'expired-access-token'
|
||||
mock_token.refresh_token = 'valid-refresh-token'
|
||||
# Expired access token
|
||||
mock_token.access_token_expires_at = current_time - 100
|
||||
mock_token.refresh_token_expires_at = current_time + 10000
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.one_or_none.return_value = mock_token
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
|
||||
result = await auth_token_store.load_tokens(check_expiration_and_refresh=None)
|
||||
|
||||
assert result is not None
|
||||
assert result['access_token'] == 'expired-access-token'
|
||||
|
||||
|
||||
class TestLoadTokensSlowPath:
|
||||
"""Tests for load_tokens slow path (lock required for refresh)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slow_path_successful_refresh(self):
|
||||
"""Test slow path successfully refreshes expired tokens."""
|
||||
current_time = int(time.time())
|
||||
mock_session = create_mock_session()
|
||||
|
||||
# First call (fast path) - returns expired token
|
||||
# Second call (slow path) - returns same token for update
|
||||
expired_token = MagicMock()
|
||||
expired_token.id = 1
|
||||
expired_token.access_token = 'expired-access-token'
|
||||
expired_token.refresh_token = 'valid-refresh-token'
|
||||
expired_token.access_token_expires_at = current_time - 100 # Expired
|
||||
expired_token.refresh_token_expires_at = current_time + 10000
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.one_or_none.return_value = expired_token
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
mock_session.commit = AsyncMock()
|
||||
|
||||
mock_session_maker = create_mock_session_maker(mock_session)
|
||||
|
||||
auth_store = AuthTokenStore(
|
||||
keycloak_user_id='test-user-123',
|
||||
idp=ProviderType.GITHUB,
|
||||
a_session_maker=mock_session_maker,
|
||||
)
|
||||
|
||||
async def mock_refresh(
|
||||
idp: ProviderType, refresh_token: str, access_exp: int, refresh_exp: int
|
||||
) -> Dict[str, str | int]:
|
||||
return {
|
||||
'access_token': 'new-access-token',
|
||||
'refresh_token': 'new-refresh-token',
|
||||
'access_token_expires_at': current_time + 3600,
|
||||
'refresh_token_expires_at': current_time + 86400,
|
||||
}
|
||||
|
||||
result = await auth_store.load_tokens(check_expiration_and_refresh=mock_refresh)
|
||||
|
||||
assert result is not None
|
||||
assert result['access_token'] == 'new-access-token'
|
||||
assert result['refresh_token'] == 'new-refresh-token'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slow_path_double_check_avoids_refresh(self):
|
||||
"""Test double-check locking: token was refreshed by another request."""
|
||||
current_time = int(time.time())
|
||||
mock_session = create_mock_session()
|
||||
|
||||
# Simulate scenario:
|
||||
# 1. Fast path sees expired token
|
||||
# 2. While waiting for lock, another request refreshes
|
||||
# 3. Slow path sees fresh token, skips refresh
|
||||
|
||||
call_count = [0]
|
||||
|
||||
def create_token():
|
||||
call_count[0] += 1
|
||||
token = MagicMock()
|
||||
token.id = 1
|
||||
token.access_token = 'fresh-access-token'
|
||||
token.refresh_token = 'fresh-refresh-token'
|
||||
if call_count[0] == 1:
|
||||
# First call (fast path) - expired
|
||||
token.access_token_expires_at = current_time - 100
|
||||
else:
|
||||
# Second call (slow path) - already refreshed
|
||||
token.access_token_expires_at = (
|
||||
current_time + ACCESS_TOKEN_EXPIRY_BUFFER + 1000
|
||||
)
|
||||
token.refresh_token_expires_at = current_time + 86400
|
||||
return token
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.one_or_none.side_effect = (
|
||||
lambda: create_token()
|
||||
)
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
mock_session.commit = AsyncMock()
|
||||
|
||||
mock_session_maker = create_mock_session_maker(mock_session)
|
||||
|
||||
auth_store = AuthTokenStore(
|
||||
keycloak_user_id='test-user-123',
|
||||
idp=ProviderType.GITHUB,
|
||||
a_session_maker=mock_session_maker,
|
||||
)
|
||||
|
||||
refresh_called = [False]
|
||||
|
||||
async def mock_refresh(
|
||||
idp: ProviderType, refresh_token: str, access_exp: int, refresh_exp: int
|
||||
) -> Dict[str, str | int]:
|
||||
refresh_called[0] = True
|
||||
return {
|
||||
'access_token': 'should-not-be-used',
|
||||
'refresh_token': 'should-not-be-used',
|
||||
'access_token_expires_at': current_time + 3600,
|
||||
'refresh_token_expires_at': current_time + 86400,
|
||||
}
|
||||
|
||||
result = await auth_store.load_tokens(check_expiration_and_refresh=mock_refresh)
|
||||
|
||||
# The refresh callback should not be called because double-check
|
||||
# found the token was already refreshed
|
||||
assert result is not None
|
||||
assert result['access_token'] == 'fresh-access-token'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slow_path_token_not_found_after_lock(self):
|
||||
"""Test slow path returns None if token record disappears after lock."""
|
||||
current_time = int(time.time())
|
||||
mock_session = create_mock_session()
|
||||
|
||||
# First call (fast path) - token exists but expired
|
||||
# Second call (slow path with lock) - token no longer exists
|
||||
call_count = [0]
|
||||
|
||||
def get_token():
|
||||
call_count[0] += 1
|
||||
if call_count[0] == 1:
|
||||
token = MagicMock()
|
||||
token.access_token_expires_at = current_time - 100 # Expired
|
||||
token.refresh_token_expires_at = current_time + 10000
|
||||
return token
|
||||
return None
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.one_or_none.side_effect = get_token
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
|
||||
mock_session_maker = create_mock_session_maker(mock_session)
|
||||
|
||||
auth_store = AuthTokenStore(
|
||||
keycloak_user_id='test-user-123',
|
||||
idp=ProviderType.GITHUB,
|
||||
a_session_maker=mock_session_maker,
|
||||
)
|
||||
|
||||
async def mock_refresh(*args) -> Dict[str, str | int]:
|
||||
return {
|
||||
'access_token': 'new-token',
|
||||
'refresh_token': 'new-refresh',
|
||||
'access_token_expires_at': current_time + 3600,
|
||||
'refresh_token_expires_at': current_time + 86400,
|
||||
}
|
||||
|
||||
result = await auth_store.load_tokens(check_expiration_and_refresh=mock_refresh)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestLoadTokensLockTimeout:
|
||||
"""Tests for lock timeout handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lock_timeout_raises_token_refresh_error(self):
|
||||
"""Test that lock timeout raises TokenRefreshError."""
|
||||
current_time = int(time.time())
|
||||
mock_session = create_mock_session()
|
||||
|
||||
# First call (fast path) - returns expired token
|
||||
expired_token = MagicMock()
|
||||
expired_token.access_token_expires_at = current_time - 100
|
||||
expired_token.refresh_token_expires_at = current_time + 10000
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.one_or_none.return_value = expired_token
|
||||
|
||||
# First execute for fast path succeeds
|
||||
# Second execute (for slow path) raises OperationalError
|
||||
call_count = [0]
|
||||
|
||||
async def execute_side_effect(*args, **kwargs):
|
||||
call_count[0] += 1
|
||||
if call_count[0] <= 1:
|
||||
return mock_result
|
||||
# Simulate lock timeout
|
||||
raise OperationalError(
|
||||
'canceling statement due to lock timeout', None, None
|
||||
)
|
||||
|
||||
mock_session.execute = execute_side_effect
|
||||
|
||||
mock_session_maker = create_mock_session_maker(mock_session)
|
||||
|
||||
auth_store = AuthTokenStore(
|
||||
keycloak_user_id='test-user-123',
|
||||
idp=ProviderType.GITHUB,
|
||||
a_session_maker=mock_session_maker,
|
||||
)
|
||||
|
||||
async def mock_refresh(*args) -> Dict[str, str | int]:
|
||||
return {
|
||||
'access_token': 'new-token',
|
||||
'refresh_token': 'new-refresh',
|
||||
'access_token_expires_at': current_time + 3600,
|
||||
'refresh_token_expires_at': current_time + 86400,
|
||||
}
|
||||
|
||||
with pytest.raises(TokenRefreshError) as exc_info:
|
||||
await auth_store.load_tokens(check_expiration_and_refresh=mock_refresh)
|
||||
|
||||
assert 'lock timeout' in str(exc_info.value).lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lock_timeout_preserves_original_exception(self):
|
||||
"""Test that TokenRefreshError preserves the original OperationalError."""
|
||||
current_time = int(time.time())
|
||||
mock_session = create_mock_session()
|
||||
|
||||
expired_token = MagicMock()
|
||||
expired_token.access_token_expires_at = current_time - 100
|
||||
expired_token.refresh_token_expires_at = current_time + 10000
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.one_or_none.return_value = expired_token
|
||||
|
||||
original_error = OperationalError(
|
||||
'canceling statement due to lock timeout', None, None
|
||||
)
|
||||
|
||||
call_count = [0]
|
||||
|
||||
async def execute_side_effect(*args, **kwargs):
|
||||
call_count[0] += 1
|
||||
if call_count[0] <= 1:
|
||||
return mock_result
|
||||
raise original_error
|
||||
|
||||
mock_session.execute = execute_side_effect
|
||||
|
||||
mock_session_maker = create_mock_session_maker(mock_session)
|
||||
|
||||
auth_store = AuthTokenStore(
|
||||
keycloak_user_id='test-user-123',
|
||||
idp=ProviderType.GITHUB,
|
||||
a_session_maker=mock_session_maker,
|
||||
)
|
||||
|
||||
async def mock_refresh(*args) -> Dict[str, str | int]:
|
||||
return {
|
||||
'access_token': 'new-token',
|
||||
'refresh_token': 'new-refresh',
|
||||
'access_token_expires_at': current_time + 3600,
|
||||
'refresh_token_expires_at': current_time + 86400,
|
||||
}
|
||||
|
||||
with pytest.raises(TokenRefreshError) as exc_info:
|
||||
await auth_store.load_tokens(check_expiration_and_refresh=mock_refresh)
|
||||
|
||||
# Verify the original exception is chained
|
||||
assert exc_info.value.__cause__ is original_error
|
||||
|
||||
|
||||
class TestLoadTokensRefreshCallbackBehavior:
|
||||
"""Tests for refresh callback return values."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_callback_returns_none(self):
|
||||
"""Test behavior when refresh callback returns None (no refresh performed)."""
|
||||
current_time = int(time.time())
|
||||
mock_session = create_mock_session()
|
||||
|
||||
expired_token = MagicMock()
|
||||
expired_token.id = 1
|
||||
expired_token.access_token = 'old-access-token'
|
||||
expired_token.refresh_token = 'old-refresh-token'
|
||||
expired_token.access_token_expires_at = current_time - 100 # Expired
|
||||
expired_token.refresh_token_expires_at = current_time + 10000
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.one_or_none.return_value = expired_token
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
mock_session.commit = AsyncMock()
|
||||
|
||||
mock_session_maker = create_mock_session_maker(mock_session)
|
||||
|
||||
auth_store = AuthTokenStore(
|
||||
keycloak_user_id='test-user-123',
|
||||
idp=ProviderType.GITHUB,
|
||||
a_session_maker=mock_session_maker,
|
||||
)
|
||||
|
||||
async def mock_refresh_returns_none(
|
||||
idp: ProviderType, refresh_token: str, access_exp: int, refresh_exp: int
|
||||
) -> Dict[str, str | int] | None:
|
||||
return None
|
||||
|
||||
result = await auth_store.load_tokens(
|
||||
check_expiration_and_refresh=mock_refresh_returns_none
|
||||
)
|
||||
|
||||
# Should return the old tokens when refresh returns None
|
||||
assert result is not None
|
||||
assert result['access_token'] == 'old-access-token'
|
||||
assert result['refresh_token'] == 'old-refresh-token'
|
||||
|
||||
|
||||
class TestStoreTokens:
|
||||
"""Tests for store_tokens method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_tokens_creates_new_record(self):
|
||||
"""Test storing tokens when no existing record."""
|
||||
mock_session = create_mock_session()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.first.return_value = None
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
mock_session.add = MagicMock()
|
||||
mock_session.commit = AsyncMock()
|
||||
|
||||
mock_session_maker = create_mock_session_maker(mock_session)
|
||||
|
||||
auth_store = AuthTokenStore(
|
||||
keycloak_user_id='test-user-123',
|
||||
idp=ProviderType.GITHUB,
|
||||
a_session_maker=mock_session_maker,
|
||||
)
|
||||
|
||||
await auth_store.store_tokens(
|
||||
access_token='new-access-token',
|
||||
refresh_token='new-refresh-token',
|
||||
access_token_expires_at=1234567890,
|
||||
refresh_token_expires_at=1234657890,
|
||||
)
|
||||
|
||||
mock_session.add.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_tokens_updates_existing_record(self):
|
||||
"""Test storing tokens updates existing record."""
|
||||
mock_session = create_mock_session()
|
||||
existing_token = MagicMock()
|
||||
existing_token.access_token = 'old-access'
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.first.return_value = existing_token
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
mock_session.commit = AsyncMock()
|
||||
|
||||
mock_session_maker = create_mock_session_maker(mock_session)
|
||||
|
||||
auth_store = AuthTokenStore(
|
||||
keycloak_user_id='test-user-123',
|
||||
idp=ProviderType.GITHUB,
|
||||
a_session_maker=mock_session_maker,
|
||||
)
|
||||
|
||||
await auth_store.store_tokens(
|
||||
access_token='new-access-token',
|
||||
refresh_token='new-refresh-token',
|
||||
access_token_expires_at=1234567890,
|
||||
refresh_token_expires_at=1234657890,
|
||||
)
|
||||
|
||||
assert existing_token.access_token == 'new-access-token'
|
||||
assert existing_token.refresh_token == 'new-refresh-token'
|
||||
|
||||
|
||||
class TestIsAccessTokenValid:
|
||||
"""Tests for is_access_token_valid method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_access_token_valid_returns_false_when_no_tokens(
|
||||
self, auth_token_store, mock_session_maker, mock_session
|
||||
):
|
||||
"""Test returns False when no tokens found."""
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.one_or_none.return_value = None
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
|
||||
result = await auth_token_store.is_access_token_valid()
|
||||
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_access_token_valid_returns_true_for_valid_token(
|
||||
self, auth_token_store, mock_session_maker, mock_session
|
||||
):
|
||||
"""Test returns True when token is valid."""
|
||||
current_time = int(time.time())
|
||||
mock_token = MagicMock()
|
||||
mock_token.access_token = 'valid-access'
|
||||
mock_token.refresh_token = 'valid-refresh'
|
||||
mock_token.access_token_expires_at = current_time + 1000
|
||||
mock_token.refresh_token_expires_at = current_time + 10000
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.one_or_none.return_value = mock_token
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
|
||||
result = await auth_token_store.is_access_token_valid()
|
||||
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_access_token_valid_returns_false_for_expired_token(
|
||||
self, auth_token_store, mock_session_maker, mock_session
|
||||
):
|
||||
"""Test returns False when token is expired."""
|
||||
current_time = int(time.time())
|
||||
mock_token = MagicMock()
|
||||
mock_token.access_token = 'expired-access'
|
||||
mock_token.refresh_token = 'valid-refresh'
|
||||
mock_token.access_token_expires_at = current_time - 100 # Expired
|
||||
mock_token.refresh_token_expires_at = current_time + 10000
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.one_or_none.return_value = mock_token
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
|
||||
result = await auth_token_store.is_access_token_valid()
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestGetInstance:
|
||||
"""Tests for get_instance class method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_instance_creates_auth_token_store(self):
|
||||
"""Test get_instance creates an AuthTokenStore with correct params."""
|
||||
with patch('storage.auth_token_store.a_session_maker') as mock_a_session_maker:
|
||||
store = await AuthTokenStore.get_instance(
|
||||
keycloak_user_id='user-123', idp=ProviderType.GITHUB
|
||||
)
|
||||
|
||||
assert store.keycloak_user_id == 'user-123'
|
||||
assert store.idp == ProviderType.GITHUB
|
||||
assert store.a_session_maker is mock_a_session_maker
|
||||
|
||||
|
||||
class TestIdentityProviderValue:
|
||||
"""Tests for identity_provider_value property."""
|
||||
|
||||
def test_identity_provider_value_returns_idp_value(self, auth_token_store):
|
||||
"""Test that identity_provider_value returns the enum value."""
|
||||
assert auth_token_store.identity_provider_value == ProviderType.GITHUB.value
|
||||
|
||||
def test_identity_provider_value_for_different_providers(self):
|
||||
"""Test identity_provider_value for different providers."""
|
||||
for provider in [
|
||||
ProviderType.GITHUB,
|
||||
ProviderType.GITLAB,
|
||||
ProviderType.BITBUCKET,
|
||||
]:
|
||||
store = AuthTokenStore(
|
||||
keycloak_user_id='test-user',
|
||||
idp=provider,
|
||||
a_session_maker=MagicMock(),
|
||||
)
|
||||
assert store.identity_provider_value == provider.value
|
||||
|
||||
|
||||
class TestConstants:
|
||||
"""Tests for module constants."""
|
||||
|
||||
def test_access_token_expiry_buffer_value(self):
|
||||
"""Test ACCESS_TOKEN_EXPIRY_BUFFER is set to 15 minutes."""
|
||||
assert ACCESS_TOKEN_EXPIRY_BUFFER == 900
|
||||
|
||||
def test_lock_timeout_seconds_value(self):
|
||||
"""Test LOCK_TIMEOUT_SECONDS is set to 5 seconds."""
|
||||
assert LOCK_TIMEOUT_SECONDS == 5
|
||||
99
enterprise/tests/unit/storage/test_database.py
Normal file
99
enterprise/tests/unit/storage/test_database.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Tests for the enterprise storage.database module.
|
||||
|
||||
These tests verify that the session_maker function properly forwards
|
||||
keyword arguments to the underlying session maker for backward compatibility.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
class TestSessionMaker:
|
||||
"""Test cases for the session_maker function."""
|
||||
|
||||
@patch('enterprise.storage.database._get_db_session_injector')
|
||||
def test_session_maker_without_args(self, mock_get_injector):
|
||||
"""Test that session_maker works without any arguments."""
|
||||
from enterprise.storage.database import session_maker
|
||||
|
||||
# Set up mock
|
||||
mock_injector = MagicMock()
|
||||
mock_inner_session_maker = MagicMock()
|
||||
mock_session = MagicMock()
|
||||
mock_inner_session_maker.return_value = mock_session
|
||||
mock_injector.get_session_maker.return_value = mock_inner_session_maker
|
||||
mock_get_injector.return_value = mock_injector
|
||||
|
||||
# Call session_maker without arguments
|
||||
result = session_maker()
|
||||
|
||||
# Verify the inner session maker was called without arguments
|
||||
mock_inner_session_maker.assert_called_once_with()
|
||||
assert result == mock_session
|
||||
|
||||
@patch('enterprise.storage.database._get_db_session_injector')
|
||||
def test_session_maker_with_expire_on_commit_false(self, mock_get_injector):
|
||||
"""Test that session_maker accepts expire_on_commit keyword argument.
|
||||
|
||||
This is a critical backward compatibility test - the session_maker
|
||||
must accept keyword arguments like expire_on_commit=False which is
|
||||
used in slack.py and potentially other integration modules.
|
||||
"""
|
||||
from enterprise.storage.database import session_maker
|
||||
|
||||
# Set up mock
|
||||
mock_injector = MagicMock()
|
||||
mock_inner_session_maker = MagicMock()
|
||||
mock_session = MagicMock()
|
||||
mock_inner_session_maker.return_value = mock_session
|
||||
mock_injector.get_session_maker.return_value = mock_inner_session_maker
|
||||
mock_get_injector.return_value = mock_injector
|
||||
|
||||
# Call session_maker with expire_on_commit=False
|
||||
# This is the exact call pattern used in slack.py line 242
|
||||
result = session_maker(expire_on_commit=False)
|
||||
|
||||
# Verify the inner session maker was called with the keyword argument
|
||||
mock_inner_session_maker.assert_called_once_with(expire_on_commit=False)
|
||||
assert result == mock_session
|
||||
|
||||
@patch('enterprise.storage.database._get_db_session_injector')
|
||||
def test_session_maker_with_multiple_kwargs(self, mock_get_injector):
|
||||
"""Test that session_maker passes through multiple keyword arguments."""
|
||||
from enterprise.storage.database import session_maker
|
||||
|
||||
# Set up mock
|
||||
mock_injector = MagicMock()
|
||||
mock_inner_session_maker = MagicMock()
|
||||
mock_session = MagicMock()
|
||||
mock_inner_session_maker.return_value = mock_session
|
||||
mock_injector.get_session_maker.return_value = mock_inner_session_maker
|
||||
mock_get_injector.return_value = mock_injector
|
||||
|
||||
# Call with multiple kwargs
|
||||
result = session_maker(
|
||||
expire_on_commit=False, autoflush=False, autocommit=False
|
||||
)
|
||||
|
||||
# Verify all kwargs were passed through
|
||||
mock_inner_session_maker.assert_called_once_with(
|
||||
expire_on_commit=False, autoflush=False, autocommit=False
|
||||
)
|
||||
assert result == mock_session
|
||||
|
||||
@patch('enterprise.storage.database._get_db_session_injector')
|
||||
def test_session_maker_returns_correct_session(self, mock_get_injector):
|
||||
"""Test that session_maker returns the session from the inner session maker."""
|
||||
from enterprise.storage.database import session_maker
|
||||
|
||||
# Set up mock
|
||||
mock_injector = MagicMock()
|
||||
mock_inner_session_maker = MagicMock()
|
||||
mock_session = MagicMock()
|
||||
mock_inner_session_maker.return_value = mock_session
|
||||
mock_injector.get_session_maker.return_value = mock_inner_session_maker
|
||||
mock_get_injector.return_value = mock_injector
|
||||
|
||||
result = session_maker()
|
||||
|
||||
# Verify the returned session is from the inner session maker
|
||||
assert result is mock_session
|
||||
189
enterprise/tests/unit/storage/test_org_app_settings_store.py
Normal file
189
enterprise/tests/unit/storage/test_org_app_settings_store.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
Unit tests for OrgAppSettingsStore.
|
||||
|
||||
Tests the async database operations for organization app settings.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
# Mock the database module before importing
|
||||
with patch('storage.database.engine', create=True), patch(
|
||||
'storage.database.a_engine', create=True
|
||||
):
|
||||
from server.routes.org_models import OrgAppSettingsUpdate
|
||||
from storage.base import Base
|
||||
from storage.org import Org
|
||||
from storage.org_app_settings_store import OrgAppSettingsStore
|
||||
from storage.user import User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_engine():
|
||||
"""Create an async SQLite engine for testing."""
|
||||
engine = create_async_engine(
|
||||
'sqlite+aiosqlite:///:memory:',
|
||||
poolclass=StaticPool,
|
||||
connect_args={'check_same_thread': False},
|
||||
echo=False,
|
||||
)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield engine
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_session_maker(async_engine):
|
||||
"""Create an async session maker for testing."""
|
||||
return async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_current_org_by_user_id_success(async_session_maker):
|
||||
"""
|
||||
GIVEN: A user exists with a current organization
|
||||
WHEN: get_current_org_by_user_id is called with the user's ID
|
||||
THEN: The organization is returned with correct data
|
||||
"""
|
||||
# Arrange
|
||||
async with async_session_maker() as session:
|
||||
org = Org(
|
||||
name='test-org',
|
||||
enable_proactive_conversation_starters=True,
|
||||
enable_solvability_analysis=False,
|
||||
max_budget_per_task=25.0,
|
||||
)
|
||||
session.add(org)
|
||||
await session.flush()
|
||||
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
current_org_id=org.id,
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
user_id = str(user.id)
|
||||
|
||||
# Act
|
||||
store = OrgAppSettingsStore(db_session=session)
|
||||
result = await store.get_current_org_by_user_id(user_id)
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert result.name == 'test-org'
|
||||
assert result.enable_proactive_conversation_starters is True
|
||||
assert result.enable_solvability_analysis is False
|
||||
assert result.max_budget_per_task == 25.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_current_org_by_user_id_user_not_found(async_session_maker):
|
||||
"""
|
||||
GIVEN: A user does not exist in the database
|
||||
WHEN: get_current_org_by_user_id is called with a non-existent ID
|
||||
THEN: None is returned
|
||||
"""
|
||||
# Arrange
|
||||
non_existent_id = str(uuid.uuid4())
|
||||
|
||||
# Act
|
||||
async with async_session_maker() as session:
|
||||
store = OrgAppSettingsStore(db_session=session)
|
||||
result = await store.get_current_org_by_user_id(non_existent_id)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_app_settings_success(async_session_maker):
|
||||
"""
|
||||
GIVEN: An organization exists in the database
|
||||
WHEN: update_org_app_settings is called with new values
|
||||
THEN: The organization's settings are updated and returned
|
||||
"""
|
||||
# Arrange
|
||||
async with async_session_maker() as session:
|
||||
org = Org(
|
||||
name='test-org',
|
||||
enable_proactive_conversation_starters=True,
|
||||
enable_solvability_analysis=False,
|
||||
max_budget_per_task=10.0,
|
||||
)
|
||||
session.add(org)
|
||||
await session.commit()
|
||||
org_id = org.id
|
||||
|
||||
update_data = OrgAppSettingsUpdate(
|
||||
enable_proactive_conversation_starters=False,
|
||||
enable_solvability_analysis=True,
|
||||
max_budget_per_task=50.0,
|
||||
)
|
||||
|
||||
# Act
|
||||
store = OrgAppSettingsStore(db_session=session)
|
||||
result = await store.update_org_app_settings(org_id, update_data)
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert result.enable_proactive_conversation_starters is False
|
||||
assert result.enable_solvability_analysis is True
|
||||
assert result.max_budget_per_task == 50.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_app_settings_partial(async_session_maker):
|
||||
"""
|
||||
GIVEN: An organization exists with existing settings
|
||||
WHEN: update_org_app_settings is called with only some fields
|
||||
THEN: Only the provided fields are updated, others remain unchanged
|
||||
"""
|
||||
# Arrange
|
||||
async with async_session_maker() as session:
|
||||
org = Org(
|
||||
name='test-org',
|
||||
enable_proactive_conversation_starters=True,
|
||||
enable_solvability_analysis=False,
|
||||
max_budget_per_task=10.0,
|
||||
)
|
||||
session.add(org)
|
||||
await session.commit()
|
||||
org_id = org.id
|
||||
|
||||
# Only update max_budget_per_task
|
||||
update_data = OrgAppSettingsUpdate(max_budget_per_task=100.0)
|
||||
|
||||
# Act
|
||||
store = OrgAppSettingsStore(db_session=session)
|
||||
result = await store.update_org_app_settings(org_id, update_data)
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert result.max_budget_per_task == 100.0
|
||||
assert result.enable_proactive_conversation_starters is True # Unchanged
|
||||
assert result.enable_solvability_analysis is False # Unchanged
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_app_settings_org_not_found(async_session_maker):
|
||||
"""
|
||||
GIVEN: An organization does not exist in the database
|
||||
WHEN: update_org_app_settings is called
|
||||
THEN: None is returned
|
||||
"""
|
||||
# Arrange
|
||||
non_existent_id = uuid.uuid4()
|
||||
update_data = OrgAppSettingsUpdate(enable_proactive_conversation_starters=False)
|
||||
|
||||
# Act
|
||||
async with async_session_maker() as session:
|
||||
store = OrgAppSettingsStore(db_session=session)
|
||||
result = await store.update_org_app_settings(non_existent_id, update_data)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
180
enterprise/tests/unit/storage/test_org_llm_settings_store.py
Normal file
180
enterprise/tests/unit/storage/test_org_llm_settings_store.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
Unit tests for OrgLLMSettingsStore.
|
||||
|
||||
Tests the async database operations for organization LLM settings.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
# Mock the database module before importing
|
||||
with patch('storage.database.engine', create=True), patch(
|
||||
'storage.database.a_engine', create=True
|
||||
):
|
||||
from server.routes.org_models import OrgLLMSettingsUpdate
|
||||
from storage.base import Base
|
||||
from storage.org import Org
|
||||
from storage.org_llm_settings_store import OrgLLMSettingsStore
|
||||
from storage.user import User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_engine():
|
||||
"""Create an async SQLite engine for testing."""
|
||||
engine = create_async_engine(
|
||||
'sqlite+aiosqlite:///:memory:',
|
||||
poolclass=StaticPool,
|
||||
connect_args={'check_same_thread': False},
|
||||
echo=False,
|
||||
)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield engine
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_session_maker(async_engine):
|
||||
"""Create an async session maker for testing."""
|
||||
return async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_current_org_by_user_id_success(async_session_maker):
|
||||
"""
|
||||
GIVEN: A user exists with a current_org_id
|
||||
WHEN: get_current_org_by_user_id is called
|
||||
THEN: The user's current organization is returned
|
||||
"""
|
||||
# Arrange
|
||||
async with async_session_maker() as session:
|
||||
org = Org(name='test-org', default_llm_model='claude-3')
|
||||
session.add(org)
|
||||
await session.flush()
|
||||
|
||||
user = User(id=uuid.uuid4(), current_org_id=org.id)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
user_id = str(user.id)
|
||||
|
||||
# Act
|
||||
store = OrgLLMSettingsStore(db_session=session)
|
||||
result = await store.get_current_org_by_user_id(user_id)
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert result.name == 'test-org'
|
||||
assert result.default_llm_model == 'claude-3'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_current_org_by_user_id_user_not_found(async_session_maker):
|
||||
"""
|
||||
GIVEN: A user does not exist in the database
|
||||
WHEN: get_current_org_by_user_id is called
|
||||
THEN: None is returned
|
||||
"""
|
||||
# Arrange
|
||||
non_existent_id = str(uuid.uuid4())
|
||||
|
||||
# Act
|
||||
async with async_session_maker() as session:
|
||||
store = OrgLLMSettingsStore(db_session=session)
|
||||
result = await store.get_current_org_by_user_id(non_existent_id)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_llm_settings_success(async_session_maker):
|
||||
"""
|
||||
GIVEN: An organization exists in the database
|
||||
WHEN: update_org_llm_settings is called with new values
|
||||
THEN: The organization's LLM settings are updated and returned
|
||||
"""
|
||||
# Arrange
|
||||
async with async_session_maker() as session:
|
||||
org = Org(name='test-org', default_llm_model='old-model')
|
||||
session.add(org)
|
||||
await session.commit()
|
||||
org_id = org.id
|
||||
|
||||
update_data = OrgLLMSettingsUpdate(
|
||||
default_llm_model='new-model',
|
||||
agent='CodeActAgent',
|
||||
confirmation_mode=True,
|
||||
)
|
||||
|
||||
# Act
|
||||
store = OrgLLMSettingsStore(db_session=session)
|
||||
with patch(
|
||||
'storage.org_llm_settings_store.OrgMemberStore.update_all_members_llm_settings_async',
|
||||
AsyncMock(),
|
||||
):
|
||||
result = await store.update_org_llm_settings(org_id, update_data)
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert result.default_llm_model == 'new-model'
|
||||
assert result.agent == 'CodeActAgent'
|
||||
assert result.confirmation_mode is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_llm_settings_org_not_found(async_session_maker):
|
||||
"""
|
||||
GIVEN: An organization does not exist in the database
|
||||
WHEN: update_org_llm_settings is called
|
||||
THEN: None is returned
|
||||
"""
|
||||
# Arrange
|
||||
non_existent_org_id = uuid.uuid4()
|
||||
update_data = OrgLLMSettingsUpdate(default_llm_model='new-model')
|
||||
|
||||
# Act
|
||||
async with async_session_maker() as session:
|
||||
store = OrgLLMSettingsStore(db_session=session)
|
||||
result = await store.update_org_llm_settings(non_existent_org_id, update_data)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_llm_settings_propagates_to_members(async_session_maker):
|
||||
"""
|
||||
GIVEN: An organization exists with update data containing member-relevant settings
|
||||
WHEN: update_org_llm_settings is called
|
||||
THEN: Member settings are propagated via OrgMemberStore
|
||||
"""
|
||||
# Arrange
|
||||
async with async_session_maker() as session:
|
||||
org = Org(name='test-org', default_llm_model='old-model')
|
||||
session.add(org)
|
||||
await session.commit()
|
||||
org_id = org.id
|
||||
|
||||
update_data = OrgLLMSettingsUpdate(
|
||||
default_llm_model='new-model',
|
||||
llm_api_key='new-api-key',
|
||||
)
|
||||
|
||||
# Act
|
||||
store = OrgLLMSettingsStore(db_session=session)
|
||||
with patch(
|
||||
'storage.org_llm_settings_store.OrgMemberStore.update_all_members_llm_settings_async',
|
||||
AsyncMock(),
|
||||
) as mock_update_members:
|
||||
await store.update_org_llm_settings(org_id, update_data)
|
||||
|
||||
# Assert
|
||||
mock_update_members.assert_called_once()
|
||||
call_args = mock_update_members.call_args
|
||||
member_settings = call_args[0][2]
|
||||
assert member_settings.llm_model == 'new-model'
|
||||
assert member_settings.llm_api_key == 'new-api-key'
|
||||
@@ -486,3 +486,180 @@ class TestSaasSQLAppConversationInfoService:
|
||||
# Count should be 0 in org2
|
||||
count_org2 = await user1_service_org2.count_app_conversation_info()
|
||||
assert count_org2 == 0
|
||||
|
||||
|
||||
class TestSaasSQLAppConversationInfoServiceAdminContext:
|
||||
"""Test suite for SaasSQLAppConversationInfoService with ADMIN context."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_context_returns_unfiltered_data(
|
||||
self,
|
||||
async_session_with_users: AsyncSession,
|
||||
):
|
||||
"""Test that ADMIN context returns unfiltered data (no user/org filtering)."""
|
||||
# Create conversations for different users
|
||||
user1_service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=SpecifyUserContext(user_id=str(USER1_ID)),
|
||||
)
|
||||
|
||||
# Create conversations for user1 in org1
|
||||
for i in range(3):
|
||||
conv = AppConversationInfo(
|
||||
id=uuid4(),
|
||||
created_by_user_id=str(USER1_ID),
|
||||
sandbox_id=f'sandbox_user1_{i}',
|
||||
title=f'User1 Conversation {i}',
|
||||
)
|
||||
await user1_service.save_app_conversation_info(conv)
|
||||
|
||||
# Now create an ADMIN service
|
||||
from openhands.app_server.user.specifiy_user_context import ADMIN
|
||||
|
||||
admin_service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=ADMIN,
|
||||
)
|
||||
|
||||
# ADMIN should see ALL conversations (unfiltered)
|
||||
admin_page = await admin_service.search_app_conversation_info()
|
||||
assert (
|
||||
len(admin_page.items) == 3
|
||||
), 'ADMIN context should see all conversations without filtering'
|
||||
|
||||
# ADMIN count should return total count (3)
|
||||
admin_count = await admin_service.count_app_conversation_info()
|
||||
assert (
|
||||
admin_count == 3
|
||||
), 'ADMIN context should count all conversations without filtering'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_context_can_access_any_conversation(
|
||||
self,
|
||||
async_session_with_users: AsyncSession,
|
||||
):
|
||||
"""Test that ADMIN context can access any conversation regardless of owner."""
|
||||
from openhands.app_server.user.specifiy_user_context import ADMIN
|
||||
|
||||
# Create a conversation as user1
|
||||
user1_service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=SpecifyUserContext(user_id=str(USER1_ID)),
|
||||
)
|
||||
|
||||
conv = AppConversationInfo(
|
||||
id=uuid4(),
|
||||
created_by_user_id=str(USER1_ID),
|
||||
sandbox_id='sandbox_user1',
|
||||
title='User1 Private Conversation',
|
||||
)
|
||||
await user1_service.save_app_conversation_info(conv)
|
||||
|
||||
# Create a service as user2 in org2 - should not see user1's conversation
|
||||
user2_service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=SpecifyUserContext(user_id=str(USER2_ID)),
|
||||
)
|
||||
|
||||
user2_page = await user2_service.search_app_conversation_info()
|
||||
assert len(user2_page.items) == 0, 'User2 should not see User1 conversation'
|
||||
|
||||
# But ADMIN should see ALL conversations including user1's
|
||||
admin_service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=ADMIN,
|
||||
)
|
||||
|
||||
admin_page = await admin_service.search_app_conversation_info()
|
||||
assert len(admin_page.items) == 1
|
||||
assert admin_page.items[0].id == conv.id
|
||||
|
||||
# ADMIN should also be able to get specific conversation by ID
|
||||
admin_get_conv = await admin_service.get_app_conversation_info(conv.id)
|
||||
assert admin_get_conv is not None
|
||||
assert admin_get_conv.id == conv.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_secure_select_admin_bypasses_filtering(
|
||||
self,
|
||||
async_session_with_users: AsyncSession,
|
||||
):
|
||||
"""Test that _secure_select returns unfiltered query for ADMIN context."""
|
||||
from openhands.app_server.user.specifiy_user_context import ADMIN
|
||||
|
||||
# Create an ADMIN service
|
||||
admin_service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=ADMIN,
|
||||
)
|
||||
|
||||
# Get the secure select query
|
||||
query = await admin_service._secure_select()
|
||||
|
||||
# Convert query to string to verify NO filters are present
|
||||
query_str = str(query.compile(compile_kwargs={'literal_binds': True}))
|
||||
|
||||
# For ADMIN, there should be no user_id or org_id filtering
|
||||
# The query should not contain filters for user_id or org_id
|
||||
assert str(USER1_ID) not in query_str.replace(
|
||||
'-', ''
|
||||
), 'ADMIN context should not filter by user_id'
|
||||
assert str(USER2_ID) not in query_str.replace(
|
||||
'-', ''
|
||||
), 'ADMIN context should not filter by user_id'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_regular_user_context_filters_correctly(
|
||||
self,
|
||||
async_session_with_users: AsyncSession,
|
||||
):
|
||||
"""Test that regular user context properly filters data (control test)."""
|
||||
from openhands.app_server.user.specifiy_user_context import ADMIN
|
||||
|
||||
# Create conversations for different users
|
||||
user1_service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=SpecifyUserContext(user_id=str(USER1_ID)),
|
||||
)
|
||||
|
||||
# Create 3 conversations for user1
|
||||
for i in range(3):
|
||||
conv = AppConversationInfo(
|
||||
id=uuid4(),
|
||||
created_by_user_id=str(USER1_ID),
|
||||
sandbox_id=f'sandbox_user1_{i}',
|
||||
title=f'User1 Conversation {i}',
|
||||
)
|
||||
await user1_service.save_app_conversation_info(conv)
|
||||
|
||||
# Create 2 conversations for user2
|
||||
user2_service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=SpecifyUserContext(user_id=str(USER2_ID)),
|
||||
)
|
||||
|
||||
for i in range(2):
|
||||
conv = AppConversationInfo(
|
||||
id=uuid4(),
|
||||
created_by_user_id=str(USER2_ID),
|
||||
sandbox_id=f'sandbox_user2_{i}',
|
||||
title=f'User2 Conversation {i}',
|
||||
)
|
||||
await user2_service.save_app_conversation_info(conv)
|
||||
|
||||
# User1 should only see their 3 conversations
|
||||
user1_page = await user1_service.search_app_conversation_info()
|
||||
assert len(user1_page.items) == 3
|
||||
|
||||
# User2 should only see their 2 conversations
|
||||
user2_page = await user2_service.search_app_conversation_info()
|
||||
assert len(user2_page.items) == 2
|
||||
|
||||
# But ADMIN should see all 5 conversations
|
||||
admin_service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=ADMIN,
|
||||
)
|
||||
|
||||
admin_page = await admin_service.search_app_conversation_info()
|
||||
assert len(admin_page.items) == 5
|
||||
|
||||
204
enterprise/tests/unit/storage/test_user_app_settings_store.py
Normal file
204
enterprise/tests/unit/storage/test_user_app_settings_store.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
Unit tests for UserAppSettingsStore.
|
||||
|
||||
Tests the async database operations for user app settings.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
# Mock the database module before importing
|
||||
with patch('storage.database.engine', create=True), patch(
|
||||
'storage.database.a_engine', create=True
|
||||
):
|
||||
from server.routes.user_app_settings_models import UserAppSettingsUpdate
|
||||
from storage.base import Base
|
||||
from storage.org import Org
|
||||
from storage.user import User
|
||||
from storage.user_app_settings_store import UserAppSettingsStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_engine():
|
||||
"""Create an async SQLite engine for testing."""
|
||||
engine = create_async_engine(
|
||||
'sqlite+aiosqlite:///:memory:',
|
||||
poolclass=StaticPool,
|
||||
connect_args={'check_same_thread': False},
|
||||
echo=False,
|
||||
)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield engine
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_session_maker(async_engine):
|
||||
"""Create an async session maker for testing."""
|
||||
return async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_by_id_success(async_session_maker):
|
||||
"""
|
||||
GIVEN: A user exists in the database
|
||||
WHEN: get_user_by_id is called with the user's ID
|
||||
THEN: The user is returned with correct data
|
||||
"""
|
||||
# Arrange
|
||||
async with async_session_maker() as session:
|
||||
org = Org(name='test-org')
|
||||
session.add(org)
|
||||
await session.flush()
|
||||
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
current_org_id=org.id,
|
||||
language='en',
|
||||
user_consents_to_analytics=True,
|
||||
enable_sound_notifications=False,
|
||||
git_user_name='testuser',
|
||||
git_user_email='test@example.com',
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
user_id = str(user.id)
|
||||
|
||||
# Act - create store with the session
|
||||
store = UserAppSettingsStore(db_session=session)
|
||||
result = await store.get_user_by_id(user_id)
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert str(result.id) == user_id
|
||||
assert result.language == 'en'
|
||||
assert result.user_consents_to_analytics is True
|
||||
assert result.enable_sound_notifications is False
|
||||
assert result.git_user_name == 'testuser'
|
||||
assert result.git_user_email == 'test@example.com'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_by_id_not_found(async_session_maker):
|
||||
"""
|
||||
GIVEN: A user does not exist in the database
|
||||
WHEN: get_user_by_id is called with a non-existent ID
|
||||
THEN: None is returned
|
||||
"""
|
||||
# Arrange
|
||||
non_existent_id = str(uuid.uuid4())
|
||||
|
||||
# Act
|
||||
async with async_session_maker() as session:
|
||||
store = UserAppSettingsStore(db_session=session)
|
||||
result = await store.get_user_by_id(non_existent_id)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_app_settings_success(async_session_maker):
|
||||
"""
|
||||
GIVEN: A user exists in the database
|
||||
WHEN: update_user_app_settings is called with new values
|
||||
THEN: The user's settings are updated and returned
|
||||
"""
|
||||
# Arrange
|
||||
async with async_session_maker() as session:
|
||||
org = Org(name='test-org')
|
||||
session.add(org)
|
||||
await session.flush()
|
||||
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
current_org_id=org.id,
|
||||
language='en',
|
||||
user_consents_to_analytics=False,
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
user_id = str(user.id)
|
||||
|
||||
update_data = UserAppSettingsUpdate(
|
||||
language='es',
|
||||
user_consents_to_analytics=True,
|
||||
enable_sound_notifications=True,
|
||||
git_user_name='newuser',
|
||||
git_user_email='new@example.com',
|
||||
)
|
||||
|
||||
# Act - create store with the session
|
||||
store = UserAppSettingsStore(db_session=session)
|
||||
result = await store.update_user_app_settings(user_id, update_data)
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert result.language == 'es'
|
||||
assert result.user_consents_to_analytics is True
|
||||
assert result.enable_sound_notifications is True
|
||||
assert result.git_user_name == 'newuser'
|
||||
assert result.git_user_email == 'new@example.com'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_app_settings_partial(async_session_maker):
|
||||
"""
|
||||
GIVEN: A user exists with existing settings
|
||||
WHEN: update_user_app_settings is called with only some fields
|
||||
THEN: Only the provided fields are updated, others remain unchanged
|
||||
"""
|
||||
# Arrange
|
||||
async with async_session_maker() as session:
|
||||
org = Org(name='test-org')
|
||||
session.add(org)
|
||||
await session.flush()
|
||||
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
current_org_id=org.id,
|
||||
language='en',
|
||||
user_consents_to_analytics=True,
|
||||
git_user_name='original',
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
user_id = str(user.id)
|
||||
|
||||
# Only update language
|
||||
update_data = UserAppSettingsUpdate(language='fr')
|
||||
|
||||
# Act - create store with the session
|
||||
store = UserAppSettingsStore(db_session=session)
|
||||
result = await store.update_user_app_settings(user_id, update_data)
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert result.language == 'fr'
|
||||
assert result.user_consents_to_analytics is True # Unchanged
|
||||
assert result.git_user_name == 'original' # Unchanged
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_app_settings_user_not_found(async_session_maker):
|
||||
"""
|
||||
GIVEN: A user does not exist in the database
|
||||
WHEN: update_user_app_settings is called
|
||||
THEN: None is returned
|
||||
"""
|
||||
# Arrange
|
||||
non_existent_id = str(uuid.uuid4())
|
||||
update_data = UserAppSettingsUpdate(language='en')
|
||||
|
||||
# Act
|
||||
async with async_session_maker() as session:
|
||||
store = UserAppSettingsStore(db_session=session)
|
||||
result = await store.update_user_app_settings(non_existent_id, update_data)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
@@ -1,6 +1,25 @@
|
||||
"""Tests for resend_keycloak email validation."""
|
||||
"""Tests for Resend Keycloak sync functionality."""
|
||||
|
||||
from sync.resend_keycloak import is_valid_email
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from resend.exceptions import ResendError
|
||||
from tenacity import RetryError
|
||||
|
||||
# Set required environment variables before importing the module
|
||||
# that reads them at import time
|
||||
os.environ['RESEND_API_KEY'] = 'test_api_key'
|
||||
os.environ['RESEND_AUDIENCE_ID'] = 'test_audience_id'
|
||||
os.environ['KEYCLOAK_SERVER_URL'] = 'http://localhost:8080'
|
||||
os.environ['KEYCLOAK_REALM_NAME'] = 'test_realm'
|
||||
os.environ['KEYCLOAK_ADMIN_PASSWORD'] = 'test_password'
|
||||
|
||||
from enterprise.sync.resend_keycloak import ( # noqa: E402
|
||||
add_contact_to_resend,
|
||||
is_valid_email,
|
||||
send_welcome_email,
|
||||
)
|
||||
|
||||
|
||||
class TestIsValidEmail:
|
||||
@@ -115,3 +134,134 @@ class TestIsValidEmail:
|
||||
"""Test that validation works for uppercase emails."""
|
||||
assert is_valid_email('USER@EXAMPLE.COM') is True
|
||||
assert is_valid_email('User@Example.Com') is True
|
||||
|
||||
|
||||
class TestSendWelcomeEmail:
|
||||
"""Tests for send_welcome_email function."""
|
||||
|
||||
@patch('enterprise.sync.resend_keycloak.resend.Emails.send')
|
||||
def test_send_welcome_email_success(self, mock_send: MagicMock) -> None:
|
||||
"""Test successful welcome email sending."""
|
||||
mock_send.return_value = {'id': 'email_123'}
|
||||
|
||||
result = send_welcome_email(
|
||||
email='test@example.com',
|
||||
first_name='John',
|
||||
last_name='Doe',
|
||||
)
|
||||
|
||||
assert result == {'id': 'email_123'}
|
||||
mock_send.assert_called_once()
|
||||
call_args = mock_send.call_args[0][0]
|
||||
assert call_args['to'] == ['test@example.com']
|
||||
assert call_args['subject'] == 'Welcome to OpenHands Cloud'
|
||||
assert 'Hi John Doe,' in call_args['html']
|
||||
|
||||
@patch('enterprise.sync.resend_keycloak.resend.Emails.send')
|
||||
def test_send_welcome_email_retries_on_rate_limit(
|
||||
self, mock_send: MagicMock
|
||||
) -> None:
|
||||
"""Test that send_welcome_email retries on rate limit errors."""
|
||||
# First two calls raise rate limit error, third succeeds
|
||||
mock_send.side_effect = [
|
||||
ResendError(
|
||||
code=429,
|
||||
message='Too many requests',
|
||||
error_type='rate_limit_exceeded',
|
||||
suggested_action='',
|
||||
),
|
||||
ResendError(
|
||||
code=429,
|
||||
message='Too many requests',
|
||||
error_type='rate_limit_exceeded',
|
||||
suggested_action='',
|
||||
),
|
||||
{'id': 'email_123'},
|
||||
]
|
||||
|
||||
result = send_welcome_email(
|
||||
email='test@example.com',
|
||||
first_name='John',
|
||||
last_name='Doe',
|
||||
)
|
||||
|
||||
assert result == {'id': 'email_123'}
|
||||
assert mock_send.call_count == 3
|
||||
|
||||
@patch('enterprise.sync.resend_keycloak.resend.Emails.send')
|
||||
def test_send_welcome_email_fails_after_max_retries(
|
||||
self, mock_send: MagicMock
|
||||
) -> None:
|
||||
"""Test that send_welcome_email fails after max retries."""
|
||||
# All calls raise rate limit error
|
||||
mock_send.side_effect = ResendError(
|
||||
code=429,
|
||||
message='Too many requests',
|
||||
error_type='rate_limit_exceeded',
|
||||
suggested_action='',
|
||||
)
|
||||
|
||||
# Tenacity wraps the final exception in RetryError
|
||||
with pytest.raises(RetryError):
|
||||
send_welcome_email(
|
||||
email='test@example.com',
|
||||
first_name='John',
|
||||
last_name='Doe',
|
||||
)
|
||||
|
||||
# Default MAX_RETRIES is 3
|
||||
assert mock_send.call_count == 3
|
||||
|
||||
@patch('enterprise.sync.resend_keycloak.resend.Emails.send')
|
||||
def test_send_welcome_email_no_name(self, mock_send: MagicMock) -> None:
|
||||
"""Test welcome email with no name provided."""
|
||||
mock_send.return_value = {'id': 'email_123'}
|
||||
|
||||
result = send_welcome_email(email='test@example.com')
|
||||
|
||||
assert result == {'id': 'email_123'}
|
||||
call_args = mock_send.call_args[0][0]
|
||||
assert 'Hi there,' in call_args['html']
|
||||
|
||||
|
||||
class TestAddContactToResend:
|
||||
"""Tests for add_contact_to_resend function."""
|
||||
|
||||
@patch('enterprise.sync.resend_keycloak.resend.Contacts.create')
|
||||
def test_add_contact_to_resend_success(self, mock_create: MagicMock) -> None:
|
||||
"""Test successful contact addition."""
|
||||
mock_create.return_value = {'id': 'contact_123'}
|
||||
|
||||
result = add_contact_to_resend(
|
||||
audience_id='test_audience',
|
||||
email='test@example.com',
|
||||
first_name='John',
|
||||
last_name='Doe',
|
||||
)
|
||||
|
||||
assert result == {'id': 'contact_123'}
|
||||
mock_create.assert_called_once()
|
||||
|
||||
@patch('enterprise.sync.resend_keycloak.resend.Contacts.create')
|
||||
def test_add_contact_to_resend_retries_on_rate_limit(
|
||||
self, mock_create: MagicMock
|
||||
) -> None:
|
||||
"""Test that add_contact_to_resend retries on rate limit errors."""
|
||||
# First call raises rate limit error, second succeeds
|
||||
mock_create.side_effect = [
|
||||
ResendError(
|
||||
code=429,
|
||||
message='Too many requests',
|
||||
error_type='rate_limit_exceeded',
|
||||
suggested_action='',
|
||||
),
|
||||
{'id': 'contact_123'},
|
||||
]
|
||||
|
||||
result = add_contact_to_resend(
|
||||
audience_id='test_audience',
|
||||
email='test@example.com',
|
||||
)
|
||||
|
||||
assert result == {'id': 'contact_123'}
|
||||
assert mock_create.call_count == 2
|
||||
|
||||
@@ -284,3 +284,85 @@ async def test_middleware_ignores_email_resend_path_no_tos_check(
|
||||
assert result == mock_response
|
||||
mock_call_next.assert_called_once_with(mock_request)
|
||||
# Should not raise TosNotAcceptedError for this path
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_middleware_skips_webhook_endpoints(
|
||||
middleware, mock_request, mock_response
|
||||
):
|
||||
"""Test middleware skips webhook endpoints (/api/v1/webhooks/*) and doesn't require auth."""
|
||||
# Test various webhook paths
|
||||
webhook_paths = [
|
||||
'/api/v1/webhooks/events',
|
||||
'/api/v1/webhooks/events/123',
|
||||
'/api/v1/webhooks/stats',
|
||||
'/api/v1/webhooks/parent-conversation',
|
||||
]
|
||||
|
||||
for path in webhook_paths:
|
||||
mock_request.cookies = {}
|
||||
mock_request.url = MagicMock()
|
||||
mock_request.url.hostname = 'localhost'
|
||||
mock_request.url.path = path
|
||||
mock_call_next = AsyncMock(return_value=mock_response)
|
||||
|
||||
# Act
|
||||
result = await middleware(mock_request, mock_call_next)
|
||||
|
||||
# Assert - middleware should skip auth check and call next
|
||||
assert result == mock_response
|
||||
mock_call_next.assert_called_once_with(mock_request)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_middleware_skips_webhook_secrets_endpoint(
|
||||
middleware, mock_request, mock_response
|
||||
):
|
||||
"""Test middleware skips the old /api/v1/webhooks/secrets endpoint."""
|
||||
# This was explicitly in ignore_paths but is now handled by the prefix check
|
||||
mock_request.cookies = {}
|
||||
mock_request.url = MagicMock()
|
||||
mock_request.url.hostname = 'localhost'
|
||||
mock_request.url.path = '/api/v1/webhooks/secrets'
|
||||
mock_call_next = AsyncMock(return_value=mock_response)
|
||||
|
||||
# Act
|
||||
result = await middleware(mock_request, mock_call_next)
|
||||
|
||||
# Assert - middleware should skip auth check and call next
|
||||
assert result == mock_response
|
||||
mock_call_next.assert_called_once_with(mock_request)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_middleware_does_not_skip_similar_non_webhook_paths(
|
||||
middleware, mock_response
|
||||
):
|
||||
"""Test middleware does NOT skip paths that start with /api/v1/webhook (without 's')."""
|
||||
# These paths should still be processed by the middleware (not skipped)
|
||||
# They start with /api so _should_attach returns True, and since there's no auth,
|
||||
# middleware should return 401 response (it catches NoCredentialsError internally)
|
||||
non_webhook_paths = [
|
||||
'/api/v1/webhook/events',
|
||||
'/api/v1/webhook/something',
|
||||
]
|
||||
|
||||
for path in non_webhook_paths:
|
||||
# Create a fresh mock request for each test
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.cookies = {}
|
||||
mock_request.url = MagicMock()
|
||||
mock_request.url.hostname = 'localhost'
|
||||
mock_request.url.path = path
|
||||
mock_request.headers = MagicMock()
|
||||
mock_request.headers.get = MagicMock(side_effect=lambda k: None)
|
||||
|
||||
# Since these paths start with /api, _should_attach returns True
|
||||
# Since there's no auth, middleware catches NoCredentialsError and returns 401
|
||||
mock_call_next = AsyncMock()
|
||||
result = await middleware(mock_request, mock_call_next)
|
||||
|
||||
# Should return a 401 response, not raise an exception
|
||||
assert result.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
# Should NOT call next for non-webhook paths when auth is missing
|
||||
mock_call_next.assert_not_called()
|
||||
|
||||
@@ -154,6 +154,7 @@ async def test_keycloak_callback_user_not_allowed(mock_request):
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.migrate_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
mock_user_store.backfill_user_email = AsyncMock()
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = False
|
||||
@@ -190,6 +191,7 @@ async def test_keycloak_callback_success_with_valid_offline_token(mock_request):
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.migrate_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
mock_user_store.backfill_user_email = AsyncMock()
|
||||
|
||||
mock_token_manager.get_keycloak_tokens = AsyncMock(
|
||||
return_value=('test_access_token', 'test_refresh_token')
|
||||
@@ -262,6 +264,7 @@ async def test_keycloak_callback_email_not_verified(mock_request):
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
mock_user_store.backfill_user_email = AsyncMock()
|
||||
|
||||
# Act
|
||||
result = await keycloak_callback(
|
||||
@@ -310,6 +313,7 @@ async def test_keycloak_callback_email_not_verified_missing_field(mock_request):
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
mock_user_store.backfill_user_email = AsyncMock()
|
||||
|
||||
# Act
|
||||
result = await keycloak_callback(
|
||||
@@ -352,6 +356,7 @@ async def test_keycloak_callback_success_without_offline_token(mock_request):
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.migrate_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
mock_user_store.backfill_user_email = AsyncMock()
|
||||
|
||||
mock_token_manager.get_keycloak_tokens = AsyncMock(
|
||||
return_value=('test_access_token', 'test_refresh_token')
|
||||
@@ -587,6 +592,7 @@ async def test_keycloak_callback_blocked_email_domain(mock_request):
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
mock_user_store.backfill_user_email = AsyncMock()
|
||||
|
||||
mock_domain_blocker.is_active.return_value = True
|
||||
mock_domain_blocker.is_domain_blocked.return_value = True
|
||||
@@ -651,6 +657,7 @@ async def test_keycloak_callback_allowed_email_domain(mock_request):
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
mock_user_store.backfill_user_email = AsyncMock()
|
||||
|
||||
mock_domain_blocker.is_active.return_value = True
|
||||
mock_domain_blocker.is_domain_blocked.return_value = False
|
||||
@@ -715,6 +722,7 @@ async def test_keycloak_callback_domain_blocking_inactive(mock_request):
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
mock_user_store.backfill_user_email = AsyncMock()
|
||||
|
||||
mock_domain_blocker.is_active.return_value = False
|
||||
mock_domain_blocker.is_domain_blocked.return_value = False
|
||||
@@ -777,6 +785,7 @@ async def test_keycloak_callback_missing_email(mock_request):
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
mock_user_store.backfill_user_email = AsyncMock()
|
||||
|
||||
mock_domain_blocker.is_active.return_value = True
|
||||
|
||||
@@ -823,6 +832,7 @@ async def test_keycloak_callback_duplicate_email_detected(mock_request):
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
mock_user_store.backfill_user_email = AsyncMock()
|
||||
|
||||
# Act
|
||||
result = await keycloak_callback(
|
||||
@@ -868,6 +878,7 @@ async def test_keycloak_callback_duplicate_email_deletion_fails(mock_request):
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
mock_user_store.backfill_user_email = AsyncMock()
|
||||
|
||||
# Act
|
||||
result = await keycloak_callback(
|
||||
@@ -926,6 +937,7 @@ async def test_keycloak_callback_duplicate_check_exception(mock_request):
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
mock_user_store.backfill_user_email = AsyncMock()
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
@@ -984,6 +996,7 @@ async def test_keycloak_callback_no_duplicate_email(mock_request):
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
mock_user_store.backfill_user_email = AsyncMock()
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
@@ -1045,6 +1058,7 @@ async def test_keycloak_callback_no_email_in_user_info(mock_request):
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
mock_user_store.backfill_user_email = AsyncMock()
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
@@ -1202,6 +1216,7 @@ class TestKeycloakCallbackRecaptcha:
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
mock_user_store.backfill_user_email = AsyncMock()
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
@@ -1267,6 +1282,7 @@ class TestKeycloakCallbackRecaptcha:
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
mock_user_store.backfill_user_email = AsyncMock()
|
||||
|
||||
mock_domain_blocker.is_domain_blocked.return_value = False
|
||||
|
||||
@@ -1350,6 +1366,7 @@ class TestKeycloakCallbackRecaptcha:
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
mock_user_store.backfill_user_email = AsyncMock()
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
@@ -1438,6 +1455,7 @@ class TestKeycloakCallbackRecaptcha:
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
mock_user_store.backfill_user_email = AsyncMock()
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
@@ -1523,6 +1541,7 @@ class TestKeycloakCallbackRecaptcha:
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
mock_user_store.backfill_user_email = AsyncMock()
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
@@ -1607,6 +1626,7 @@ class TestKeycloakCallbackRecaptcha:
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
mock_user_store.backfill_user_email = AsyncMock()
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
@@ -1688,6 +1708,7 @@ class TestKeycloakCallbackRecaptcha:
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
mock_user_store.backfill_user_email = AsyncMock()
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
@@ -1755,6 +1776,7 @@ class TestKeycloakCallbackRecaptcha:
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
mock_user_store.backfill_user_email = AsyncMock()
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
@@ -1828,6 +1850,7 @@ class TestKeycloakCallbackRecaptcha:
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
mock_user_store.backfill_user_email = AsyncMock()
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
@@ -1899,6 +1922,7 @@ class TestKeycloakCallbackRecaptcha:
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
mock_user_store.backfill_user_email = AsyncMock()
|
||||
|
||||
mock_domain_blocker.is_domain_blocked.return_value = False
|
||||
|
||||
@@ -1918,3 +1942,57 @@ class TestKeycloakCallbackRecaptcha:
|
||||
assert call_kwargs[0][0] == 'recaptcha_blocked_at_callback'
|
||||
assert call_kwargs[1]['extra']['score'] == 0.2
|
||||
assert call_kwargs[1]['extra']['user_id'] == 'test_user_id'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keycloak_callback_calls_backfill_user_email_for_existing_user(
|
||||
mock_request,
|
||||
):
|
||||
"""When an existing user logs in, backfill_user_email should be called."""
|
||||
user_info = {
|
||||
'sub': 'test_user_id',
|
||||
'preferred_username': 'test_user',
|
||||
'identity_provider': 'github',
|
||||
'email': 'test@example.com',
|
||||
'email_verified': True,
|
||||
}
|
||||
|
||||
with (
|
||||
patch('server.routes.auth.token_manager') as mock_token_manager,
|
||||
patch('server.routes.auth.user_verifier') as mock_verifier,
|
||||
patch('server.routes.auth.set_response_cookie'),
|
||||
patch('server.routes.auth.UserStore') as mock_user_store,
|
||||
patch('server.routes.auth.posthog'),
|
||||
):
|
||||
mock_user = MagicMock()
|
||||
mock_user.id = 'test_user_id'
|
||||
mock_user.current_org_id = 'test_org_id'
|
||||
mock_user.accepted_tos = '2025-01-01'
|
||||
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
mock_user_store.backfill_user_email = AsyncMock()
|
||||
|
||||
mock_token_manager.get_keycloak_tokens = AsyncMock(
|
||||
return_value=('test_access_token', 'test_refresh_token')
|
||||
)
|
||||
mock_token_manager.get_user_info = AsyncMock(return_value=user_info)
|
||||
mock_token_manager.store_idp_tokens = AsyncMock()
|
||||
mock_token_manager.validate_offline_token = AsyncMock(return_value=True)
|
||||
mock_token_manager.check_duplicate_base_email = AsyncMock(return_value=False)
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
|
||||
result = await keycloak_callback(
|
||||
code='test_code', state='test_state', request=mock_request
|
||||
)
|
||||
|
||||
assert isinstance(result, RedirectResponse)
|
||||
assert result.status_code == 302
|
||||
|
||||
# backfill_user_email should have been called with the user_id and user_info
|
||||
mock_user_store.backfill_user_email.assert_called_once_with(
|
||||
'test_user_id', user_info
|
||||
)
|
||||
|
||||
@@ -655,3 +655,506 @@ async def test_get_org_members_paginated_eager_loading(async_session_maker):
|
||||
assert member.role is not None
|
||||
assert member.role.name == 'owner'
|
||||
assert member.role.rank == 10
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_org_members_count_no_filter(async_session_maker):
|
||||
"""Test get_org_members_count returns correct count without email filter."""
|
||||
# Arrange
|
||||
async with async_session_maker() as session:
|
||||
org = Org(name='test-org')
|
||||
session.add(org)
|
||||
await session.flush()
|
||||
|
||||
role = Role(name='admin', rank=1)
|
||||
session.add(role)
|
||||
await session.flush()
|
||||
|
||||
users = [
|
||||
User(id=uuid.uuid4(), current_org_id=org.id, email=f'user{i}@example.com')
|
||||
for i in range(5)
|
||||
]
|
||||
session.add_all(users)
|
||||
await session.flush()
|
||||
|
||||
org_members = [
|
||||
OrgMember(
|
||||
org_id=org.id,
|
||||
user_id=user.id,
|
||||
role_id=role.id,
|
||||
llm_api_key=f'test-key-{i}',
|
||||
status='active',
|
||||
)
|
||||
for i, user in enumerate(users)
|
||||
]
|
||||
session.add_all(org_members)
|
||||
await session.commit()
|
||||
org_id = org.id
|
||||
|
||||
# Act
|
||||
with patch('storage.org_member_store.a_session_maker', async_session_maker):
|
||||
count = await OrgMemberStore.get_org_members_count(org_id=org_id)
|
||||
|
||||
# Assert
|
||||
assert count == 5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_org_members_count_with_email_filter(async_session_maker):
|
||||
"""Test get_org_members_count filters by email correctly."""
|
||||
# Arrange
|
||||
async with async_session_maker() as session:
|
||||
org = Org(name='test-org')
|
||||
session.add(org)
|
||||
await session.flush()
|
||||
|
||||
role = Role(name='admin', rank=1)
|
||||
session.add(role)
|
||||
await session.flush()
|
||||
|
||||
users = [
|
||||
User(id=uuid.uuid4(), current_org_id=org.id, email='alice@example.com'),
|
||||
User(id=uuid.uuid4(), current_org_id=org.id, email='bob@example.com'),
|
||||
User(
|
||||
id=uuid.uuid4(), current_org_id=org.id, email='alice.smith@example.com'
|
||||
),
|
||||
]
|
||||
session.add_all(users)
|
||||
await session.flush()
|
||||
|
||||
org_members = [
|
||||
OrgMember(
|
||||
org_id=org.id,
|
||||
user_id=user.id,
|
||||
role_id=role.id,
|
||||
llm_api_key=f'test-key-{i}',
|
||||
status='active',
|
||||
)
|
||||
for i, user in enumerate(users)
|
||||
]
|
||||
session.add_all(org_members)
|
||||
await session.commit()
|
||||
org_id = org.id
|
||||
|
||||
# Act
|
||||
with patch('storage.org_member_store.a_session_maker', async_session_maker):
|
||||
count = await OrgMemberStore.get_org_members_count(
|
||||
org_id=org_id, email_filter='alice'
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_org_members_paginated_with_email_filter(async_session_maker):
|
||||
"""Test get_org_members_paginated filters by email correctly."""
|
||||
# Arrange
|
||||
async with async_session_maker() as session:
|
||||
org = Org(name='test-org')
|
||||
session.add(org)
|
||||
await session.flush()
|
||||
|
||||
role = Role(name='admin', rank=1)
|
||||
session.add(role)
|
||||
await session.flush()
|
||||
|
||||
users = [
|
||||
User(id=uuid.uuid4(), current_org_id=org.id, email='alice@example.com'),
|
||||
User(id=uuid.uuid4(), current_org_id=org.id, email='bob@example.com'),
|
||||
User(id=uuid.uuid4(), current_org_id=org.id, email='charlie@example.com'),
|
||||
]
|
||||
session.add_all(users)
|
||||
await session.flush()
|
||||
|
||||
org_members = [
|
||||
OrgMember(
|
||||
org_id=org.id,
|
||||
user_id=user.id,
|
||||
role_id=role.id,
|
||||
llm_api_key=f'test-key-{i}',
|
||||
status='active',
|
||||
)
|
||||
for i, user in enumerate(users)
|
||||
]
|
||||
session.add_all(org_members)
|
||||
await session.commit()
|
||||
org_id = org.id
|
||||
|
||||
# Act
|
||||
with patch('storage.org_member_store.a_session_maker', async_session_maker):
|
||||
members, has_more = await OrgMemberStore.get_org_members_paginated(
|
||||
org_id=org_id, offset=0, limit=10, email_filter='bob'
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(members) == 1
|
||||
assert members[0].user.email == 'bob@example.com'
|
||||
assert has_more is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_org_members_paginated_email_filter_case_insensitive(
|
||||
async_session_maker,
|
||||
):
|
||||
"""Test email filter is case-insensitive."""
|
||||
# Arrange
|
||||
async with async_session_maker() as session:
|
||||
org = Org(name='test-org')
|
||||
session.add(org)
|
||||
await session.flush()
|
||||
|
||||
role = Role(name='admin', rank=1)
|
||||
session.add(role)
|
||||
await session.flush()
|
||||
|
||||
user = User(id=uuid.uuid4(), current_org_id=org.id, email='Alice@Example.COM')
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
|
||||
org_member = OrgMember(
|
||||
org_id=org.id,
|
||||
user_id=user.id,
|
||||
role_id=role.id,
|
||||
llm_api_key='test-key',
|
||||
status='active',
|
||||
)
|
||||
session.add(org_member)
|
||||
await session.commit()
|
||||
org_id = org.id
|
||||
|
||||
# Act
|
||||
with patch('storage.org_member_store.a_session_maker', async_session_maker):
|
||||
members, has_more = await OrgMemberStore.get_org_members_paginated(
|
||||
org_id=org_id, offset=0, limit=10, email_filter='alice@example'
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(members) == 1
|
||||
assert members[0].user.email == 'Alice@Example.COM'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_all_members_llm_settings_async_with_llm_api_key(
|
||||
async_session_maker,
|
||||
):
|
||||
"""
|
||||
GIVEN: Organization with members and llm_api_key in member settings
|
||||
WHEN: update_all_members_llm_settings_async is called with llm_api_key
|
||||
THEN: The llm_api_key is encrypted and stored in _llm_api_key column for all members
|
||||
"""
|
||||
from server.routes.org_models import OrgMemberLLMSettings
|
||||
from storage.encrypt_utils import decrypt_value
|
||||
|
||||
# Arrange
|
||||
async with async_session_maker() as session:
|
||||
org = Org(name='test-org')
|
||||
session.add(org)
|
||||
await session.flush()
|
||||
|
||||
role = Role(name='member', rank=2)
|
||||
session.add(role)
|
||||
await session.flush()
|
||||
|
||||
users = [
|
||||
User(id=uuid.uuid4(), current_org_id=org.id, email=f'user{i}@example.com')
|
||||
for i in range(2)
|
||||
]
|
||||
session.add_all(users)
|
||||
await session.flush()
|
||||
|
||||
org_members = [
|
||||
OrgMember(
|
||||
org_id=org.id,
|
||||
user_id=user.id,
|
||||
role_id=role.id,
|
||||
llm_api_key='old-key',
|
||||
status='active',
|
||||
)
|
||||
for user in users
|
||||
]
|
||||
session.add_all(org_members)
|
||||
await session.commit()
|
||||
org_id = org.id
|
||||
|
||||
# Act
|
||||
new_api_key = 'new-test-api-key-12345'
|
||||
member_settings = OrgMemberLLMSettings(llm_api_key=new_api_key)
|
||||
|
||||
async with async_session_maker() as session:
|
||||
await OrgMemberStore.update_all_members_llm_settings_async(
|
||||
session, org_id, member_settings
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
# Assert
|
||||
async with async_session_maker() as session:
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await session.execute(
|
||||
select(OrgMember).filter(OrgMember.org_id == org_id)
|
||||
)
|
||||
updated_members = result.scalars().all()
|
||||
|
||||
assert len(updated_members) == 2
|
||||
for member in updated_members:
|
||||
# Verify the encrypted value can be decrypted to the original
|
||||
decrypted_key = decrypt_value(member._llm_api_key)
|
||||
assert decrypted_key == new_api_key
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_all_members_llm_settings_async_with_non_encrypted_fields(
|
||||
async_session_maker,
|
||||
):
|
||||
"""
|
||||
GIVEN: Organization with members
|
||||
WHEN: update_all_members_llm_settings_async is called with non-encrypted fields
|
||||
THEN: The fields are updated directly without encryption
|
||||
"""
|
||||
from server.routes.org_models import OrgMemberLLMSettings
|
||||
|
||||
# Arrange
|
||||
async with async_session_maker() as session:
|
||||
org = Org(name='test-org')
|
||||
session.add(org)
|
||||
await session.flush()
|
||||
|
||||
role = Role(name='member', rank=2)
|
||||
session.add(role)
|
||||
await session.flush()
|
||||
|
||||
user = User(id=uuid.uuid4(), current_org_id=org.id, email='user@example.com')
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
|
||||
org_member = OrgMember(
|
||||
org_id=org.id,
|
||||
user_id=user.id,
|
||||
role_id=role.id,
|
||||
llm_api_key='test-key',
|
||||
llm_model='old-model',
|
||||
max_iterations=10,
|
||||
status='active',
|
||||
)
|
||||
session.add(org_member)
|
||||
await session.commit()
|
||||
org_id = org.id
|
||||
|
||||
# Act
|
||||
member_settings = OrgMemberLLMSettings(
|
||||
llm_model='new-model',
|
||||
llm_base_url='https://new-url.com',
|
||||
max_iterations=50,
|
||||
)
|
||||
|
||||
async with async_session_maker() as session:
|
||||
await OrgMemberStore.update_all_members_llm_settings_async(
|
||||
session, org_id, member_settings
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
# Assert
|
||||
async with async_session_maker() as session:
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await session.execute(
|
||||
select(OrgMember).filter(OrgMember.org_id == org_id)
|
||||
)
|
||||
updated_member = result.scalars().first()
|
||||
|
||||
assert updated_member.llm_model == 'new-model'
|
||||
assert updated_member.llm_base_url == 'https://new-url.com'
|
||||
assert updated_member.max_iterations == 50
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_all_members_llm_settings_async_with_empty_settings(
|
||||
async_session_maker,
|
||||
):
|
||||
"""
|
||||
GIVEN: Organization with members and empty member settings
|
||||
WHEN: update_all_members_llm_settings_async is called with no fields set
|
||||
THEN: No database update is performed
|
||||
"""
|
||||
from server.routes.org_models import OrgMemberLLMSettings
|
||||
|
||||
# Arrange
|
||||
async with async_session_maker() as session:
|
||||
org = Org(name='test-org')
|
||||
session.add(org)
|
||||
await session.flush()
|
||||
|
||||
role = Role(name='member', rank=2)
|
||||
session.add(role)
|
||||
await session.flush()
|
||||
|
||||
user = User(id=uuid.uuid4(), current_org_id=org.id, email='user@example.com')
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
|
||||
org_member = OrgMember(
|
||||
org_id=org.id,
|
||||
user_id=user.id,
|
||||
role_id=role.id,
|
||||
llm_api_key='original-key',
|
||||
llm_model='original-model',
|
||||
status='active',
|
||||
)
|
||||
session.add(org_member)
|
||||
await session.commit()
|
||||
org_id = org.id
|
||||
|
||||
# Act - Empty settings (all None)
|
||||
member_settings = OrgMemberLLMSettings()
|
||||
|
||||
async with async_session_maker() as session:
|
||||
await OrgMemberStore.update_all_members_llm_settings_async(
|
||||
session, org_id, member_settings
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
# Assert - Original values should be unchanged
|
||||
async with async_session_maker() as session:
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await session.execute(
|
||||
select(OrgMember).filter(OrgMember.org_id == org_id)
|
||||
)
|
||||
member = result.scalars().first()
|
||||
|
||||
assert member.llm_model == 'original-model'
|
||||
# Original key should still be there (encrypted)
|
||||
assert member._llm_api_key is not None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# OrgMemberLLMSettings and OrgLLMSettingsUpdate Model Unit Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_org_member_llm_settings_has_updates_with_llm_api_key():
|
||||
"""
|
||||
GIVEN: OrgMemberLLMSettings with only llm_api_key set
|
||||
WHEN: has_updates() is called
|
||||
THEN: Returns True
|
||||
"""
|
||||
from server.routes.org_models import OrgMemberLLMSettings
|
||||
|
||||
# Arrange
|
||||
settings = OrgMemberLLMSettings(llm_api_key='test-key')
|
||||
|
||||
# Act
|
||||
result = settings.has_updates()
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_org_member_llm_settings_has_updates_empty():
|
||||
"""
|
||||
GIVEN: OrgMemberLLMSettings with no fields set
|
||||
WHEN: has_updates() is called
|
||||
THEN: Returns False
|
||||
"""
|
||||
from server.routes.org_models import OrgMemberLLMSettings
|
||||
|
||||
# Arrange
|
||||
settings = OrgMemberLLMSettings()
|
||||
|
||||
# Act
|
||||
result = settings.has_updates()
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_org_llm_settings_update_apply_to_org_skips_llm_api_key():
|
||||
"""
|
||||
GIVEN: OrgLLMSettingsUpdate with llm_api_key and other fields set
|
||||
WHEN: apply_to_org() is called
|
||||
THEN: llm_api_key is NOT applied to org, but other fields are
|
||||
"""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from server.routes.org_models import OrgLLMSettingsUpdate
|
||||
|
||||
# Arrange
|
||||
settings = OrgLLMSettingsUpdate(
|
||||
default_llm_model='claude-3',
|
||||
llm_api_key='should-not-be-applied',
|
||||
)
|
||||
mock_org = MagicMock()
|
||||
mock_org.default_llm_model = None
|
||||
|
||||
# Act
|
||||
settings.apply_to_org(mock_org)
|
||||
|
||||
# Assert
|
||||
assert mock_org.default_llm_model == 'claude-3'
|
||||
# llm_api_key should NOT be set on org (it's member-only)
|
||||
assert (
|
||||
not hasattr(mock_org, 'llm_api_key')
|
||||
or mock_org.llm_api_key != 'should-not-be-applied'
|
||||
)
|
||||
|
||||
|
||||
def test_org_llm_settings_update_get_member_updates_includes_llm_api_key():
|
||||
"""
|
||||
GIVEN: OrgLLMSettingsUpdate with llm_api_key set
|
||||
WHEN: get_member_updates() is called
|
||||
THEN: Returns OrgMemberLLMSettings with llm_api_key included
|
||||
"""
|
||||
from server.routes.org_models import OrgLLMSettingsUpdate
|
||||
|
||||
# Arrange
|
||||
settings = OrgLLMSettingsUpdate(
|
||||
default_llm_model='claude-3',
|
||||
llm_api_key='new-member-key',
|
||||
)
|
||||
|
||||
# Act
|
||||
member_updates = settings.get_member_updates()
|
||||
|
||||
# Assert
|
||||
assert member_updates is not None
|
||||
assert member_updates.llm_api_key == 'new-member-key'
|
||||
assert member_updates.llm_model == 'claude-3'
|
||||
|
||||
|
||||
def test_org_llm_settings_update_get_member_updates_only_llm_api_key():
|
||||
"""
|
||||
GIVEN: OrgLLMSettingsUpdate with only llm_api_key set
|
||||
WHEN: get_member_updates() is called
|
||||
THEN: Returns OrgMemberLLMSettings with llm_api_key (not None)
|
||||
"""
|
||||
from server.routes.org_models import OrgLLMSettingsUpdate
|
||||
|
||||
# Arrange
|
||||
settings = OrgLLMSettingsUpdate(llm_api_key='member-key-only')
|
||||
|
||||
# Act
|
||||
member_updates = settings.get_member_updates()
|
||||
|
||||
# Assert
|
||||
assert member_updates is not None
|
||||
assert member_updates.llm_api_key == 'member-key-only'
|
||||
assert member_updates.llm_model is None
|
||||
|
||||
|
||||
def test_org_llm_settings_update_has_updates_with_llm_api_key():
|
||||
"""
|
||||
GIVEN: OrgLLMSettingsUpdate with only llm_api_key set
|
||||
WHEN: has_updates() is called
|
||||
THEN: Returns True
|
||||
"""
|
||||
from server.routes.org_models import OrgLLMSettingsUpdate
|
||||
|
||||
# Arrange
|
||||
settings = OrgLLMSettingsUpdate(llm_api_key='test-key')
|
||||
|
||||
# Act
|
||||
result = settings.has_updates()
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -10,6 +11,7 @@ with patch('storage.database.engine', create=True), patch(
|
||||
'storage.database.a_engine', create=True
|
||||
):
|
||||
from storage.org import Org
|
||||
from storage.org_invitation import OrgInvitation
|
||||
from storage.org_member import OrgMember
|
||||
from storage.org_store import OrgStore
|
||||
from storage.role import Role
|
||||
@@ -806,3 +808,183 @@ def test_orphaned_user_error_contains_user_ids():
|
||||
assert error.user_ids == user_ids
|
||||
assert '2 user(s)' in str(error)
|
||||
assert 'no remaining organization' in str(error)
|
||||
|
||||
|
||||
def test_org_deletion_with_invitations_uses_passive_deletes(
|
||||
session_maker, mock_litellm_api
|
||||
):
|
||||
"""
|
||||
GIVEN: Organization has associated invitations with non-nullable org_id foreign key
|
||||
WHEN: Organization is deleted via SQLAlchemy session.delete()
|
||||
THEN: Deletion succeeds without NOT NULL constraint violation
|
||||
(passive_deletes=True defers to database CASCADE instead of setting org_id to NULL)
|
||||
|
||||
This test verifies the fix for the bug where SQLAlchemy would try to
|
||||
SET org_id=NULL on org_invitation before deleting the org, causing:
|
||||
"NOT NULL constraint failed: org_invitation.org_id"
|
||||
|
||||
With passive_deletes=True on the relationship, SQLAlchemy defers to the
|
||||
database's CASCADE constraint instead of trying to nullify the foreign key.
|
||||
|
||||
Note: SQLite doesn't enforce CASCADE by default, so we only verify that
|
||||
the deletion succeeds. In production (PostgreSQL), CASCADE handles cleanup.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Arrange
|
||||
org_id = uuid.uuid4()
|
||||
other_org_id = uuid.uuid4()
|
||||
user_id = uuid.uuid4()
|
||||
|
||||
with session_maker() as session:
|
||||
# Create role first (required for invitation)
|
||||
role = Role(id=1, name='owner', rank=1)
|
||||
session.add(role)
|
||||
session.flush()
|
||||
|
||||
# Create organization to be deleted
|
||||
org = Org(id=org_id, name='test-org-with-invitations')
|
||||
session.add(org)
|
||||
session.flush()
|
||||
|
||||
# Create a second org for the user's current_org_id
|
||||
# (to avoid the user.current_org_id constraint issue during deletion)
|
||||
other_org = Org(id=other_org_id, name='other-org')
|
||||
session.add(other_org)
|
||||
session.flush()
|
||||
|
||||
# Create user with current_org pointing to the OTHER org (not the one being deleted)
|
||||
user = User(id=user_id, current_org_id=other_org_id)
|
||||
session.add(user)
|
||||
session.flush()
|
||||
|
||||
# Create invitation associated with the organization to be deleted
|
||||
invitation = OrgInvitation(
|
||||
token='test-invitation-token-12345',
|
||||
org_id=org_id,
|
||||
email='invitee@example.com',
|
||||
role_id=1,
|
||||
inviter_id=user_id,
|
||||
status='pending',
|
||||
created_at=datetime.now(),
|
||||
expires_at=datetime.now() + timedelta(days=7),
|
||||
)
|
||||
session.add(invitation)
|
||||
session.commit()
|
||||
|
||||
# Verify invitation was created
|
||||
invitation_count = session.query(OrgInvitation).filter_by(org_id=org_id).count()
|
||||
assert invitation_count == 1
|
||||
|
||||
# Act - Delete organization via SQLAlchemy (this is what triggered the bug)
|
||||
# Without passive_deletes=True, SQLAlchemy would try to SET org_id=NULL
|
||||
# which violates the NOT NULL constraint on org_invitation.org_id
|
||||
with session_maker() as session:
|
||||
org = session.query(Org).filter(Org.id == org_id).first()
|
||||
assert org is not None
|
||||
|
||||
# This should NOT raise IntegrityError with passive_deletes=True
|
||||
# Previously this would fail with:
|
||||
# "NOT NULL constraint failed: org_invitation.org_id"
|
||||
session.delete(org)
|
||||
session.commit() # Success indicates passive_deletes=True is working
|
||||
|
||||
# Assert - Organization should be deleted
|
||||
with session_maker() as session:
|
||||
deleted_org = session.query(Org).filter(Org.id == org_id).first()
|
||||
assert deleted_org is None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for async LLM settings methods
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_llm_settings_async_with_llm_api_key():
|
||||
"""
|
||||
GIVEN: Organization with members and llm_api_key in update settings
|
||||
WHEN: update_org_llm_settings_async is called
|
||||
THEN: Org fields are updated and llm_api_key is propagated to all members
|
||||
"""
|
||||
from server.routes.org_models import OrgLLMSettingsUpdate
|
||||
|
||||
# Arrange
|
||||
org_id = uuid.uuid4()
|
||||
|
||||
mock_org = Org(
|
||||
id=org_id,
|
||||
name='Test Organization',
|
||||
default_llm_model='old-model',
|
||||
)
|
||||
|
||||
llm_settings = OrgLLMSettingsUpdate(
|
||||
default_llm_model='new-model',
|
||||
llm_api_key='new-member-api-key',
|
||||
)
|
||||
|
||||
# Mock the async session and member store
|
||||
mock_session = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.first.return_value = mock_org
|
||||
mock_session.execute.return_value = mock_result
|
||||
mock_session.commit = AsyncMock()
|
||||
mock_session.refresh = AsyncMock()
|
||||
|
||||
@asynccontextmanager
|
||||
async def mock_a_session_maker():
|
||||
yield mock_session
|
||||
|
||||
with (
|
||||
patch('storage.org_store.a_session_maker', mock_a_session_maker),
|
||||
patch(
|
||||
'storage.org_member_store.OrgMemberStore.update_all_members_llm_settings_async',
|
||||
AsyncMock(),
|
||||
) as mock_member_update,
|
||||
):
|
||||
# Act
|
||||
result = await OrgStore.update_org_llm_settings_async(org_id, llm_settings)
|
||||
|
||||
# Assert - Org is returned
|
||||
assert result is not None
|
||||
assert result.default_llm_model == 'new-model'
|
||||
|
||||
# Assert - Member update was called with correct settings
|
||||
mock_member_update.assert_called_once()
|
||||
call_args = mock_member_update.call_args
|
||||
member_settings = call_args[0][2] # Third positional arg is member_settings
|
||||
assert member_settings.llm_api_key == 'new-member-api-key'
|
||||
assert member_settings.llm_model == 'new-model'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_llm_settings_async_org_not_found():
|
||||
"""
|
||||
GIVEN: Non-existent organization ID
|
||||
WHEN: update_org_llm_settings_async is called
|
||||
THEN: Returns None
|
||||
"""
|
||||
from server.routes.org_models import OrgLLMSettingsUpdate
|
||||
|
||||
# Arrange
|
||||
non_existent_org_id = uuid.uuid4()
|
||||
llm_settings = OrgLLMSettingsUpdate(default_llm_model='new-model')
|
||||
|
||||
# Mock the async session to return None for org
|
||||
mock_session = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.first.return_value = None
|
||||
mock_session.execute.return_value = mock_result
|
||||
|
||||
@asynccontextmanager
|
||||
async def mock_a_session_maker():
|
||||
yield mock_session
|
||||
|
||||
# Act
|
||||
with patch('storage.org_store.a_session_maker', mock_a_session_maker):
|
||||
result = await OrgStore.update_org_llm_settings_async(
|
||||
non_existent_org_id, llm_settings
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
|
||||
@@ -5,7 +5,7 @@ the endpoint constructs a User from OIDC claims. These tests verify that name an
|
||||
fields are correctly populated from Keycloak claims in this fallback path.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
@@ -33,9 +33,20 @@ def mock_check_idp():
|
||||
yield mock_fn
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user_store():
|
||||
"""Mock UserStore.get_user_by_id_async to return None by default."""
|
||||
with patch(
|
||||
'server.routes.user.UserStore.get_user_by_id_async',
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
) as mock_fn:
|
||||
yield mock_fn
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_user_includes_name_from_name_claim(
|
||||
mock_token_manager, mock_check_idp
|
||||
mock_token_manager, mock_check_idp, mock_user_store
|
||||
):
|
||||
"""When Keycloak provides a 'name' claim, the fallback User should include it."""
|
||||
from server.routes.user import saas_get_user
|
||||
@@ -62,7 +73,7 @@ async def test_fallback_user_includes_name_from_name_claim(
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_user_combines_given_and_family_name(
|
||||
mock_token_manager, mock_check_idp
|
||||
mock_token_manager, mock_check_idp, mock_user_store
|
||||
):
|
||||
"""When 'name' is absent, combine given_name + family_name."""
|
||||
from server.routes.user import saas_get_user
|
||||
@@ -89,7 +100,7 @@ async def test_fallback_user_combines_given_and_family_name(
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_user_name_is_none_when_no_name_claims(
|
||||
mock_token_manager, mock_check_idp
|
||||
mock_token_manager, mock_check_idp, mock_user_store
|
||||
):
|
||||
"""When no name claims exist, name should be None."""
|
||||
from server.routes.user import saas_get_user
|
||||
@@ -113,7 +124,9 @@ async def test_fallback_user_name_is_none_when_no_name_claims(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_user_includes_company_claim(mock_token_manager, mock_check_idp):
|
||||
async def test_fallback_user_includes_company_claim(
|
||||
mock_token_manager, mock_check_idp, mock_user_store
|
||||
):
|
||||
"""When Keycloak provides a 'company' claim, include it in the User."""
|
||||
from server.routes.user import saas_get_user
|
||||
|
||||
@@ -139,7 +152,7 @@ async def test_fallback_user_includes_company_claim(mock_token_manager, mock_che
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_user_company_is_none_when_absent(
|
||||
mock_token_manager, mock_check_idp
|
||||
mock_token_manager, mock_check_idp, mock_user_store
|
||||
):
|
||||
"""When 'company' is not in Keycloak claims, company should be None."""
|
||||
from server.routes.user import saas_get_user
|
||||
@@ -161,3 +174,88 @@ async def test_fallback_user_company_is_none_when_absent(
|
||||
|
||||
assert isinstance(result, User)
|
||||
assert result.company is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_user_email_from_db_when_available(
|
||||
mock_token_manager, mock_check_idp, mock_user_store
|
||||
):
|
||||
"""When User.email is stored in DB, use it instead of Keycloak's live email."""
|
||||
from server.routes.user import saas_get_user
|
||||
|
||||
mock_token_manager.get_user_info = AsyncMock(
|
||||
return_value={
|
||||
'sub': '248289761001',
|
||||
'preferred_username': 'j.doe',
|
||||
'email': 'keycloak@example.com',
|
||||
}
|
||||
)
|
||||
|
||||
mock_db_user = MagicMock()
|
||||
mock_db_user.email = 'db@example.com'
|
||||
mock_user_store.return_value = mock_db_user
|
||||
|
||||
result = await saas_get_user(
|
||||
provider_tokens=None,
|
||||
access_token=SecretStr('test-token'),
|
||||
user_id='248289761001',
|
||||
)
|
||||
|
||||
assert isinstance(result, User)
|
||||
assert result.email == 'db@example.com'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_user_email_falls_back_to_keycloak_when_db_null(
|
||||
mock_token_manager, mock_check_idp, mock_user_store
|
||||
):
|
||||
"""When User.email is NULL in DB, fall back to Keycloak's email."""
|
||||
from server.routes.user import saas_get_user
|
||||
|
||||
mock_token_manager.get_user_info = AsyncMock(
|
||||
return_value={
|
||||
'sub': '248289761001',
|
||||
'preferred_username': 'j.doe',
|
||||
'email': 'keycloak@example.com',
|
||||
}
|
||||
)
|
||||
|
||||
mock_db_user = MagicMock()
|
||||
mock_db_user.email = None
|
||||
mock_user_store.return_value = mock_db_user
|
||||
|
||||
result = await saas_get_user(
|
||||
provider_tokens=None,
|
||||
access_token=SecretStr('test-token'),
|
||||
user_id='248289761001',
|
||||
)
|
||||
|
||||
assert isinstance(result, User)
|
||||
assert result.email == 'keycloak@example.com'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_user_email_falls_back_to_keycloak_when_no_db_user(
|
||||
mock_token_manager, mock_check_idp, mock_user_store
|
||||
):
|
||||
"""When DB user doesn't exist, fall back to Keycloak's email."""
|
||||
from server.routes.user import saas_get_user
|
||||
|
||||
mock_token_manager.get_user_info = AsyncMock(
|
||||
return_value={
|
||||
'sub': '248289761001',
|
||||
'preferred_username': 'j.doe',
|
||||
'email': 'keycloak@example.com',
|
||||
}
|
||||
)
|
||||
|
||||
# mock_user_store already returns None by default
|
||||
|
||||
result = await saas_get_user(
|
||||
provider_tokens=None,
|
||||
access_token=SecretStr('test-token'),
|
||||
user_id='248289761001',
|
||||
)
|
||||
|
||||
assert isinstance(result, User)
|
||||
assert result.email == 'keycloak@example.com'
|
||||
|
||||
@@ -639,6 +639,204 @@ async def test_backfill_contact_name_preserves_custom_value(session_maker):
|
||||
assert org.contact_name == 'Custom Corp Name'
|
||||
|
||||
|
||||
# --- Tests for backfill_user_email on login ---
|
||||
# Existing users created before the email capture fix may have NULL
|
||||
# email in the User table. The backfill sets User.email from the IDP
|
||||
# when the user next logs in, but preserves manual changes (non-NULL).
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backfill_user_email_sets_email_when_null(session_maker):
|
||||
"""When User.email is NULL, backfill_user_email should set it from user_info."""
|
||||
user_id = str(uuid.uuid4())
|
||||
with session_maker() as session:
|
||||
org = Org(
|
||||
id=uuid.UUID(user_id),
|
||||
name=f'user_{user_id}_org',
|
||||
contact_email='jdoe@example.com',
|
||||
)
|
||||
session.add(org)
|
||||
user = User(
|
||||
id=uuid.UUID(user_id),
|
||||
current_org_id=org.id,
|
||||
email=None,
|
||||
email_verified=None,
|
||||
)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
|
||||
user_info = {
|
||||
'email': 'jdoe@example.com',
|
||||
'email_verified': True,
|
||||
}
|
||||
|
||||
with patch(
|
||||
'storage.user_store.a_session_maker',
|
||||
_wrap_sync_as_async_session_maker(session_maker),
|
||||
):
|
||||
await UserStore.backfill_user_email(user_id, user_info)
|
||||
|
||||
with session_maker() as session:
|
||||
user = session.query(User).filter(User.id == uuid.UUID(user_id)).first()
|
||||
assert user.email == 'jdoe@example.com'
|
||||
assert user.email_verified is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backfill_user_email_does_not_overwrite_existing(session_maker):
|
||||
"""When User.email is already set, backfill_user_email should NOT overwrite it."""
|
||||
user_id = str(uuid.uuid4())
|
||||
with session_maker() as session:
|
||||
org = Org(
|
||||
id=uuid.UUID(user_id),
|
||||
name=f'user_{user_id}_org',
|
||||
contact_email='original@example.com',
|
||||
)
|
||||
session.add(org)
|
||||
user = User(
|
||||
id=uuid.UUID(user_id),
|
||||
current_org_id=org.id,
|
||||
email='custom@example.com',
|
||||
email_verified=True,
|
||||
)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
|
||||
user_info = {
|
||||
'email': 'different@example.com',
|
||||
'email_verified': False,
|
||||
}
|
||||
|
||||
with patch(
|
||||
'storage.user_store.a_session_maker',
|
||||
_wrap_sync_as_async_session_maker(session_maker),
|
||||
):
|
||||
await UserStore.backfill_user_email(user_id, user_info)
|
||||
|
||||
with session_maker() as session:
|
||||
user = session.query(User).filter(User.id == uuid.UUID(user_id)).first()
|
||||
assert user.email == 'custom@example.com'
|
||||
assert user.email_verified is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backfill_user_email_sets_verified_when_null(session_maker):
|
||||
"""When User.email is set but email_verified is NULL, backfill should set email_verified."""
|
||||
user_id = str(uuid.uuid4())
|
||||
with session_maker() as session:
|
||||
org = Org(
|
||||
id=uuid.UUID(user_id),
|
||||
name=f'user_{user_id}_org',
|
||||
contact_email='jdoe@example.com',
|
||||
)
|
||||
session.add(org)
|
||||
user = User(
|
||||
id=uuid.UUID(user_id),
|
||||
current_org_id=org.id,
|
||||
email='jdoe@example.com',
|
||||
email_verified=None,
|
||||
)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
|
||||
user_info = {
|
||||
'email': 'different@example.com',
|
||||
'email_verified': True,
|
||||
}
|
||||
|
||||
with patch(
|
||||
'storage.user_store.a_session_maker',
|
||||
_wrap_sync_as_async_session_maker(session_maker),
|
||||
):
|
||||
await UserStore.backfill_user_email(user_id, user_info)
|
||||
|
||||
with session_maker() as session:
|
||||
user = session.query(User).filter(User.id == uuid.UUID(user_id)).first()
|
||||
# email should NOT be overwritten since it's non-NULL
|
||||
assert user.email == 'jdoe@example.com'
|
||||
# email_verified should be set since it was NULL
|
||||
assert user.email_verified is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user_sets_email_verified_false_from_user_info():
|
||||
"""When user_info has email_verified=False, create_user() should set User.email_verified=False."""
|
||||
user_id = str(uuid.uuid4())
|
||||
user_info = {
|
||||
'preferred_username': 'jsmith',
|
||||
'email': 'jsmith@example.com',
|
||||
'email_verified': False,
|
||||
}
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_sm = MagicMock()
|
||||
mock_sm.return_value.__enter__ = MagicMock(return_value=mock_session)
|
||||
mock_sm.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
mock_settings = Settings(
|
||||
language='en',
|
||||
llm_api_key=SecretStr('test-key'),
|
||||
llm_base_url='http://test.url',
|
||||
)
|
||||
|
||||
mock_role = MagicMock()
|
||||
mock_role.id = 1
|
||||
|
||||
with (
|
||||
patch('storage.user_store.session_maker', mock_sm),
|
||||
patch.object(
|
||||
UserStore,
|
||||
'create_default_settings',
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_settings,
|
||||
),
|
||||
patch('storage.user_store.RoleStore.get_role_by_name', return_value=mock_role),
|
||||
patch(
|
||||
'storage.org_member_store.OrgMemberStore.get_kwargs_from_settings',
|
||||
return_value={'llm_model': None, 'llm_base_url': None},
|
||||
),
|
||||
):
|
||||
mock_session.commit.side_effect = _StopAfterUserCreation
|
||||
with pytest.raises(_StopAfterUserCreation):
|
||||
await UserStore.create_user(user_id, user_info)
|
||||
|
||||
user = mock_session.add.call_args_list[1][0][0]
|
||||
assert isinstance(user, User)
|
||||
assert user.email == 'jsmith@example.com'
|
||||
assert user.email_verified is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user_preserves_org_contact_email():
|
||||
"""create_user() must still set Org.contact_email (no regression)."""
|
||||
user_id = str(uuid.uuid4())
|
||||
user_info = {
|
||||
'preferred_username': 'jdoe',
|
||||
'email': 'jdoe@example.com',
|
||||
'email_verified': True,
|
||||
}
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_sm = MagicMock()
|
||||
mock_sm.return_value.__enter__ = MagicMock(return_value=mock_session)
|
||||
mock_sm.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with (
|
||||
patch('storage.user_store.session_maker', mock_sm),
|
||||
patch.object(
|
||||
UserStore,
|
||||
'create_default_settings',
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
await UserStore.create_user(user_id, user_info)
|
||||
|
||||
org = mock_session.add.call_args_list[0][0][0]
|
||||
assert isinstance(org, Org)
|
||||
assert org.contact_email == 'jdoe@example.com'
|
||||
|
||||
|
||||
def test_update_current_org_success(session_maker):
|
||||
"""
|
||||
GIVEN: User exists in database
|
||||
@@ -680,3 +878,100 @@ def test_update_current_org_user_not_found(session_maker):
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
|
||||
|
||||
# --- Tests for update_user_email ---
|
||||
# update_user_email() should unconditionally overwrite User.email and/or email_verified.
|
||||
# Unlike backfill_user_email(), it does not check for NULL before writing.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_email_overwrites_existing(session_maker):
|
||||
"""update_user_email() should overwrite existing email and email_verified values."""
|
||||
user_id = str(uuid.uuid4())
|
||||
with session_maker() as session:
|
||||
org = Org(
|
||||
id=uuid.UUID(user_id),
|
||||
name=f'user_{user_id}_org',
|
||||
contact_email='old@example.com',
|
||||
)
|
||||
session.add(org)
|
||||
user = User(
|
||||
id=uuid.UUID(user_id),
|
||||
current_org_id=org.id,
|
||||
email='old@example.com',
|
||||
email_verified=True,
|
||||
)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
|
||||
with patch(
|
||||
'storage.user_store.a_session_maker',
|
||||
_wrap_sync_as_async_session_maker(session_maker),
|
||||
):
|
||||
await UserStore.update_user_email(
|
||||
user_id, email='new@example.com', email_verified=False
|
||||
)
|
||||
|
||||
with session_maker() as session:
|
||||
user = session.query(User).filter(User.id == uuid.UUID(user_id)).first()
|
||||
assert user.email == 'new@example.com'
|
||||
assert user.email_verified is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_email_updates_only_email_verified(session_maker):
|
||||
"""update_user_email() with email=None should only update email_verified."""
|
||||
user_id = str(uuid.uuid4())
|
||||
with session_maker() as session:
|
||||
org = Org(
|
||||
id=uuid.UUID(user_id),
|
||||
name=f'user_{user_id}_org',
|
||||
contact_email='keep@example.com',
|
||||
)
|
||||
session.add(org)
|
||||
user = User(
|
||||
id=uuid.UUID(user_id),
|
||||
current_org_id=org.id,
|
||||
email='keep@example.com',
|
||||
email_verified=False,
|
||||
)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
|
||||
with patch(
|
||||
'storage.user_store.a_session_maker',
|
||||
_wrap_sync_as_async_session_maker(session_maker),
|
||||
):
|
||||
await UserStore.update_user_email(user_id, email_verified=True)
|
||||
|
||||
with session_maker() as session:
|
||||
user = session.query(User).filter(User.id == uuid.UUID(user_id)).first()
|
||||
assert user.email == 'keep@example.com'
|
||||
assert user.email_verified is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_email_noop_when_both_none():
|
||||
"""update_user_email() with both args None should not open a session."""
|
||||
user_id = str(uuid.uuid4())
|
||||
mock_session_maker = MagicMock()
|
||||
|
||||
with patch('storage.user_store.a_session_maker', mock_session_maker):
|
||||
await UserStore.update_user_email(user_id, email=None, email_verified=None)
|
||||
|
||||
mock_session_maker.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_email_missing_user_returns_without_error(session_maker):
|
||||
"""update_user_email() with a non-existent user_id should return without error."""
|
||||
user_id = str(uuid.uuid4())
|
||||
|
||||
with patch(
|
||||
'storage.user_store.a_session_maker',
|
||||
_wrap_sync_as_async_session_maker(session_maker),
|
||||
):
|
||||
await UserStore.update_user_email(
|
||||
user_id, email='new@example.com', email_verified=False
|
||||
)
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Unit tests for VerifiedModelService."""
|
||||
|
||||
import pytest
|
||||
from server.verified_models.verified_model_service import (
|
||||
VerifiedModelService,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from storage.base import Base
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_engine():
|
||||
"""Create an async SQLite engine for testing."""
|
||||
engine = create_async_engine(
|
||||
'sqlite+aiosqlite:///:memory:',
|
||||
poolclass=StaticPool,
|
||||
connect_args={'check_same_thread': False},
|
||||
echo=False,
|
||||
)
|
||||
|
||||
# Create all tables
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
yield engine
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_session_maker(async_engine):
|
||||
"""Create an async session maker for testing."""
|
||||
return async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def _seed_models(async_session_maker):
|
||||
"""Seed the database with test models."""
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
await service.create_verified_model(
|
||||
model_name='claude-sonnet', provider='openhands'
|
||||
)
|
||||
await service.create_verified_model(
|
||||
model_name='claude-sonnet', provider='anthropic'
|
||||
)
|
||||
await service.create_verified_model(
|
||||
model_name='gpt-4o', provider='openhands', is_enabled=False
|
||||
)
|
||||
|
||||
|
||||
class TestCreateVerifiedModel:
|
||||
async def test_create_model(self, async_session_maker):
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
model = await service.create_verified_model(
|
||||
model_name='test-model', provider='test-provider'
|
||||
)
|
||||
assert model.model_name == 'test-model'
|
||||
assert model.provider == 'test-provider'
|
||||
assert model.is_enabled is True
|
||||
assert model.id is not None
|
||||
|
||||
async def test_create_duplicate_raises(self, async_session_maker):
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
await service.create_verified_model(
|
||||
model_name='test-model', provider='test'
|
||||
)
|
||||
with pytest.raises(ValueError, match='test/test-model already exists'):
|
||||
await service.create_verified_model(
|
||||
model_name='test-model', provider='test'
|
||||
)
|
||||
|
||||
async def test_same_name_different_provider_allowed(self, async_session_maker):
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
await service.create_verified_model(
|
||||
model_name='claude', provider='openhands'
|
||||
)
|
||||
model = await service.create_verified_model(
|
||||
model_name='claude', provider='anthropic'
|
||||
)
|
||||
assert model.provider == 'anthropic'
|
||||
|
||||
|
||||
class TestGetModel:
|
||||
async def test_get_model(self, _seed_models, async_session_maker):
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
model = await service.get_model('claude-sonnet', 'openhands')
|
||||
assert model is not None
|
||||
assert model.provider == 'openhands'
|
||||
|
||||
async def test_get_model_not_found(self, _seed_models, async_session_maker):
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
assert await service.get_model('nonexistent', 'openhands') is None
|
||||
|
||||
async def test_get_model_wrong_provider(self, _seed_models, async_session_maker):
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
assert await service.get_model('claude-sonnet', 'openai') is None
|
||||
|
||||
|
||||
class TestSearchVerifiedModels:
|
||||
async def test_search_models_no_filters(self, _seed_models, async_session_maker):
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
result = await service.search_verified_models()
|
||||
assert len(result.items) == 2 # Only enabled models
|
||||
assert result.next_page_id is None
|
||||
|
||||
async def test_search_models_enabled_only_true(
|
||||
self, _seed_models, async_session_maker
|
||||
):
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
result = await service.search_verified_models(enabled_only=True)
|
||||
assert len(result.items) == 2
|
||||
names = {m.model_name for m in result.items}
|
||||
assert 'gpt-4o' not in names # Disabled model not included
|
||||
|
||||
async def test_search_models_enabled_only_false(
|
||||
self, _seed_models, async_session_maker
|
||||
):
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
result = await service.search_verified_models(enabled_only=False)
|
||||
assert len(result.items) == 3 # All models including disabled
|
||||
|
||||
async def test_search_models_by_provider(self, _seed_models, async_session_maker):
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
result = await service.search_verified_models(provider='openhands')
|
||||
assert len(result.items) == 1
|
||||
assert result.items[0].model_name == 'claude-sonnet'
|
||||
|
||||
async def test_search_models_pagination(self, _seed_models, async_session_maker):
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
# Create more models for pagination testing
|
||||
await service.create_verified_model(model_name='model-1', provider='test')
|
||||
await service.create_verified_model(model_name='model-2', provider='test')
|
||||
await service.create_verified_model(model_name='model-3', provider='test')
|
||||
await service.create_verified_model(model_name='model-4', provider='test')
|
||||
|
||||
# Total: 7 models (3 initial + 4 new)
|
||||
# First page
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
result = await service.search_verified_models(
|
||||
enabled_only=False, page_id='0', limit=3
|
||||
)
|
||||
assert len(result.items) == 3
|
||||
assert result.next_page_id == '3' # 4 more items after position 2
|
||||
|
||||
# Second page (page_id 3)
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
result = await service.search_verified_models(
|
||||
enabled_only=False, page_id='3', limit=3
|
||||
)
|
||||
assert len(result.items) == 3
|
||||
# There are 4 items total starting at offset 3 (positions 3,4,5,6), so next_page_id exists
|
||||
assert result.next_page_id == '6'
|
||||
|
||||
# Third page (page_id 6) - last item
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
result = await service.search_verified_models(
|
||||
enabled_only=False, page_id='6', limit=3
|
||||
)
|
||||
assert len(result.items) == 1
|
||||
assert result.next_page_id is None # No more items after position 6
|
||||
|
||||
|
||||
class TestUpdateVerifiedModel:
|
||||
async def test_update_model(self, _seed_models, async_session_maker):
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
updated = await service.update_verified_model(
|
||||
model_name='claude-sonnet', provider='openhands', is_enabled=False
|
||||
)
|
||||
assert updated is not None
|
||||
assert updated.is_enabled is False
|
||||
|
||||
async def test_update_not_found(self, _seed_models, async_session_maker):
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
assert (
|
||||
await service.update_verified_model(
|
||||
model_name='nonexistent', provider='openhands', is_enabled=False
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
async def test_update_no_change(self, _seed_models, async_session_maker):
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
updated = await service.update_verified_model(
|
||||
model_name='claude-sonnet', provider='openhands'
|
||||
)
|
||||
assert updated is not None
|
||||
assert updated.is_enabled is True
|
||||
|
||||
|
||||
class TestDeleteVerifiedModel:
|
||||
async def test_delete_model(self, _seed_models, async_session_maker):
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
await service.delete_verified_model('claude-sonnet', 'openhands')
|
||||
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
assert await service.get_model('claude-sonnet', 'openhands') is None
|
||||
# Other provider's version should still exist
|
||||
assert await service.get_model('claude-sonnet', 'anthropic') is not None
|
||||
|
||||
async def test_delete_not_found(self, _seed_models, async_session_maker):
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
with pytest.raises(ValueError):
|
||||
assert await service.delete_verified_model('nonexistent', 'openhands')
|
||||
@@ -1,18 +1,175 @@
|
||||
import { test, expect, vi } from "vitest";
|
||||
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||
import axios from "axios";
|
||||
import V1GitService from "../../src/api/git-service/v1-git-service.api";
|
||||
|
||||
vi.mock("axios");
|
||||
|
||||
test("getGitChanges throws when response is not an array (dead runtime returns HTML)", async () => {
|
||||
const htmlResponse = "<!DOCTYPE html><html>...</html>";
|
||||
vi.mocked(axios.get).mockResolvedValue({ data: htmlResponse });
|
||||
describe("V1GitService", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
await expect(
|
||||
V1GitService.getGitChanges(
|
||||
"http://localhost:3000/api/conversations/123",
|
||||
"test-api-key",
|
||||
"/workspace",
|
||||
),
|
||||
).rejects.toThrow("Invalid response from runtime");
|
||||
describe("getGitChanges", () => {
|
||||
test("throws when response is not an array (dead runtime returns HTML)", async () => {
|
||||
const htmlResponse = "<!DOCTYPE html><html>...</html>";
|
||||
vi.mocked(axios.get).mockResolvedValue({ data: htmlResponse });
|
||||
|
||||
await expect(
|
||||
V1GitService.getGitChanges(
|
||||
"http://localhost:3000/api/conversations/123",
|
||||
"test-api-key",
|
||||
"/workspace",
|
||||
),
|
||||
).rejects.toThrow("Invalid response from runtime");
|
||||
});
|
||||
|
||||
test("uses query parameters instead of path segments for the path", async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue({ data: [] });
|
||||
|
||||
await V1GitService.getGitChanges(
|
||||
"http://localhost:3000/api/conversations/123",
|
||||
"test-api-key",
|
||||
"/workspace/project",
|
||||
);
|
||||
|
||||
expect(axios.get).toHaveBeenCalledTimes(1);
|
||||
const [url, config] = vi.mocked(axios.get).mock.calls[0];
|
||||
|
||||
// URL should NOT contain the path - it should end with /api/git/changes
|
||||
expect(url).toContain("/api/git/changes");
|
||||
expect(url).not.toContain("/workspace/project");
|
||||
expect(url).not.toContain(encodeURIComponent("/workspace/project"));
|
||||
|
||||
// Path should be passed as a query parameter
|
||||
expect(config).toHaveProperty("params");
|
||||
expect(config?.params).toEqual({ path: "/workspace/project" });
|
||||
});
|
||||
|
||||
test("preserves slashes in path when using query parameters", async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue({ data: [] });
|
||||
|
||||
const pathWithSlashes = "/workspace/project/src/components";
|
||||
await V1GitService.getGitChanges(
|
||||
"http://localhost:3000/api/conversations/123",
|
||||
"test-api-key",
|
||||
pathWithSlashes,
|
||||
);
|
||||
|
||||
const [, config] = vi.mocked(axios.get).mock.calls[0];
|
||||
|
||||
// Path should be preserved exactly as provided (slashes intact)
|
||||
expect(config?.params).toEqual({ path: pathWithSlashes });
|
||||
});
|
||||
|
||||
test("includes session API key in headers when provided", async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue({ data: [] });
|
||||
|
||||
await V1GitService.getGitChanges(
|
||||
"http://localhost:3000/api/conversations/123",
|
||||
"my-session-key",
|
||||
"/workspace",
|
||||
);
|
||||
|
||||
const [, config] = vi.mocked(axios.get).mock.calls[0];
|
||||
expect(config?.headers).toEqual({ "X-Session-API-Key": "my-session-key" });
|
||||
});
|
||||
|
||||
test("maps V1 git statuses to V0 format", async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue({
|
||||
data: [
|
||||
{ status: "ADDED", path: "new-file.ts" },
|
||||
{ status: "DELETED", path: "removed-file.ts" },
|
||||
{ status: "UPDATED", path: "changed-file.ts" },
|
||||
{ status: "MOVED", path: "renamed-file.ts" },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await V1GitService.getGitChanges(
|
||||
"http://localhost:3000/api/conversations/123",
|
||||
"test-api-key",
|
||||
"/workspace",
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ status: "A", path: "new-file.ts" },
|
||||
{ status: "D", path: "removed-file.ts" },
|
||||
{ status: "M", path: "changed-file.ts" },
|
||||
{ status: "R", path: "renamed-file.ts" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getGitChangeDiff", () => {
|
||||
test("uses query parameters instead of path segments for the path", async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue({
|
||||
data: { diff: "--- a/file.ts\n+++ b/file.ts\n..." },
|
||||
});
|
||||
|
||||
await V1GitService.getGitChangeDiff(
|
||||
"http://localhost:3000/api/conversations/123",
|
||||
"test-api-key",
|
||||
"/workspace/project/file.ts",
|
||||
);
|
||||
|
||||
expect(axios.get).toHaveBeenCalledTimes(1);
|
||||
const [url, config] = vi.mocked(axios.get).mock.calls[0];
|
||||
|
||||
// URL should NOT contain the path - it should end with /api/git/diff
|
||||
expect(url).toContain("/api/git/diff");
|
||||
expect(url).not.toContain("/workspace/project/file.ts");
|
||||
expect(url).not.toContain(encodeURIComponent("/workspace/project/file.ts"));
|
||||
|
||||
// Path should be passed as a query parameter
|
||||
expect(config).toHaveProperty("params");
|
||||
expect(config?.params).toEqual({ path: "/workspace/project/file.ts" });
|
||||
});
|
||||
|
||||
test("preserves slashes in file path when using query parameters", async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue({
|
||||
data: { diff: "diff content" },
|
||||
});
|
||||
|
||||
const filePath = "/workspace/project/src/components/Button.tsx";
|
||||
await V1GitService.getGitChangeDiff(
|
||||
"http://localhost:3000/api/conversations/123",
|
||||
"test-api-key",
|
||||
filePath,
|
||||
);
|
||||
|
||||
const [, config] = vi.mocked(axios.get).mock.calls[0];
|
||||
|
||||
// Path should be preserved exactly as provided (slashes intact)
|
||||
expect(config?.params).toEqual({ path: filePath });
|
||||
});
|
||||
|
||||
test("includes session API key in headers when provided", async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue({
|
||||
data: { diff: "diff content" },
|
||||
});
|
||||
|
||||
await V1GitService.getGitChangeDiff(
|
||||
"http://localhost:3000/api/conversations/123",
|
||||
"my-session-key",
|
||||
"/workspace/file.ts",
|
||||
);
|
||||
|
||||
const [, config] = vi.mocked(axios.get).mock.calls[0];
|
||||
expect(config?.headers).toEqual({ "X-Session-API-Key": "my-session-key" });
|
||||
});
|
||||
|
||||
test("returns the diff data from the response", async () => {
|
||||
const expectedDiff = {
|
||||
diff: "--- a/file.ts\n+++ b/file.ts\n@@ -1,3 +1,4 @@\n+new line",
|
||||
};
|
||||
vi.mocked(axios.get).mockResolvedValue({ data: expectedDiff });
|
||||
|
||||
const result = await V1GitService.getGitChangeDiff(
|
||||
"http://localhost:3000/api/conversations/123",
|
||||
"test-api-key",
|
||||
"/workspace/file.ts",
|
||||
);
|
||||
|
||||
expect(result).toEqual(expectedDiff);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, test, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest";
|
||||
import { AccountSettingsContextMenu } from "#/components/features/context-menu/account-settings-context-menu";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { renderWithProviders } from "../../../test-utils";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createMockWebClientConfig } from "../../helpers/mock-config";
|
||||
|
||||
const mockTrackAddTeamMembersButtonClick = vi.fn();
|
||||
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackAddTeamMembersButtonClick: mockTrackAddTeamMembersButtonClick,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock posthog feature flag
|
||||
vi.mock("posthog-js/react", () => ({
|
||||
useFeatureFlagEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import the mocked module to get access to the mock
|
||||
import * as posthog from "posthog-js/react";
|
||||
|
||||
describe("AccountSettingsContextMenu", () => {
|
||||
const user = userEvent.setup();
|
||||
@@ -11,15 +29,45 @@ describe("AccountSettingsContextMenu", () => {
|
||||
const onLogoutMock = vi.fn();
|
||||
const onCloseMock = vi.fn();
|
||||
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
// Set default feature flag to false
|
||||
vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(false);
|
||||
});
|
||||
|
||||
// Create a wrapper with MemoryRouter and renderWithProviders
|
||||
const renderWithRouter = (ui: React.ReactElement) => {
|
||||
return renderWithProviders(<MemoryRouter>{ui}</MemoryRouter>);
|
||||
};
|
||||
|
||||
const renderWithSaasConfig = (ui: React.ReactElement) => {
|
||||
queryClient.setQueryData(["web-client-config"], createMockWebClientConfig({ app_mode: "saas" }));
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const renderWithOssConfig = (ui: React.ReactElement) => {
|
||||
queryClient.setQueryData(["web-client-config"], createMockWebClientConfig({ app_mode: "oss" }));
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
onClickAccountSettingsMock.mockClear();
|
||||
onLogoutMock.mockClear();
|
||||
onCloseMock.mockClear();
|
||||
mockTrackAddTeamMembersButtonClick.mockClear();
|
||||
vi.mocked(posthog.useFeatureFlagEnabled).mockClear();
|
||||
});
|
||||
|
||||
it("should always render the right options", () => {
|
||||
@@ -93,4 +141,59 @@ describe("AccountSettingsContextMenu", () => {
|
||||
|
||||
expect(onCloseMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should show Add Team Members button in SaaS mode when feature flag is enabled", () => {
|
||||
vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(true);
|
||||
renderWithSaasConfig(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("add-team-members-button")).toBeInTheDocument();
|
||||
expect(screen.getByText("SETTINGS$NAV_ADD_TEAM_MEMBERS")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show Add Team Members button in SaaS mode when feature flag is disabled", () => {
|
||||
vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(false);
|
||||
renderWithSaasConfig(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("add-team-members-button")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("SETTINGS$NAV_ADD_TEAM_MEMBERS")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show Add Team Members button in OSS mode even when feature flag is enabled", () => {
|
||||
vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(true);
|
||||
renderWithOssConfig(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("add-team-members-button")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("SETTINGS$NAV_ADD_TEAM_MEMBERS")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call tracking function and onClose when Add Team Members button is clicked", async () => {
|
||||
vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(true);
|
||||
renderWithSaasConfig(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const addTeamMembersButton = screen.getByTestId("add-team-members-button");
|
||||
await user.click(addTeamMembersButton);
|
||||
|
||||
expect(mockTrackAddTeamMembersButtonClick).toHaveBeenCalledOnce();
|
||||
expect(onCloseMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -235,6 +235,38 @@ describe("LoginContent", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display Bitbucket signup disabled message when Bitbucket is configured", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginContent
|
||||
githubAuthUrl="https://github.com/oauth/authorize"
|
||||
appMode="saas"
|
||||
providersConfigured={["github", "bitbucket"]}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("AUTH$BITBUCKET_SIGNUP_DISABLED"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not display Bitbucket signup disabled message when Bitbucket is not configured", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginContent
|
||||
githubAuthUrl="https://github.com/oauth/authorize"
|
||||
appMode="saas"
|
||||
providersConfigured={["github"]}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByText("AUTH$BITBUCKET_SIGNUP_DISABLED"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call buildOAuthStateData when clicking auth button", async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockBuildOAuthStateData = vi.fn((baseState) => ({
|
||||
|
||||
@@ -5,11 +5,6 @@ import { ChangeAgentButton } from "#/components/features/chat/change-agent-butto
|
||||
import { renderWithProviders } from "../../../../test-utils";
|
||||
import { useConversationStore } from "#/stores/conversation-store";
|
||||
|
||||
// Mock feature flag to enable planning agent
|
||||
vi.mock("#/utils/feature-flags", () => ({
|
||||
USE_PLANNING_AGENT: () => true,
|
||||
}));
|
||||
|
||||
// Mock WebSocket status
|
||||
vi.mock("#/hooks/use-unified-websocket-status", () => ({
|
||||
useUnifiedWebSocketStatus: () => "CONNECTED",
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { GitControlBarRepoButton } from "#/components/features/chat/git-control-bar-repo-button";
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock GitProviderIcon
|
||||
vi.mock("#/components/shared/git-provider-icon", () => ({
|
||||
GitProviderIcon: ({ gitProvider }: { gitProvider: string }) => (
|
||||
<span data-testid="git-provider-icon">{gitProvider}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock GitExternalLinkIcon
|
||||
vi.mock(
|
||||
"#/components/features/chat/git-external-link-icon",
|
||||
() => ({
|
||||
GitExternalLinkIcon: () => (
|
||||
<span data-testid="git-external-link-icon">external</span>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
// Mock RepoForkedIcon
|
||||
vi.mock("#/icons/repo-forked.svg?react", () => ({
|
||||
default: () => <span data-testid="repo-forked-icon">forked</span>,
|
||||
}));
|
||||
|
||||
// Mock constructRepositoryUrl
|
||||
vi.mock("#/utils/utils", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("#/utils/utils")>();
|
||||
return {
|
||||
...actual,
|
||||
constructRepositoryUrl: (provider: string, repo: string) =>
|
||||
`https://${provider}.com/${repo}`,
|
||||
};
|
||||
});
|
||||
|
||||
describe("GitControlBarRepoButton", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("when repository is connected", () => {
|
||||
it("should render as a link with repository name", () => {
|
||||
render(
|
||||
<GitControlBarRepoButton
|
||||
selectedRepository="owner/repo"
|
||||
gitProvider="github"
|
||||
/>,
|
||||
);
|
||||
|
||||
const link = screen.getByRole("link");
|
||||
expect(link).toHaveAttribute("href", "https://github.com/owner/repo");
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(screen.getByText("owner/repo")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show git provider icon and external link icon", () => {
|
||||
render(
|
||||
<GitControlBarRepoButton
|
||||
selectedRepository="owner/repo"
|
||||
gitProvider="github"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("git-provider-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("git-external-link-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show repo forked icon", () => {
|
||||
render(
|
||||
<GitControlBarRepoButton
|
||||
selectedRepository="owner/repo"
|
||||
gitProvider="github"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("repo-forked-icon"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when no repository is connected", () => {
|
||||
it("should render as a button with 'No Repo Connected' text", () => {
|
||||
render(
|
||||
<GitControlBarRepoButton
|
||||
selectedRepository={null}
|
||||
gitProvider={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("COMMON$NO_REPO_CONNECTED"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show repo forked icon instead of provider icon", () => {
|
||||
render(
|
||||
<GitControlBarRepoButton
|
||||
selectedRepository={null}
|
||||
gitProvider={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("repo-forked-icon")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("git-provider-icon"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show external link icon", () => {
|
||||
render(
|
||||
<GitControlBarRepoButton
|
||||
selectedRepository={null}
|
||||
gitProvider={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("git-external-link-icon"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onClick when clicked", async () => {
|
||||
const handleClick = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<GitControlBarRepoButton
|
||||
selectedRepository={null}
|
||||
gitProvider={null}
|
||||
onClick={handleClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button"));
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should be disabled when disabled prop is true", () => {
|
||||
render(
|
||||
<GitControlBarRepoButton
|
||||
selectedRepository={null}
|
||||
gitProvider={null}
|
||||
disabled={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).toHaveClass("cursor-not-allowed");
|
||||
});
|
||||
|
||||
it("should be clickable when disabled prop is false", () => {
|
||||
render(
|
||||
<GitControlBarRepoButton
|
||||
selectedRepository={null}
|
||||
gitProvider={null}
|
||||
disabled={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
expect(button).not.toBeDisabled();
|
||||
expect(button).toHaveClass("cursor-pointer");
|
||||
});
|
||||
|
||||
it("should not call onClick when disabled", async () => {
|
||||
const handleClick = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<GitControlBarRepoButton
|
||||
selectedRepository={null}
|
||||
gitProvider={null}
|
||||
onClick={handleClick}
|
||||
disabled={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button"));
|
||||
expect(handleClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
describe("GitControlBar clone prompt format", () => {
|
||||
// Helper function that mirrors the logic in git-control-bar.tsx
|
||||
const generateClonePrompt = (
|
||||
fullName: string,
|
||||
gitProvider: string,
|
||||
branchName: string,
|
||||
) => {
|
||||
const providerName =
|
||||
gitProvider.charAt(0).toUpperCase() + gitProvider.slice(1);
|
||||
return `Clone ${fullName} from ${providerName} and checkout branch ${branchName}.`;
|
||||
};
|
||||
|
||||
it("should include GitHub in clone prompt for github provider", () => {
|
||||
const prompt = generateClonePrompt("user/repo", "github", "main");
|
||||
expect(prompt).toBe("Clone user/repo from Github and checkout branch main.");
|
||||
});
|
||||
|
||||
it("should include GitLab in clone prompt for gitlab provider", () => {
|
||||
const prompt = generateClonePrompt("group/project", "gitlab", "develop");
|
||||
expect(prompt).toBe(
|
||||
"Clone group/project from Gitlab and checkout branch develop.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle different branch names", () => {
|
||||
const prompt = generateClonePrompt(
|
||||
"hieptl.developer-group/hieptl.developer-project",
|
||||
"gitlab",
|
||||
"add-batman-microagent",
|
||||
);
|
||||
expect(prompt).toBe(
|
||||
"Clone hieptl.developer-group/hieptl.developer-project from Gitlab and checkout branch add-batman-microagent.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should capitalize first letter of provider name", () => {
|
||||
const githubPrompt = generateClonePrompt("a/b", "github", "main");
|
||||
const gitlabPrompt = generateClonePrompt("a/b", "gitlab", "main");
|
||||
|
||||
expect(githubPrompt).toContain("from Github");
|
||||
expect(gitlabPrompt).toContain("from Gitlab");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,381 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { OpenRepositoryModal } from "#/components/features/chat/open-repository-modal";
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock useUserProviders - default to single provider (no dropdown shown)
|
||||
const mockProviders = vi.hoisted(() => ({
|
||||
current: ["github"] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-user-providers", () => ({
|
||||
useUserProviders: () => ({
|
||||
providers: mockProviders.current,
|
||||
isLoadingSettings: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock GitProviderDropdown
|
||||
vi.mock(
|
||||
"#/components/features/home/git-provider-dropdown/git-provider-dropdown",
|
||||
() => ({
|
||||
GitProviderDropdown: ({
|
||||
providers,
|
||||
onChange,
|
||||
}: {
|
||||
providers: string[];
|
||||
onChange: (provider: string | null) => void;
|
||||
}) => (
|
||||
<div data-testid="git-provider-dropdown">
|
||||
{providers.map((p: string) => (
|
||||
<button
|
||||
key={p}
|
||||
data-testid={`provider-${p}`}
|
||||
onClick={() => onChange(p)}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
// Mock GitRepoDropdown
|
||||
vi.mock(
|
||||
"#/components/features/home/git-repo-dropdown/git-repo-dropdown",
|
||||
() => ({
|
||||
GitRepoDropdown: ({
|
||||
onChange,
|
||||
}: {
|
||||
onChange: (repo?: {
|
||||
id: number;
|
||||
full_name: string;
|
||||
git_provider: string;
|
||||
main_branch: string;
|
||||
}) => void;
|
||||
}) => (
|
||||
<button
|
||||
data-testid="git-repo-dropdown"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
id: 1,
|
||||
full_name: "owner/repo",
|
||||
git_provider: "github",
|
||||
main_branch: "main",
|
||||
})
|
||||
}
|
||||
>
|
||||
Mock Repo Dropdown
|
||||
</button>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
// Mock GitBranchDropdown
|
||||
vi.mock(
|
||||
"#/components/features/home/git-branch-dropdown/git-branch-dropdown",
|
||||
() => ({
|
||||
GitBranchDropdown: ({
|
||||
onBranchSelect,
|
||||
disabled,
|
||||
}: {
|
||||
onBranchSelect: (branch: { name: string } | null) => void;
|
||||
disabled: boolean;
|
||||
}) => (
|
||||
<button
|
||||
data-testid="git-branch-dropdown"
|
||||
disabled={disabled}
|
||||
onClick={() => onBranchSelect({ name: "main" })}
|
||||
>
|
||||
Mock Branch Dropdown
|
||||
</button>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
// Mock RepoForkedIcon
|
||||
vi.mock("#/icons/repo-forked.svg?react", () => ({
|
||||
default: () => <div data-testid="repo-forked-icon" />,
|
||||
}));
|
||||
|
||||
describe("OpenRepositoryModal", () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const mockOnLaunch = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockProviders.current = ["github"];
|
||||
});
|
||||
|
||||
it("should not render when isOpen is false", () => {
|
||||
render(
|
||||
<OpenRepositoryModal
|
||||
isOpen={false}
|
||||
onClose={mockOnClose}
|
||||
onLaunch={mockOnLaunch}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByText("CONVERSATION$OPEN_REPOSITORY"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render modal with title and description when open", () => {
|
||||
render(
|
||||
<OpenRepositoryModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onLaunch={mockOnLaunch}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("CONVERSATION$OPEN_REPOSITORY"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("CONVERSATION$SELECT_OR_INSERT_LINK"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("repo-forked-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render Launch and Cancel buttons", () => {
|
||||
render(
|
||||
<OpenRepositoryModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onLaunch={mockOnLaunch}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("BUTTON$LAUNCH")).toBeInTheDocument();
|
||||
expect(screen.getByText("BUTTON$CANCEL")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should disable Launch button when no repository or branch is selected", () => {
|
||||
render(
|
||||
<OpenRepositoryModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onLaunch={mockOnLaunch}
|
||||
/>,
|
||||
);
|
||||
|
||||
const launchButton = screen.getByText("BUTTON$LAUNCH").closest("button");
|
||||
expect(launchButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should call onClose and reset state when Cancel is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<OpenRepositoryModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onLaunch={mockOnLaunch}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText("BUTTON$CANCEL"));
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should enable Launch button after selecting repository and branch", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<OpenRepositoryModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onLaunch={mockOnLaunch}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Select a repository
|
||||
await user.click(screen.getByTestId("git-repo-dropdown"));
|
||||
|
||||
// Select a branch
|
||||
await user.click(screen.getByTestId("git-branch-dropdown"));
|
||||
|
||||
const launchButton = screen.getByText("BUTTON$LAUNCH").closest("button");
|
||||
expect(launchButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should call onLaunch with selected repository and branch, then close", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<OpenRepositoryModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onLaunch={mockOnLaunch}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Select repository and branch
|
||||
await user.click(screen.getByTestId("git-repo-dropdown"));
|
||||
await user.click(screen.getByTestId("git-branch-dropdown"));
|
||||
|
||||
// Click Launch
|
||||
await user.click(screen.getByText("BUTTON$LAUNCH"));
|
||||
|
||||
expect(mockOnLaunch).toHaveBeenCalledWith(
|
||||
{
|
||||
id: 1,
|
||||
full_name: "owner/repo",
|
||||
git_provider: "github",
|
||||
main_branch: "main",
|
||||
},
|
||||
{ name: "main" },
|
||||
);
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should not call onLaunch when Launch is clicked without selections", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<OpenRepositoryModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onLaunch={mockOnLaunch}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Force click the launch button even though it's disabled
|
||||
const launchButton = screen.getByText("BUTTON$LAUNCH").closest("button")!;
|
||||
await user.click(launchButton);
|
||||
|
||||
expect(mockOnLaunch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should reset branch selection when repository changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<OpenRepositoryModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onLaunch={mockOnLaunch}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Select repository and branch
|
||||
await user.click(screen.getByTestId("git-repo-dropdown"));
|
||||
await user.click(screen.getByTestId("git-branch-dropdown"));
|
||||
|
||||
// Launch button should be enabled
|
||||
let launchButton = screen.getByText("BUTTON$LAUNCH").closest("button");
|
||||
expect(launchButton).not.toBeDisabled();
|
||||
|
||||
// Select a new repository (resets branch)
|
||||
await user.click(screen.getByTestId("git-repo-dropdown"));
|
||||
|
||||
// Launch button should be disabled again (branch was reset)
|
||||
launchButton = screen.getByText("BUTTON$LAUNCH").closest("button");
|
||||
expect(launchButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should use small modal width", () => {
|
||||
render(
|
||||
<OpenRepositoryModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onLaunch={mockOnLaunch}
|
||||
/>,
|
||||
);
|
||||
|
||||
// ModalBody with width="small" renders w-[384px]
|
||||
const modalBody = screen
|
||||
.getByText("CONVERSATION$OPEN_REPOSITORY")
|
||||
.closest(".bg-base-secondary");
|
||||
expect(modalBody).toHaveClass("w-[384px]");
|
||||
});
|
||||
|
||||
it("should override default gap with !gap-4 for tighter spacing", () => {
|
||||
render(
|
||||
<OpenRepositoryModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onLaunch={mockOnLaunch}
|
||||
/>,
|
||||
);
|
||||
|
||||
const modalBody = screen
|
||||
.getByText("CONVERSATION$OPEN_REPOSITORY")
|
||||
.closest(".bg-base-secondary");
|
||||
expect(modalBody).toHaveClass("!gap-4");
|
||||
});
|
||||
|
||||
describe("provider switching", () => {
|
||||
it("should not show provider dropdown when only one provider exists", () => {
|
||||
mockProviders.current = ["github"];
|
||||
|
||||
render(
|
||||
<OpenRepositoryModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onLaunch={mockOnLaunch}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("git-provider-dropdown"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show provider dropdown when multiple providers exist", () => {
|
||||
mockProviders.current = ["github", "gitlab"];
|
||||
|
||||
render(
|
||||
<OpenRepositoryModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onLaunch={mockOnLaunch}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId("git-provider-dropdown"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("provider-github")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("provider-gitlab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should reset repository and branch when provider changes", async () => {
|
||||
mockProviders.current = ["github", "gitlab"];
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<OpenRepositoryModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onLaunch={mockOnLaunch}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Select repo and branch
|
||||
await user.click(screen.getByTestId("git-repo-dropdown"));
|
||||
await user.click(screen.getByTestId("git-branch-dropdown"));
|
||||
|
||||
// Launch should be enabled
|
||||
let launchButton = screen.getByText("BUTTON$LAUNCH").closest("button");
|
||||
expect(launchButton).not.toBeDisabled();
|
||||
|
||||
// Switch provider — should reset selections
|
||||
await user.click(screen.getByTestId("provider-gitlab"));
|
||||
|
||||
// Launch should be disabled (repo and branch reset)
|
||||
launchButton = screen.getByText("BUTTON$LAUNCH").closest("button");
|
||||
expect(launchButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -27,11 +27,6 @@ function renderPlanPreview(ui: React.ReactElement) {
|
||||
);
|
||||
}
|
||||
|
||||
// Mock the feature flag to always return true (not testing feature flag behavior)
|
||||
vi.mock("#/utils/feature-flags", () => ({
|
||||
USE_PLANNING_AGENT: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
// Mock i18n - need to preserve initReactI18next and I18nextProvider for test-utils
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeAll } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import {
|
||||
SlashCommandMenu,
|
||||
getSkillDescription,
|
||||
stripMarkdown,
|
||||
} from "#/components/features/chat/components/slash-command-menu";
|
||||
import { SlashCommandItem } from "#/hooks/chat/use-slash-command";
|
||||
|
||||
// jsdom does not implement scrollIntoView
|
||||
beforeAll(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
});
|
||||
|
||||
const makeItem = (
|
||||
name: string,
|
||||
command: string,
|
||||
content: string = "",
|
||||
): SlashCommandItem => ({
|
||||
skill: {
|
||||
name,
|
||||
type: "agentskills" as const,
|
||||
content,
|
||||
triggers: [command],
|
||||
},
|
||||
command,
|
||||
});
|
||||
|
||||
const defaultItems: SlashCommandItem[] = [
|
||||
makeItem("code-search", "/code-search", "Search code semantically."),
|
||||
makeItem("random-number", "/random-number", "Generate a random number."),
|
||||
makeItem(
|
||||
"init",
|
||||
"/init",
|
||||
"---\nname: init\ndescription: Initialize a project\n---\n## Usage\nRun /init to start.",
|
||||
),
|
||||
];
|
||||
|
||||
describe("SlashCommandMenu", () => {
|
||||
it("renders nothing when items is empty", () => {
|
||||
const { container } = renderWithProviders(
|
||||
<SlashCommandMenu items={[]} selectedIndex={0} onSelect={vi.fn()} />,
|
||||
);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("renders all items with slash commands as primary text", () => {
|
||||
renderWithProviders(
|
||||
<SlashCommandMenu
|
||||
items={defaultItems}
|
||||
selectedIndex={0}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("/code-search")).toBeInTheDocument();
|
||||
expect(screen.getByText("/random-number")).toBeInTheDocument();
|
||||
expect(screen.getByText("/init")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks the selected item with aria-selected", () => {
|
||||
renderWithProviders(
|
||||
<SlashCommandMenu
|
||||
items={defaultItems}
|
||||
selectedIndex={1}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const options = screen.getAllByRole("option");
|
||||
expect(options[0]).toHaveAttribute("aria-selected", "false");
|
||||
expect(options[1]).toHaveAttribute("aria-selected", "true");
|
||||
expect(options[2]).toHaveAttribute("aria-selected", "false");
|
||||
});
|
||||
|
||||
it("calls onSelect on mouseDown", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(
|
||||
<SlashCommandMenu
|
||||
items={defaultItems}
|
||||
selectedIndex={0}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
);
|
||||
|
||||
const options = screen.getAllByRole("option");
|
||||
await user.click(options[1]);
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(defaultItems[1]);
|
||||
});
|
||||
|
||||
it("displays skill descriptions", () => {
|
||||
renderWithProviders(
|
||||
<SlashCommandMenu
|
||||
items={defaultItems}
|
||||
selectedIndex={0}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// First item: first-sentence extraction
|
||||
expect(screen.getByText("Search code semantically.")).toBeInTheDocument();
|
||||
|
||||
// Third item: frontmatter description extraction
|
||||
expect(screen.getByText("Initialize a project")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has an accessible listbox role and translated aria-label", () => {
|
||||
renderWithProviders(
|
||||
<SlashCommandMenu
|
||||
items={defaultItems}
|
||||
selectedIndex={0}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const listbox = screen.getByRole("listbox");
|
||||
expect(listbox).toBeInTheDocument();
|
||||
// In test env, translation key is returned as-is
|
||||
expect(listbox).toHaveAttribute("aria-label", "CHAT_INTERFACE$COMMANDS");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSkillDescription", () => {
|
||||
it("extracts description from YAML frontmatter", () => {
|
||||
const content =
|
||||
"---\nname: test\ndescription: A test skill\n---\n## Usage\nDetails here.";
|
||||
expect(getSkillDescription(content)).toBe("A test skill");
|
||||
});
|
||||
|
||||
it("strips double quotes from frontmatter description", () => {
|
||||
const content = '---\ndescription: "Quoted description"\n---\nBody.';
|
||||
expect(getSkillDescription(content)).toBe("Quoted description");
|
||||
});
|
||||
|
||||
it("strips single quotes from frontmatter description", () => {
|
||||
const content = "---\ndescription: 'Single quoted'\n---\nBody.";
|
||||
expect(getSkillDescription(content)).toBe("Single quoted");
|
||||
});
|
||||
|
||||
it("falls back to first meaningful line when no frontmatter", () => {
|
||||
const content = "# Title\n\nThis is a description.";
|
||||
expect(getSkillDescription(content)).toBe("This is a description.");
|
||||
});
|
||||
|
||||
it("falls back to first sentence from body when frontmatter has no description", () => {
|
||||
const content =
|
||||
"---\nname: test\ntriggers: ['/test']\n---\nA helpful tool. It does things.";
|
||||
expect(getSkillDescription(content)).toBe("A helpful tool.");
|
||||
});
|
||||
|
||||
it("skips headers and empty lines", () => {
|
||||
const content = "\n\n# Header\n## Subheader\n\nActual content here";
|
||||
expect(getSkillDescription(content)).toBe("Actual content here");
|
||||
});
|
||||
|
||||
it("returns null for empty content", () => {
|
||||
expect(getSkillDescription("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for content with only headers", () => {
|
||||
expect(getSkillDescription("# Title\n## Subtitle")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the whole line when there is no sentence-ending punctuation", () => {
|
||||
const content = "A description without punctuation";
|
||||
expect(getSkillDescription(content)).toBe(
|
||||
"A description without punctuation",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips markdown from frontmatter description", () => {
|
||||
const content =
|
||||
'---\ndescription: "A **bold** and *italic* description"\n---\nBody.';
|
||||
expect(getSkillDescription(content)).toBe(
|
||||
"A bold and italic description",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips markdown from body fallback", () => {
|
||||
const content = "# Title\n\nUse `code` and [links](http://example.com).";
|
||||
expect(getSkillDescription(content)).toBe("Use code and links.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripMarkdown", () => {
|
||||
it("strips bold syntax", () => {
|
||||
expect(stripMarkdown("a **bold** word")).toBe("a bold word");
|
||||
});
|
||||
|
||||
it("strips italic syntax", () => {
|
||||
expect(stripMarkdown("an *italic* word")).toBe("an italic word");
|
||||
});
|
||||
|
||||
it("strips bold-italic syntax", () => {
|
||||
expect(stripMarkdown("***both***")).toBe("both");
|
||||
});
|
||||
|
||||
it("strips inline code", () => {
|
||||
expect(stripMarkdown("run `npm test` now")).toBe("run npm test now");
|
||||
});
|
||||
|
||||
it("strips links", () => {
|
||||
expect(stripMarkdown("see [docs](http://example.com)")).toBe("see docs");
|
||||
});
|
||||
|
||||
it("strips images", () => {
|
||||
expect(stripMarkdown("")).toBe("alt text");
|
||||
});
|
||||
|
||||
it("strips strikethrough", () => {
|
||||
expect(stripMarkdown("~~removed~~")).toBe("removed");
|
||||
});
|
||||
|
||||
it("strips underscore emphasis", () => {
|
||||
expect(stripMarkdown("__bold__ and _italic_")).toBe("bold and italic");
|
||||
});
|
||||
|
||||
it("returns plain text unchanged", () => {
|
||||
expect(stripMarkdown("plain text")).toBe("plain text");
|
||||
});
|
||||
});
|
||||
@@ -10,10 +10,6 @@ import { useConversationStore } from "#/stores/conversation-store";
|
||||
const TASK_CONVERSATION_ID = "task-ec03fb2ab8604517b24af632b058c2fd";
|
||||
const REAL_CONVERSATION_ID = "conv-abc123";
|
||||
|
||||
vi.mock("#/utils/feature-flags", () => ({
|
||||
USE_PLANNING_AGENT: () => false,
|
||||
}));
|
||||
|
||||
let mockConversationId = TASK_CONVERSATION_ID;
|
||||
|
||||
vi.mock("#/hooks/use-conversation-id", () => ({
|
||||
@@ -120,9 +116,7 @@ describe("ConversationTabs localStorage behavior", () => {
|
||||
|
||||
// Verify localStorage was updated
|
||||
const storedState = JSON.parse(
|
||||
localStorage.getItem(
|
||||
`conversation-state-${REAL_CONVERSATION_ID}`,
|
||||
)!,
|
||||
localStorage.getItem(`conversation-state-${REAL_CONVERSATION_ID}`)!,
|
||||
);
|
||||
expect(storedState.selectedTab).toBe("terminal");
|
||||
expect(storedState.rightPanelShown).toBe(true);
|
||||
@@ -152,9 +146,7 @@ describe("ConversationTabs localStorage behavior", () => {
|
||||
|
||||
// Verify localStorage was updated
|
||||
const storedState = JSON.parse(
|
||||
localStorage.getItem(
|
||||
`conversation-state-${REAL_CONVERSATION_ID}`,
|
||||
)!,
|
||||
localStorage.getItem(`conversation-state-${REAL_CONVERSATION_ID}`)!,
|
||||
);
|
||||
expect(storedState.rightPanelShown).toBe(false);
|
||||
});
|
||||
@@ -184,9 +176,7 @@ describe("ConversationTabs localStorage behavior", () => {
|
||||
|
||||
// Verify localStorage was updated
|
||||
const storedState = JSON.parse(
|
||||
localStorage.getItem(
|
||||
`conversation-state-${REAL_CONVERSATION_ID}`,
|
||||
)!,
|
||||
localStorage.getItem(`conversation-state-${REAL_CONVERSATION_ID}`)!,
|
||||
);
|
||||
expect(storedState.selectedTab).toBe("browser");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "../../../../test-utils";
|
||||
import OnboardingForm from "#/routes/onboarding-form";
|
||||
|
||||
const mockMutate = vi.fn();
|
||||
const mockNavigate = vi.fn();
|
||||
|
||||
vi.mock("react-router", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("react-router")>();
|
||||
return {
|
||||
...original,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("#/hooks/mutation/use-submit-onboarding", () => ({
|
||||
useSubmitOnboarding: () => ({
|
||||
mutate: mockMutate,
|
||||
}),
|
||||
}));
|
||||
|
||||
const renderOnboardingForm = () => {
|
||||
return renderWithProviders(
|
||||
<MemoryRouter>
|
||||
<OnboardingForm />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
};
|
||||
|
||||
describe("OnboardingForm", () => {
|
||||
beforeEach(() => {
|
||||
mockMutate.mockClear();
|
||||
mockNavigate.mockClear();
|
||||
});
|
||||
|
||||
it("should render with the correct test id", () => {
|
||||
renderOnboardingForm();
|
||||
|
||||
expect(screen.getByTestId("onboarding-form")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the first step initially", () => {
|
||||
renderOnboardingForm();
|
||||
|
||||
expect(screen.getByTestId("step-header")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("step-content")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("step-actions")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display step progress indicator with 3 bars", () => {
|
||||
renderOnboardingForm();
|
||||
|
||||
const stepHeader = screen.getByTestId("step-header");
|
||||
const progressBars = stepHeader.querySelectorAll(".rounded-full");
|
||||
expect(progressBars).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("should have the Next button disabled when no option is selected", () => {
|
||||
renderOnboardingForm();
|
||||
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
expect(nextButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should enable the Next button when an option is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should advance to the next step when Next is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
// On step 1, first progress bar should be filled (bg-white)
|
||||
const stepHeader = screen.getByTestId("step-header");
|
||||
let progressBars = stepHeader.querySelectorAll(".bg-white");
|
||||
expect(progressBars).toHaveLength(1);
|
||||
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// On step 2, first two progress bars should be filled
|
||||
progressBars = stepHeader.querySelectorAll(".bg-white");
|
||||
expect(progressBars).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should disable Next button again on new step until option is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
expect(nextButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should call submitOnboarding with selections when finishing the last step", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
// Step 1 - select role
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Step 2 - select org size
|
||||
await user.click(screen.getByTestId("step-option-org_2_10"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Step 3 - select use case
|
||||
await user.click(screen.getByTestId("step-option-new_features"));
|
||||
await user.click(screen.getByRole("button", { name: /finish/i }));
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockMutate).toHaveBeenCalledWith({
|
||||
selections: {
|
||||
step1: "software_engineer",
|
||||
step2: "org_2_10",
|
||||
step3: "new_features",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should render 6 options on step 1", () => {
|
||||
renderOnboardingForm();
|
||||
|
||||
const options = screen
|
||||
.getAllByRole("button")
|
||||
.filter((btn) =>
|
||||
btn.getAttribute("data-testid")?.startsWith("step-option-"),
|
||||
);
|
||||
expect(options).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("should preserve selections when navigating through steps", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
// Select role on step 1
|
||||
await user.click(screen.getByTestId("step-option-cto_founder"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Select org size on step 2
|
||||
await user.click(screen.getByTestId("step-option-solo"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Select use case on step 3
|
||||
await user.click(screen.getByTestId("step-option-fixing_bugs"));
|
||||
await user.click(screen.getByRole("button", { name: /finish/i }));
|
||||
|
||||
// Verify all selections were preserved
|
||||
expect(mockMutate).toHaveBeenCalledWith({
|
||||
selections: {
|
||||
step1: "cto_founder",
|
||||
step2: "solo",
|
||||
step3: "fixing_bugs",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should show all progress bars filled on the last step", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
// Navigate to step 3
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
await user.click(screen.getByTestId("step-option-solo"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// On step 3, all three progress bars should be filled
|
||||
const stepHeader = screen.getByTestId("step-header");
|
||||
const progressBars = stepHeader.querySelectorAll(".bg-white");
|
||||
expect(progressBars).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("should not render the Back button on the first step", () => {
|
||||
renderOnboardingForm();
|
||||
|
||||
const backButton = screen.queryByRole("button", { name: /back/i });
|
||||
expect(backButton).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the Back button on step 2", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
const backButton = screen.getByRole("button", { name: /back/i });
|
||||
expect(backButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should go back to the previous step when Back is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
// Navigate to step 2
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Verify we're on step 2 (2 progress bars filled)
|
||||
const stepHeader = screen.getByTestId("step-header");
|
||||
let progressBars = stepHeader.querySelectorAll(".bg-white");
|
||||
expect(progressBars).toHaveLength(2);
|
||||
|
||||
// Click Back
|
||||
await user.click(screen.getByRole("button", { name: /back/i }));
|
||||
|
||||
// Verify we're back on step 1 (1 progress bar filled)
|
||||
progressBars = stepHeader.querySelectorAll(".bg-white");
|
||||
expect(progressBars).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { StepContent } from "#/components/features/onboarding/step-content";
|
||||
|
||||
describe("StepContent", () => {
|
||||
const mockOptions = [
|
||||
{ id: "option1", label: "Option 1" },
|
||||
{ id: "option2", label: "Option 2" },
|
||||
{ id: "option3", label: "Option 3" },
|
||||
];
|
||||
|
||||
const defaultProps = {
|
||||
options: mockOptions,
|
||||
selectedOptionId: null,
|
||||
onSelectOption: vi.fn(),
|
||||
};
|
||||
|
||||
it("should render with the correct test id", () => {
|
||||
render(<StepContent {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("step-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render all options", () => {
|
||||
render(<StepContent {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Option 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Option 2")).toBeInTheDocument();
|
||||
expect(screen.getByText("Option 3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onSelectOption with correct id when option is clicked", async () => {
|
||||
const onSelectOptionMock = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<StepContent {...defaultProps} onSelectOption={onSelectOptionMock} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("step-option-option2"));
|
||||
|
||||
expect(onSelectOptionMock).toHaveBeenCalledWith("option2");
|
||||
});
|
||||
|
||||
it("should mark the selected option as selected", () => {
|
||||
render(<StepContent {...defaultProps} selectedOptionId="option1" />);
|
||||
|
||||
const selectedOption = screen.getByTestId("step-option-option1");
|
||||
const unselectedOption = screen.getByTestId("step-option-option2");
|
||||
|
||||
expect(selectedOption).toHaveClass("border-white");
|
||||
expect(unselectedOption).toHaveClass("border-[#3a3a3a]");
|
||||
});
|
||||
|
||||
it("should render no options when options array is empty", () => {
|
||||
render(<StepContent {...defaultProps} options={[]} />);
|
||||
|
||||
expect(screen.getByTestId("step-content")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render correct number of options", () => {
|
||||
render(<StepContent {...defaultProps} />);
|
||||
|
||||
const options = screen.getAllByRole("button");
|
||||
expect(options).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("should allow selecting different options", async () => {
|
||||
const onSelectOptionMock = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<StepContent {...defaultProps} onSelectOption={onSelectOptionMock} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("step-option-option1"));
|
||||
expect(onSelectOptionMock).toHaveBeenCalledWith("option1");
|
||||
|
||||
await user.click(screen.getByTestId("step-option-option3"));
|
||||
expect(onSelectOptionMock).toHaveBeenCalledWith("option3");
|
||||
|
||||
expect(onSelectOptionMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import StepHeader from "#/components/features/onboarding/step-header";
|
||||
|
||||
describe("StepHeader", () => {
|
||||
const defaultProps = {
|
||||
title: "Test Title",
|
||||
currentStep: 1,
|
||||
totalSteps: 3,
|
||||
};
|
||||
|
||||
it("should render with the correct test id", () => {
|
||||
render(<StepHeader {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("step-header")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display the title", () => {
|
||||
render(<StepHeader {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Test Title")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render correct number of progress dots based on totalSteps", () => {
|
||||
render(<StepHeader {...defaultProps} totalSteps={5} />);
|
||||
|
||||
const stepHeader = screen.getByTestId("step-header");
|
||||
const progressDots = stepHeader.querySelectorAll(".rounded-full");
|
||||
expect(progressDots).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("should fill progress dots up to currentStep", () => {
|
||||
render(<StepHeader {...defaultProps} currentStep={2} totalSteps={4} />);
|
||||
|
||||
const stepHeader = screen.getByTestId("step-header");
|
||||
const filledDots = stepHeader.querySelectorAll(".bg-white");
|
||||
const unfilledDots = stepHeader.querySelectorAll(".bg-neutral-600");
|
||||
|
||||
expect(filledDots).toHaveLength(2);
|
||||
expect(unfilledDots).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should show all dots filled when on last step", () => {
|
||||
render(<StepHeader {...defaultProps} currentStep={3} totalSteps={3} />);
|
||||
|
||||
const stepHeader = screen.getByTestId("step-header");
|
||||
const filledDots = stepHeader.querySelectorAll(".bg-white");
|
||||
const unfilledDots = stepHeader.querySelectorAll(".bg-neutral-600");
|
||||
|
||||
expect(filledDots).toHaveLength(3);
|
||||
expect(unfilledDots).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should show no dots filled when currentStep is 0", () => {
|
||||
render(<StepHeader {...defaultProps} currentStep={0} totalSteps={3} />);
|
||||
|
||||
const stepHeader = screen.getByTestId("step-header");
|
||||
const filledDots = stepHeader.querySelectorAll(".bg-white");
|
||||
const unfilledDots = stepHeader.querySelectorAll(".bg-neutral-600");
|
||||
|
||||
expect(filledDots).toHaveLength(0);
|
||||
expect(unfilledDots).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("should handle single step progress", () => {
|
||||
render(<StepHeader {...defaultProps} currentStep={1} totalSteps={1} />);
|
||||
|
||||
const stepHeader = screen.getByTestId("step-header");
|
||||
const progressDots = stepHeader.querySelectorAll(".rounded-full");
|
||||
const filledDots = stepHeader.querySelectorAll(".bg-white");
|
||||
|
||||
expect(progressDots).toHaveLength(1);
|
||||
expect(filledDots).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { StepOption } from "#/components/features/onboarding/step-option";
|
||||
|
||||
describe("StepOption", () => {
|
||||
const defaultProps = {
|
||||
id: "test-option",
|
||||
label: "Test Label",
|
||||
selected: false,
|
||||
onClick: vi.fn(),
|
||||
};
|
||||
|
||||
it("should render with the correct test id", () => {
|
||||
render(<StepOption {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("step-option-test-option")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display the label", () => {
|
||||
render(<StepOption {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Test Label")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onClick when clicked", async () => {
|
||||
const onClickMock = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<StepOption {...defaultProps} onClick={onClickMock} />);
|
||||
|
||||
await user.click(screen.getByTestId("step-option-test-option"));
|
||||
|
||||
expect(onClickMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call onClick when Enter key is pressed", async () => {
|
||||
const onClickMock = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<StepOption {...defaultProps} onClick={onClickMock} />);
|
||||
|
||||
const option = screen.getByTestId("step-option-test-option");
|
||||
option.focus();
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
expect(onClickMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call onClick when Space key is pressed", async () => {
|
||||
const onClickMock = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<StepOption {...defaultProps} onClick={onClickMock} />);
|
||||
|
||||
const option = screen.getByTestId("step-option-test-option");
|
||||
option.focus();
|
||||
await user.keyboard(" ");
|
||||
|
||||
expect(onClickMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should have role='button' for accessibility", () => {
|
||||
render(<StepOption {...defaultProps} />);
|
||||
|
||||
expect(screen.getByRole("button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should be focusable with tabIndex=0", () => {
|
||||
render(<StepOption {...defaultProps} />);
|
||||
|
||||
const option = screen.getByTestId("step-option-test-option");
|
||||
expect(option).toHaveAttribute("tabIndex", "0");
|
||||
});
|
||||
|
||||
it("should have selected styling when selected is true", () => {
|
||||
render(<StepOption {...defaultProps} selected />);
|
||||
|
||||
const option = screen.getByTestId("step-option-test-option");
|
||||
expect(option).toHaveClass("border-white");
|
||||
});
|
||||
|
||||
it("should have unselected styling when selected is false", () => {
|
||||
render(<StepOption {...defaultProps} selected={false} />);
|
||||
|
||||
const option = screen.getByTestId("step-option-test-option");
|
||||
expect(option).toHaveClass("border-[#3a3a3a]");
|
||||
});
|
||||
});
|
||||
@@ -9,11 +9,6 @@ import {
|
||||
createPlanningObservationEvent,
|
||||
} from "test-utils";
|
||||
|
||||
// Mock the feature flag
|
||||
vi.mock("#/utils/feature-flags", () => ({
|
||||
USE_PLANNING_AGENT: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
// Mock useConfig
|
||||
vi.mock("#/hooks/query/use-config", () => ({
|
||||
useConfig: () => ({
|
||||
|
||||
@@ -358,6 +358,30 @@ describe("Conversation WebSocket Handler", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should show friendly i18n message for budget ConversationErrorEvent", async () => {
|
||||
const mockBudgetConversationError = createMockConversationErrorEvent({
|
||||
detail:
|
||||
"Budget has been exceeded! Current cost: 18.51, Max budget: 18.24",
|
||||
});
|
||||
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
client.send(JSON.stringify(mockBudgetConversationError));
|
||||
}),
|
||||
);
|
||||
|
||||
renderWithWebSocketContext(<ErrorMessageStoreComponent />);
|
||||
|
||||
expect(screen.getByTestId("error-message")).toHaveTextContent("none");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("error-message")).toHaveTextContent(
|
||||
"STATUS$ERROR_LLM_OUT_OF_CREDITS",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should set error message store on WebSocket connection errors", async () => {
|
||||
// Simulate a connect-then-fail sequence (the MSW server auto-connects by default).
|
||||
// This should surface an error message because the app has previously connected.
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { useUpdateConversationRepository } from "#/hooks/mutation/use-update-conversation-repository";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
|
||||
// Mock the V1ConversationService
|
||||
vi.mock("#/api/conversation-service/v1-conversation-service.api", () => ({
|
||||
default: {
|
||||
updateConversationRepository: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock toast handlers
|
||||
vi.mock("#/utils/custom-toast-handlers", () => ({
|
||||
displaySuccessToast: vi.fn(),
|
||||
displayErrorToast: vi.fn(),
|
||||
}));
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe("useUpdateConversationRepository", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should call updateConversationRepository with correct parameters", async () => {
|
||||
const mockResponse = {
|
||||
id: "test-conversation-id",
|
||||
selected_repository: "owner/repo",
|
||||
selected_branch: "main",
|
||||
git_provider: "github",
|
||||
};
|
||||
|
||||
vi.mocked(V1ConversationService.updateConversationRepository).mockResolvedValue(
|
||||
mockResponse as any,
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useUpdateConversationRepository(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
conversationId: "test-conversation-id",
|
||||
repository: "owner/repo",
|
||||
branch: "main",
|
||||
gitProvider: "github",
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(V1ConversationService.updateConversationRepository).toHaveBeenCalledWith(
|
||||
"test-conversation-id",
|
||||
"owner/repo",
|
||||
"main",
|
||||
"github",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle repository removal (null values)", async () => {
|
||||
const mockResponse = {
|
||||
id: "test-conversation-id",
|
||||
selected_repository: null,
|
||||
selected_branch: null,
|
||||
git_provider: null,
|
||||
};
|
||||
|
||||
vi.mocked(V1ConversationService.updateConversationRepository).mockResolvedValue(
|
||||
mockResponse as any,
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useUpdateConversationRepository(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
conversationId: "test-conversation-id",
|
||||
repository: null,
|
||||
branch: null,
|
||||
gitProvider: null,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(V1ConversationService.updateConversationRepository).toHaveBeenCalledWith(
|
||||
"test-conversation-id",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle errors gracefully", async () => {
|
||||
vi.mocked(V1ConversationService.updateConversationRepository).mockRejectedValue(
|
||||
new Error("Failed to update repository"),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useUpdateConversationRepository(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
conversationId: "test-conversation-id",
|
||||
repository: "owner/repo",
|
||||
branch: "main",
|
||||
gitProvider: "github",
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
113
frontend/__tests__/hooks/use-git-user.test.tsx
Normal file
113
frontend/__tests__/hooks/use-git-user.test.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import { useGitUser } from "#/hooks/query/use-git-user";
|
||||
import { useLogout } from "#/hooks/mutation/use-logout";
|
||||
import UserService from "#/api/user-service/user-service.api";
|
||||
import * as useShouldShowUserFeaturesModule from "#/hooks/use-should-show-user-features";
|
||||
import * as useConfigModule from "#/hooks/query/use-config";
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
vi.mock("#/hooks/use-should-show-user-features");
|
||||
vi.mock("#/hooks/query/use-config");
|
||||
vi.mock("#/hooks/mutation/use-logout");
|
||||
vi.mock("#/api/user-service/user-service.api");
|
||||
vi.mock("posthog-js/react", () => ({
|
||||
usePostHog: vi.fn(() => ({
|
||||
identify: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("useGitUser", () => {
|
||||
let mockLogout: ReturnType<typeof useLogout>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockLogout = {
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
data: undefined,
|
||||
error: null,
|
||||
isPending: false,
|
||||
isSuccess: false,
|
||||
isError: false,
|
||||
isIdle: true,
|
||||
reset: vi.fn(),
|
||||
status: "idle",
|
||||
} as unknown as ReturnType<typeof useLogout>;
|
||||
|
||||
vi.mocked(useShouldShowUserFeaturesModule.useShouldShowUserFeatures).mockReturnValue(true);
|
||||
vi.mocked(useConfigModule.useConfig).mockReturnValue({
|
||||
data: { app_mode: "saas" },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
vi.mocked(useLogout).mockReturnValue(mockLogout);
|
||||
});
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
it("should call logout when receiving a 401 error", async () => {
|
||||
// Mock the user service to throw a 401 error
|
||||
const mockError = new AxiosError("Unauthorized", "401", undefined, undefined, {
|
||||
status: 401,
|
||||
data: { message: "Unauthorized" },
|
||||
} as any);
|
||||
|
||||
vi.mocked(UserService.getUser).mockRejectedValue(mockError);
|
||||
|
||||
const { result } = renderHook(() => useGitUser(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
// Wait for the query to fail (status becomes 'error')
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toBe("error");
|
||||
});
|
||||
|
||||
// Wait for the useEffect to trigger logout
|
||||
await waitFor(() => {
|
||||
expect(mockLogout.mutate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not call logout for non-401 errors", async () => {
|
||||
// Mock the user service to throw a 500 error
|
||||
const mockError = new AxiosError("Server Error", "500", undefined, undefined, {
|
||||
status: 500,
|
||||
data: { message: "Internal Server Error" },
|
||||
} as any);
|
||||
|
||||
vi.mocked(UserService.getUser).mockRejectedValue(mockError);
|
||||
|
||||
const { result } = renderHook(() => useGitUser(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
// Wait for the query to fail (status becomes 'error')
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toBe("error");
|
||||
});
|
||||
|
||||
// Wait a bit to ensure logout is not called
|
||||
await waitFor(() => {
|
||||
expect(mockLogout.mutate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
64
frontend/__tests__/hooks/use-is-on-intermediate-page.test.ts
Normal file
64
frontend/__tests__/hooks/use-is-on-intermediate-page.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Unmock the hook so we can test the real implementation
|
||||
vi.unmock("#/hooks/use-is-on-intermediate-page");
|
||||
|
||||
const useLocationMock = vi.fn();
|
||||
|
||||
vi.mock("react-router", async () => {
|
||||
const actual = await vi.importActual("react-router");
|
||||
return {
|
||||
...actual,
|
||||
useLocation: useLocationMock,
|
||||
};
|
||||
});
|
||||
|
||||
// Import after mock setup
|
||||
const { useIsOnIntermediatePage } = await import(
|
||||
"#/hooks/use-is-on-intermediate-page"
|
||||
);
|
||||
|
||||
describe("useIsOnIntermediatePage", () => {
|
||||
describe("returns true for intermediate pages", () => {
|
||||
it("should return true when on /accept-tos page", () => {
|
||||
useLocationMock.mockReturnValue({ pathname: "/accept-tos" });
|
||||
const { result } = renderHook(() => useIsOnIntermediatePage());
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when on /onboarding page", () => {
|
||||
useLocationMock.mockReturnValue({ pathname: "/onboarding" });
|
||||
const { result } = renderHook(() => useIsOnIntermediatePage());
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("returns false for non-intermediate pages", () => {
|
||||
it("should return false when on root page", () => {
|
||||
useLocationMock.mockReturnValue({ pathname: "/" });
|
||||
const { result } = renderHook(() => useIsOnIntermediatePage());
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when on /settings page", () => {
|
||||
useLocationMock.mockReturnValue({ pathname: "/settings" });
|
||||
const { result } = renderHook(() => useIsOnIntermediatePage());
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handles edge cases", () => {
|
||||
it("should return false for paths containing intermediate page names", () => {
|
||||
useLocationMock.mockReturnValue({ pathname: "/accept-tos-extra" });
|
||||
const { result } = renderHook(() => useIsOnIntermediatePage());
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for paths with intermediate page names as subpaths", () => {
|
||||
useLocationMock.mockReturnValue({ pathname: "/settings/accept-tos" });
|
||||
const { result } = renderHook(() => useIsOnIntermediatePage());
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
} from "vitest";
|
||||
import { screen, waitFor, render, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createMockAgentErrorEvent } from "#/mocks/mock-ws-helpers";
|
||||
import {
|
||||
createMockAgentErrorEvent,
|
||||
createMockConversationErrorEvent,
|
||||
} from "#/mocks/mock-ws-helpers";
|
||||
import { ConversationWebSocketProvider } from "#/contexts/conversation-websocket-context";
|
||||
import { conversationWebSocketTestSetup } from "./helpers/msw-websocket-setup";
|
||||
import { ConnectionStatusComponent } from "./helpers/websocket-test-components";
|
||||
@@ -229,5 +232,35 @@ describe("PostHog Analytics Tracking", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should track credit_limit_reached when ConversationErrorEvent contains budget error", async () => {
|
||||
const mockBudgetConversationError = createMockConversationErrorEvent({
|
||||
detail:
|
||||
"Budget has been exceeded! Current cost: 18.51, Max budget: 18.24",
|
||||
});
|
||||
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
client.send(JSON.stringify(mockBudgetConversationError));
|
||||
}),
|
||||
);
|
||||
|
||||
renderWithProviders(<ConnectionStatusComponent />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"OPEN",
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTrackCreditLimitReached).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
conversationId: "test-conversation-123",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
364
frontend/package-lock.json
generated
364
frontend/package-lock.json
generated
@@ -18,7 +18,7 @@
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"axios": "^1.13.2",
|
||||
"axios": "^1.13.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"downshift": "^9.0.13",
|
||||
@@ -1263,11 +1263,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
@@ -2888,11 +2887,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/config-array/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
@@ -5179,325 +5177,300 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.0.tgz",
|
||||
"integrity": "sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
|
||||
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.0.tgz",
|
||||
"integrity": "sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.0.tgz",
|
||||
"integrity": "sha512-/NNIj9A7yLjKdmkx5dC2XQ9DmjIECpGpwHoGmA5E1AhU0fuICSqSWScPhN1yLCkEdkCwJIDu2xIeLPs60MNIVg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.0.tgz",
|
||||
"integrity": "sha512-xoh8abqgPrPYPr7pTYipqnUi1V3em56JzE/HgDgitTqZBZ3yKCWI+7KUkceM6tNweyUKYru1UMi7FC060RyKwA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.0.tgz",
|
||||
"integrity": "sha512-PCkMh7fNahWSbA0OTUQ2OpYHpjZZr0hPr8lId8twD7a7SeWrvT3xJVyza+dQwXSSq4yEQTMoXgNOfMCsn8584g==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.0.tgz",
|
||||
"integrity": "sha512-1j3stGx+qbhXql4OCDZhnK7b01s6rBKNybfsX+TNrEe9JNq4DLi1yGiR1xW+nL+FNVvI4D02PUnl6gJ/2y6WJA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.0.tgz",
|
||||
"integrity": "sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.0.tgz",
|
||||
"integrity": "sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.0.tgz",
|
||||
"integrity": "sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.0.tgz",
|
||||
"integrity": "sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.0.tgz",
|
||||
"integrity": "sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.0.tgz",
|
||||
"integrity": "sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.0.tgz",
|
||||
"integrity": "sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.0.tgz",
|
||||
"integrity": "sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.0.tgz",
|
||||
"integrity": "sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.0.tgz",
|
||||
"integrity": "sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.0.tgz",
|
||||
"integrity": "sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.0.tgz",
|
||||
"integrity": "sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.0.tgz",
|
||||
"integrity": "sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.0.tgz",
|
||||
"integrity": "sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.0.tgz",
|
||||
"integrity": "sha512-v5xwKDWcu7qhAEcsUubiav7r+48Uk/ENWdr82MBZZRIm7zThSxCIVDfb3ZeRRq9yqk+oIzMdDo6fCcA5DHfMyA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.0.tgz",
|
||||
"integrity": "sha512-XnaaaSMGSI6Wk8F4KK3QP7GfuuhjGchElsVerCplUuxRIzdvZ7hRBpLR0omCmw+kI2RFJB80nenhOoGXlJ5TfQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.0.tgz",
|
||||
"integrity": "sha512-3K1lP+3BXY4t4VihLw5MEg6IZD3ojSYzqzBG571W3kNQe4G4CcFpSUQVgurYgib5d+YaCjeFow8QivWp8vuSvA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.0.tgz",
|
||||
"integrity": "sha512-MDk610P/vJGc5L5ImE4k5s+GZT3en0KoK1MKPXCRgzmksAMk79j4h3k1IerxTNqwDLxsGxStEZVBqG0gIqZqoA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.0.tgz",
|
||||
"integrity": "sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -5981,6 +5954,60 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.7.1",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.1.0",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.7.1",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.1.0",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.0",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1",
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
|
||||
@@ -7335,13 +7362,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.3",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.3.tgz",
|
||||
"integrity": "sha512-ERT8kdX7DZjtUm7IitEyV7InTHAF42iJuMArIiDIV5YtPanJkgw4hw5Dyg9fh0mihdWNn1GKaeIWErfe56UQ1g==",
|
||||
"license": "MIT",
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
|
||||
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
@@ -9200,11 +9226,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-import/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
@@ -9274,11 +9299,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-jsx-a11y/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
@@ -9388,11 +9412,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
@@ -9486,11 +9509,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
@@ -10212,11 +10234,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
@@ -13082,13 +13103,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
@@ -14173,10 +14193,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"version": "6.14.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
@@ -14763,10 +14782,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz",
|
||||
"integrity": "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA==",
|
||||
"license": "MIT",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -14778,31 +14796,31 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.57.0",
|
||||
"@rollup/rollup-android-arm64": "4.57.0",
|
||||
"@rollup/rollup-darwin-arm64": "4.57.0",
|
||||
"@rollup/rollup-darwin-x64": "4.57.0",
|
||||
"@rollup/rollup-freebsd-arm64": "4.57.0",
|
||||
"@rollup/rollup-freebsd-x64": "4.57.0",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.57.0",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.57.0",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.57.0",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.57.0",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.57.0",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.57.0",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.57.0",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.57.0",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.57.0",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.57.0",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.57.0",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.57.0",
|
||||
"@rollup/rollup-linux-x64-musl": "4.57.0",
|
||||
"@rollup/rollup-openbsd-x64": "4.57.0",
|
||||
"@rollup/rollup-openharmony-arm64": "4.57.0",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.57.0",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.57.0",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.57.0",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.57.0",
|
||||
"@rollup/rollup-android-arm-eabi": "4.59.0",
|
||||
"@rollup/rollup-android-arm64": "4.59.0",
|
||||
"@rollup/rollup-darwin-arm64": "4.59.0",
|
||||
"@rollup/rollup-darwin-x64": "4.59.0",
|
||||
"@rollup/rollup-freebsd-arm64": "4.59.0",
|
||||
"@rollup/rollup-freebsd-x64": "4.59.0",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-x64-musl": "4.59.0",
|
||||
"@rollup/rollup-openbsd-x64": "4.59.0",
|
||||
"@rollup/rollup-openharmony-arm64": "4.59.0",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.59.0",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.59.0",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"axios": "^1.13.2",
|
||||
"axios": "^1.13.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"downshift": "^9.0.13",
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { openHands } from "../open-hands-axios";
|
||||
import {
|
||||
CancelSubscriptionResponse,
|
||||
SubscriptionAccess,
|
||||
} from "./billing.types";
|
||||
|
||||
/**
|
||||
* Billing Service API - Handles all billing-related API endpoints
|
||||
@@ -44,41 +40,6 @@ class BillingService {
|
||||
);
|
||||
return data.credits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's subscription access information
|
||||
* @returns The user's subscription access details or null if not available
|
||||
*/
|
||||
static async getSubscriptionAccess(): Promise<SubscriptionAccess | null> {
|
||||
const { data } = await openHands.get<SubscriptionAccess | null>(
|
||||
"/api/billing/subscription-access",
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a subscription checkout session for subscribing to a plan
|
||||
* @returns The redirect URL for the subscription checkout session
|
||||
*/
|
||||
static async createSubscriptionCheckoutSession(): Promise<{
|
||||
redirect_url?: string;
|
||||
}> {
|
||||
const { data } = await openHands.post(
|
||||
"/api/billing/subscription-checkout-session",
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the user's subscription
|
||||
* @returns The response indicating the result of the cancellation request
|
||||
*/
|
||||
static async cancelSubscription(): Promise<CancelSubscriptionResponse> {
|
||||
const { data } = await openHands.post<CancelSubscriptionResponse>(
|
||||
"/api/billing/cancel-subscription",
|
||||
);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export default BillingService;
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
export type SubscriptionAccess = {
|
||||
start_at: string;
|
||||
end_at: string;
|
||||
created_at: string;
|
||||
cancelled_at?: string | null;
|
||||
stripe_subscription_id?: string | null;
|
||||
};
|
||||
|
||||
export interface CancelSubscriptionResponse {
|
||||
status: string;
|
||||
message: string;
|
||||
}
|
||||
@@ -319,6 +319,39 @@ class V1ConversationService {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a V1 conversation's repository settings
|
||||
* @param conversationId The conversation ID
|
||||
* @param repository The repository to attach (e.g., "owner/repo") or null to remove
|
||||
* @param branch The branch to use (optional)
|
||||
* @param gitProvider The git provider (e.g., "github", "gitlab")
|
||||
* @returns Updated conversation info
|
||||
*/
|
||||
static async updateConversationRepository(
|
||||
conversationId: string,
|
||||
repository: string | null,
|
||||
branch?: string | null,
|
||||
gitProvider?: string | null,
|
||||
): Promise<V1AppConversation> {
|
||||
const payload: Record<string, string | null | undefined> = {};
|
||||
|
||||
if (repository !== undefined) {
|
||||
payload.selected_repository = repository;
|
||||
}
|
||||
if (branch !== undefined) {
|
||||
payload.selected_branch = branch;
|
||||
}
|
||||
if (gitProvider !== undefined) {
|
||||
payload.git_provider = gitProvider;
|
||||
}
|
||||
|
||||
const { data } = await openHands.patch<V1AppConversation>(
|
||||
`/api/v1/app-conversations/${conversationId}`,
|
||||
payload,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a file from a specific conversation's sandbox workspace
|
||||
* @param conversationId The conversation ID
|
||||
|
||||
@@ -30,7 +30,7 @@ class V1GitService {
|
||||
|
||||
/**
|
||||
* Get git changes for a V1 conversation
|
||||
* Uses the agent server endpoint: GET /api/git/changes/{path}
|
||||
* Uses the agent server endpoint: GET /api/git/changes?path={path}
|
||||
* Maps V1 status types (ADDED, DELETED, etc.) to V0 format (A, D, etc.)
|
||||
*
|
||||
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
||||
@@ -43,15 +43,14 @@ class V1GitService {
|
||||
sessionApiKey: string | null | undefined,
|
||||
path: string,
|
||||
): Promise<GitChange[]> {
|
||||
const encodedPath = encodeURIComponent(path);
|
||||
const url = this.buildRuntimeUrl(
|
||||
conversationUrl,
|
||||
`/api/git/changes/${encodedPath}`,
|
||||
);
|
||||
const url = this.buildRuntimeUrl(conversationUrl, `/api/git/changes`);
|
||||
const headers = buildSessionHeaders(sessionApiKey);
|
||||
|
||||
// V1 API returns V1GitChangeStatus types, we need to map them to V0 format
|
||||
const { data } = await axios.get<V1GitChange[]>(url, { headers });
|
||||
const { data } = await axios.get<V1GitChange[]>(url, {
|
||||
headers,
|
||||
params: { path },
|
||||
});
|
||||
|
||||
// Validate response is an array (could be HTML error page if runtime is dead)
|
||||
if (!Array.isArray(data)) {
|
||||
@@ -69,7 +68,7 @@ class V1GitService {
|
||||
|
||||
/**
|
||||
* Get git change diff for a specific file in a V1 conversation
|
||||
* Uses the agent server endpoint: GET /api/git/diff/{path}
|
||||
* Uses the agent server endpoint: GET /api/git/diff?path={path}
|
||||
*
|
||||
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
||||
* @param sessionApiKey Session API key for authentication (required for V1)
|
||||
@@ -81,14 +80,13 @@ class V1GitService {
|
||||
sessionApiKey: string | null | undefined,
|
||||
path: string,
|
||||
): Promise<GitChangeDiff> {
|
||||
const encodedPath = encodeURIComponent(path);
|
||||
const url = this.buildRuntimeUrl(
|
||||
conversationUrl,
|
||||
`/api/git/diff/${encodedPath}`,
|
||||
);
|
||||
const url = this.buildRuntimeUrl(conversationUrl, `/api/git/diff`);
|
||||
const headers = buildSessionHeaders(sessionApiKey);
|
||||
|
||||
const { data } = await axios.get<GitChangeDiff>(url, { headers });
|
||||
const { data } = await axios.get<GitChangeDiff>(url, {
|
||||
headers,
|
||||
params: { path },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ export interface Conversation {
|
||||
conversation_version?: "V0" | "V1";
|
||||
sub_conversation_ids?: string[];
|
||||
public?: boolean;
|
||||
sandbox_id?: string | null;
|
||||
}
|
||||
|
||||
export interface ResultSet<T> {
|
||||
|
||||
@@ -1 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Bitbucket</title><path d="M.778 1.213a.768.768 0 00-.768.892l3.263 19.81c.084.5.515.868 1.022.873H19.95a.772.772 0 00.77-.646l3.27-20.03a.768.768 0 00-.768-.891zM14.52 15.53H9.522L8.17 8.466h7.561z"/></svg>
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Bitbucket</title><path fill="currentColor" d="M.778 1.213a.768.768 0 00-.768.892l3.263 19.81c.084.5.515.868 1.022.873H19.95a.772.772 0 00.77-.646l3.27-20.03a.768.768 0 00-.768-.891zM14.52 15.53H9.522L8.17 8.466h7.561z"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 285 B After Width: | Height: | Size: 305 B |
@@ -136,7 +136,11 @@ export function LoginContent({
|
||||
const buttonLabelClasses = "text-sm font-medium leading-5 px-1";
|
||||
|
||||
const shouldShownHelperText =
|
||||
emailVerified || hasDuplicatedEmail || recaptchaBlocked || hasInvitation;
|
||||
emailVerified ||
|
||||
hasDuplicatedEmail ||
|
||||
recaptchaBlocked ||
|
||||
hasInvitation ||
|
||||
showBitbucket;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -173,6 +177,11 @@ export function LoginContent({
|
||||
{t(I18nKey.AUTH$INVITATION_PENDING)}
|
||||
</p>
|
||||
)}
|
||||
{showBitbucket && (
|
||||
<p className="text-sm text-white text-center max-w-125">
|
||||
{t(I18nKey.AUTH$BITBUCKET_SIGNUP_DISABLED)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
|
||||
import { useConversationStore } from "#/stores/conversation-store";
|
||||
import { ChangeAgentContextMenu } from "./change-agent-context-menu";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { USE_PLANNING_AGENT } from "#/utils/feature-flags";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
@@ -27,8 +26,6 @@ export function ChangeAgentButton() {
|
||||
|
||||
const isWebSocketConnected = webSocketStatus === "CONNECTED";
|
||||
|
||||
const shouldUsePlanningAgent = USE_PLANNING_AGENT();
|
||||
|
||||
const { curAgentState } = useAgentState();
|
||||
|
||||
const { t } = useTranslation();
|
||||
@@ -83,10 +80,7 @@ export function ChangeAgentButton() {
|
||||
}, [isAgentRunning, contextMenuOpen, isWebSocketConnected]);
|
||||
|
||||
const isButtonDisabled =
|
||||
isAgentRunning ||
|
||||
isCreatingConversation ||
|
||||
!isWebSocketConnected ||
|
||||
!shouldUsePlanningAgent;
|
||||
isAgentRunning || isCreatingConversation || !isWebSocketConnected;
|
||||
|
||||
// Handle Shift + Tab keyboard shortcut to cycle through modes
|
||||
useEffect(() => {
|
||||
@@ -151,10 +145,6 @@ export function ChangeAgentButton() {
|
||||
return <LessonPlanIcon width={18} height={18} color="#ffffff" />;
|
||||
}, [isExecutionAgent]);
|
||||
|
||||
if (!shouldUsePlanningAgent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
|
||||
@@ -19,7 +19,6 @@ import { useInitialQueryStore } from "#/stores/initial-query-store";
|
||||
import { useSendMessage } from "#/hooks/use-send-message";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useHandleBuildPlanClick } from "#/hooks/use-handle-build-plan-click";
|
||||
import { USE_PLANNING_AGENT } from "#/utils/feature-flags";
|
||||
|
||||
import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
@@ -84,7 +83,6 @@ export function ChatInterface() {
|
||||
|
||||
const { curAgentState } = useAgentState();
|
||||
const { handleBuildPlanClick } = useHandleBuildPlanClick();
|
||||
const shouldUsePlanningAgent = USE_PLANNING_AGENT();
|
||||
|
||||
// Disable Build button while agent is running (streaming)
|
||||
const isAgentRunning =
|
||||
@@ -95,7 +93,7 @@ export function ChatInterface() {
|
||||
// This is placed here instead of PlanPreview to avoid duplicate listeners
|
||||
// when multiple PlanPreview components exist in the chat
|
||||
React.useEffect(() => {
|
||||
if (!shouldUsePlanningAgent || isAgentRunning) {
|
||||
if (isAgentRunning) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -114,12 +112,7 @@ export function ChatInterface() {
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [
|
||||
shouldUsePlanningAgent,
|
||||
isAgentRunning,
|
||||
handleBuildPlanClick,
|
||||
scrollDomToBottom,
|
||||
]);
|
||||
}, [isAgentRunning, handleBuildPlanClick, scrollDomToBottom]);
|
||||
|
||||
const [feedbackPolarity, setFeedbackPolarity] = React.useState<
|
||||
"positive" | "negative"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user