From 0a8c4ec21e8562d5b018a24aed4d0dd94aaa24a6 Mon Sep 17 00:00:00 2001 From: coso Date: Thu, 13 Aug 2026 08:54:46 +0800 Subject: [PATCH 1/6] v0.24.0: harden routing policy and E2E coverage --- .codex/skills/code-review-testing/SKILL.md | 6 +- .codex/skills/remote-tests/SKILL.md | 4 +- .codex/skills/test-ui/SKILL.md | 5 +- .github/workflows/build.yml | 22 + .github/workflows/ci.yml | 8 +- .github/workflows/nightly.yml | 48 +- .github/workflows/release.yml | 23 + CHANGELOG.md | 21 + README.md | 15 + README.zh-CN.md | 15 + backend/cmd/asterrouter/VERSION | 2 +- .../appcmd/server/infrastructure_test.go | 2 +- backend/internal/appcmd/server/runtime.go | 68 + .../appcmd/server/runtime_e2e_test.go | 33 + .../internal/auth/external_identity_http.go | 18 +- backend/internal/auth/mailer.go | 29 +- backend/internal/auth/mailer_test.go | 27 + .../effective_pricing_service_test.go | 6 + .../internal/controlplane/gateway_affinity.go | 31 +- .../internal/controlplane/gateway_pipeline.go | 112 +- .../controlplane/gateway_pipeline_test.go | 9 + .../controlplane/gateway_scheduler_test.go | 4 +- .../controlplane/gateway_simulator.go | 144 +- .../controlplane/portal_service_test.go | 36 + .../controlplane/retention_service_test.go | 15 + .../routing_affinity_coordinator_test.go | 4 +- .../controlplane/routing_policy_runtime.go | 100 +- .../routing_policy_runtime_test.go | 340 ++ backend/internal/plugins/service_test.go | 17 +- backend/internal/server/admin_routes_test.go | 701 ++- .../ai_job_artifact_admin_contract_test.go | 143 + backend/internal/server/alert_routes_test.go | 29 + .../server/application_routes_test.go | 92 + .../internal/server/department_routes_test.go | 54 + .../server/effective_pricing_routes_test.go | 37 + .../internal/server/email_settings_test.go | 243 + backend/internal/server/export_routes_test.go | 52 + backend/internal/server/gateway.go | 20 +- .../internal/server/gateway_cooldown_test.go | 89 + .../internal/server/gateway_count_tokens.go | 5 +- backend/internal/server/gateway_images.go | 6 +- backend/internal/server/gateway_protocols.go | 2 +- backend/internal/server/gateway_realtime.go | 6 +- .../internal/server/gateway_routes_test.go | 271 + .../internal/server/identity_routes_test.go | 68 + backend/internal/server/plugin_routes_test.go | 263 +- .../server/pricing_rule_routes_test.go | 176 + backend/internal/server/rbac_test.go | 91 +- backend/internal/server/server_test.go | 38 + backend/internal/settings/service_test.go | 20 + backend/internal/system/s3_backup.go | 3 +- backend/internal/system/service_test.go | 107 + docs/README.md | 1 + docs/test/v1/README.md | 130 + docs/test/v1/capability-registry.json | 5363 +++++++++++++++++ docs/test/v1/owner-evidence.json | 2331 +++++++ .../v1/performance-baseline.ubuntu-24.04.json | 12 + .../v1/routing-policy-algorithm-matrix.md | 115 + docs/test/v1/scenario-registry.json | 107 + frontend/e2e/accessibility.spec.ts | 4 +- frontend/e2e/account-identity.spec.ts | 73 + frontend/e2e/artifact-sink.spec.ts | 5 +- frontend/e2e/auth.spec.ts | 144 +- frontend/e2e/effective-pricing.spec.ts | 441 +- frontend/e2e/email-settings.spec.ts | 76 +- frontend/e2e/enterprise-capabilities.spec.ts | 606 ++ frontend/e2e/enterprise-setup.spec.ts | 2 +- frontend/e2e/fixtures.ts | 50 +- frontend/e2e/gateway-protocols.spec.ts | 172 + frontend/e2e/gateway.spec.ts | 173 +- frontend/e2e/isolation.spec.ts | 2 +- frontend/e2e/marketing-home.spec.ts | 7 +- frontend/e2e/models.spec.ts | 189 +- frontend/e2e/operations-recovery.spec.ts | 295 + frontend/e2e/plugin-management.spec.ts | 371 ++ frontend/e2e/pricing.spec.ts | 192 +- frontend/e2e/record-exports.spec.ts | 184 + frontend/e2e/routing-policy.spec.ts | 127 +- frontend/e2e/session.spec.ts | 4 +- frontend/e2e/settings.spec.ts | 83 + frontend/e2e/smoke.spec.ts | 13 +- frontend/e2e/surfaces.spec.ts | 152 + frontend/e2e/system-maintenance.spec.ts | 434 ++ frontend/package-lock.json | 4 +- frontend/package.json | 11 +- frontend/playwright.config.ts | 52 +- frontend/scripts/check-e2e-completeness.mjs | 173 + frontend/scripts/check-e2e-coverage.mjs | 142 + .../scripts/check-e2e-lifecycle-contract.mjs | 95 + .../check-release-browser-contract.mjs | 15 +- frontend/scripts/e2e-registry.mjs | 270 + frontend/scripts/e2e-registry.test.mjs | 55 + .../generate-e2e-capability-registry.mjs | 81 + frontend/scripts/run-e2e-gate.mjs | 77 + frontend/scripts/run-e2e-gate.test.mjs | 39 + .../marketing/MarketingHeroVisual.vue | 288 + frontend/src/i18n/locales/en-US.ts | 9 +- frontend/src/i18n/locales/marketing.ts | 8 +- frontend/src/i18n/locales/zh-CN.ts | 9 +- frontend/src/router/index.test.ts | 11 +- frontend/src/router/index.ts | 2 + frontend/src/testing/browser-errors.test.ts | 23 + frontend/src/testing/browser-errors.ts | 9 + frontend/src/types.ts | 4 + frontend/src/views/LegalDocumentView.vue | 12 +- frontend/src/views/LoginView.vue | 2 +- frontend/src/views/MarketingHomeView.test.ts | 5 + frontend/src/views/MarketingHomeView.vue | 70 +- .../views/admin/AdminArtifactsView.test.ts | 26 + .../src/views/admin/AdminArtifactsView.vue | 4 + .../views/admin/AdminCostAllocationView.vue | 26 +- .../views/admin/AdminGatewaySimulatorView.vue | 37 +- .../views/admin/AdminModelRoutesView.test.ts | 42 + .../src/views/admin/AdminModelRoutesView.vue | 20 +- .../src/views/admin/AdminOnboardingView.vue | 2 +- .../src/views/admin/AdminPluginsView.test.ts | 7 + frontend/src/views/admin/AdminPluginsView.vue | 3 +- .../src/views/admin/AdminProvidersView.vue | 2 +- .../src/views/admin/AdminSettingsView.vue | 8 +- frontend/src/views/admin/AdminUsageView.vue | 1 + frontend/src/views/console/ConsoleShell.vue | 3 +- frontend/vite.config.ts | 3 + scripts/dev-env.test.mjs | 47 + scripts/dev.sh | 6 +- scripts/e2e.sh | 144 +- scripts/fake-official.mjs | 419 ++ scripts/fake-official.test.mjs | 131 + scripts/fake-oidc.mjs | 163 + scripts/fake-oidc.test.mjs | 125 + scripts/fake-openai.mjs | 85 +- scripts/fake-s3.mjs | 248 + scripts/fake-s3.test.mjs | 50 + scripts/fake-smtp.mjs | 191 + scripts/fake-smtp.test.mjs | 22 + scripts/test-release-browser-journeys.sh | 66 +- scripts/test-system-update-lifecycle.sh | 257 + scripts/test.sh | 1 + 137 files changed, 18801 insertions(+), 342 deletions(-) create mode 100644 backend/internal/appcmd/server/runtime_e2e_test.go create mode 100644 backend/internal/server/ai_job_artifact_admin_contract_test.go create mode 100644 backend/internal/server/application_routes_test.go create mode 100644 backend/internal/server/gateway_cooldown_test.go create mode 100644 docs/test/v1/README.md create mode 100644 docs/test/v1/capability-registry.json create mode 100644 docs/test/v1/owner-evidence.json create mode 100644 docs/test/v1/performance-baseline.ubuntu-24.04.json create mode 100644 docs/test/v1/routing-policy-algorithm-matrix.md create mode 100644 docs/test/v1/scenario-registry.json create mode 100644 frontend/e2e/account-identity.spec.ts create mode 100644 frontend/e2e/enterprise-capabilities.spec.ts create mode 100644 frontend/e2e/gateway-protocols.spec.ts create mode 100644 frontend/e2e/operations-recovery.spec.ts create mode 100644 frontend/e2e/plugin-management.spec.ts create mode 100644 frontend/e2e/record-exports.spec.ts create mode 100644 frontend/e2e/settings.spec.ts create mode 100644 frontend/e2e/surfaces.spec.ts create mode 100644 frontend/e2e/system-maintenance.spec.ts create mode 100644 frontend/scripts/check-e2e-completeness.mjs create mode 100644 frontend/scripts/check-e2e-coverage.mjs create mode 100644 frontend/scripts/check-e2e-lifecycle-contract.mjs create mode 100644 frontend/scripts/e2e-registry.mjs create mode 100644 frontend/scripts/e2e-registry.test.mjs create mode 100644 frontend/scripts/generate-e2e-capability-registry.mjs create mode 100644 frontend/scripts/run-e2e-gate.mjs create mode 100644 frontend/scripts/run-e2e-gate.test.mjs create mode 100644 frontend/src/components/marketing/MarketingHeroVisual.vue create mode 100644 frontend/src/testing/browser-errors.test.ts create mode 100644 frontend/src/testing/browser-errors.ts create mode 100644 scripts/dev-env.test.mjs create mode 100644 scripts/fake-official.mjs create mode 100644 scripts/fake-official.test.mjs create mode 100644 scripts/fake-oidc.mjs create mode 100644 scripts/fake-oidc.test.mjs create mode 100644 scripts/fake-s3.mjs create mode 100644 scripts/fake-s3.test.mjs create mode 100644 scripts/fake-smtp.mjs create mode 100644 scripts/fake-smtp.test.mjs create mode 100644 scripts/test-system-update-lifecycle.sh diff --git a/.codex/skills/code-review-testing/SKILL.md b/.codex/skills/code-review-testing/SKILL.md index f8adc85..57b65b2 100644 --- a/.codex/skills/code-review-testing/SKILL.md +++ b/.codex/skills/code-review-testing/SKILL.md @@ -5,7 +5,7 @@ description: Author and run risk-based tests for AsterRouter changes across the # AsterRouter Test Authoring -Read `docs/test/v1/README.md` before broad or cross-surface changes. Treat that plan as the source of truth for priorities, environments, and release gates. +Read `docs/test/v1/README.md` and `docs/test/v1/scenario-registry.json` before broad or cross-surface changes. Treat them as the source of truth for proof levels, priorities, environments, and delivery gates. ## Select the test layer @@ -20,6 +20,7 @@ Use these project conventions: - Use `httptest` for HTTP routes, authentication middleware, gateway forwarding, streaming, and upstream failures. - Use memory repositories for fast domain tests; use PostgreSQL for SQL, transactions, constraints, migration compatibility, and restart persistence. - Add frontend unit or component tests for deterministic state and rendering. Use browser tests for routing, authentication, forms, responsive behavior, and multi-step workflows. +- Give every Playwright test one unique `@e2e-*` scenario ID and register its owner, fixture, proof level, claim boundary, routes, and delivery gates. New user-visible routes must have both a surface contract and a vertical journey. - Do not add production-only test hooks. Extract a real interface only when it improves production design. - Mock official services, identity providers, mail, object storage, plugins, and model upstreams by default. Never send secrets or test traffic to production. @@ -55,9 +56,10 @@ cd frontend npm run typecheck npm run build npm run check:enterprise-surface +npm run check:e2e-coverage ``` -When the test scripts defined by the v1 plan are present, also run the relevant `test:unit` and `test:e2e` targets. Do not claim frontend regression coverage based on typecheck or build alone. +Run `test:e2e:pr`, `test:e2e:full`, or `test:e2e:release` according to the gate declared by the registry. Do not select release scenarios with hand-maintained grep lists, and do not claim frontend regression coverage based on typecheck or build alone. ## Report evidence diff --git a/.codex/skills/remote-tests/SKILL.md b/.codex/skills/remote-tests/SKILL.md index 0e1a4b4..bcde63e 100644 --- a/.codex/skills/remote-tests/SKILL.md +++ b/.codex/skills/remote-tests/SKILL.md @@ -5,7 +5,7 @@ description: Validate AsterRouter outside its in-memory fast path using PostgreS # AsterRouter Environment Tests -Read the environment matrix and release gates in `docs/test/v1/README.md` before testing. Start with an isolated local environment and move outward only when the narrower environment passes. +Read `docs/test/v1/README.md` and `docs/test/v1/scenario-registry.json` before testing. Start with an isolated local environment and move outward only when the narrower proof level passes. ## Choose the environment @@ -44,6 +44,8 @@ For containers and release artifacts, verify: - checksums, `--version`, archive contents, install, upgrade, rollback, and restart; - backup creation, restore into an empty database, and restored critical-record counts. +Gate B scenarios are selected from `gates: ["release"]` in the registry. Never restore hand-maintained `@jNN` grep lists. Candidate evidence must include the selected stable scenario IDs, version, commit, database class, candidate path, and single-origin URL. + Use fake upstream model APIs and fake official services. Cover normal JSON, server-sent streaming, timeout, malformed payload, 429, 5xx, and connection interruption without contacting a live provider. ## Remote deployment discipline diff --git a/.codex/skills/test-ui/SKILL.md b/.codex/skills/test-ui/SKILL.md index 63926ce..2a6b1df 100644 --- a/.codex/skills/test-ui/SKILL.md +++ b/.codex/skills/test-ui/SKILL.md @@ -5,7 +5,7 @@ description: Exercise and verify AsterRouter's Vue user interfaces in a browser, # AsterRouter UI Tests -Read the UI coverage and critical journeys in `docs/test/v1/README.md`. Use an available browser-control tool for exploratory checks and Playwright suites once the v1 harness exists. +Read `docs/test/v1/README.md` and `docs/test/v1/scenario-registry.json`. The registry is the machine source of truth for browser routes, stable scenario IDs, fixtures, proof levels, and delivery gates. ## Start safely @@ -22,6 +22,7 @@ Read the UI coverage and critical journeys in `docs/test/v1/README.md`. Use an a 4. Verify the browser URL, visible result, API request, persisted state after reload, and relevant audit evidence. 5. Check that forbidden surfaces redirect or return an authorization-safe state without leaking data. 6. Inspect console errors, failed network requests, unhandled promises, and unexpected page reloads. +7. Assert the canonical URL after navigation so Vue catch-all redirects cannot make a stale route appear to pass. Prioritize these journeys: @@ -39,6 +40,8 @@ For changed controls, verify keyboard navigation, visible focus, accessible name Use screenshots as evidence, not as the only assertion. Prefer stable semantic locators and behavior assertions over CSS selectors or pixel-perfect snapshots. +Do not use fixed sleeps. Use Playwright web-first assertions, response/event waits, or `expect.poll`. A new user-visible route requires a surface contract at all supported viewports and at least one registered vertical journey; run `npm run check:e2e-coverage` before broader browser tests. + ## Report evidence Record the URL, profile/role, viewport, locale/theme, actions, expected and actual result, console/network findings, and screenshot path. For failures, provide the shortest reproducible sequence and the request ID when available. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ed2e478..a32a700 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -104,6 +104,14 @@ jobs: bash scripts/test-release-browser-journeys.sh "${{ steps.version.outputs.version }}" grep -Fxq 'release_browser_journeys=passed' "${{ runner.temp }}/asterrouter-release-journeys/report.txt" + - name: Validate system update lifecycle + env: + ASTER_SYSTEM_UPDATE_E2E_DIR: ${{ runner.temp }}/asterrouter-system-update-lifecycle + ASTER_SYSTEM_UPDATE_E2E_OLD_VERSION: ${{ steps.version.outputs.version }} + run: | + bash scripts/test-system-update-lifecycle.sh + grep -Fxq 'system_update_lifecycle=passed' "${{ runner.temp }}/asterrouter-system-update-lifecycle/report.txt" + - name: Validate Linux install, upgrade, and rollback env: ASTER_INSTALLER_TEST_DIR: ${{ runner.temp }}/asterrouter-installer-test @@ -152,3 +160,17 @@ jobs: ${{ runner.temp }}/asterrouter-release-journeys/enterprise/runtime.log ${{ runner.temp }}/asterrouter-release-journeys/enterprise/playwright if-no-files-found: ignore + - name: Upload system update lifecycle evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: asterrouter-system-update-lifecycle + path: | + ${{ runner.temp }}/asterrouter-system-update-lifecycle/report.txt + ${{ runner.temp }}/asterrouter-system-update-lifecycle/generations.log + ${{ runner.temp }}/asterrouter-system-update-lifecycle/supervisor.log + ${{ runner.temp }}/asterrouter-system-update-lifecycle/runtime.log + ${{ runner.temp }}/asterrouter-system-update-lifecycle/postgres.log + ${{ runner.temp }}/asterrouter-system-update-lifecycle/official.log + ${{ runner.temp }}/asterrouter-system-update-lifecycle/playwright + if-no-files-found: ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 448edfe..a8d48da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -199,6 +199,7 @@ jobs: --health-retries 20 env: ASTERROUTER_SERVER_STORAGE_DATABASE_URL: postgres://asterrouter:asterrouter@127.0.0.1:5432/asterrouter_e2e_test?sslmode=disable + ASTER_E2E_ALLOW_DESTRUCTIVE_RESTORE: '1' steps: - uses: actions/checkout@v7 - uses: actions/setup-go@v6 @@ -216,9 +217,12 @@ jobs: - name: Install Chromium working-directory: frontend run: npx playwright install --with-deps chromium - - name: Browser smoke tests + - name: Check E2E coverage contract working-directory: frontend - run: npm run test:e2e:smoke + run: npm run check:e2e-coverage + - name: Pull request browser gate + working-directory: frontend + run: npm run test:e2e:pr - name: Create isolated first-install database env: PGPASSWORD: asterrouter diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 21db4eb..e66f39e 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -77,6 +77,7 @@ jobs: --health-retries 20 env: ASTERROUTER_SERVER_STORAGE_DATABASE_URL: postgres://asterrouter:asterrouter@127.0.0.1:5432/asterrouter_nightly_e2e?sslmode=disable + ASTER_E2E_ALLOW_DESTRUCTIVE_RESTORE: '1' steps: - uses: actions/checkout@v7 - uses: actions/setup-go@v6 @@ -96,13 +97,34 @@ jobs: run: npx playwright install --with-deps chromium firefox webkit - name: Frontend unit and component tests working-directory: frontend - run: npx vitest run --reporter=default --reporter=junit --outputFile.junit=test-results/vitest-junit.xml - - name: Full browser suite + run: npx vitest run --reporter=default --reporter=junit --outputFile.junit=unit-test-results/vitest-junit.xml + - name: Check E2E coverage contract + working-directory: frontend + run: npm run check:e2e-coverage + - name: Create isolated nightly first-install database + env: + PGPASSWORD: asterrouter + run: >- + psql --host=127.0.0.1 --username=asterrouter --dbname=postgres + --command='CREATE DATABASE asterrouter_nightly_e2e_setup_test' + - name: Nightly first-install setup browser journey + env: + ASTER_SETUP_JOURNEY_DIR: ${{ runner.temp }}/asterrouter-nightly-setup-journey + ASTER_SETUP_JOURNEY_DATABASE_URL: postgres://asterrouter:asterrouter@127.0.0.1:5432/asterrouter_nightly_e2e_setup_test?sslmode=disable + run: bash scripts/test-setup-browser-journey.sh + - name: Nightly browser gate working-directory: frontend env: ASTER_E2E_ALL_BROWSERS: '1' ASTER_E2E_RETRIES: '0' - run: npm run test:e2e + ASTER_E2E_OIDC_ENABLED: '1' + run: npm run test:e2e:nightly + - name: System update lifecycle browser journey + env: + ASTER_SYSTEM_UPDATE_E2E_DIR: ${{ runner.temp }}/asterrouter-system-update-lifecycle + run: | + bash scripts/test-system-update-lifecycle.sh + grep -Fxq 'system_update_lifecycle=passed' "${{ runner.temp }}/asterrouter-system-update-lifecycle/report.txt" - name: Upload browser evidence if: always() uses: actions/upload-artifact@v7 @@ -111,6 +133,22 @@ jobs: path: | frontend/playwright-report frontend/test-results + ${{ runner.temp }}/asterrouter-nightly-setup-journey + if-no-files-found: ignore + + - name: Upload system update lifecycle evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: nightly-system-update-lifecycle + path: | + ${{ runner.temp }}/asterrouter-system-update-lifecycle/report.txt + ${{ runner.temp }}/asterrouter-system-update-lifecycle/generations.log + ${{ runner.temp }}/asterrouter-system-update-lifecycle/supervisor.log + ${{ runner.temp }}/asterrouter-system-update-lifecycle/runtime.log + ${{ runner.temp }}/asterrouter-system-update-lifecycle/postgres.log + ${{ runner.temp }}/asterrouter-system-update-lifecycle/official.log + ${{ runner.temp }}/asterrouter-system-update-lifecycle/playwright if-no-files-found: ignore - name: Upload frontend test results @@ -118,7 +156,9 @@ jobs: uses: actions/upload-artifact@v7 with: name: nightly-frontend-test-results - path: frontend/test-results + path: | + frontend/test-results + frontend/unit-test-results if-no-files-found: ignore test-health: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8d2c23c..b665772 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -118,6 +118,14 @@ jobs: bash scripts/test-release-browser-journeys.sh "${{ steps.meta.outputs.version }}" grep -Fxq 'release_browser_journeys=passed' "${{ runner.temp }}/asterrouter-release-journeys/report.txt" + - name: Validate system update lifecycle + env: + ASTER_SYSTEM_UPDATE_E2E_DIR: ${{ runner.temp }}/asterrouter-system-update-lifecycle + ASTER_SYSTEM_UPDATE_E2E_OLD_VERSION: ${{ steps.meta.outputs.version }} + run: | + bash scripts/test-system-update-lifecycle.sh + grep -Fxq 'system_update_lifecycle=passed' "${{ runner.temp }}/asterrouter-system-update-lifecycle/report.txt" + - name: Validate Linux install, upgrade, and rollback env: ASTER_INSTALLER_TEST_DIR: ${{ runner.temp }}/asterrouter-installer-test @@ -157,6 +165,21 @@ jobs: ${{ runner.temp }}/asterrouter-release-journeys/enterprise/playwright if-no-files-found: ignore + - name: Upload system update lifecycle evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: asterrouter-system-update-lifecycle + path: | + ${{ runner.temp }}/asterrouter-system-update-lifecycle/report.txt + ${{ runner.temp }}/asterrouter-system-update-lifecycle/generations.log + ${{ runner.temp }}/asterrouter-system-update-lifecycle/supervisor.log + ${{ runner.temp }}/asterrouter-system-update-lifecycle/runtime.log + ${{ runner.temp }}/asterrouter-system-update-lifecycle/postgres.log + ${{ runner.temp }}/asterrouter-system-update-lifecycle/official.log + ${{ runner.temp }}/asterrouter-system-update-lifecycle/playwright + if-no-files-found: ignore + - name: Write release notes run: | set -euo pipefail diff --git a/CHANGELOG.md b/CHANGELOG.md index 1aab700..9bfd2be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## [0.24.0] - 2026-08-13 + +### Added + +- 建立企业功能、API 操作与浏览器旅程的 E2E 机器事实源,并将覆盖完整性合同纳入 PR、夜间和发布门禁。 +- 补齐企业身份、应用与凭据、计价、邮件、网关协议、插件中心、数据导出、系统备份、更新与恢复的端到端旅程。 +- 新增合成 OIDC、SMTP、S3 和官方服务测试替身,默认隔离外部依赖与生产数据。 +- 新增路由策略算法矩阵,覆盖硬约束、价格护栏、预置排序、批次选择、粘性、熔断、容量和失败切换。 + +### Changed + +- 路由策略执行链统一输出策略 ID、版本、预置和候选排除原因,调度与模拟器共用同一套候选规则。 +- 发布工作流增加 PostgreSQL 16、真实 Linux 产物、单源浏览器旅程、系统更新与安装升级回滚验收。 +- 官网首屏改为直观的企业路由决策可视化,并完善响应式布局。 + +### Fixed + +- 修复策略模型准入未在实际网关计划阶段阻断,以及原生协议、价格和首字节前切换排除原因不可见的问题。 +- 修复路由模拟器与真实调度策略结果不一致,并补齐拒绝原因和容量证据。 +- 修复插件工作台兼容路由、模型路由更新请求泄漏服务端字段,以及 OIDC/SMTP 自定义 CA 在隔离测试环境中无法验证的问题。 + ## [0.23.1] - 2026-08-12 ### Fixed diff --git a/README.md b/README.md index 5a4b837..c136739 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,21 @@ cd frontend npm run build ``` +## Testing + +The machine-readable browser coverage source is [docs/test/v1/scenario-registry.json](./docs/test/v1/scenario-registry.json). The proof levels, fixtures, delivery gates, and evidence rules are documented in [docs/test/v1/README.md](./docs/test/v1/README.md). + +```bash +# Static coverage contract and pull-request browser gate +cd frontend +npm run check:e2e-coverage +npm run test:e2e:pr + +# Full local backend, frontend, and browser suite +cd .. +bash scripts/test.sh all +``` + ## License AsterRouter is licensed under the [Apache License 2.0](./LICENSE). diff --git a/README.zh-CN.md b/README.zh-CN.md index 9a1b3ec..9c892e8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -92,6 +92,21 @@ cd frontend npm run build ``` +## 测试 + +浏览器覆盖的机器事实源为 [docs/test/v1/scenario-registry.json](./docs/test/v1/scenario-registry.json)。证明层级、测试夹具、交付门禁和证据规则见 [docs/test/v1/README.md](./docs/test/v1/README.md)。 + +```bash +# 静态覆盖合同与 PR 浏览器门禁 +cd frontend +npm run check:e2e-coverage +npm run test:e2e:pr + +# 本地后端、前端与浏览器完整测试 +cd .. +bash scripts/test.sh all +``` + ## 许可证 AsterRouter 使用 [Apache License 2.0](./LICENSE) 授权。 diff --git a/backend/cmd/asterrouter/VERSION b/backend/cmd/asterrouter/VERSION index 610e287..2094a10 100644 --- a/backend/cmd/asterrouter/VERSION +++ b/backend/cmd/asterrouter/VERSION @@ -1 +1 @@ -0.23.1 +0.24.0 diff --git a/backend/internal/appcmd/server/infrastructure_test.go b/backend/internal/appcmd/server/infrastructure_test.go index c35a0cf..124a1db 100644 --- a/backend/internal/appcmd/server/infrastructure_test.go +++ b/backend/internal/appcmd/server/infrastructure_test.go @@ -32,7 +32,7 @@ func TestConfigureAIJobInfrastructureSupportsRedisAffinityWithMemoryQueue(t *tes } input := controlplane.GatewayAffinityInput{ ApplicationID: "application", PrincipalID: "principal", CredentialID: "credential", Model: "model", - Protocol: "openai_chat_completions", RouteGroup: "default", PolicyVersion: 1, + Protocol: "openai_chat_completions", RouteGroup: "default", RoutingPolicyVersion: 1, } if err = service.BindGatewayCandidateAffinity(t.Context(), input, controlplane.GatewayProvider{ID: "provider-a"}); err != nil { t.Fatal(err) diff --git a/backend/internal/appcmd/server/runtime.go b/backend/internal/appcmd/server/runtime.go index 9376a5d..5e9ec2e 100644 --- a/backend/internal/appcmd/server/runtime.go +++ b/backend/internal/appcmd/server/runtime.go @@ -6,6 +6,10 @@ import ( "fmt" "log/slog" "net/http" + "net/url" + "os" + "regexp" + "strings" "sync" "time" @@ -42,6 +46,67 @@ type runtime struct { closeErr error } +// applyE2EOIDCSettings is opt-in and exists only for the isolated browser +// journey. It configures the real runtime before identity services initialize. +func applyE2EOIDCSettings(ctx context.Context, service *settings.Service, current *settings.AdminSettings, databaseURL string) error { + if strings.TrimSpace(os.Getenv("ASTER_E2E_OIDC_ENABLED")) != "1" { + return nil + } + if !e2eOIDCStorageAllowed(databaseURL) { + return fmt.Errorf("ASTER_E2E_OIDC_ENABLED requires isolated memory or an explicitly available PostgreSQL database with an e2e or test name token") + } + port := strings.TrimSpace(os.Getenv("ASTER_E2E_OIDC_PORT")) + if port == "" { + return fmt.Errorf("ASTER_E2E_OIDC_PORT is required when ASTER_E2E_OIDC_ENABLED=1") + } + next := *current + next.PublicBaseURL = "https://127.0.0.1:" + port + next.OIDCEnabled = true + next.OIDCProviderName = "Fake OIDC" + next.OIDCIssuerURL = next.PublicBaseURL + "/oidc" + next.OIDCClientID = envOr("ASTER_E2E_OIDC_CLIENT_ID", "asterrouter-e2e") + next.OIDCClientSecret = envOr("ASTER_E2E_OIDC_CLIENT_SECRET", "asterrouter-e2e-secret") + next.OIDCRequireVerifiedEmail = true + updated, err := service.Update(ctx, next) + if err != nil { + return fmt.Errorf("configure E2E OIDC settings: %w", err) + } + *current = updated + return nil +} + +var e2eDatabaseTokenSeparator = regexp.MustCompile(`[^a-z0-9]+`) + +func e2eOIDCStorageAllowed(databaseURL string) bool { + if strings.TrimSpace(os.Getenv("ASTER_DEV_ISOLATED_MEMORY")) == "1" { + return true + } + if strings.TrimSpace(os.Getenv("ASTER_E2E_POSTGRES_AVAILABLE")) != "1" { + return false + } + parsed, err := url.Parse(strings.TrimSpace(databaseURL)) + if err != nil || (parsed.Scheme != "postgres" && parsed.Scheme != "postgresql") || strings.TrimSpace(parsed.Hostname()) == "" { + return false + } + databaseName, err := url.PathUnescape(strings.TrimPrefix(parsed.Path, "/")) + if err != nil || strings.Contains(databaseName, "/") { + return false + } + for _, token := range e2eDatabaseTokenSeparator.Split(strings.ToLower(databaseName), -1) { + if token == "e2e" || token == "test" { + return true + } + } + return false +} + +func envOr(name, fallback string) string { + if value := strings.TrimSpace(os.Getenv(name)); value != "" { + return value + } + return fallback +} + func newRuntime(ctx context.Context, cfg *config.Server) (_ *runtime, err error) { rt := &runtime{closeInfrastructure: func() {}, backgroundErrors: make(chan error, 1)} defer func() { @@ -73,6 +138,9 @@ func newRuntime(ctx context.Context, cfg *config.Server) (_ *runtime, err error) if err != nil { return nil, fmt.Errorf("load settings: %w", err) } + if err := applyE2EOIDCSettings(ctx, rt.settingsService, &adminSettings, cfg.Storage.DatabaseURL); err != nil { + return nil, err + } oidcService, feishuService, githubOAuthService, googleOAuthService, dingTalkService, err := newIdentityServices(ctx, rt.settingsService, adminSettings) if err != nil { diff --git a/backend/internal/appcmd/server/runtime_e2e_test.go b/backend/internal/appcmd/server/runtime_e2e_test.go new file mode 100644 index 0000000..1c65c5b --- /dev/null +++ b/backend/internal/appcmd/server/runtime_e2e_test.go @@ -0,0 +1,33 @@ +package server + +import "testing" + +func TestE2EOIDCStorageAllowed(t *testing.T) { + tests := []struct { + name string + databaseURL string + isolatedMemory string + postgresAvailable string + want bool + }{ + {name: "isolated memory", isolatedMemory: "1", want: true}, + {name: "dedicated e2e PostgreSQL", databaseURL: "postgresql://tester@127.0.0.1:5432/asterrouter_e2e_oidc?sslmode=disable", postgresAvailable: "1", want: true}, + {name: "dedicated test PostgreSQL", databaseURL: "postgres://tester@127.0.0.1:5432/asterrouter-oidc-test?sslmode=disable", postgresAvailable: "1", want: true}, + {name: "marker missing", databaseURL: "postgresql://tester@127.0.0.1:5432/asterrouter_e2e_oidc", want: false}, + {name: "unsafe production name", databaseURL: "postgresql://tester@127.0.0.1:5432/asterrouter", postgresAvailable: "1", want: false}, + {name: "embedded token is not isolated", databaseURL: "postgresql://tester@127.0.0.1:5432/asterrouter_testdata", postgresAvailable: "1", want: false}, + {name: "non PostgreSQL URL", databaseURL: "https://127.0.0.1/asterrouter_e2e_oidc", postgresAvailable: "1", want: false}, + {name: "host missing", databaseURL: "postgresql:///asterrouter_e2e_oidc", postgresAvailable: "1", want: false}, + {name: "nested path", databaseURL: "postgresql://tester@127.0.0.1/cluster/asterrouter_e2e_oidc", postgresAvailable: "1", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("ASTER_DEV_ISOLATED_MEMORY", tt.isolatedMemory) + t.Setenv("ASTER_E2E_POSTGRES_AVAILABLE", tt.postgresAvailable) + if got := e2eOIDCStorageAllowed(tt.databaseURL); got != tt.want { + t.Fatalf("e2eOIDCStorageAllowed(%q) = %v, want %v", tt.databaseURL, got, tt.want) + } + }) + } +} diff --git a/backend/internal/auth/external_identity_http.go b/backend/internal/auth/external_identity_http.go index fd0efa7..b02271a 100644 --- a/backend/internal/auth/external_identity_http.go +++ b/backend/internal/auth/external_identity_http.go @@ -1,12 +1,28 @@ package auth import ( + "crypto/tls" + "crypto/x509" "net/http" + "os" + "strings" "time" ) const externalIdentityHTTPTimeout = 15 * time.Second func newExternalIdentityHTTPClient() *http.Client { - return &http.Client{Timeout: externalIdentityHTTPTimeout} + transport := http.DefaultTransport.(*http.Transport).Clone() + if caFile := strings.TrimSpace(os.Getenv("SSL_CERT_FILE")); caFile != "" { + if pem, err := os.ReadFile(caFile); err == nil { + pool, _ := x509.SystemCertPool() + if pool == nil { + pool = x509.NewCertPool() + } + if pool.AppendCertsFromPEM(pem) { + transport.TLSClientConfig = &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12} + } + } + } + return &http.Client{Timeout: externalIdentityHTTPTimeout, Transport: transport} } diff --git a/backend/internal/auth/mailer.go b/backend/internal/auth/mailer.go index 3f5c4d3..82091c2 100644 --- a/backend/internal/auth/mailer.go +++ b/backend/internal/auth/mailer.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "crypto/tls" + "crypto/x509" "encoding/base64" "errors" "fmt" @@ -11,6 +12,7 @@ import ( "net" "net/mail" "net/smtp" + "os" "strings" "time" ) @@ -123,8 +125,12 @@ func (m SMTPMailer) openClient(ctx context.Context) (*smtp.Client, error) { if err := conn.SetDeadline(deadline); err != nil { return nil, err } + tlsConfig, err := smtpTLSConfig(cfg.Host) + if err != nil { + return nil, err + } if cfg.UseTLS { - tlsConn := tls.Client(conn, &tls.Config{ServerName: cfg.Host, MinVersion: tls.VersionTLS12}) + tlsConn := tls.Client(conn, tlsConfig) if err := tlsConn.HandshakeContext(ctx); err != nil { return nil, err } @@ -140,7 +146,7 @@ func (m SMTPMailer) openClient(ctx context.Context) (*smtp.Client, error) { _ = client.Close() return nil, errors.New("SMTP server does not support STARTTLS") } - if err := client.StartTLS(&tls.Config{ServerName: cfg.Host, MinVersion: tls.VersionTLS12}); err != nil { + if err := client.StartTLS(tlsConfig); err != nil { _ = client.Close() return nil, err } @@ -155,6 +161,25 @@ func (m SMTPMailer) openClient(ctx context.Context) (*smtp.Client, error) { return client, nil } +func smtpTLSConfig(host string) (*tls.Config, error) { + roots, systemErr := x509.SystemCertPool() + if roots == nil { + roots = x509.NewCertPool() + } + if caFile := strings.TrimSpace(os.Getenv("SSL_CERT_FILE")); caFile != "" { + pemData, err := os.ReadFile(caFile) + if err != nil { + return nil, fmt.Errorf("read SMTP CA file: %w", err) + } + if !roots.AppendCertsFromPEM(pemData) { + return nil, errors.New("SMTP CA file does not contain a valid certificate") + } + } else if systemErr != nil { + return nil, fmt.Errorf("load system CA certificates: %w", systemErr) + } + return &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12, RootCAs: roots}, nil +} + func parseSMTPAddress(value string) (*mail.Address, error) { value = strings.TrimSpace(value) if value == "" || strings.ContainsAny(value, "\r\n") { diff --git a/backend/internal/auth/mailer_test.go b/backend/internal/auth/mailer_test.go index 73b53db..6e5619f 100644 --- a/backend/internal/auth/mailer_test.go +++ b/backend/internal/auth/mailer_test.go @@ -3,9 +3,14 @@ package auth import ( "bufio" "context" + "crypto/tls" + "crypto/x509" "encoding/base64" + "encoding/pem" "fmt" "net" + "net/http/httptest" + "os" "strings" "testing" "time" @@ -131,3 +136,25 @@ func TestSMTPConnectionTestRequiresSTARTTLS(t *testing.T) { t.Fatalf("TestConnection() error = %v, want mandatory STARTTLS failure", err) } } + +func TestSMTPTLSConfigAppendsSSL_CERTFile(t *testing.T) { + server := httptest.NewTLSServer(nil) + defer server.Close() + certificate := server.Certificate() + caFile := t.TempDir() + "/smtp-ca.pem" + if err := os.WriteFile(caFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificate.Raw}), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("SSL_CERT_FILE", caFile) + + config, err := smtpTLSConfig("127.0.0.1") + if err != nil { + t.Fatal(err) + } + if config.MinVersion != tls.VersionTLS12 || config.InsecureSkipVerify { + t.Fatalf("SMTP TLS config weakened verification: %+v", config) + } + if _, err := certificate.Verify(x509.VerifyOptions{DNSName: "127.0.0.1", Roots: config.RootCAs}); err != nil { + t.Fatalf("custom SMTP CA was not trusted: %v", err) + } +} diff --git a/backend/internal/controlplane/effective_pricing_service_test.go b/backend/internal/controlplane/effective_pricing_service_test.go index 1bc6ac1..6b4a3ee 100644 --- a/backend/internal/controlplane/effective_pricing_service_test.go +++ b/backend/internal/controlplane/effective_pricing_service_test.go @@ -225,6 +225,9 @@ func TestEffectivePricingDecisionCanaryOrdersCandidateAndRollbackStopsIt(t *test if err != nil || decision.Status != EffectivePricingDecisionCanary { t.Fatalf("approve canary decision=%+v err=%v", decision, err) } + if _, err := svc.ActOnEffectivePricingDecision(ctx, "tester", decision.ID, EffectivePricingDecisionActionRequest{Action: "approve_canary", CanaryPercent: 100}); err == nil || !strings.Contains(err.Error(), "only recommended decisions") { + t.Fatalf("repeated approve canary error=%v", err) + } candidates := []GatewayProvider{{ID: "provider-a", AccountID: "account-a"}, {ID: "provider-b", AccountID: "account-b"}} ordered := svc.OrderGatewayCandidatesByEffectivePricing(ctx, "public-model", "openai_chat_completions", "fingerprint-a", candidates) if ordered[0].AccountID != "account-b" || !strings.Contains(ordered[0].SelectionReason, decision.ID) { @@ -257,6 +260,9 @@ func TestEffectivePricingDecisionCanaryOrdersCandidateAndRollbackStopsIt(t *test if ordered[0].AccountID != "account-a" { t.Fatalf("rolled back decision still changed order=%+v", ordered) } + if _, err := svc.ActOnEffectivePricingDecision(ctx, "tester", "missing", EffectivePricingDecisionActionRequest{Action: "hold"}); err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("missing decision action error=%v", err) + } } func TestEffectivePricingCanaryUsesStableCohortDistribution(t *testing.T) { diff --git a/backend/internal/controlplane/gateway_affinity.go b/backend/internal/controlplane/gateway_affinity.go index 2f7269d..2868750 100644 --- a/backend/internal/controlplane/gateway_affinity.go +++ b/backend/internal/controlplane/gateway_affinity.go @@ -16,14 +16,16 @@ const ( ) type GatewayAffinityInput struct { - ApplicationID string - PrincipalID string - CredentialID string - Model string - Protocol string - RouteGroup string - StickyKey string - PolicyVersion int + ApplicationID string + PrincipalID string + CredentialID string + Model string + Protocol string + RouteGroup string + StickyKey string + AccessPolicyVersion int + RoutingPolicyID string + RoutingPolicyVersion int } type GatewayUpstreamAffinity struct { @@ -73,7 +75,8 @@ func (s *Service) gatewayUpstreamAffinityValue(input GatewayAffinityInput, provi } identity := strings.Join([]string{ "upstream_cache", input.ApplicationID, principalID, input.CredentialID, input.Model, input.Protocol, - input.RouteGroup, input.StickyKey, strconv.Itoa(input.PolicyVersion), provider.ID, provider.AccountID, provider.UpstreamModel, + input.RouteGroup, input.StickyKey, strconv.Itoa(input.AccessPolicyVersion), input.RoutingPolicyID, + strconv.Itoa(input.RoutingPolicyVersion), provider.ID, provider.AccountID, provider.UpstreamModel, }, "\x00") mac := hmac.New(sha256.New, []byte(s.secretKey)) _, _ = mac.Write([]byte(identity)) @@ -118,7 +121,7 @@ func (s *Service) BindGatewayCandidateAffinity(ctx context.Context, input Gatewa supplierBinding := RoutingAffinityBinding{ ScopeKey: s.gatewayAffinityScopeKey(AffinityBindingSupplier, input), Kind: AffinityBindingSupplier, ProviderID: provider.ID, Model: strings.TrimSpace(input.Model), Protocol: strings.TrimSpace(input.Protocol), - PolicyVersion: input.PolicyVersion, CreatedAt: now, LastReusedAt: now, ExpiresAt: now.Add(supplierTTL), + PolicyVersion: input.RoutingPolicyVersion, CreatedAt: now, LastReusedAt: now, ExpiresAt: now.Add(supplierTTL), } supplierWinner, err := s.claimRoutingAffinityBinding(ctx, supplierBinding, supplierTTL) if err != nil { @@ -136,7 +139,7 @@ func (s *Service) BindGatewayCandidateAffinity(ctx context.Context, input Gatewa accountBinding := RoutingAffinityBinding{ ScopeKey: s.gatewayAffinityScopeKey(AffinityBindingAccount, input), Kind: AffinityBindingAccount, ProviderID: provider.ID, ProviderAccountID: provider.AccountID, RouteID: provider.RouteID, - Model: strings.TrimSpace(input.Model), Protocol: strings.TrimSpace(input.Protocol), PolicyVersion: input.PolicyVersion, + Model: strings.TrimSpace(input.Model), Protocol: strings.TrimSpace(input.Protocol), PolicyVersion: input.RoutingPolicyVersion, CreatedAt: now, LastReusedAt: now, ExpiresAt: now.Add(accountTTL), } _, err = s.claimRoutingAffinityBinding(ctx, accountBinding, accountTTL) @@ -234,13 +237,15 @@ func (s *Service) gatewayAffinityScopeKey(kind string, input GatewayAffinityInpu identity := "" switch kind { case AffinityBindingAccount: - identity = strings.Join([]string{kind, input.CredentialID, input.Model, input.Protocol, input.RouteGroup, input.StickyKey, strconv.Itoa(input.PolicyVersion)}, "\x00") + identity = strings.Join([]string{kind, input.CredentialID, input.Model, input.Protocol, input.RouteGroup, input.StickyKey, + strconv.Itoa(input.AccessPolicyVersion), input.RoutingPolicyID, strconv.Itoa(input.RoutingPolicyVersion)}, "\x00") default: principalID := strings.TrimSpace(input.PrincipalID) if principalID == "" { principalID = strings.TrimSpace(input.CredentialID) } - identity = strings.Join([]string{kind, input.ApplicationID, principalID, input.Model, input.Protocol, input.RouteGroup, strconv.Itoa(input.PolicyVersion)}, "\x00") + identity = strings.Join([]string{kind, input.ApplicationID, principalID, input.Model, input.Protocol, input.RouteGroup, + strconv.Itoa(input.AccessPolicyVersion), input.RoutingPolicyID, strconv.Itoa(input.RoutingPolicyVersion)}, "\x00") } mac := hmac.New(sha256.New, []byte(s.secretKey)) _, _ = mac.Write([]byte(identity)) diff --git a/backend/internal/controlplane/gateway_pipeline.go b/backend/internal/controlplane/gateway_pipeline.go index 634322f..fe2de64 100644 --- a/backend/internal/controlplane/gateway_pipeline.go +++ b/backend/internal/controlplane/gateway_pipeline.go @@ -8,14 +8,17 @@ import ( ) type GatewayExecutionPlan struct { - Request gatewaycore.CanonicalRequest `json:"request"` - Auth gatewaycore.CanonicalAuthContext `json:"auth"` - GatewayModelID string `json:"gateway_model_id"` - RouteGroup string `json:"route_group"` - Candidates []GatewayProvider `json:"-"` - Exclusions []GatewayCandidateExclusion `json:"exclusions"` - HasRoutes bool `json:"has_routes"` - RejectionReason string `json:"rejection_reason,omitempty"` + Request gatewaycore.CanonicalRequest `json:"request"` + Auth gatewaycore.CanonicalAuthContext `json:"auth"` + GatewayModelID string `json:"gateway_model_id"` + RouteGroup string `json:"route_group"` + RoutingPolicyID string `json:"routing_policy_id,omitempty"` + RoutingPolicyVersion int `json:"routing_policy_version,omitempty"` + RoutingPolicyPreset string `json:"routing_policy_preset,omitempty"` + Candidates []GatewayProvider `json:"-"` + Exclusions []GatewayCandidateExclusion `json:"exclusions"` + HasRoutes bool `json:"has_routes"` + RejectionReason string `json:"rejection_reason,omitempty"` } type GatewayCandidateExclusion struct { @@ -26,6 +29,11 @@ type GatewayCandidateExclusion struct { Reason string `json:"reason"` } +type routingPolicyCandidateDecision struct { + Candidates []GatewayProvider + Exclusions []GatewayCandidateExclusion +} + func (s *Service) AuthorizeCanonicalGatewayRequest(ctx context.Context, credential gatewaycore.CredentialEnvelope, request gatewaycore.CanonicalRequest) (GatewayAuthContext, gatewaycore.CanonicalAuthContext, error) { return s.authorizeCanonicalGatewayRequest(ctx, credential, request, true) } @@ -80,9 +88,17 @@ func (s *Service) planCanonicalGatewayRequest(ctx context.Context, auth gatewayc if err != nil { return GatewayExecutionPlan{}, err } + if routingPolicy != nil && !routingPolicyAllowsModel(routingPolicy.Strategy, resolved.RequestedID) { + return GatewayExecutionPlan{ + Request: request, Auth: auth, GatewayModelID: resolved.GatewayModel.ID, RouteGroup: resolved.RouteGroup, + RoutingPolicyID: routingPolicy.ID, RoutingPolicyVersion: routingPolicy.Version, RoutingPolicyPreset: routingPolicy.Strategy.Preset, + HasRoutes: true, RejectionReason: "routing_policy_model_blocked", + }, nil + } if routingPolicy != nil && !routingPolicyAllowsProtocol(routingPolicy.Strategy, string(request.Protocol)) { return GatewayExecutionPlan{ Request: request, Auth: auth, GatewayModelID: resolved.GatewayModel.ID, RouteGroup: resolved.RouteGroup, + RoutingPolicyID: routingPolicy.ID, RoutingPolicyVersion: routingPolicy.Version, RoutingPolicyPreset: routingPolicy.Strategy.Preset, HasRoutes: true, RejectionReason: "routing_policy_protocol_blocked", }, nil } @@ -90,23 +106,13 @@ func (s *Service) planCanonicalGatewayRequest(ctx context.Context, auth gatewayc if err != nil { return GatewayExecutionPlan{}, err } - if routingPolicy != nil && routingPolicy.Strategy.NativeProtocolOnly { - filtered := candidates[:0] - for _, candidate := range candidates { - if routingPolicyNativeProtocolMatches(string(request.Protocol), candidate.UpstreamFormat) { - filtered = append(filtered, candidate) - } - } - candidates = filtered - } - candidates, err = s.applyRoutingPolicyPriceRules(ctx, routingPolicy, string(request.Protocol), candidates) + consideredCandidates := append([]GatewayProvider(nil), candidates...) + decision, err := s.applyRoutingPolicyCandidateRules(ctx, routingPolicy, string(request.Protocol), candidates) if err != nil { return GatewayExecutionPlan{}, err } - if len(candidates) > 1 && routingPolicy != nil && !routingPolicy.Strategy.FailoverBeforeFirstByte { - candidates = candidates[:1] - } - exclusions, err := s.gatewayCandidateExclusions(ctx, resolved, candidates) + candidates = decision.Candidates + exclusions, err := s.gatewayCandidateExclusions(ctx, resolved, consideredCandidates, decision.Exclusions) if err != nil { return GatewayExecutionPlan{}, err } @@ -114,7 +120,7 @@ func (s *Service) planCanonicalGatewayRequest(ctx context.Context, auth gatewayc if len(candidates) == 0 && hasRoutes { rejectionReason = "all_candidates_excluded" } - return GatewayExecutionPlan{ + plan := GatewayExecutionPlan{ Request: request, Auth: auth, GatewayModelID: resolved.GatewayModel.ID, @@ -123,12 +129,61 @@ func (s *Service) planCanonicalGatewayRequest(ctx context.Context, auth gatewayc Exclusions: exclusions, HasRoutes: hasRoutes, RejectionReason: rejectionReason, - }, nil + } + if routingPolicy != nil { + plan.RoutingPolicyID = routingPolicy.ID + plan.RoutingPolicyVersion = routingPolicy.Version + plan.RoutingPolicyPreset = routingPolicy.Strategy.Preset + } + return plan, nil +} + +func (s *Service) applyRoutingPolicyCandidateRules(ctx context.Context, policy *RoutingPolicy, protocol string, candidates []GatewayProvider) (routingPolicyCandidateDecision, error) { + decision := routingPolicyCandidateDecision{Candidates: append([]GatewayProvider(nil), candidates...), Exclusions: []GatewayCandidateExclusion{}} + if policy == nil || len(candidates) == 0 { + return decision, nil + } + + if policy.Strategy.NativeProtocolOnly { + retained := make([]GatewayProvider, 0, len(decision.Candidates)) + for _, candidate := range decision.Candidates { + if routingPolicyNativeProtocolMatches(protocol, candidate.UpstreamFormat) { + retained = append(retained, candidate) + continue + } + decision.Exclusions = append(decision.Exclusions, gatewayCandidatePolicyExclusion(candidate, "routing_policy_native_protocol_required")) + } + decision.Candidates = retained + } + + priceDecision, err := s.evaluateRoutingPolicyPriceRules(ctx, policy, protocol, decision.Candidates) + if err != nil { + return routingPolicyCandidateDecision{}, err + } + for _, exclusion := range priceDecision.exclusions { + decision.Exclusions = append(decision.Exclusions, gatewayCandidatePolicyExclusion(exclusion.candidate, exclusion.reason)) + } + decision.Candidates = priceDecision.candidates + + if len(decision.Candidates) > 1 && !policy.Strategy.FailoverBeforeFirstByte { + for _, candidate := range decision.Candidates[1:] { + decision.Exclusions = append(decision.Exclusions, gatewayCandidatePolicyExclusion(candidate, "routing_policy_failover_disabled")) + } + decision.Candidates = decision.Candidates[:1] + } + return decision, nil +} + +func gatewayCandidatePolicyExclusion(candidate GatewayProvider, reason string) GatewayCandidateExclusion { + return GatewayCandidateExclusion{ + RouteID: candidate.RouteID, ProviderID: candidate.ID, ProviderAccountID: candidate.AccountID, + UpstreamModel: candidate.UpstreamModel, Reason: reason, + } } -func (s *Service) gatewayCandidateExclusions(ctx context.Context, resolved ResolvedGatewayModel, candidates []GatewayProvider) ([]GatewayCandidateExclusion, error) { - included := make(map[string]struct{}, len(candidates)) - for _, candidate := range candidates { +func (s *Service) gatewayCandidateExclusions(ctx context.Context, resolved ResolvedGatewayModel, considered []GatewayProvider, policyExclusions []GatewayCandidateExclusion) ([]GatewayCandidateExclusion, error) { + included := make(map[string]struct{}, len(considered)) + for _, candidate := range considered { if candidate.RouteID != "" { included[candidate.RouteID] = struct{}{} } @@ -137,13 +192,14 @@ func (s *Service) gatewayCandidateExclusions(ctx context.Context, resolved Resol if err != nil { return nil, err } - exclusions := make([]GatewayCandidateExclusion, 0, len(skipped)) + exclusions := make([]GatewayCandidateExclusion, 0, len(skipped)+len(policyExclusions)) for _, candidate := range skipped { exclusions = append(exclusions, GatewayCandidateExclusion{ RouteID: candidate.RouteID, ProviderID: candidate.ProviderID, ProviderAccountID: candidate.ProviderAccountID, UpstreamModel: candidate.UpstreamModel, Reason: candidate.Reason, }) } + exclusions = append(exclusions, policyExclusions...) return exclusions, nil } diff --git a/backend/internal/controlplane/gateway_pipeline_test.go b/backend/internal/controlplane/gateway_pipeline_test.go index 5f28b1d..7b71ad0 100644 --- a/backend/internal/controlplane/gateway_pipeline_test.go +++ b/backend/internal/controlplane/gateway_pipeline_test.go @@ -234,6 +234,15 @@ func TestPlanCanonicalGatewayRequestRecordsCandidateExclusions(t *testing.T) { if !plan.HasRoutes || len(plan.Candidates) != 0 || plan.RejectionReason != "all_candidates_excluded" || len(plan.Exclusions) != 1 || plan.Exclusions[0].RouteID != route.ID || plan.Exclusions[0].Reason != test.wantReason { t.Fatalf("plan = %+v", plan) } + simulation, err := svc.SimulateGatewayRouting(ctx, GatewaySimulationRequest{ + Model: "public-model", Protocol: string(gatewaycore.ProtocolOpenAIChat), EstimatedTokens: 100, + }) + if err != nil { + t.Fatalf("SimulateGatewayRouting(): %v", err) + } + if simulation.Status != "blocked" || simulation.RejectionReason != "all_candidates_excluded" || len(simulation.Candidates) != 1 || simulation.Candidates[0].RouteID != route.ID || simulation.Candidates[0].Eligible || simulation.Candidates[0].Reason != test.wantReason { + t.Fatalf("simulation = %+v", simulation) + } }) } } diff --git a/backend/internal/controlplane/gateway_scheduler_test.go b/backend/internal/controlplane/gateway_scheduler_test.go index 3938387..d052911 100644 --- a/backend/internal/controlplane/gateway_scheduler_test.go +++ b/backend/internal/controlplane/gateway_scheduler_test.go @@ -109,7 +109,7 @@ func TestGatewayCandidateAffinityReusesAccountThenSupplierWithinScope(t *testing } input := GatewayAffinityInput{ ApplicationID: "application-a", PrincipalID: "customer-a", CredentialID: "key-a", Model: "public-model", - Protocol: "openai_chat", RouteGroup: "stable", StickyKey: "session-1", PolicyVersion: 3, + Protocol: "openai_chat", RouteGroup: "stable", StickyKey: "session-1", RoutingPolicyVersion: 3, } if err := svc.BindGatewayCandidateAffinity(ctx, input, candidates[1]); err != nil { t.Fatalf("BindGatewayCandidateAffinity(): %v", err) @@ -168,7 +168,7 @@ func TestResolveGatewayUpstreamAffinityUsesVerifiedCapabilityAndOpaqueValue(t *t } input := GatewayAffinityInput{ ApplicationID: "application-a", PrincipalID: "customer-a", CredentialID: "key-a", Model: "public-model", - Protocol: "openai_chat_completions", RouteGroup: "default", StickyKey: "raw-session-secret", PolicyVersion: 3, + Protocol: "openai_chat_completions", RouteGroup: "default", StickyKey: "raw-session-secret", RoutingPolicyVersion: 3, } provider := GatewayProvider{ID: "provider-a", AccountID: "account-a", UpstreamModel: "upstream-model"} first, found, err := svc.ResolveGatewayUpstreamAffinity(ctx, input, provider) diff --git a/backend/internal/controlplane/gateway_simulator.go b/backend/internal/controlplane/gateway_simulator.go index 881ddc7..15b515a 100644 --- a/backend/internal/controlplane/gateway_simulator.go +++ b/backend/internal/controlplane/gateway_simulator.go @@ -34,12 +34,16 @@ type GatewaySimulationCandidate struct { } type GatewaySimulation struct { - RequestedModel string `json:"requested_model"` - ResolvedModel string `json:"resolved_model"` - RouteGroup string `json:"route_group"` - Status string `json:"status"` - Summary string `json:"summary"` - Candidates []GatewaySimulationCandidate `json:"candidates"` + RequestedModel string `json:"requested_model"` + ResolvedModel string `json:"resolved_model"` + RouteGroup string `json:"route_group"` + Status string `json:"status"` + Summary string `json:"summary"` + RejectionReason string `json:"rejection_reason,omitempty"` + RoutingPolicyID string `json:"routing_policy_id,omitempty"` + RoutingPolicyVersion int `json:"routing_policy_version,omitempty"` + RoutingPolicyPreset string `json:"routing_policy_preset,omitempty"` + Candidates []GatewaySimulationCandidate `json:"candidates"` } func (s *Service) SimulateGatewayRouting(ctx context.Context, req GatewaySimulationRequest) (GatewaySimulation, error) { @@ -54,7 +58,28 @@ func (s *Service) SimulateGatewayRouting(ctx context.Context, req GatewaySimulat } result.ResolvedModel = resolved.GatewayModel.ModelID result.RouteGroup = resolved.RouteGroup - ranked, hasRoutes, err := s.rankedModelRouteCandidates(ctx, resolved) + policy, err := s.activeRoutingPolicyForGroup(ctx, resolved.RouteGroup) + if err != nil { + return GatewaySimulation{}, err + } + if policy != nil { + result.RoutingPolicyID = policy.ID + result.RoutingPolicyVersion = policy.Version + result.RoutingPolicyPreset = policy.Strategy.Preset + } + if policy != nil && !routingPolicyAllowsModel(policy.Strategy, resolved.RequestedID) { + return s.blockedGatewaySimulation(ctx, result, resolved, "routing_policy_model_blocked") + } + if protocol := strings.TrimSpace(req.Protocol); protocol != "" { + if !routingPolicyProtocolSupported(protocol) { + return s.blockedGatewaySimulation(ctx, result, resolved, "client_protocol_unsupported") + } + if policy != nil && !routingPolicyAllowsProtocol(policy.Strategy, protocol) { + return s.blockedGatewaySimulation(ctx, result, resolved, "routing_policy_protocol_blocked") + } + } + + baseCandidates, hasRoutes, err := s.GatewayProviderCandidatesForModel(ctx, req.Model) if err != nil { return GatewaySimulation{}, err } @@ -63,38 +88,95 @@ func (s *Service) SimulateGatewayRouting(ctx context.Context, req GatewaySimulat result.Summary = "no model routes exist for the resolved route group" return result, nil } - rankedByRouteID := make(map[string]struct{}, len(ranked)) - for _, candidate := range ranked { - rankedByRouteID[candidate.route.ID] = struct{}{} + decision, err := s.applyRoutingPolicyCandidateRules(ctx, policy, strings.TrimSpace(req.Protocol), baseCandidates) + if err != nil { + return GatewaySimulation{}, err } - for index, candidate := range ranked { - provider := GatewayProvider{ - AccountID: candidate.account.ID, RPMLimit: candidate.account.RPMLimit, TPMLimit: candidate.account.TPMLimit, - CircuitState: candidate.circuitState, CircuitProbe: candidate.circuitProbe, Concurrency: candidate.account.Concurrency, - } - reason := simulationPermitReason(s, provider, req.EstimatedTokens) + baseByRouteID := make(map[string]GatewayProvider, len(baseCandidates)) + for _, candidate := range baseCandidates { + baseByRouteID[candidate.RouteID] = candidate + } + for index, candidate := range decision.Candidates { + reason := simulationPermitReason(s, candidate, req.EstimatedTokens) if reason == "" { - reason = simulationProtocolReason(req.Protocol, req.RequiredFeatures, candidate.route.UpstreamFormat) + reason = simulationProtocolReason(req.Protocol, req.RequiredFeatures, candidate.UpstreamFormat) + } + result.Candidates = append(result.Candidates, gatewaySimulationCandidate(candidate, index+1, reason)) + } + for _, exclusion := range decision.Exclusions { + candidate, found := baseByRouteID[exclusion.RouteID] + if !found { + continue } - result.Candidates = append(result.Candidates, GatewaySimulationCandidate{ - Rank: index + 1, RouteID: candidate.route.ID, RouteGroup: candidate.route.RouteGroup, - ProviderID: candidate.provider.ID, ProviderAccountID: candidate.account.ID, UpstreamModel: ProviderAccountDispatchModel(candidate.account, candidate.route.UpstreamModel, resolved.RequestedID), - ProviderType: candidate.provider.Type, UpstreamFormat: candidate.route.UpstreamFormat, Adapter: candidate.provider.Type, - Headroom: candidate.headroom, RPMLimit: candidate.account.RPMLimit, TPMLimit: candidate.account.TPMLimit, - Concurrency: candidate.account.Concurrency, CircuitState: candidate.circuitState, - Eligible: reason == "", Reason: reason, - }) - } - skipped, err := s.skippedSimulationCandidates(ctx, resolved, rankedByRouteID, len(result.Candidates)+1) + result.Candidates = append(result.Candidates, gatewaySimulationCandidate(candidate, len(result.Candidates)+1, exclusion.Reason)) + } + consideredByRouteID := make(map[string]struct{}, len(baseCandidates)) + for _, candidate := range baseCandidates { + consideredByRouteID[candidate.RouteID] = struct{}{} + } + skipped, err := s.skippedSimulationCandidates(ctx, resolved, consideredByRouteID, len(result.Candidates)+1) if err != nil { return GatewaySimulation{}, err } result.Candidates = append(result.Candidates, skipped...) - result.Status = "ready" - result.Summary = fmt.Sprintf("resolved %d candidates without consuming scheduling capacity", len(result.Candidates)) + eligible := 0 + for _, candidate := range result.Candidates { + if candidate.Eligible { + eligible++ + } + } + if eligible == 0 { + result.Status = "blocked" + result.RejectionReason = "all_candidates_excluded" + } else { + result.Status = "ready" + } + result.Summary = fmt.Sprintf("resolved %d eligible candidates from %d routes without consuming scheduling capacity", eligible, len(result.Candidates)) + return result, nil +} + +func (s *Service) blockedGatewaySimulation(ctx context.Context, result GatewaySimulation, resolved ResolvedGatewayModel, reason string) (GatewaySimulation, error) { + result.Status = "blocked" + result.RejectionReason = reason + ranked, _, err := s.rankedModelRouteCandidates(ctx, resolved) + if err != nil { + return GatewaySimulation{}, err + } + considered := make(map[string]struct{}, len(ranked)) + for _, candidate := range ranked { + considered[candidate.route.ID] = struct{}{} + provider := GatewayProvider{ + ID: candidate.provider.ID, Type: candidate.provider.Type, AccountID: candidate.account.ID, + UpstreamModel: ProviderAccountDispatchModel(candidate.account, candidate.route.UpstreamModel, resolved.RequestedID), + UpstreamFormat: candidate.route.UpstreamFormat, RouteID: candidate.route.ID, RouteGroup: candidate.route.RouteGroup, + RPMLimit: candidate.account.RPMLimit, TPMLimit: candidate.account.TPMLimit, Concurrency: candidate.account.Concurrency, + CircuitState: candidate.circuitState, Headroom: candidate.headroom, + } + result.Candidates = append(result.Candidates, gatewaySimulationCandidate(provider, len(result.Candidates)+1, reason)) + } + skipped, err := s.skippedSimulationCandidates(ctx, resolved, considered, len(result.Candidates)+1) + if err != nil { + return GatewaySimulation{}, err + } + for index := range skipped { + skipped[index].Reason = reason + } + result.Candidates = append(result.Candidates, skipped...) + result.Summary = fmt.Sprintf("routing policy blocked all %d routes: %s", len(result.Candidates), reason) return result, nil } +func gatewaySimulationCandidate(candidate GatewayProvider, rank int, reason string) GatewaySimulationCandidate { + return GatewaySimulationCandidate{ + Rank: rank, RouteID: candidate.RouteID, RouteGroup: candidate.RouteGroup, + ProviderID: candidate.ID, ProviderAccountID: candidate.AccountID, UpstreamModel: candidate.UpstreamModel, + ProviderType: candidate.Type, UpstreamFormat: candidate.UpstreamFormat, Adapter: candidate.Type, + Headroom: candidate.Headroom, RPMLimit: candidate.RPMLimit, TPMLimit: candidate.TPMLimit, + Concurrency: candidate.Concurrency, CircuitState: candidate.CircuitState, + Eligible: reason == "", Reason: reason, + } +} + func (s *Service) skippedSimulationCandidates(ctx context.Context, resolved ResolvedGatewayModel, ranked map[string]struct{}, rankStart int) ([]GatewaySimulationCandidate, error) { routes, err := s.repo.ListModelRoutes(ctx) if err != nil { @@ -173,13 +255,13 @@ func (s *Service) skippedSimulationCandidates(ctx context.Context, resolved Reso func simulationProtocolReason(protocol string, features []string, upstreamFormat string) string { protocol = strings.TrimSpace(protocol) - if protocol != "" && !oneOf(protocol, "openai_chat_completions", "openai_responses", "openai_embeddings", "anthropic_messages", "gemini_generate_content") { + if protocol != "" && !routingPolicyProtocolSupported(protocol) { return "client_protocol_unsupported" } if protocol == "openai_embeddings" && upstreamFormat != UpstreamFormatOpenAIEmbeddings { return "protocol_incompatible:openai_embeddings" } - if protocol != "" && upstreamFormat == UpstreamFormatNativeMedia { + if protocol != "" && upstreamFormat == UpstreamFormatNativeMedia && !routingPolicyNativeProtocolMatches(protocol, upstreamFormat) { return "protocol_incompatible:native_media" } for _, feature := range cleanStringList(features) { diff --git a/backend/internal/controlplane/portal_service_test.go b/backend/internal/controlplane/portal_service_test.go index 83417f2..3c41cdc 100644 --- a/backend/internal/controlplane/portal_service_test.go +++ b/backend/internal/controlplane/portal_service_test.go @@ -2,6 +2,7 @@ package controlplane import ( "context" + "strings" "testing" "time" ) @@ -153,3 +154,38 @@ func TestPortalKeyManagementRequiresKeyManagerRole(t *testing.T) { t.Fatalf("DisablePortalAPIKey(): %v", err) } } + +func TestPortalAPIKeyCreationRejectsReadOnlyPrincipal(t *testing.T) { + ctx := context.Background() + svc := NewService(NewMemoryRepository(), "/v1") + user, err := svc.CreateWorkspaceUser(ctx, "tester", WorkspaceUserRequest{ + Email: "auditor@example.com", + DisplayName: "Read-only Auditor", + Status: WorkspaceUserStatusActive, + Role: RoleReadOnlyAuditor, + }) + if err != nil { + t.Fatalf("CreateWorkspaceUser(): %v", err) + } + + workspace, err := svc.PortalWorkspace(ctx, user.Email) + if err != nil { + t.Fatalf("PortalWorkspace(): %v", err) + } + if workspace.CanManageKeys { + t.Fatal("read-only auditor unexpectedly received portal key management access") + } + if _, err := svc.CreatePortalAPIKey(ctx, user.Email, APIKeyCreateRequest{ + Name: "Forbidden Key", + ModelAllowlist: []string{"gpt-4o-mini"}, + }); err == nil || !strings.Contains(err.Error(), "cannot manage workspace keys") { + t.Fatalf("CreatePortalAPIKey() error=%v, want key management rejection", err) + } + keys, err := svc.ListAPIKeys(ctx) + if err != nil { + t.Fatalf("ListAPIKeys(): %v", err) + } + if len(keys) != 0 { + t.Fatalf("rejected portal key creation mutated repository: %+v", keys) + } +} diff --git a/backend/internal/controlplane/retention_service_test.go b/backend/internal/controlplane/retention_service_test.go index 52d5719..8b31ad3 100644 --- a/backend/internal/controlplane/retention_service_test.go +++ b/backend/internal/controlplane/retention_service_test.go @@ -46,3 +46,18 @@ func TestCleanupRetainedDataDeletesExpiredRecordsButPreservesActiveAlertsAndAudi t.Fatalf("cleanup audit evidence missing: %+v", logs) } } + +func TestCleanupRetainedDataRejectsInvalidCutoffWithoutMutation(t *testing.T) { + repo := NewMemoryRepository() + repo.usageRecords["old_usage"] = UsageRecord{ID: "old_usage", CreatedAt: time.Now().UTC().Add(-60 * 24 * time.Hour)} + svc := NewService(repo, "/v1", "secret") + + for _, cutoff := range []time.Time{{}, time.Now().UTC().Add(time.Hour)} { + if _, err := svc.CleanupRetainedData(t.Context(), "admin", cutoff); err == nil { + t.Fatalf("CleanupRetainedData(%s) error = nil", cutoff) + } + if _, ok := repo.usageRecords["old_usage"]; !ok { + t.Fatalf("CleanupRetainedData(%s) mutated retained records", cutoff) + } + } +} diff --git a/backend/internal/controlplane/routing_affinity_coordinator_test.go b/backend/internal/controlplane/routing_affinity_coordinator_test.go index 352741d..1aa367e 100644 --- a/backend/internal/controlplane/routing_affinity_coordinator_test.go +++ b/backend/internal/controlplane/routing_affinity_coordinator_test.go @@ -21,7 +21,7 @@ func TestRoutingAffinityCoordinatorMakesFirstProviderWinAcrossServices(t *testin serviceB.SetRoutingAffinityCoordinator(coordinator) input := GatewayAffinityInput{ ApplicationID: "application-a", PrincipalID: "principal-a", CredentialID: "credential-a", - Model: "public-model", Protocol: "openai_chat_completions", RouteGroup: "default", StickyKey: "session-a", PolicyVersion: 1, + Model: "public-model", Protocol: "openai_chat_completions", RouteGroup: "default", StickyKey: "session-a", RoutingPolicyVersion: 1, } providerA := GatewayProvider{ID: "provider-a", AccountID: "account-a", RouteID: "route-a", StickyEnabled: true, StickyTTLSeconds: 1800} providerB := GatewayProvider{ID: "provider-b", AccountID: "account-b", RouteID: "route-b", StickyEnabled: true, StickyTTLSeconds: 1800} @@ -50,7 +50,7 @@ func TestRoutingAffinityCoordinatorFailureFallsBackToRepository(t *testing.T) { service := NewService(repo, "/v1", "fallback-affinity-secret") service.now = func() time.Time { return now } service.SetRoutingAffinityCoordinator(&memoryRoutingAffinityCoordinator{fail: true, bindings: map[string]RoutingAffinityBinding{}}) - input := GatewayAffinityInput{ApplicationID: "application", PrincipalID: "principal", CredentialID: "credential", Model: "model", Protocol: "openai_chat_completions", RouteGroup: "default", PolicyVersion: 1} + input := GatewayAffinityInput{ApplicationID: "application", PrincipalID: "principal", CredentialID: "credential", Model: "model", Protocol: "openai_chat_completions", RouteGroup: "default", RoutingPolicyVersion: 1} providerA := GatewayProvider{ID: "provider-a"} providerB := GatewayProvider{ID: "provider-b"} if err := service.BindGatewayCandidateAffinity(ctx, input, providerB); err != nil { diff --git a/backend/internal/controlplane/routing_policy_runtime.go b/backend/internal/controlplane/routing_policy_runtime.go index 08e2c2f..52f06d3 100644 --- a/backend/internal/controlplane/routing_policy_runtime.go +++ b/backend/internal/controlplane/routing_policy_runtime.go @@ -17,37 +17,66 @@ type routingPolicyCandidatePrice struct { order int } +type routingPolicyPriceExclusion struct { + candidate GatewayProvider + reason string +} + +type routingPolicyPriceDecision struct { + candidates []GatewayProvider + exclusions []routingPolicyPriceExclusion +} + func (s *Service) applyRoutingPolicyPriceRules(ctx context.Context, policy *RoutingPolicy, protocol string, candidates []GatewayProvider) ([]GatewayProvider, error) { + decision, err := s.evaluateRoutingPolicyPriceRules(ctx, policy, protocol, candidates) + return decision.candidates, err +} + +func (s *Service) evaluateRoutingPolicyPriceRules(ctx context.Context, policy *RoutingPolicy, protocol string, candidates []GatewayProvider) (routingPolicyPriceDecision, error) { + decision := routingPolicyPriceDecision{candidates: candidates, exclusions: []routingPolicyPriceExclusion{}} if policy == nil || len(candidates) == 0 { - return candidates, nil + return decision, nil } strategy := policy.Strategy priceRulesConfigured := strategy.AbsoluteMaxInputPer1M > 0 || strategy.AbsoluteMaxOutputPer1M > 0 || strategy.MaxPriceMultipleOfCheapest > 0 || oneOf(strategy.LowPricePoolMode, RoutingPolicyLowPriceAuto, RoutingPolicyLowPriceStrict, RoutingPolicyLowPricePercent) if !priceRulesConfigured && strategy.Preset != RoutingPolicyPresetCost { - return candidates, nil + return decision, nil } prices, err := s.repo.ListProcurementPrices(ctx) if err != nil { - return nil, err + return routingPolicyPriceDecision{}, err } now := s.nowUTC() activePrices := activeRoutingPrices(prices, protocol, now) if len(activePrices) == 0 { - return candidates, nil + return decision, nil + } + comparablePriceByBatch := make(map[int]bool) + for _, candidate := range candidates { + if _, found := activePrices[routingPriceKey(candidate.AccountID, candidate.UpstreamModel)]; found { + comparablePriceByBatch[candidate.PolicyBatchOrder] = true + } + } + if len(comparablePriceByBatch) == 0 { + return decision, nil } priced := make([]routingPolicyCandidatePrice, 0, len(candidates)) for index, candidate := range candidates { price, found := activePrices[routingPriceKey(candidate.AccountID, candidate.UpstreamModel)] if !found { - if !priceRulesConfigured { + if !priceRulesConfigured || !comparablePriceByBatch[candidate.PolicyBatchOrder] { priced = append(priced, routingPolicyCandidatePrice{candidate: candidate, order: index}) + } else { + decision.exclusions = append(decision.exclusions, routingPolicyPriceExclusion{candidate: candidate, reason: "routing_policy_price_fact_missing"}) } continue } if strategy.AbsoluteMaxInputPer1M > 0 && price.UncachedInputMicrosPer1MTokens > dollarsToMicros(strategy.AbsoluteMaxInputPer1M) { + decision.exclusions = append(decision.exclusions, routingPolicyPriceExclusion{candidate: candidate, reason: "routing_policy_input_price_exceeded"}) continue } if strategy.AbsoluteMaxOutputPer1M > 0 && price.OutputMicrosPer1MTokens > dollarsToMicros(strategy.AbsoluteMaxOutputPer1M) { + decision.exclusions = append(decision.exclusions, routingPolicyPriceExclusion{candidate: candidate, reason: "routing_policy_output_price_exceeded"}) continue } priced = append(priced, routingPolicyCandidatePrice{ @@ -58,26 +87,37 @@ func (s *Service) applyRoutingPolicyPriceRules(ctx context.Context, policy *Rout }) } if len(priced) == 0 { - return nil, nil + decision.candidates = nil + return decision, nil } - cheapest := int64(0) + cheapestByBatch := make(map[int]int64) for _, candidate := range priced { - if candidate.priced && (cheapest == 0 || candidate.price < cheapest) { - cheapest = candidate.price + batch := candidate.candidate.PolicyBatchOrder + current, found := cheapestByBatch[batch] + if candidate.priced && (!found || candidate.price < current) { + cheapestByBatch[batch] = candidate.price } } - if strategy.MaxPriceMultipleOfCheapest > 0 && cheapest > 0 { + if strategy.MaxPriceMultipleOfCheapest > 0 { filtered := priced[:0] - limit := float64(cheapest) * strategy.MaxPriceMultipleOfCheapest for _, candidate := range priced { - if candidate.priced && float64(candidate.price) <= limit { + if !candidate.priced { filtered = append(filtered, candidate) + continue + } + cheapest, found := cheapestByBatch[candidate.candidate.PolicyBatchOrder] + limit := float64(cheapest) * strategy.MaxPriceMultipleOfCheapest + if candidate.priced && found && float64(candidate.price) <= limit { + filtered = append(filtered, candidate) + } else { + decision.exclusions = append(decision.exclusions, routingPolicyPriceExclusion{candidate: candidate.candidate, reason: "routing_policy_relative_price_exceeded"}) } } priced = filtered } if len(priced) == 0 { - return nil, nil + decision.candidates = nil + return decision, nil } sort.SliceStable(priced, func(i, j int) bool { if priced[i].candidate.PolicyBatchOrder != priced[j].candidate.PolicyBatchOrder { @@ -92,18 +132,35 @@ func (s *Service) applyRoutingPolicyPriceRules(ctx context.Context, policy *Rout return false }) if oneOf(strategy.LowPricePoolMode, RoutingPolicyLowPriceAuto, RoutingPolicyLowPriceStrict, RoutingPolicyLowPricePercent) { + beforePool := append([]routingPolicyCandidatePrice(nil), priced...) priced = lowPriceCandidatePool(priced, strategy) + retained := make(map[string]struct{}, len(priced)) + for _, candidate := range priced { + retained[routingPolicyPriceCandidateKey(candidate.candidate)] = struct{}{} + } + for _, candidate := range beforePool { + if _, found := retained[routingPolicyPriceCandidateKey(candidate.candidate)]; !found { + decision.exclusions = append(decision.exclusions, routingPolicyPriceExclusion{candidate: candidate.candidate, reason: "routing_policy_low_price_pool_excluded"}) + } + } } if strategy.Preset != RoutingPolicyPresetCost { sort.SliceStable(priced, func(i, j int) bool { return priced[i].order < priced[j].order }) } - out := make([]GatewayProvider, 0, len(priced)) + decision.candidates = make([]GatewayProvider, 0, len(priced)) for _, candidate := range priced { - out = append(out, candidate.candidate) + decision.candidates = append(decision.candidates, candidate.candidate) + } + return decision, nil +} + +func routingPolicyPriceCandidateKey(candidate GatewayProvider) string { + if candidate.RouteID != "" { + return candidate.RouteID } - return out, nil + return routingPriceKey(candidate.AccountID, candidate.UpstreamModel) } func activeRoutingPrices(prices []ProcurementPrice, protocol string, now time.Time) map[string]ProcurementPrice { @@ -150,6 +207,17 @@ func lowPriceCandidatePool(candidates []routingPolicyCandidatePrice, strategy Ro out := make([]routingPolicyCandidatePrice, 0, len(candidates)) for _, batch := range batchOrder { items := byBatch[batch] + hasPrice := false + for _, item := range items { + if item.priced { + hasPrice = true + break + } + } + if !hasPrice { + out = append(out, items...) + continue + } keep := 1 if oneOf(strategy.LowPricePoolMode, RoutingPolicyLowPriceAuto, RoutingPolicyLowPricePercent) { keep = int(math.Ceil(float64(len(items)) * float64(strategy.LowPricePoolPercent) / 100)) diff --git a/backend/internal/controlplane/routing_policy_runtime_test.go b/backend/internal/controlplane/routing_policy_runtime_test.go index 8e534f7..8849a04 100644 --- a/backend/internal/controlplane/routing_policy_runtime_test.go +++ b/backend/internal/controlplane/routing_policy_runtime_test.go @@ -2,6 +2,7 @@ package controlplane import ( "context" + "strings" "testing" "time" @@ -44,6 +45,52 @@ func TestRoutingPolicyOrdersBatchesAndControlsFailover(t *testing.T) { if !candidates[0].StickyEnabled || candidates[0].StickyTTLSeconds != 900 || candidates[0].RoutingPolicyID != policy.ID || !candidates[0].FailoverEnabled { t.Fatalf("routing policy runtime fields were not propagated: %+v", candidates[0]) } + now := time.Now().UTC().Add(-time.Minute) + for _, price := range []ProcurementPrice{ + {ID: "price-primary", ProviderAccountID: primary.ID, UpstreamModel: "public-model", Protocol: string(gatewaycore.ProtocolOpenAIChat), Currency: "USD", UncachedInputMicrosPer1MTokens: 1_000_000, OutputMicrosPer1MTokens: 1_000_000, RechargeMultiplier: 1, Status: ProcurementPriceStatusActive, EffectiveFrom: now}, + {ID: "price-secondary", ProviderAccountID: secondary.ID, UpstreamModel: "public-model", Protocol: string(gatewaycore.ProtocolOpenAIChat), Currency: "USD", RechargeMultiplier: 1, Status: ProcurementPriceStatusActive, EffectiveFrom: now}, + } { + if err := svc.repo.SaveProcurementPrice(ctx, price); err != nil { + t.Fatal(err) + } + } + policy, err = svc.UpdateRoutingPolicy(ctx, "tester", policy.ID, RoutingPolicyRequest{ + Name: policy.Name, RouteGroup: policy.RouteGroup, Status: policy.Status, + Strategy: RoutingPolicyStrategy{ + Preset: RoutingPolicyPresetCost, StickyRouting: true, StickyTTLSeconds: 900, FailoverBeforeFirstByte: true, + MaxPriceMultipleOfCheapest: 2, LowPricePoolMode: RoutingPolicyLowPriceNone, ResourceBatches: policy.Strategy.ResourceBatches, + }, + }) + if err != nil { + t.Fatal(err) + } + candidates, _, err = svc.GatewayProviderCandidatesForModel(ctx, "public-model") + if err != nil { + t.Fatal(err) + } + priceDecision, err := svc.applyRoutingPolicyCandidateRules(ctx, &policy, string(gatewaycore.ProtocolOpenAIChat), candidates) + if err != nil || len(priceDecision.Candidates) != 2 || priceDecision.Candidates[0].AccountID != primary.ID || priceDecision.Candidates[1].AccountID != secondary.ID { + t.Fatalf("relative price guardrail crossed resource batches: decision=%+v err=%v", priceDecision, err) + } + unpricedPrimary := candidates[0] + unpricedPrimary.AccountID = "unpriced-primary" + priceDecision, err = svc.applyRoutingPolicyCandidateRules(ctx, &policy, string(gatewaycore.ProtocolOpenAIChat), []GatewayProvider{unpricedPrimary, candidates[1]}) + if err != nil || len(priceDecision.Candidates) != 2 || priceDecision.Candidates[0].AccountID != unpricedPrimary.AccountID || priceDecision.Candidates[1].AccountID != secondary.ID { + t.Fatalf("fallback price facts excluded an unpriced primary batch: decision=%+v err=%v", priceDecision, err) + } + pricePlan, err := svc.PlanCanonicalGatewayRequest(ctx, gatewaycore.CanonicalAuthContext{CredentialID: "routing-policy-key"}, gatewaycore.CanonicalRequest{ + Protocol: gatewaycore.ProtocolOpenAIChat, Operation: GatewayOperationChatCompletion, + Modality: GatewayModalityText, Lane: gatewaycore.LaneDirect, Model: "public-model", + }) + if err != nil || len(pricePlan.Candidates) != 2 || pricePlan.Candidates[0].AccountID != primary.ID || pricePlan.Candidates[1].AccountID != secondary.ID { + t.Fatalf("planner relative price guardrail crossed resource batches: plan=%+v err=%v", pricePlan, err) + } + priceSimulation, err := svc.SimulateGatewayRouting(ctx, GatewaySimulationRequest{Model: "public-model", Protocol: string(gatewaycore.ProtocolOpenAIChat)}) + if err != nil { + t.Fatal(err) + } + assertSimulationCandidateReason(t, priceSimulation, primary.ID, "") + assertSimulationCandidateReason(t, priceSimulation, secondary.ID, "") binding := RoutingAffinityBinding{ProviderID: provider.ID, ProviderAccountID: secondary.ID, RouteID: candidates[1].RouteID} if preferred, ok := preferBoundGatewayCandidate(candidates, binding, true, "test"); ok || preferred[0].AccountID != primary.ID { t.Fatalf("affinity crossed a policy batch: ok=%t candidates=%+v", ok, preferred) @@ -108,6 +155,55 @@ func TestRoutingPolicyModelScopeAndPriceGuardrails(t *testing.T) { } } +func TestRoutingPolicyPriceExclusionsRemainExplainable(t *testing.T) { + ctx := context.Background() + repo := NewMemoryRepository() + svc := NewService(repo, "/v1", "test-secret") + now := time.Now().UTC().Add(-time.Minute) + for _, price := range []ProcurementPrice{ + {ID: "price-cheap", ProviderAccountID: "cheap", UpstreamModel: "upstream", Protocol: string(gatewaycore.ProtocolOpenAIChat), Currency: "USD", UncachedInputMicrosPer1MTokens: 100_000, OutputMicrosPer1MTokens: 100_000, RechargeMultiplier: 1, Status: ProcurementPriceStatusActive, EffectiveFrom: now}, + {ID: "price-pool", ProviderAccountID: "pool", UpstreamModel: "upstream", Protocol: string(gatewaycore.ProtocolOpenAIChat), Currency: "USD", UncachedInputMicrosPer1MTokens: 150_000, OutputMicrosPer1MTokens: 150_000, RechargeMultiplier: 1, Status: ProcurementPriceStatusActive, EffectiveFrom: now}, + {ID: "price-relative", ProviderAccountID: "relative", UpstreamModel: "upstream", Protocol: string(gatewaycore.ProtocolOpenAIChat), Currency: "USD", UncachedInputMicrosPer1MTokens: 800_000, OutputMicrosPer1MTokens: 800_000, RechargeMultiplier: 1, Status: ProcurementPriceStatusActive, EffectiveFrom: now}, + {ID: "price-input", ProviderAccountID: "input", UpstreamModel: "upstream", Protocol: string(gatewaycore.ProtocolOpenAIChat), Currency: "USD", UncachedInputMicrosPer1MTokens: 2_000_000, OutputMicrosPer1MTokens: 100_000, RechargeMultiplier: 1, Status: ProcurementPriceStatusActive, EffectiveFrom: now}, + {ID: "price-output", ProviderAccountID: "output", UpstreamModel: "upstream", Protocol: string(gatewaycore.ProtocolOpenAIChat), Currency: "USD", UncachedInputMicrosPer1MTokens: 100_000, OutputMicrosPer1MTokens: 2_000_000, RechargeMultiplier: 1, Status: ProcurementPriceStatusActive, EffectiveFrom: now}, + } { + if err := repo.SaveProcurementPrice(ctx, price); err != nil { + t.Fatal(err) + } + } + policy := &RoutingPolicy{Strategy: RoutingPolicyStrategy{ + Preset: RoutingPolicyPresetBalanced, AbsoluteMaxInputPer1M: 1, AbsoluteMaxOutputPer1M: 1, + MaxPriceMultipleOfCheapest: 2, LowPricePoolMode: RoutingPolicyLowPriceStrict, + }} + candidates := []GatewayProvider{ + {RouteID: "route-missing", AccountID: "missing", UpstreamModel: "upstream"}, + {RouteID: "route-input", AccountID: "input", UpstreamModel: "upstream"}, + {RouteID: "route-output", AccountID: "output", UpstreamModel: "upstream"}, + {RouteID: "route-relative", AccountID: "relative", UpstreamModel: "upstream"}, + {RouteID: "route-pool", AccountID: "pool", UpstreamModel: "upstream"}, + {RouteID: "route-cheap", AccountID: "cheap", UpstreamModel: "upstream"}, + } + decision, err := svc.evaluateRoutingPolicyPriceRules(ctx, policy, string(gatewaycore.ProtocolOpenAIChat), candidates) + if err != nil || len(decision.candidates) != 1 || decision.candidates[0].AccountID != "cheap" { + t.Fatalf("price decision=%+v err=%v", decision, err) + } + wantReasons := map[string]string{ + "missing": "routing_policy_price_fact_missing", + "input": "routing_policy_input_price_exceeded", + "output": "routing_policy_output_price_exceeded", + "relative": "routing_policy_relative_price_exceeded", + "pool": "routing_policy_low_price_pool_excluded", + } + if len(decision.exclusions) != len(wantReasons) { + t.Fatalf("price exclusions=%+v", decision.exclusions) + } + for _, exclusion := range decision.exclusions { + if wantReasons[exclusion.candidate.AccountID] != exclusion.reason { + t.Fatalf("unexpected price exclusion=%+v", exclusion) + } + } +} + func TestRoutingPolicyAutomaticLowPricePoolUsesNormalizedDefaults(t *testing.T) { ctx := context.Background() repo := NewMemoryRepository() @@ -182,6 +278,17 @@ func TestRoutingPolicyAutomaticLowPricePoolUsesNormalizedDefaults(t *testing.T) if len(withoutFacts) != len(candidates) || withoutFacts[0].AccountID != candidates[0].AccountID { t.Fatalf("candidates changed without comparable price facts: %+v", withoutFacts) } + withoutComparableFacts := []GatewayProvider{ + {AccountID: "unpriced-a", UpstreamModel: "unpriced-model"}, + {AccountID: "unpriced-b", UpstreamModel: "unpriced-model"}, + } + withoutFacts, err = svc.applyRoutingPolicyPriceRules(ctx, &policy, string(gatewaycore.ProtocolOpenAIChat), withoutComparableFacts) + if err != nil { + t.Fatal(err) + } + if len(withoutFacts) != len(withoutComparableFacts) || withoutFacts[0].AccountID != withoutComparableFacts[0].AccountID { + t.Fatalf("unrelated protocol prices changed candidates without comparable facts: %+v", withoutFacts) + } } func TestRoutingPolicyProtocolMatrixIsCompleteAndRejectsUnknownValues(t *testing.T) { @@ -310,6 +417,239 @@ func TestRoutingPolicyProtocolAdmissionAndNativeProtocolAreEnforcedByPlanner(t * } } +func TestRoutingPolicyPresetsResolveConflictingSignalsDeterministically(t *testing.T) { + ctx := context.Background() + repo := NewMemoryRepository() + svc := NewService(repo, "/v1", "test-secret") + provider, err := svc.CreateProvider(ctx, "tester", ProviderRequest{ + Name: "Preset provider", Type: ProviderTypeOpenAICompatible, BaseURL: "https://provider.example/v1", Status: ProviderStatusActive, + }) + if err != nil { + t.Fatal(err) + } + cost := createRoutingPolicyTestAccountWithLimits(t, svc, provider.ID, "Cost", 0.1, 10) + speed := createRoutingPolicyTestAccountWithLimits(t, svc, provider.ID, "Speed", 2, 10) + stability := createRoutingPolicyTestAccountWithLimits(t, svc, provider.ID, "Stability", 1, 10) + balanced := createRoutingPolicyTestAccountWithLimits(t, svc, provider.ID, "Balanced", 1, 10) + model := mustCreateGatewayModelRoutes(t, svc, "preset-model", []ProviderAccount{balanced, stability, speed, cost}) + + balanced.CircuitState = CircuitStateHalfOpen + if err := repo.SaveProviderAccount(ctx, balanced); err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + svc.scheduler.rateSamples[cost.ID] = []gatewayRateSample{{at: now}, {at: now}, {at: now}} + svc.scheduler.rateSamples[stability.ID] = []gatewayRateSample{{at: now}} + svc.scheduler.rateSamples[balanced.ID] = []gatewayRateSample{{at: now}, {at: now}} + + policy, err := svc.CreateRoutingPolicy(ctx, "tester", RoutingPolicyRequest{ + Name: "Preset matrix", RouteGroup: model.DefaultRouteGroup, Status: RoutingPolicyStatusActive, + Strategy: RoutingPolicyStrategy{Preset: RoutingPolicyPresetBalanced, StickyTTLSeconds: 900, LowPricePoolMode: RoutingPolicyLowPriceNone, FailoverBeforeFirstByte: true}, + }) + if err != nil { + t.Fatal(err) + } + + tests := []struct { + preset string + want string + }{ + {preset: RoutingPolicyPresetCost, want: cost.ID}, + {preset: RoutingPolicyPresetSpeed, want: speed.ID}, + {preset: RoutingPolicyPresetStability, want: stability.ID}, + {preset: RoutingPolicyPresetBalanced, want: balanced.ID}, + } + for _, test := range tests { + t.Run(test.preset, func(t *testing.T) { + updated, err := svc.UpdateRoutingPolicy(ctx, "tester", policy.ID, RoutingPolicyRequest{ + Name: policy.Name, RouteGroup: policy.RouteGroup, Status: policy.Status, + Strategy: RoutingPolicyStrategy{Preset: test.preset, StickyTTLSeconds: 900, LowPricePoolMode: RoutingPolicyLowPriceNone, FailoverBeforeFirstByte: true}, + }) + if err != nil { + t.Fatal(err) + } + policy = updated + candidates, hasRoutes, err := svc.GatewayProviderCandidatesForModel(ctx, model.ModelID) + if err != nil || !hasRoutes || len(candidates) != 4 { + t.Fatalf("candidates=%+v hasRoutes=%t err=%v", candidates, hasRoutes, err) + } + if candidates[0].AccountID != test.want || !strings.Contains(candidates[0].SelectionReason, "preset="+test.preset) { + t.Fatalf("preset %s selected %s, want %s; candidates=%+v", test.preset, candidates[0].AccountID, test.want, candidates) + } + }) + } +} + +func TestRoutingPolicySimulatorMatchesPlannerHardConstraints(t *testing.T) { + ctx := context.Background() + repo := NewMemoryRepository() + svc := NewService(repo, "/v1", "test-secret") + provider, err := svc.CreateProvider(ctx, "tester", ProviderRequest{ + Name: "Simulation provider", Type: ProviderTypeOpenAICompatible, BaseURL: "https://provider.example/v1", Status: ProviderStatusActive, + }) + if err != nil { + t.Fatal(err) + } + cheap := createRoutingPolicyTestAccount(t, svc, provider.ID, "Cheap", "cheap-upstream", 1) + backup := createRoutingPolicyTestAccount(t, svc, provider.ID, "Backup", "backup-upstream", 1) + expensive := createRoutingPolicyTestAccount(t, svc, provider.ID, "Expensive", "expensive-upstream", 1) + model, err := svc.CreateGatewayModel(ctx, "tester", GatewayModelRequest{ModelID: "simulation-model", Name: "Simulation", Modality: "chat", Status: GatewayModelStatusActive}) + if err != nil { + t.Fatal(err) + } + for index, route := range []struct { + account ProviderAccount + model string + format string + }{ + {account: backup, model: "backup-upstream", format: UpstreamFormatOpenAIChat}, + {account: expensive, model: "expensive-upstream", format: UpstreamFormatOpenAIChat}, + {account: cheap, model: "cheap-upstream", format: UpstreamFormatOpenAIChat}, + } { + if _, err := svc.CreateModelRoute(ctx, "tester", ModelRouteRequest{ + GatewayModelID: model.ID, ProviderAccountID: route.account.ID, UpstreamModel: route.model, + UpstreamFormat: route.format, Priority: (index + 1) * 10, Weight: 100, Status: ModelRouteStatusActive, + }); err != nil { + t.Fatal(err) + } + } + now := time.Now().UTC().Add(-time.Minute) + for _, price := range []ProcurementPrice{ + {ID: "price-cheap", ProviderAccountID: cheap.ID, UpstreamModel: "cheap-upstream", Protocol: string(gatewaycore.ProtocolOpenAIChat), Currency: "USD", UncachedInputMicrosPer1MTokens: 100_000, OutputMicrosPer1MTokens: 100_000, RechargeMultiplier: 1, Status: ProcurementPriceStatusActive, EffectiveFrom: now}, + {ID: "price-backup", ProviderAccountID: backup.ID, UpstreamModel: "backup-upstream", Protocol: string(gatewaycore.ProtocolOpenAIChat), Currency: "USD", UncachedInputMicrosPer1MTokens: 150_000, OutputMicrosPer1MTokens: 150_000, RechargeMultiplier: 1, Status: ProcurementPriceStatusActive, EffectiveFrom: now}, + {ID: "price-expensive", ProviderAccountID: expensive.ID, UpstreamModel: "expensive-upstream", Protocol: string(gatewaycore.ProtocolOpenAIChat), Currency: "USD", UncachedInputMicrosPer1MTokens: 1_000_000, OutputMicrosPer1MTokens: 1_000_000, RechargeMultiplier: 1, Status: ProcurementPriceStatusActive, EffectiveFrom: now}, + } { + if err := repo.SaveProcurementPrice(ctx, price); err != nil { + t.Fatal(err) + } + } + policy, err := svc.CreateRoutingPolicy(ctx, "tester", RoutingPolicyRequest{ + Name: "Simulation parity", RouteGroup: DefaultModelRouteGroup, Status: RoutingPolicyStatusActive, + Strategy: RoutingPolicyStrategy{ + Preset: RoutingPolicyPresetCost, StickyTTLSeconds: 900, FailoverBeforeFirstByte: false, + MaxPriceMultipleOfCheapest: 2, LowPricePoolMode: RoutingPolicyLowPriceNone, + AllowedProtocols: []string{string(gatewaycore.ProtocolOpenAIChat)}, + }, + }) + if err != nil { + t.Fatal(err) + } + auth := gatewaycore.CanonicalAuthContext{CredentialID: "simulation-key"} + request := gatewaycore.CanonicalRequest{Protocol: gatewaycore.ProtocolOpenAIChat, Operation: GatewayOperationChatCompletion, Modality: GatewayModalityText, Lane: gatewaycore.LaneDirect, Model: model.ModelID} + plan, err := svc.PlanCanonicalGatewayRequest(ctx, auth, request) + if err != nil || len(plan.Candidates) != 1 || plan.Candidates[0].AccountID != cheap.ID { + t.Fatalf("planner candidates=%+v exclusions=%+v err=%v", plan.Candidates, plan.Exclusions, err) + } + simulation, err := svc.SimulateGatewayRouting(ctx, GatewaySimulationRequest{Model: model.ModelID, Protocol: string(gatewaycore.ProtocolOpenAIChat), EstimatedTokens: 100}) + if err != nil { + t.Fatal(err) + } + assertSimulationCandidateReason(t, simulation, cheap.ID, "") + assertSimulationCandidateReason(t, simulation, backup.ID, "routing_policy_failover_disabled") + assertSimulationCandidateReason(t, simulation, expensive.ID, "routing_policy_relative_price_exceeded") + if simulation.RoutingPolicyID != policy.ID || simulation.RoutingPolicyVersion != policy.Version || simulation.RoutingPolicyPreset != RoutingPolicyPresetCost { + t.Fatalf("simulation policy evidence mismatch: %+v", simulation) + } + + _, err = svc.UpdateRoutingPolicy(ctx, "tester", policy.ID, RoutingPolicyRequest{ + Name: policy.Name, RouteGroup: policy.RouteGroup, Status: policy.Status, + Strategy: RoutingPolicyStrategy{ + Preset: RoutingPolicyPresetBalanced, StickyTTLSeconds: 900, FailoverBeforeFirstByte: true, LowPricePoolMode: RoutingPolicyLowPriceNone, + AllowedProtocols: []string{string(gatewaycore.ProtocolOpenAIChat)}, DeniedProtocols: []string{string(gatewaycore.ProtocolOpenAIChat)}, + }, + }) + if err != nil { + t.Fatal(err) + } + blockedPlan, err := svc.PlanCanonicalGatewayRequest(ctx, auth, request) + if err != nil || blockedPlan.RejectionReason != "routing_policy_protocol_blocked" || len(blockedPlan.Candidates) != 0 { + t.Fatalf("blocked planner=%+v err=%v", blockedPlan, err) + } + blockedSimulation, err := svc.SimulateGatewayRouting(ctx, GatewaySimulationRequest{Model: model.ModelID, Protocol: string(gatewaycore.ProtocolOpenAIChat)}) + if err != nil || blockedSimulation.Status != "blocked" || blockedSimulation.RejectionReason != "routing_policy_protocol_blocked" { + t.Fatalf("blocked simulation=%+v err=%v", blockedSimulation, err) + } + for _, candidate := range blockedSimulation.Candidates { + if candidate.Eligible || candidate.Reason != "routing_policy_protocol_blocked" { + t.Fatalf("protocol-blocked simulation candidate=%+v", candidate) + } + } + + _, err = svc.UpdateRoutingPolicy(ctx, "tester", policy.ID, RoutingPolicyRequest{ + Name: policy.Name, RouteGroup: policy.RouteGroup, Status: policy.Status, + Strategy: RoutingPolicyStrategy{ + Preset: RoutingPolicyPresetBalanced, StickyTTLSeconds: 900, FailoverBeforeFirstByte: true, LowPricePoolMode: RoutingPolicyLowPriceNone, + AllowedModels: []string{"other-model"}, + }, + }) + if err != nil { + t.Fatal(err) + } + modelBlockedPlan, err := svc.PlanCanonicalGatewayRequest(ctx, auth, request) + if err != nil || modelBlockedPlan.RejectionReason != "routing_policy_model_blocked" || len(modelBlockedPlan.Candidates) != 0 { + t.Fatalf("model-blocked planner=%+v err=%v", modelBlockedPlan, err) + } + modelBlockedSimulation, err := svc.SimulateGatewayRouting(ctx, GatewaySimulationRequest{Model: model.ModelID, Protocol: string(gatewaycore.ProtocolOpenAIChat)}) + if err != nil || modelBlockedSimulation.Status != "blocked" || modelBlockedSimulation.RejectionReason != "routing_policy_model_blocked" { + t.Fatalf("model-blocked simulation=%+v err=%v", modelBlockedSimulation, err) + } + for _, candidate := range modelBlockedSimulation.Candidates { + if candidate.Eligible || candidate.Reason != "routing_policy_model_blocked" { + t.Fatalf("model-blocked simulation candidate=%+v", candidate) + } + } +} + +func TestRoutingPolicyVersionInvalidatesAffinityScope(t *testing.T) { + svc := NewService(NewMemoryRepository(), "/v1", "affinity-policy-version-secret") + input := GatewayAffinityInput{ + ApplicationID: "application", PrincipalID: "principal", CredentialID: "credential", + Model: "public-model", Protocol: string(gatewaycore.ProtocolOpenAIChat), RouteGroup: "default", + StickyKey: "session", AccessPolicyVersion: 7, RoutingPolicyID: "routing-policy", RoutingPolicyVersion: 1, + } + candidates := []GatewayProvider{ + {ID: "provider-a", AccountID: "account-a", RouteID: "route-a", RoutingPolicyID: input.RoutingPolicyID, StickyEnabled: true}, + {ID: "provider-b", AccountID: "account-b", RouteID: "route-b", RoutingPolicyID: input.RoutingPolicyID, StickyEnabled: true}, + } + if err := svc.BindGatewayCandidateAffinity(context.Background(), input, candidates[1]); err != nil { + t.Fatal(err) + } + if got := svc.PreferGatewayCandidatesWithAffinity(context.Background(), input, candidates); got[0].AccountID != "account-b" { + t.Fatalf("version one did not reuse affinity: %+v", got) + } + input.RoutingPolicyVersion = 2 + if got := svc.PreferGatewayCandidatesWithAffinity(context.Background(), input, candidates); got[0].AccountID != "account-a" { + t.Fatalf("version two reused a stale routing-policy binding: %+v", got) + } +} + +func createRoutingPolicyTestAccountWithLimits(t *testing.T, svc *Service, providerID, name string, rate float64, rpm int) ProviderAccount { + t.Helper() + account, err := svc.CreateProviderAccount(context.Background(), "tester", ProviderAccountRequest{ + ProviderID: providerID, Name: name, Platform: ProviderTypeOpenAICompatible, AuthType: ProviderAuthAPIKey, + Status: AccountStatusActive, Priority: 50, Concurrency: 10, RPMLimit: rpm, RateMultiplier: rate, + Models: []string{"preset-model"}, Secret: name + "-secret", + }) + if err != nil { + t.Fatal(err) + } + return account +} + +func assertSimulationCandidateReason(t *testing.T, simulation GatewaySimulation, accountID, reason string) { + t.Helper() + for _, candidate := range simulation.Candidates { + if candidate.ProviderAccountID != accountID { + continue + } + if candidate.Eligible != (reason == "") || candidate.Reason != reason { + t.Fatalf("simulation candidate %s=%+v, want reason %q", accountID, candidate, reason) + } + return + } + t.Fatalf("simulation candidate %s missing: %+v", accountID, simulation.Candidates) +} + func createRoutingPolicyTestAccount(t *testing.T, svc *Service, providerID, name, model string, rate float64) ProviderAccount { t.Helper() account, err := svc.CreateProviderAccount(context.Background(), "tester", ProviderAccountRequest{ diff --git a/backend/internal/plugins/service_test.go b/backend/internal/plugins/service_test.go index fd267a5..84dcfc1 100644 --- a/backend/internal/plugins/service_test.go +++ b/backend/internal/plugins/service_test.go @@ -60,7 +60,7 @@ func findPluginForCatalogTest(plugins []Plugin, id string) *Plugin { return nil } -func TestServiceEnablesFreeCorePlugin(t *testing.T) { +func TestServiceFreePluginEnableDisableLifecycle(t *testing.T) { svc := NewService(NewMemoryRepository()) if err := svc.EnsureSeedData(context.Background()); err != nil { t.Fatalf("EnsureSeedData(): %v", err) @@ -73,6 +73,21 @@ func TestServiceEnablesFreeCorePlugin(t *testing.T) { if plugin.Status != StatusEnabled { t.Fatalf("status = %q", plugin.Status) } + plugin, err = svc.Enable(context.Background(), plugin.ID) + if err != nil || plugin.Status != StatusEnabled { + t.Fatalf("repeated Enable() = %+v, %v", plugin, err) + } + disabled, err := svc.Disable(context.Background(), plugin.ID) + if err != nil { + t.Fatalf("Disable(): %v", err) + } + if disabled.Status != StatusDisabled { + t.Fatalf("disabled status = %q", disabled.Status) + } + disabled, err = svc.Disable(context.Background(), plugin.ID) + if err != nil || disabled.Status != StatusDisabled { + t.Fatalf("repeated Disable() = %+v, %v", disabled, err) + } } func TestServiceRejectsLockedPaidPlugin(t *testing.T) { diff --git a/backend/internal/server/admin_routes_test.go b/backend/internal/server/admin_routes_test.go index 518172c..f4d2314 100644 --- a/backend/internal/server/admin_routes_test.go +++ b/backend/internal/server/admin_routes_test.go @@ -1,19 +1,25 @@ package server import ( + "archive/tar" "bytes" + "compress/gzip" "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "net/url" + "os" + "path/filepath" "strings" "testing" "time" "github.com/astercloud/asterrouter/backend/internal/controlplane" + "github.com/astercloud/asterrouter/backend/internal/settings" "github.com/astercloud/asterrouter/backend/internal/system" + "github.com/gin-gonic/gin" ) func TestAdminDashboardEndpoint(t *testing.T) { @@ -128,6 +134,13 @@ func TestAdminGatewayModelAndRouteEndpoints(t *testing.T) { if err := json.Unmarshal(modelCreateRec.Body.Bytes(), &modelResp); err != nil { t.Fatalf("decode gateway model: %v", err) } + modelUpdate := httptest.NewRequest(http.MethodPut, "/api/v1/console/gateway-models/"+modelResp.Data.ID, bytes.NewBufferString(`{"model_id":"public-chat","name":"Public Chat Updated","modality":"chat","default_route_group":"stable","status":"active"}`)) + modelUpdate.Header.Set("Content-Type", "application/json") + modelUpdateRec := httptest.NewRecorder() + handler.ServeHTTP(modelUpdateRec, modelUpdate) + if modelUpdateRec.Code != http.StatusOK || !strings.Contains(modelUpdateRec.Body.String(), `"name":"Public Chat Updated"`) { + t.Fatalf("update gateway model status = %d body=%s", modelUpdateRec.Code, modelUpdateRec.Body.String()) + } missingFormatBody := fmt.Sprintf(`{"gateway_model_id":%q,"route_group":"stable","provider_account_id":%q,"upstream_model":"upstream-chat","priority":10,"weight":100,"status":"active"}`, modelResp.Data.ID, account.ID) missingFormat := httptest.NewRequest(http.MethodPost, "/api/v1/console/model-routes", bytes.NewBufferString(missingFormatBody)) @@ -176,6 +189,12 @@ func TestAdminGatewayModelAndRouteEndpoints(t *testing.T) { if bulkCreateRec.Code != http.StatusOK || !strings.Contains(bulkCreateRec.Body.String(), `"routes"`) { t.Fatalf("bulk model route status = %d body=%s", bulkCreateRec.Code, bulkCreateRec.Body.String()) } + var bulkResp struct { + Data controlplane.ModelRouteBulkCreateResult `json:"data"` + } + if err := json.Unmarshal(bulkCreateRec.Body.Bytes(), &bulkResp); err != nil || len(bulkResp.Data.Routes) != 1 { + t.Fatalf("decode bulk model route response: data=%+v err=%v", bulkResp.Data, err) + } modelList := httptest.NewRequest(http.MethodGet, "/api/v1/console/gateway-models", nil) modelListRec := httptest.NewRecorder() @@ -183,6 +202,12 @@ func TestAdminGatewayModelAndRouteEndpoints(t *testing.T) { if modelListRec.Code != http.StatusOK || !strings.Contains(modelListRec.Body.String(), `"route_count":1`) { t.Fatalf("gateway model list status = %d body=%s", modelListRec.Code, modelListRec.Body.String()) } + routeList := httptest.NewRequest(http.MethodGet, "/api/v1/console/model-routes", nil) + routeListRec := httptest.NewRecorder() + handler.ServeHTTP(routeListRec, routeList) + if routeListRec.Code != http.StatusOK || !strings.Contains(routeListRec.Body.String(), routeResp.Data.ID) || !strings.Contains(routeListRec.Body.String(), bulkResp.Data.Routes[0].ID) { + t.Fatalf("model route list status = %d body=%s", routeListRec.Code, routeListRec.Body.String()) + } routeUpdateBody := fmt.Sprintf(`{"gateway_model_id":%q,"route_group":"stable","provider_account_id":%q,"upstream_model":"upstream-chat","upstream_format":"openai_chat","priority":20,"weight":250,"status":"disabled"}`, modelResp.Data.ID, account.ID) routeUpdate := httptest.NewRequest(http.MethodPut, "/api/v1/console/model-routes/"+routeResp.Data.ID, bytes.NewBufferString(routeUpdateBody)) @@ -192,6 +217,12 @@ func TestAdminGatewayModelAndRouteEndpoints(t *testing.T) { if routeUpdateRec.Code != http.StatusOK || !strings.Contains(routeUpdateRec.Body.String(), `"weight":250`) { t.Fatalf("update model route status = %d body=%s", routeUpdateRec.Code, routeUpdateRec.Body.String()) } + bulkDelete := httptest.NewRequest(http.MethodDelete, "/api/v1/console/model-routes/"+bulkResp.Data.Routes[0].ID, nil) + bulkDeleteRec := httptest.NewRecorder() + handler.ServeHTTP(bulkDeleteRec, bulkDelete) + if bulkDeleteRec.Code != http.StatusOK { + t.Fatalf("delete model route status = %d body=%s", bulkDeleteRec.Code, bulkDeleteRec.Body.String()) + } modelDelete := httptest.NewRequest(http.MethodDelete, "/api/v1/console/gateway-models/"+modelResp.Data.ID, nil) modelDeleteRec := httptest.NewRecorder() @@ -200,8 +231,33 @@ func TestAdminGatewayModelAndRouteEndpoints(t *testing.T) { t.Fatalf("delete gateway model status = %d body=%s", modelDeleteRec.Code, modelDeleteRec.Body.String()) } routes, err := control.ListModelRoutes(context.Background()) - if err != nil || len(routes) != 1 || routes[0].GatewayModelID != bulkModel.ID { - t.Fatalf("expected only the deleted model's routes to be cascade deleted: routes=%+v err=%v", routes, err) + if err != nil || len(routes) != 0 { + t.Fatalf("expected explicit and cascade route deletion to leave no routes: routes=%+v err=%v", routes, err) + } +} + +func TestAdminGatewayModelAndRouteMissingResourceContracts(t *testing.T) { + handler := newTestHandler(t, RuntimeConfig{}) + tests := []struct { + method string + path string + body string + }{ + {method: http.MethodPut, path: "/api/v1/console/gateway-models/missing", body: `{"model_id":"missing","name":"Missing","status":"active"}`}, + {method: http.MethodDelete, path: "/api/v1/console/gateway-models/missing"}, + {method: http.MethodPut, path: "/api/v1/console/model-routes/missing", body: `{"gateway_model_id":"missing","provider_account_id":"missing","upstream_model":"missing","upstream_format":"openai_chat","status":"active"}`}, + {method: http.MethodDelete, path: "/api/v1/console/model-routes/missing"}, + } + for _, test := range tests { + req := httptest.NewRequest(test.method, test.path, strings.NewReader(test.body)) + if test.body != "" { + req.Header.Set("Content-Type", "application/json") + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "not found") { + t.Fatalf("%s %s status=%d body=%s", test.method, test.path, rec.Code, rec.Body.String()) + } } } @@ -241,6 +297,30 @@ func TestAdminProviderAccountModelEndpoints(t *testing.T) { } } +func TestAdminProviderAccountModelEndpointsRejectMissingAccount(t *testing.T) { + handler := newTestHandler(t, RuntimeConfig{}) + tests := []struct { + method string + path string + body string + }{ + {method: http.MethodGet, path: "/api/v1/console/provider-accounts/missing/models"}, + {method: http.MethodPost, path: "/api/v1/console/provider-accounts/missing/models/discover"}, + {method: http.MethodPost, path: "/api/v1/console/provider-accounts/missing/models/sync", body: `{"enabled_models":[]}`}, + } + for _, test := range tests { + req := httptest.NewRequest(test.method, test.path, strings.NewReader(test.body)) + if test.body != "" { + req.Header.Set("Content-Type", "application/json") + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "not found") { + t.Fatalf("%s %s status=%d body=%s", test.method, test.path, rec.Code, rec.Body.String()) + } + } +} + func TestAdminGovernancePolicyEndpoints(t *testing.T) { handler := newTestHandler(t, RuntimeConfig{}) @@ -295,6 +375,22 @@ func TestAdminGovernancePolicyEndpoints(t *testing.T) { if len(listResp.Data) != 1 || listResp.Data[0].ID != createResp.Data.ID { t.Fatalf("list policy mismatch: %+v", listResp.Data) } + + missingReq := httptest.NewRequest(http.MethodPut, "/api/v1/console/policies/missing", bytes.NewBufferString(`{"name":"Missing","scope_type":"global","overage_action":"warn","prompt_logging_mode":"disabled","status":"active"}`)) + missingReq.Header.Set("Content-Type", "application/json") + missingRec := httptest.NewRecorder() + handler.ServeHTTP(missingRec, missingReq) + if missingRec.Code != http.StatusBadRequest || !strings.Contains(missingRec.Body.String(), "not found") { + t.Fatalf("missing policy update status = %d body=%s", missingRec.Code, missingRec.Body.String()) + } + + invalidReq := httptest.NewRequest(http.MethodPost, "/api/v1/console/policies", bytes.NewBufferString(`{"name":"Invalid","scope_type":"customer","overage_action":"ignore","prompt_logging_mode":"full","status":"active"}`)) + invalidReq.Header.Set("Content-Type", "application/json") + invalidRec := httptest.NewRecorder() + handler.ServeHTTP(invalidRec, invalidReq) + if invalidRec.Code != http.StatusBadRequest { + t.Fatalf("invalid policy status = %d body=%s", invalidRec.Code, invalidRec.Body.String()) + } } func TestAdminRecordEndpointsSupportQueryParameters(t *testing.T) { @@ -636,6 +732,80 @@ func TestAPIKeyPolicyExplanationEndpoint(t *testing.T) { if len(explainResp.Data.Candidates) == 0 || !explainResp.Data.Candidates[0].Selected { t.Fatalf("explanation candidates mismatch: %+v", explainResp.Data.Candidates) } + + missingReq := httptest.NewRequest(http.MethodGet, "/api/v1/console/api-keys/missing/policy-explanation", nil) + missingRec := httptest.NewRecorder() + handler.ServeHTTP(missingRec, missingReq) + if missingRec.Code != http.StatusBadRequest { + t.Fatalf("missing key explanation status = %d body=%s", missingRec.Code, missingRec.Body.String()) + } + var missingResp struct { + Code int `json:"code"` + Message string `json:"message"` + } + if err := json.Unmarshal(missingRec.Body.Bytes(), &missingResp); err != nil { + t.Fatalf("decode missing key explanation: %v", err) + } + if missingResp.Code != 1507 || !strings.Contains(missingResp.Message, "api key") || !strings.Contains(missingResp.Message, "not found") { + t.Fatalf("missing key explanation contract mismatch: %+v", missingResp) + } +} + +func TestAdminSupplyCollectionsExposeEmptyArraysAndFirstModelDefaults(t *testing.T) { + control := controlplane.NewService(controlplane.NewMemoryRepository(), "/v1") + router := gin.New() + admin := router.Group("/api/v1/console") + registerProviderAdminRoutes(admin, control) + registerGatewayModelAdminRoutes(admin, control) + registerEffectivePricingAdminRoutes(admin, control) + + for _, path := range []string{ + "/api/v1/console/providers", + "/api/v1/console/provider-billing-sources", + "/api/v1/console/provider-cache-capabilities", + "/api/v1/console/gateway-models", + } { + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("GET %s status=%d body=%s", path, rec.Code, rec.Body.String()) + } + var response struct { + Code int `json:"code"` + Data []json.RawMessage `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatalf("decode GET %s: %v", path, err) + } + if response.Code != 0 || response.Data == nil || len(response.Data) != 0 { + t.Fatalf("GET %s must return a non-nil empty array: %+v body=%s", path, response, rec.Body.String()) + } + } + + createReq := httptest.NewRequest(http.MethodPost, "/api/v1/console/gateway-models", strings.NewReader(`{"model_id":"first-enterprise-model"}`)) + createReq.Header.Set("Content-Type", "application/json") + createRec := httptest.NewRecorder() + router.ServeHTTP(createRec, createReq) + if createRec.Code != http.StatusOK { + t.Fatalf("create first gateway model status=%d body=%s", createRec.Code, createRec.Body.String()) + } + var created struct { + Data controlplane.GatewayModel `json:"data"` + } + if err := json.Unmarshal(createRec.Body.Bytes(), &created); err != nil { + t.Fatalf("decode first gateway model: %v", err) + } + if created.Data.ModelID != "first-enterprise-model" || created.Data.Name != "first-enterprise-model" || created.Data.Modality != "chat" || created.Data.DefaultRouteGroup != controlplane.DefaultModelRouteGroup || created.Data.StickyTTLSeconds != 1800 || created.Data.Status != controlplane.GatewayModelStatusActive { + t.Fatalf("first gateway model defaults mismatch: %+v", created.Data) + } + models, err := control.ListGatewayModels(t.Context()) + if err != nil { + t.Fatalf("ListGatewayModels(): %v", err) + } + if len(models) != 1 || models[0].ID != created.Data.ID { + t.Fatalf("first gateway model was not persisted: %+v", models) + } } func TestProviderEndpointRejectsLegacyCredentialAndModelFields(t *testing.T) { @@ -673,10 +843,25 @@ func TestProviderEndpointRejectsLegacyCredentialAndModelFields(t *testing.T) { if updateRec.Code != http.StatusBadRequest { t.Fatalf("legacy update status = %d body=%s", updateRec.Code, updateRec.Body.String()) } + + validUpdate := httptest.NewRequest(http.MethodPut, "/api/v1/console/providers/"+createResp.Data.ID, bytes.NewBufferString(`{"name":"Vendor A Updated","type":"openai_compatible","base_url":"https://example.com/v2","status":"disabled","priority":20}`)) + validUpdate.Header.Set("Content-Type", "application/json") + validUpdateRec := httptest.NewRecorder() + handler.ServeHTTP(validUpdateRec, validUpdate) + if validUpdateRec.Code != http.StatusOK || !strings.Contains(validUpdateRec.Body.String(), `"name":"Vendor A Updated"`) || !strings.Contains(validUpdateRec.Body.String(), `"status":"disabled"`) { + t.Fatalf("valid update status = %d body=%s", validUpdateRec.Code, validUpdateRec.Body.String()) + } + + listReq := httptest.NewRequest(http.MethodGet, "/api/v1/console/providers", nil) + listRec := httptest.NewRecorder() + handler.ServeHTTP(listRec, listReq) + if listRec.Code != http.StatusOK || !strings.Contains(listRec.Body.String(), createResp.Data.ID) || !strings.Contains(listRec.Body.String(), `"base_url":"https://example.com/v2"`) { + t.Fatalf("provider list status = %d body=%s", listRec.Code, listRec.Body.String()) + } } func TestCheckProviderEndpoint(t *testing.T) { - handler := newTestHandler(t, RuntimeConfig{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) req := httptest.NewRequest(http.MethodPost, "/api/v1/console/providers/prov_openai_compatible/check", nil) rec := httptest.NewRecorder() @@ -709,10 +894,28 @@ func TestCheckProviderEndpoint(t *testing.T) { if len(listResp.Data) != 1 || listResp.Data[0].ProviderID != "prov_openai_compatible" { t.Fatalf("health list missing check: %+v", listResp.Data) } + disabled, err := control.CreateProvider(context.Background(), "tester", controlplane.ProviderRequest{ + Name: "Disabled provider", Type: "openai_compatible", BaseURL: "https://disabled.example/v1", Status: controlplane.ProviderStatusDisabled, + }) + if err != nil { + t.Fatal(err) + } + disabledReq := httptest.NewRequest(http.MethodPost, "/api/v1/console/providers/"+disabled.ID+"/check", nil) + disabledRec := httptest.NewRecorder() + handler.ServeHTTP(disabledRec, disabledReq) + if disabledRec.Code != http.StatusOK || !strings.Contains(disabledRec.Body.String(), `"status":"disabled"`) { + t.Fatalf("disabled provider check status = %d body=%s", disabledRec.Code, disabledRec.Body.String()) + } + missingReq := httptest.NewRequest(http.MethodPost, "/api/v1/console/providers/missing/check", nil) + missingRec := httptest.NewRecorder() + handler.ServeHTTP(missingRec, missingReq) + if missingRec.Code != http.StatusBadRequest || !strings.Contains(missingRec.Body.String(), "not found") { + t.Fatalf("missing provider check status = %d body=%s", missingRec.Code, missingRec.Body.String()) + } } func TestAdminRoutingGroupsAndProviderAccountsEndpoints(t *testing.T) { - handler := newTestHandler(t, RuntimeConfig{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/models" { t.Fatalf("path = %s", r.URL.Path) @@ -743,6 +946,21 @@ func TestAdminRoutingGroupsAndProviderAccountsEndpoints(t *testing.T) { t.Fatalf("group id missing: %+v", groupResp.Data) } + groupListReq := httptest.NewRequest(http.MethodGet, "/api/v1/console/routing-groups", nil) + groupListRec := httptest.NewRecorder() + handler.ServeHTTP(groupListRec, groupListReq) + if groupListRec.Code != http.StatusOK || !strings.Contains(groupListRec.Body.String(), groupResp.Data.ID) { + t.Fatalf("routing group list status = %d body=%s", groupListRec.Code, groupListRec.Body.String()) + } + + groupUpdateReq := httptest.NewRequest(http.MethodPut, "/api/v1/console/routing-groups/"+groupResp.Data.ID, bytes.NewBufferString(`{"name":"OpenAI stable","platform":"openai_compatible","group_type":"subscription","rate_multiplier":1,"monthly_budget_micros":5000000,"status":"active","sort_order":20}`)) + groupUpdateReq.Header.Set("Content-Type", "application/json") + groupUpdateRec := httptest.NewRecorder() + handler.ServeHTTP(groupUpdateRec, groupUpdateReq) + if groupUpdateRec.Code != http.StatusOK || !strings.Contains(groupUpdateRec.Body.String(), `"name":"OpenAI stable"`) || !strings.Contains(groupUpdateRec.Body.String(), `"group_type":"subscription"`) { + t.Fatalf("routing group update status = %d body=%s", groupUpdateRec.Code, groupUpdateRec.Body.String()) + } + providerPayload := `{"name":"Account Provider","type":"openai_compatible","base_url":"` + upstream.URL + `/v1","status":"active","priority":10}` providerReq := httptest.NewRequest(http.MethodPost, "/api/v1/console/providers", bytes.NewBufferString(providerPayload)) providerReq.Header.Set("Content-Type", "application/json") @@ -826,6 +1044,74 @@ func TestAdminRoutingGroupsAndProviderAccountsEndpoints(t *testing.T) { if len(healthResp.Data) != 1 || healthResp.Data[0].AccountID != accountResp.Data.ID { t.Fatalf("account health list missing check: %+v", healthResp.Data) } + + accountUpdatePayload := `{"provider_id":"` + providerResp.Data.ID + `","name":"Account A Updated","platform":"openai_compatible","auth_type":"api_key","status":"active","schedulable":false,"priority":20,"concurrency":2,"rate_multiplier":1,"models":["gpt-4o-mini"],"group_ids":["` + groupResp.Data.ID + `"]}` + accountUpdate := httptest.NewRequest(http.MethodPut, "/api/v1/console/provider-accounts/"+accountResp.Data.ID, bytes.NewBufferString(accountUpdatePayload)) + accountUpdate.Header.Set("Content-Type", "application/json") + accountUpdateRec := httptest.NewRecorder() + handler.ServeHTTP(accountUpdateRec, accountUpdate) + if accountUpdateRec.Code != http.StatusOK || !strings.Contains(accountUpdateRec.Body.String(), `"name":"Account A Updated"`) || !strings.Contains(accountUpdateRec.Body.String(), `"secret_configured":true`) { + t.Fatalf("account update status = %d body=%s", accountUpdateRec.Code, accountUpdateRec.Body.String()) + } + + accountDelete := httptest.NewRequest(http.MethodDelete, "/api/v1/console/provider-accounts/"+accountResp.Data.ID, nil) + accountDeleteRec := httptest.NewRecorder() + handler.ServeHTTP(accountDeleteRec, accountDelete) + if accountDeleteRec.Code != http.StatusOK { + t.Fatalf("account delete status = %d body=%s", accountDeleteRec.Code, accountDeleteRec.Body.String()) + } + accounts, err := control.ListProviderAccounts(context.Background()) + if err != nil || len(accounts) != 0 { + t.Fatalf("provider account deletion did not persist: accounts=%+v err=%v", accounts, err) + } +} + +func TestAdminProviderAccountMissingResourceContracts(t *testing.T) { + handler := newTestHandler(t, RuntimeConfig{}) + tests := []struct { + method string + path string + body string + }{ + {method: http.MethodPut, path: "/api/v1/console/provider-accounts/missing", body: `{"provider_id":"missing","name":"Missing","platform":"openai_compatible","auth_type":"api_key","status":"active"}`}, + {method: http.MethodDelete, path: "/api/v1/console/provider-accounts/missing"}, + {method: http.MethodPost, path: "/api/v1/console/provider-accounts/missing/check"}, + {method: http.MethodPost, path: "/api/v1/console/provider-accounts/missing/clear-cooldown"}, + } + for _, test := range tests { + req := httptest.NewRequest(test.method, test.path, strings.NewReader(test.body)) + if test.body != "" { + req.Header.Set("Content-Type", "application/json") + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "not found") { + t.Fatalf("%s %s status=%d body=%s", test.method, test.path, rec.Code, rec.Body.String()) + } + } +} + +func TestAdminRoutingGroupBoundaryContracts(t *testing.T) { + handler := newTestHandler(t, RuntimeConfig{}) + tests := []struct { + method string + path string + body string + match string + }{ + {method: http.MethodPost, path: "/api/v1/console/routing-groups", body: `{"name":"No platform","status":"active"}`, match: "platform is required"}, + {method: http.MethodPost, path: "/api/v1/console/routing-groups", body: `{"name":"No budget","platform":"openai_compatible","group_type":"subscription","status":"active"}`, match: "budget limit"}, + {method: http.MethodPut, path: "/api/v1/console/routing-groups/missing", body: `{"name":"Missing","platform":"openai_compatible","status":"active"}`, match: "not found"}, + } + for _, test := range tests { + req := httptest.NewRequest(test.method, test.path, strings.NewReader(test.body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), test.match) { + t.Fatalf("%s %s status=%d body=%s", test.method, test.path, rec.Code, rec.Body.String()) + } + } } func TestAdminRoutingPolicyEndpoints(t *testing.T) { @@ -899,6 +1185,62 @@ func TestAdminRoutingPolicyEndpoints(t *testing.T) { } } +func TestAdminGatewaySimulatorContracts(t *testing.T) { + handler, control := newTestRuntime(t, RuntimeConfig{}) + provider, err := control.CreateProvider(context.Background(), "tester", controlplane.ProviderRequest{ + Name: "simulator provider", Type: "openai_compatible", BaseURL: "https://provider.example/v1", Status: controlplane.ProviderStatusActive, + }) + if err != nil { + t.Fatal(err) + } + account := createGatewayTestAccount(t, control, provider, "upstream-chat", "account-secret", 10, 3) + model, err := control.CreateGatewayModel(context.Background(), "tester", controlplane.GatewayModelRequest{ + ModelID: "simulated-chat", Name: "Simulated Chat", Modality: "chat", DefaultRouteGroup: "stable", Status: controlplane.GatewayModelStatusActive, + }) + if err != nil { + t.Fatal(err) + } + if _, err := control.CreateModelRoute(context.Background(), "tester", controlplane.ModelRouteRequest{ + GatewayModelID: model.ID, RouteGroup: "stable", ProviderAccountID: account.ID, UpstreamModel: "upstream-chat", + UpstreamFormat: controlplane.UpstreamFormatOpenAIChat, Priority: 10, Weight: 100, Status: controlplane.ModelRouteStatusActive, + }); err != nil { + t.Fatal(err) + } + + readyReq := httptest.NewRequest(http.MethodPost, "/api/v1/console/gateway-simulator", bytes.NewBufferString(`{"model":"simulated-chat","estimated_tokens":1000,"protocol":"openai_chat_completions","required_features":["text"]}`)) + readyReq.Header.Set("Content-Type", "application/json") + readyRec := httptest.NewRecorder() + handler.ServeHTTP(readyRec, readyReq) + if readyRec.Code != http.StatusOK { + t.Fatalf("ready simulation status=%d body=%s", readyRec.Code, readyRec.Body.String()) + } + var ready struct { + Data controlplane.GatewaySimulation `json:"data"` + } + if err := json.Unmarshal(readyRec.Body.Bytes(), &ready); err != nil { + t.Fatal(err) + } + if ready.Data.Status != "ready" || ready.Data.ResolvedModel != "simulated-chat" || len(ready.Data.Candidates) != 1 || !ready.Data.Candidates[0].Eligible { + t.Fatalf("unexpected ready simulation: %+v", ready.Data) + } + + unresolvedReq := httptest.NewRequest(http.MethodPost, "/api/v1/console/gateway-simulator", bytes.NewBufferString(`{"model":"missing-model","estimated_tokens":1000,"protocol":"openai_chat_completions"}`)) + unresolvedReq.Header.Set("Content-Type", "application/json") + unresolvedRec := httptest.NewRecorder() + handler.ServeHTTP(unresolvedRec, unresolvedReq) + if unresolvedRec.Code != http.StatusOK || !strings.Contains(unresolvedRec.Body.String(), `"status":"unresolved"`) { + t.Fatalf("unresolved simulation status=%d body=%s", unresolvedRec.Code, unresolvedRec.Body.String()) + } + + invalidReq := httptest.NewRequest(http.MethodPost, "/api/v1/console/gateway-simulator", bytes.NewBufferString(`{"model":"simulated-chat","estimated_tokens":1000,"legacy_provider":"provider"}`)) + invalidReq.Header.Set("Content-Type", "application/json") + invalidRec := httptest.NewRecorder() + handler.ServeHTTP(invalidRec, invalidReq) + if invalidRec.Code != http.StatusBadRequest || !strings.Contains(invalidRec.Body.String(), "invalid gateway simulation payload") { + t.Fatalf("invalid simulation status=%d body=%s", invalidRec.Code, invalidRec.Body.String()) + } +} + func TestAdminProviderAccountClearCooldownEndpoint(t *testing.T) { handler, control := newTestRuntime(t, RuntimeConfig{}) provider, err := control.CreateProvider(context.Background(), "tester", controlplane.ProviderRequest{ @@ -1086,4 +1428,355 @@ func TestSystemBackupEndpointsExposeEmptyListAndRejectMemoryBackup(t *testing.T) if rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), "confirmation") { t.Fatalf("POST restore status = %d body=%s", rec.Code, rec.Body.String()) } + + for _, test := range []struct { + method string + path string + body string + }{ + {method: http.MethodPost, path: "/api/v1/console/system/backups/s3/test"}, + {method: http.MethodGet, path: "/api/v1/console/system/backups/s3"}, + {method: http.MethodGet, path: "/api/v1/console/system/backups/s3/download?key=backups%2Fasterrouter-backup-missing.tar.gz"}, + {method: http.MethodPost, path: "/api/v1/console/system/backups/s3/restore", body: `{"key":"backups/asterrouter-backup-missing.tar.gz","confirm":true}`}, + } { + req := httptest.NewRequest(test.method, test.path, strings.NewReader(test.body)) + if test.body != "" { + req.Header.Set("Content-Type", "application/json") + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "S3 backup is not configured") { + t.Fatalf("%s %s status=%d body=%s", test.method, test.path, rec.Code, rec.Body.String()) + } + } +} + +func TestSystemBackupRoutesListAndDownloadStoredArchive(t *testing.T) { + backupDir := t.TempDir() + systemService := system.NewService(system.Config{Version: "test", BuildType: "source", BackupDir: backupDir}) + stored, err := systemService.StoreBackupArchive("asterrouter-backup-20260812T120000Z-test", strings.NewReader("synthetic-backup-content")) + if err != nil { + t.Fatalf("StoreBackupArchive(): %v", err) + } + router := gin.New() + registerSystemRoutes(router.Group("/api/v1/console/system"), systemService, nil, nil) + + listReq := httptest.NewRequest(http.MethodGet, "/api/v1/console/system/backups", nil) + listRec := httptest.NewRecorder() + router.ServeHTTP(listRec, listReq) + if listRec.Code != http.StatusOK || !strings.Contains(listRec.Body.String(), stored.ID) || strings.Contains(listRec.Body.String(), backupDir) { + t.Fatalf("list status=%d body=%s", listRec.Code, listRec.Body.String()) + } + + downloadReq := httptest.NewRequest(http.MethodGet, "/api/v1/console/system/backups/"+stored.ID+"/download", nil) + downloadRec := httptest.NewRecorder() + router.ServeHTTP(downloadRec, downloadReq) + if downloadRec.Code != http.StatusOK || downloadRec.Body.String() != "synthetic-backup-content" || !strings.Contains(downloadRec.Header().Get("Content-Disposition"), stored.Path) { + t.Fatalf("download status=%d headers=%v body=%q", downloadRec.Code, downloadRec.Header(), downloadRec.Body.String()) + } + + for _, id := range []string{"asterrouter-backup-missing", "..%2F..%2Fsecret"} { + req := httptest.NewRequest(http.MethodGet, "/api/v1/console/system/backups/"+id+"/download", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("download %q status=%d body=%s", id, rec.Code, rec.Body.String()) + } + } +} + +func TestSystemS3BackupRoutesTestListDownloadAndRestore(t *testing.T) { + const ( + backupID = "asterrouter-backup-20260812T120000Z-s3test" + backupKey = "backups/" + backupID + ".tar.gz" + ) + archive := testSystemBackupArchive(t) + lastModified := "2026-08-12T12:00:00Z" + requestCounts := map[string]int{} + s3Server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") == "" { + t.Errorf("unsigned S3 request: %s %s", r.Method, r.URL.String()) + } + switch { + case r.Method == http.MethodHead && r.URL.Path == "/test-bucket": + requestCounts["head"]++ + w.WriteHeader(http.StatusOK) + case r.Method == http.MethodGet && r.URL.Path == "/test-bucket" && r.URL.Query().Get("list-type") == "2": + requestCounts["list"]++ + if r.URL.Query().Get("prefix") != "backups/" { + t.Errorf("list prefix = %q", r.URL.Query().Get("prefix")) + } + w.Header().Set("Content-Type", "application/xml") + _, _ = fmt.Fprintf(w, ` + + test-bucketbackups/21000false + %s%s"backup-etag"%dSTANDARD + backups/readme.txt%s"ignored-etag"7STANDARD +`, backupKey, lastModified, len(archive), lastModified) + case r.Method == http.MethodGet && r.URL.Path == "/test-bucket/"+backupKey: + requestCounts["download"]++ + w.Header().Set("Content-Type", "application/gzip") + _, _ = w.Write(archive) + default: + http.Error(w, "unexpected S3 request", http.StatusNotFound) + } + })) + defer s3Server.Close() + + root := t.TempDir() + binDir := filepath.Join(root, "bin") + if err := os.MkdirAll(binDir, 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(binDir, "pg_restore"), []byte("#!/bin/sh\nset -eu\nexit 0\n"), 0750); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + settingsRepo := settings.NewMemoryRepository() + if err := settingsRepo.SetMultiple(t.Context(), map[string]string{ + settings.KeyBackupS3Enabled: "true", + settings.KeyBackupS3Endpoint: s3Server.URL, + settings.KeyBackupS3Region: "test", + settings.KeyBackupS3Bucket: "test-bucket", + settings.KeyBackupS3Prefix: "backups", + settings.KeyBackupS3AccessKey: "test-access", + settings.KeyBackupS3SecretKey: "test-secret", + settings.KeyBackupS3PathStyle: "true", + }); err != nil { + t.Fatalf("configure S3 backup: %v", err) + } + settingsService := settings.NewService(settingsRepo, settings.ServiceOptions{Version: "test", StorageMode: "memory"}) + control := controlplane.NewService(controlplane.NewMemoryRepository(), "/v1") + if err := control.EnsureSeedData(t.Context()); err != nil { + t.Fatalf("EnsureSeedData(): %v", err) + } + systemService := system.NewService(system.Config{ + Version: "test", BuildType: "source", DatabaseURL: "postgres://test.invalid/router", BackupDir: filepath.Join(root, "backups"), + }) + router := gin.New() + registerSystemRoutes(router.Group("/api/v1/console/system"), systemService, settingsService, control) + + testReq := httptest.NewRequest(http.MethodPost, "/api/v1/console/system/backups/s3/test", nil) + testRec := httptest.NewRecorder() + router.ServeHTTP(testRec, testReq) + if testRec.Code != http.StatusOK || !strings.Contains(testRec.Body.String(), `"connected":true`) { + t.Fatalf("connection test status=%d body=%s", testRec.Code, testRec.Body.String()) + } + + listReq := httptest.NewRequest(http.MethodGet, "/api/v1/console/system/backups/s3", nil) + listRec := httptest.NewRecorder() + router.ServeHTTP(listRec, listReq) + if listRec.Code != http.StatusOK { + t.Fatalf("list status=%d body=%s", listRec.Code, listRec.Body.String()) + } + var listed struct { + Data []system.S3BackupObject `json:"data"` + } + if err := json.Unmarshal(listRec.Body.Bytes(), &listed); err != nil { + t.Fatalf("decode S3 backup list: %v", err) + } + if len(listed.Data) != 1 || listed.Data[0].ID != backupID || listed.Data[0].Key != backupKey || listed.Data[0].SizeBytes != int64(len(archive)) { + t.Fatalf("S3 backup list mismatch: %+v", listed.Data) + } + + downloadReq := httptest.NewRequest(http.MethodGet, "/api/v1/console/system/backups/s3/download?key="+url.QueryEscape(backupKey), nil) + downloadRec := httptest.NewRecorder() + router.ServeHTTP(downloadRec, downloadReq) + if downloadRec.Code != http.StatusOK || !bytes.Equal(downloadRec.Body.Bytes(), archive) || !strings.Contains(downloadRec.Header().Get("Content-Disposition"), backupID+".tar.gz") { + t.Fatalf("download status=%d headers=%v size=%d", downloadRec.Code, downloadRec.Header(), downloadRec.Body.Len()) + } + + invalidDownloadReq := httptest.NewRequest(http.MethodGet, "/api/v1/console/system/backups/s3/download?key="+url.QueryEscape("backups/../secret.tar.gz"), nil) + invalidDownloadRec := httptest.NewRecorder() + router.ServeHTTP(invalidDownloadRec, invalidDownloadReq) + if invalidDownloadRec.Code != http.StatusBadRequest || !strings.Contains(invalidDownloadRec.Body.String(), "S3 backup key is invalid") { + t.Fatalf("invalid key status=%d body=%s", invalidDownloadRec.Code, invalidDownloadRec.Body.String()) + } + + unconfirmedReq := httptest.NewRequest(http.MethodPost, "/api/v1/console/system/backups/s3/restore", strings.NewReader(`{"key":"`+backupKey+`","confirm":false}`)) + unconfirmedReq.Header.Set("Content-Type", "application/json") + unconfirmedRec := httptest.NewRecorder() + router.ServeHTTP(unconfirmedRec, unconfirmedReq) + if unconfirmedRec.Code != http.StatusConflict || !strings.Contains(unconfirmedRec.Body.String(), "explicit confirmation") { + t.Fatalf("unconfirmed restore status=%d body=%s", unconfirmedRec.Code, unconfirmedRec.Body.String()) + } + + restoreReq := httptest.NewRequest(http.MethodPost, "/api/v1/console/system/backups/s3/restore", strings.NewReader(`{"key":"`+backupKey+`","confirm":true}`)) + restoreReq.Header.Set("Content-Type", "application/json") + restoreRec := httptest.NewRecorder() + router.ServeHTTP(restoreRec, restoreReq) + if restoreRec.Code != http.StatusOK { + t.Fatalf("restore status=%d body=%s", restoreRec.Code, restoreRec.Body.String()) + } + var restored struct { + Data system.RestoreResult `json:"data"` + } + if err := json.Unmarshal(restoreRec.Body.Bytes(), &restored); err != nil { + t.Fatalf("decode S3 restore: %v", err) + } + if restored.Data.BackupID != backupID || !restored.Data.NeedRestart || !strings.HasPrefix(restored.Data.OperationID, "sys_restore-s3_") { + t.Fatalf("S3 restore mismatch: %+v", restored.Data) + } + if requestCounts["head"] != 1 || requestCounts["list"] != 1 || requestCounts["download"] != 2 { + t.Fatalf("S3 request counts: %+v", requestCounts) + } + audit, err := control.ListAuditLogs(t.Context(), 20) + if err != nil { + t.Fatalf("ListAuditLogs(): %v", err) + } + for _, event := range audit { + if event.ResourceType == "system" && event.Action == "restore_s3" && event.ResourceID == backupID { + return + } + } + t.Fatalf("S3 restore audit event not found: %+v", audit) +} + +func testSystemBackupArchive(t *testing.T) []byte { + t.Helper() + files := []struct { + name string + content []byte + }{ + {name: "database.dump", content: []byte("synthetic-postgres-dump")}, + {name: "manifest.json", content: []byte(`{"schema_version":"asterrouter.archive.v1","kind":"backup","database_format":"pg_dump_custom","database_included":true,"plugin_cache_included":false,"plugin_active_included":false}`)}, + } + var archive bytes.Buffer + gzipWriter := gzip.NewWriter(&archive) + tarWriter := tar.NewWriter(gzipWriter) + for _, file := range files { + if err := tarWriter.WriteHeader(&tar.Header{Name: file.name, Mode: 0600, Size: int64(len(file.content)), Typeflag: tar.TypeReg}); err != nil { + t.Fatalf("write backup %s header: %v", file.name, err) + } + if _, err := tarWriter.Write(file.content); err != nil { + t.Fatalf("write backup %s: %v", file.name, err) + } + } + if err := tarWriter.Close(); err != nil { + t.Fatalf("close backup tar: %v", err) + } + if err := gzipWriter.Close(); err != nil { + t.Fatalf("close backup gzip: %v", err) + } + return archive.Bytes() +} + +func TestSystemRoutesRequireAuthentication(t *testing.T) { + handler, _ := newTestRuntime(t, RuntimeConfig{AdminToken: "secret"}) + tests := []struct { + method string + path string + body string + }{ + {method: http.MethodGet, path: "/api/v1/console/system/check-updates"}, + {method: http.MethodPost, path: "/api/v1/console/system/update"}, + {method: http.MethodPost, path: "/api/v1/console/system/rollback"}, + {method: http.MethodPost, path: "/api/v1/console/system/restart"}, + {method: http.MethodGet, path: "/api/v1/console/system/backups"}, + {method: http.MethodPost, path: "/api/v1/console/system/backups"}, + {method: http.MethodPost, path: "/api/v1/console/system/backups/s3/test"}, + {method: http.MethodGet, path: "/api/v1/console/system/backups/s3"}, + {method: http.MethodGet, path: "/api/v1/console/system/backups/s3/download?key=backups%2Fasterrouter-backup-missing.tar.gz"}, + {method: http.MethodPost, path: "/api/v1/console/system/backups/s3/restore", body: `{"key":"backups/asterrouter-backup-missing.tar.gz","confirm":true}`}, + {method: http.MethodGet, path: "/api/v1/console/system/backups/asterrouter-backup-missing/download"}, + {method: http.MethodPost, path: "/api/v1/console/system/backups/restore", body: `{"backup_id":"missing","confirm":true}`}, + {method: http.MethodPost, path: "/api/v1/console/system/diagnostics"}, + {method: http.MethodGet, path: "/api/v1/console/system/diagnostics/asterrouter-diagnostic-missing/download"}, + } + for _, test := range tests { + req := httptest.NewRequest(test.method, test.path, strings.NewReader(test.body)) + if test.body != "" { + req.Header.Set("Content-Type", "application/json") + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("%s %s status=%d body=%s", test.method, test.path, rec.Code, rec.Body.String()) + } + } +} + +func TestSystemDiagnosticRoutesCreateAndDownload(t *testing.T) { + diagnosticDir := t.TempDir() + control := controlplane.NewService(controlplane.NewMemoryRepository(), "/v1") + if err := control.EnsureSeedData(t.Context()); err != nil { + t.Fatalf("EnsureSeedData(): %v", err) + } + router := http.NewServeMux() + ginRouter := gin.New() + registerSystemRoutes( + ginRouter.Group("/api/v1/console/system"), + system.NewService(system.Config{Version: "test", BuildType: "source", DiagnosticDir: diagnosticDir}), + nil, + control, + ) + router.Handle("/", ginRouter) + + createReq := httptest.NewRequest(http.MethodPost, "/api/v1/console/system/diagnostics", nil) + createRec := httptest.NewRecorder() + router.ServeHTTP(createRec, createReq) + if createRec.Code != http.StatusOK { + t.Fatalf("create status = %d body=%s", createRec.Code, createRec.Body.String()) + } + var created struct { + Code int `json:"code"` + Data system.DiagnosticInfo `json:"data"` + } + if err := json.Unmarshal(createRec.Body.Bytes(), &created); err != nil { + t.Fatalf("decode create response: %v", err) + } + if created.Code != 0 || created.Data.ID == "" || created.Data.SizeBytes <= 0 || created.Data.Path != created.Data.ID+".tar.gz" { + t.Fatalf("unexpected diagnostic response: %+v", created) + } + + downloadReq := httptest.NewRequest(http.MethodGet, "/api/v1/console/system/diagnostics/"+created.Data.ID+"/download", nil) + downloadRec := httptest.NewRecorder() + router.ServeHTTP(downloadRec, downloadReq) + if downloadRec.Code != http.StatusOK { + t.Fatalf("download status = %d body=%s", downloadRec.Code, downloadRec.Body.String()) + } + if got := downloadRec.Header().Get("Content-Disposition"); !strings.Contains(got, created.Data.Path) { + t.Fatalf("Content-Disposition = %q, want filename %q", got, created.Data.Path) + } + if int64(downloadRec.Body.Len()) != created.Data.SizeBytes { + t.Fatalf("download size = %d, want %d", downloadRec.Body.Len(), created.Data.SizeBytes) + } + audit, err := control.ListAuditLogs(t.Context(), 10) + if err != nil { + t.Fatalf("ListAuditLogs(): %v", err) + } + for _, event := range audit { + if event.ResourceType == "system" && event.Action == "diagnostic" && event.ResourceID == created.Data.ID { + return + } + } + t.Fatalf("diagnostic audit event not found: %+v", audit) +} + +func TestSystemDiagnosticRoutesRejectUnavailableServiceAndInvalidArchiveID(t *testing.T) { + withoutService := gin.New() + registerSystemRoutes(withoutService.Group("/api/v1/console/system"), nil, nil, nil) + createReq := httptest.NewRequest(http.MethodPost, "/api/v1/console/system/diagnostics", nil) + createRec := httptest.NewRecorder() + withoutService.ServeHTTP(createRec, createReq) + if createRec.Code != http.StatusServiceUnavailable { + t.Fatalf("unavailable create status = %d body=%s", createRec.Code, createRec.Body.String()) + } + + withService := gin.New() + registerSystemRoutes( + withService.Group("/api/v1/console/system"), + system.NewService(system.Config{Version: "test", BuildType: "source", DiagnosticDir: t.TempDir()}), + nil, + nil, + ) + for _, id := range []string{"asterrouter-diagnostic-missing", "..%2F..%2Fsecret"} { + downloadReq := httptest.NewRequest(http.MethodGet, "/api/v1/console/system/diagnostics/"+id+"/download", nil) + downloadRec := httptest.NewRecorder() + withService.ServeHTTP(downloadRec, downloadReq) + if downloadRec.Code != http.StatusNotFound { + t.Fatalf("download %q status = %d body=%s", id, downloadRec.Code, downloadRec.Body.String()) + } + } } diff --git a/backend/internal/server/ai_job_artifact_admin_contract_test.go b/backend/internal/server/ai_job_artifact_admin_contract_test.go new file mode 100644 index 0000000..b50631e --- /dev/null +++ b/backend/internal/server/ai_job_artifact_admin_contract_test.go @@ -0,0 +1,143 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/astercloud/asterrouter/backend/internal/controlplane" + "github.com/astercloud/asterrouter/backend/internal/gatewaycore" +) + +func assertConsoleError(t *testing.T, handler http.Handler, method, path string, headers map[string]string, wantStatus, wantCode int) *httptest.ResponseRecorder { + t.Helper() + request := httptest.NewRequest(method, path, nil) + for key, value := range headers { + request.Header.Set(key, value) + } + record := httptest.NewRecorder() + handler.ServeHTTP(record, request) + var response struct { + Code int `json:"code"` + } + if err := json.Unmarshal(record.Body.Bytes(), &response); err != nil { + t.Fatalf("%s %s decode error: %v; status=%d body=%s", method, path, err, record.Code, record.Body.String()) + } + if record.Code != wantStatus || response.Code != wantCode { + t.Fatalf("%s %s status=%d code=%d, want status=%d code=%d; body=%s", method, path, record.Code, response.Code, wantStatus, wantCode, record.Body.String()) + } + return record +} + +func TestAdminAIJobHTTPFailureAndIdempotencyContracts(t *testing.T) { + handler, control := newTestRuntimeWithDurableAdmission(t, RuntimeConfig{AdminToken: "secret"}, testAIJobRuntime{}) + ctx := context.Background() + headers := map[string]string{"Authorization": "Bearer secret"} + model, err := control.CreateGatewayModel(ctx, "tester", controlplane.GatewayModelRequest{ + ModelID: "admin-job-contract-model", Name: "Admin job contract model", Modality: "image", Status: controlplane.GatewayModelStatusActive, + }) + if err != nil { + t.Fatal(err) + } + beginJob := func(marker string) controlplane.AIJob { + t.Helper() + job, _, beginErr := control.BeginDurableAIJob(ctx, gatewaycore.CanonicalAuthContext{ + CredentialSource: gatewaycore.CredentialSourceAPIKey, CredentialID: "admin-job-contract-key", + ApplicationID: "admin-job-contract-application", PrincipalType: controlplane.APIKeyTypeService, PrincipalID: "admin-job-contract-principal", + ArtifactPolicy: controlplane.GatewayArtifactPolicyTemporary, + }, gatewaycore.CanonicalRequest{ + ID: "admin-job-contract-request-" + marker, Fingerprint: "admin-job-contract-fingerprint-" + marker, + IdempotencyKey: "admin-job-contract-idempotency-" + marker, Protocol: gatewaycore.ProtocolAsterJobs, + Operation: "image_generation", Modality: "image", Lane: gatewaycore.LaneDurable, Model: model.ModelID, + Payload: []byte(`{"input":{"prompt":"synthetic"}}`), + }) + if beginErr != nil { + t.Fatal(beginErr) + } + return job + } + + assertConsoleError(t, handler, http.MethodGet, "/api/v1/console/ai-jobs/job_missing", headers, http.StatusNotFound, 1570) + assertConsoleError(t, handler, http.MethodPost, "/api/v1/console/ai-jobs/job_missing/cancel", headers, http.StatusNotFound, 1570) + assertConsoleError(t, handler, http.MethodGet, "/api/v1/console/ai-jobs?status=invalid", headers, http.StatusBadRequest, 1571) + + cancelable := beginJob("cancelable") + summaryRequest := httptest.NewRequest(http.MethodGet, "/api/v1/console/ai-jobs/summary?status=queued", nil) + summaryRequest.Header.Set("Authorization", "Bearer secret") + summaryRecord := httptest.NewRecorder() + handler.ServeHTTP(summaryRecord, summaryRequest) + var summaryResponse struct { + Code int `json:"code"` + Data controlplane.AIJobSummary `json:"data"` + } + if err := json.Unmarshal(summaryRecord.Body.Bytes(), &summaryResponse); err != nil || summaryRecord.Code != http.StatusOK || summaryResponse.Code != 0 || summaryResponse.Data.Total != 1 || summaryResponse.Data.ByStatus[controlplane.AIJobStatusQueued] != 1 { + t.Fatalf("summary status=%d response=%+v err=%v body=%s", summaryRecord.Code, summaryResponse, err, summaryRecord.Body.String()) + } + cancelRequest := httptest.NewRequest(http.MethodPost, "/api/v1/console/ai-jobs/"+cancelable.ID+"/cancel", nil) + cancelRequest.Header.Set("Authorization", "Bearer secret") + firstCancel := httptest.NewRecorder() + handler.ServeHTTP(firstCancel, cancelRequest) + if firstCancel.Code != http.StatusOK { + t.Fatalf("first cancel status=%d body=%s", firstCancel.Code, firstCancel.Body.String()) + } + replayRequest := httptest.NewRequest(http.MethodPost, "/api/v1/console/ai-jobs/"+cancelable.ID+"/cancel", nil) + replayRequest.Header.Set("Authorization", "Bearer secret") + replay := httptest.NewRecorder() + handler.ServeHTTP(replay, replayRequest) + var replayResponse struct { + Code int `json:"code"` + Data struct { + Status string `json:"status"` + Changed bool `json:"changed"` + } `json:"data"` + } + if err := json.Unmarshal(replay.Body.Bytes(), &replayResponse); err != nil || replay.Code != http.StatusOK || replayResponse.Code != 0 || replayResponse.Data.Status != controlplane.AIJobStatusCanceled || replayResponse.Data.Changed { + t.Fatalf("cancel replay status=%d response=%+v err=%v body=%s", replay.Code, replayResponse, err, replay.Body.String()) + } + + terminal := beginJob("terminal") + claimed, err := control.ClaimReadyAIJobs(ctx, "admin-job-contract-worker", time.Minute, 1) + if err != nil || len(claimed) != 1 || claimed[0].ID != terminal.ID { + t.Fatalf("claim terminal job=%+v err=%v", claimed, err) + } + running, err := control.TransitionAIJob(ctx, terminal.ID, claimed[0].StatusVersion, claimed[0].FenceToken, controlplane.AIJobStatusRunning, "") + if err != nil { + t.Fatal(err) + } + if _, err := control.TransitionAIJob(ctx, running.ID, running.StatusVersion, running.FenceToken, controlplane.AIJobStatusSucceeded, ""); err != nil { + t.Fatal(err) + } + assertConsoleError(t, handler, http.MethodPost, "/api/v1/console/ai-jobs/"+terminal.ID+"/cancel", headers, http.StatusConflict, 1572) + + job := beginJob("reconcile-owner") + foreignJob := beginJob("reconcile-foreign") + foreignAttempt, err := control.BeginAIAttempt(ctx, foreignJob.OperationID, 1, controlplane.GatewayProvider{ + ID: "provider-foreign", AccountID: "account-foreign", AdapterID: "adapter-foreign", RouteID: "route-foreign", UpstreamModel: "upstream-foreign", + }) + if err != nil { + t.Fatal(err) + } + assertConsoleError(t, handler, http.MethodPost, fmt.Sprintf("/api/v1/console/ai-jobs/%s/attempts/attempt_missing/reconcile", job.ID), headers, http.StatusNotFound, 1570) + assertConsoleError(t, handler, http.MethodPost, fmt.Sprintf("/api/v1/console/ai-jobs/%s/attempts/%s/reconcile", job.ID, foreignAttempt.ID), headers, http.StatusNotFound, 1570) +} + +func TestAdminArtifactHTTPFailureAndRangeContracts(t *testing.T) { + handler, control := newTestRuntime(t, RuntimeConfig{}) + artifact := createAdminRouteArtifact(t, control) + + assertConsoleError(t, handler, http.MethodGet, "/api/v1/console/artifacts/artifact_missing", nil, http.StatusNotFound, 1560) + assertConsoleError(t, handler, http.MethodGet, "/api/v1/console/artifacts/artifact_missing/content", nil, http.StatusNotFound, 1560) + assertConsoleError(t, handler, http.MethodPost, "/api/v1/console/artifacts/artifact_missing/retry-delivery", nil, http.StatusNotFound, 1560) + assertConsoleError(t, handler, http.MethodPost, "/api/v1/console/artifacts/"+artifact.ID+"/retry-delivery", nil, http.StatusConflict, 1562) + assertConsoleError(t, handler, http.MethodGet, "/api/v1/console/artifacts?status=invalid", nil, http.StatusBadRequest, 1561) + + rangeRecord := assertConsoleError(t, handler, http.MethodGet, "/api/v1/console/artifacts/"+artifact.ID+"/content", map[string]string{"Range": "bytes=999-"}, http.StatusRequestedRangeNotSatisfiable, 1568) + wantContentRange := fmt.Sprintf("bytes */%d", len(adminRouteArtifactPayload)) + if rangeRecord.Header().Get("Content-Range") != wantContentRange { + t.Fatalf("invalid range Content-Range=%q, want %q", rangeRecord.Header().Get("Content-Range"), wantContentRange) + } +} diff --git a/backend/internal/server/alert_routes_test.go b/backend/internal/server/alert_routes_test.go index 7fdfcfb..3d29ca8 100644 --- a/backend/internal/server/alert_routes_test.go +++ b/backend/internal/server/alert_routes_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "github.com/astercloud/asterrouter/backend/internal/controlplane" @@ -79,6 +80,13 @@ func TestAdminAlertEndpoints(t *testing.T) { t.Fatalf("ack mismatch: %+v", ackResp.Data) } + repeatAckReq := httptest.NewRequest(http.MethodPost, "/api/v1/console/alerts/"+alertID+"/acknowledge", nil) + repeatAckRec := httptest.NewRecorder() + handler.ServeHTTP(repeatAckRec, repeatAckReq) + if repeatAckRec.Code != http.StatusOK || !strings.Contains(repeatAckRec.Body.String(), `"status":"acknowledged"`) { + t.Fatalf("repeat ack status = %d body=%s", repeatAckRec.Code, repeatAckRec.Body.String()) + } + resolveReq := httptest.NewRequest(http.MethodPost, "/api/v1/console/alerts/"+alertID+"/resolve", nil) resolveRec := httptest.NewRecorder() handler.ServeHTTP(resolveRec, resolveReq) @@ -94,4 +102,25 @@ func TestAdminAlertEndpoints(t *testing.T) { if resolveResp.Data.Status != controlplane.AlertStatusResolved { t.Fatalf("resolve mismatch: %+v", resolveResp.Data) } + + repeatResolveReq := httptest.NewRequest(http.MethodPost, "/api/v1/console/alerts/"+alertID+"/resolve", nil) + repeatResolveRec := httptest.NewRecorder() + handler.ServeHTTP(repeatResolveRec, repeatResolveReq) + if repeatResolveRec.Code != http.StatusOK || !strings.Contains(repeatResolveRec.Body.String(), `"status":"resolved"`) { + t.Fatalf("repeat resolve status = %d body=%s", repeatResolveRec.Code, repeatResolveRec.Body.String()) + } + + resolvedAckReq := httptest.NewRequest(http.MethodPost, "/api/v1/console/alerts/"+alertID+"/acknowledge", nil) + resolvedAckRec := httptest.NewRecorder() + handler.ServeHTTP(resolvedAckRec, resolvedAckReq) + if resolvedAckRec.Code != http.StatusBadRequest || !strings.Contains(resolvedAckRec.Body.String(), `"code":1520`) || !strings.Contains(resolvedAckRec.Body.String(), "resolved alert cannot be acknowledged") { + t.Fatalf("resolved alert ack status = %d body=%s", resolvedAckRec.Code, resolvedAckRec.Body.String()) + } + + missingResolveReq := httptest.NewRequest(http.MethodPost, "/api/v1/console/alerts/alert_missing/resolve", nil) + missingResolveRec := httptest.NewRecorder() + handler.ServeHTTP(missingResolveRec, missingResolveReq) + if missingResolveRec.Code != http.StatusBadRequest || !strings.Contains(missingResolveRec.Body.String(), `"code":1521`) || !strings.Contains(missingResolveRec.Body.String(), `alert \"alert_missing\" not found`) { + t.Fatalf("missing alert resolve status = %d body=%s", missingResolveRec.Code, missingResolveRec.Body.String()) + } } diff --git a/backend/internal/server/application_routes_test.go b/backend/internal/server/application_routes_test.go new file mode 100644 index 0000000..a289ed9 --- /dev/null +++ b/backend/internal/server/application_routes_test.go @@ -0,0 +1,92 @@ +package server + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/astercloud/asterrouter/backend/internal/controlplane" +) + +func TestApplicationEndpointsPersistLifecycleAndRejectInvalidUpdates(t *testing.T) { + handler, _ := newTestRuntime(t, RuntimeConfig{}) + + createReq := httptest.NewRequest(http.MethodPost, "/api/v1/applications", bytes.NewBufferString(`{"name":"Customer Service","slug":"customer-service","entitlement_reference":"plan-enterprise","concurrency_limit":8,"status":"active"}`)) + createReq.Header.Set("Content-Type", "application/json") + createRec := httptest.NewRecorder() + handler.ServeHTTP(createRec, createReq) + if createRec.Code != http.StatusOK { + t.Fatalf("create status=%d body=%s", createRec.Code, createRec.Body.String()) + } + var created struct { + Data controlplane.Application `json:"data"` + } + if err := json.Unmarshal(createRec.Body.Bytes(), &created); err != nil { + t.Fatalf("decode create: %v", err) + } + if created.Data.ID == "" || created.Data.Slug != "customer-service" || created.Data.ConcurrencyLimit != 8 { + t.Fatalf("created application=%+v", created.Data) + } + + updateReq := httptest.NewRequest(http.MethodPut, "/api/v1/applications/"+created.Data.ID, bytes.NewBufferString(`{"name":"Customer Service Production","slug":"customer-service","entitlement_reference":"plan-enterprise-v2","concurrency_limit":12,"status":"disabled"}`)) + updateReq.Header.Set("Content-Type", "application/json") + updateRec := httptest.NewRecorder() + handler.ServeHTTP(updateRec, updateReq) + if updateRec.Code != http.StatusOK { + t.Fatalf("update status=%d body=%s", updateRec.Code, updateRec.Body.String()) + } + var updated struct { + Data controlplane.Application `json:"data"` + } + if err := json.Unmarshal(updateRec.Body.Bytes(), &updated); err != nil { + t.Fatalf("decode update: %v", err) + } + if updated.Data.ID != created.Data.ID || updated.Data.Name != "Customer Service Production" || updated.Data.Status != controlplane.ApplicationStatusDisabled || updated.Data.ConcurrencyLimit != 12 || !updated.Data.CreatedAt.Equal(created.Data.CreatedAt) { + t.Fatalf("updated application=%+v created=%+v", updated.Data, created.Data) + } + + listReq := httptest.NewRequest(http.MethodGet, "/api/v1/applications", nil) + listRec := httptest.NewRecorder() + handler.ServeHTTP(listRec, listReq) + if listRec.Code != http.StatusOK { + t.Fatalf("list status=%d body=%s", listRec.Code, listRec.Body.String()) + } + var listed struct { + Data []controlplane.Application `json:"data"` + } + if err := json.Unmarshal(listRec.Body.Bytes(), &listed); err != nil { + t.Fatalf("decode list: %v", err) + } + found := false + for _, application := range listed.Data { + if application.ID == created.Data.ID { + found = application.Name == updated.Data.Name && application.Status == updated.Data.Status && application.EntitlementReference == "plan-enterprise-v2" + } + } + if !found { + t.Fatalf("persisted application missing from list: %+v", listed.Data) + } + + for name, test := range map[string]struct { + id string + body string + want string + }{ + "missing application": {id: "missing", body: `{"name":"Missing","slug":"missing","status":"active"}`, want: "not found"}, + "negative concurrency": {id: created.Data.ID, body: `{"name":"Invalid","slug":"customer-service","concurrency_limit":-1,"status":"active"}`, want: "non-negative"}, + "invalid status": {id: created.Data.ID, body: `{"name":"Invalid","slug":"customer-service","status":"archived"}`, want: "active or disabled"}, + } { + t.Run(name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPut, "/api/v1/applications/"+test.id, strings.NewReader(test.body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), test.want) { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + }) + } +} diff --git a/backend/internal/server/department_routes_test.go b/backend/internal/server/department_routes_test.go index 36c9016..e947a84 100644 --- a/backend/internal/server/department_routes_test.go +++ b/backend/internal/server/department_routes_test.go @@ -101,6 +101,60 @@ func TestAdminDepartmentEndpoints(t *testing.T) { t.Fatalf("missing parent should be rejected status=%d body=%s", missingParentRec.Code, missingParentRec.Body.String()) } + missingUpdateReq := httptest.NewRequest(http.MethodPut, "/api/v1/console/departments/missing", bytes.NewBufferString(`{"name":"Missing","code":"missing","status":"active"}`)) + missingUpdateReq.Header.Set("Content-Type", "application/json") + missingUpdateRec := httptest.NewRecorder() + handler.ServeHTTP(missingUpdateRec, missingUpdateReq) + if missingUpdateRec.Code != http.StatusBadRequest || !strings.Contains(missingUpdateRec.Body.String(), "not found") { + t.Fatalf("missing department update should be rejected status=%d body=%s", missingUpdateRec.Code, missingUpdateRec.Body.String()) + } + + negativeBudgetReq := httptest.NewRequest(http.MethodPut, "/api/v1/console/departments/"+childResp.Data.ID, bytes.NewBufferString(`{"name":"Platform Services","code":"platform","parent_id":"`+parentResp.Data.ID+`","monthly_budget_micros":-1,"status":"archived"}`)) + negativeBudgetReq.Header.Set("Content-Type", "application/json") + negativeBudgetRec := httptest.NewRecorder() + handler.ServeHTTP(negativeBudgetRec, negativeBudgetReq) + if negativeBudgetRec.Code != http.StatusBadRequest || !strings.Contains(negativeBudgetRec.Body.String(), "greater than or equal to 0") { + t.Fatalf("negative department budget should be rejected status=%d body=%s", negativeBudgetRec.Code, negativeBudgetRec.Body.String()) + } + + selfParentReq := httptest.NewRequest(http.MethodPut, "/api/v1/console/departments/"+childResp.Data.ID, bytes.NewBufferString(`{"name":"Platform Services","code":"platform","parent_id":"`+childResp.Data.ID+`","monthly_budget_micros":160000,"status":"archived"}`)) + selfParentReq.Header.Set("Content-Type", "application/json") + selfParentRec := httptest.NewRecorder() + handler.ServeHTTP(selfParentRec, selfParentReq) + if selfParentRec.Code != http.StatusBadRequest || !strings.Contains(selfParentRec.Body.String(), "itself as parent") { + t.Fatalf("self-parent department update should be rejected status=%d body=%s", selfParentRec.Code, selfParentRec.Body.String()) + } + + cycleReq := httptest.NewRequest(http.MethodPut, "/api/v1/console/departments/"+parentResp.Data.ID, bytes.NewBufferString(`{"name":"Engineering","code":"eng","parent_id":"`+childResp.Data.ID+`","monthly_budget_micros":250000,"status":"active"}`)) + cycleReq.Header.Set("Content-Type", "application/json") + cycleRec := httptest.NewRecorder() + handler.ServeHTTP(cycleRec, cycleReq) + if cycleRec.Code != http.StatusBadRequest || !strings.Contains(cycleRec.Body.String(), "create a cycle") { + t.Fatalf("cyclic department update should be rejected status=%d body=%s", cycleRec.Code, cycleRec.Body.String()) + } + + departments, err := control.ListDepartments(context.Background()) + if err != nil { + t.Fatalf("ListDepartments(): %v", err) + } + if len(departments) != 2 { + t.Fatalf("rejected department updates changed collection size: %+v", departments) + } + for _, department := range departments { + switch department.ID { + case parentResp.Data.ID: + if department.ParentID != "" || department.MonthlyBudgetMicros != 250000 { + t.Fatalf("rejected cycle mutated parent: %+v", department) + } + case childResp.Data.ID: + if department.ParentID != parentResp.Data.ID || department.MonthlyBudgetMicros != 160000 || department.Status != controlplane.DepartmentStatusArchived { + t.Fatalf("rejected update mutated child: %+v", department) + } + default: + t.Fatalf("unexpected department after rejected updates: %+v", department) + } + } + audit, err := control.ListAuditLogs(context.Background(), 20) if err != nil { t.Fatalf("ListAuditLogs(): %v", err) diff --git a/backend/internal/server/effective_pricing_routes_test.go b/backend/internal/server/effective_pricing_routes_test.go index 7fa449a..f04ce3b 100644 --- a/backend/internal/server/effective_pricing_routes_test.go +++ b/backend/internal/server/effective_pricing_routes_test.go @@ -40,6 +40,12 @@ func TestEffectivePricingAdminEndpointsCreatePriceAndReconcileBilling(t *testing if createRecorder.Code != http.StatusOK { t.Fatalf("create price status=%d body=%s", createRecorder.Code, createRecorder.Body.String()) } + priceList := httptest.NewRequest(http.MethodGet, "/api/v1/console/procurement-prices", nil) + priceListRecorder := httptest.NewRecorder() + handler.ServeHTTP(priceListRecorder, priceList) + if priceListRecorder.Code != http.StatusOK || !bytes.Contains(priceListRecorder.Body.Bytes(), []byte(`"upstream_model":"upstream-model"`)) { + t.Fatalf("price list status=%d body=%s", priceListRecorder.Code, priceListRecorder.Body.String()) + } capabilityBody := fmt.Sprintf(`{"provider_account_id":%q,"upstream_model":"upstream-model","protocol":"openai_chat_completions","support_status":"claimed","pool_affinity_grade":"unknown","affinity_transport":"header","affinity_field":"X-Session-ID","cache_control_mode":"prompt_cache_key","usage_schema":"openai"}`, account.ID) capability := httptest.NewRequest(http.MethodPut, "/api/v1/console/provider-cache-capabilities", bytes.NewBufferString(capabilityBody)) @@ -49,6 +55,12 @@ func TestEffectivePricingAdminEndpointsCreatePriceAndReconcileBilling(t *testing if capabilityRecorder.Code != http.StatusOK || !bytes.Contains(capabilityRecorder.Body.Bytes(), []byte(`"affinity_field":"X-Session-ID"`)) { t.Fatalf("capability status=%d body=%s", capabilityRecorder.Code, capabilityRecorder.Body.String()) } + capabilityList := httptest.NewRequest(http.MethodGet, "/api/v1/console/provider-cache-capabilities", nil) + capabilityListRecorder := httptest.NewRecorder() + handler.ServeHTTP(capabilityListRecorder, capabilityList) + if capabilityListRecorder.Code != http.StatusOK || !bytes.Contains(capabilityListRecorder.Body.Bytes(), []byte(`"affinity_field":"X-Session-ID"`)) { + t.Fatalf("capability list status=%d body=%s", capabilityListRecorder.Code, capabilityListRecorder.Body.String()) + } if err := control.RecordGatewayUsage(context.Background(), controlplane.GatewayAuthContext{APIKey: controlplane.APIKeyRecord{ID: "billing-key"}}, controlplane.GatewayUsageInput{ Model: "public-model", UpstreamModel: "upstream-model", Protocol: "openai_chat_completions", @@ -74,6 +86,12 @@ func TestEffectivePricingAdminEndpointsCreatePriceAndReconcileBilling(t *testing if billingResponse.Data.ReconciliationStatus != controlplane.BillingReconciliationMatched || billingResponse.Data.UsageRecordID == "" { t.Fatalf("billing response=%+v", billingResponse.Data) } + billingList := httptest.NewRequest(http.MethodGet, "/api/v1/console/provider-billing-lines", nil) + billingListRecorder := httptest.NewRecorder() + handler.ServeHTTP(billingListRecorder, billingList) + if billingListRecorder.Code != http.StatusOK || !bytes.Contains(billingListRecorder.Body.Bytes(), []byte(billingResponse.Data.ID)) { + t.Fatalf("billing list status=%d body=%s", billingListRecorder.Code, billingListRecorder.Body.String()) + } report := httptest.NewRequest(http.MethodGet, "/api/v1/console/effective-pricing/report?model=upstream-model&protocol=openai_chat_completions&window_hours=24", nil) reportRecorder := httptest.NewRecorder() @@ -85,6 +103,19 @@ func TestEffectivePricingAdminEndpointsCreatePriceAndReconcileBilling(t *testing func TestEffectivePricingPolicyEndpointRejectsUnsafeValues(t *testing.T) { handler := newTestHandler(t, RuntimeConfig{}) + getRequest := httptest.NewRequest(http.MethodGet, "/api/v1/console/effective-pricing/policy", nil) + getRecorder := httptest.NewRecorder() + handler.ServeHTTP(getRecorder, getRequest) + if getRecorder.Code != http.StatusOK || !bytes.Contains(getRecorder.Body.Bytes(), []byte(`"mode":"observe_only"`)) { + t.Fatalf("get policy status=%d body=%s", getRecorder.Code, getRecorder.Body.String()) + } + valid := httptest.NewRequest(http.MethodPut, "/api/v1/console/effective-pricing/policy", bytes.NewBufferString(`{"mode":"recommend","window_hours":48,"min_sample_count":10,"min_metrics_coverage":0.8,"min_billing_consistency":0.95,"min_cost_improvement":0.08,"min_cache_hit_rate_improvement":0.1,"min_affinity_improvement":0.1,"max_cache_tiebreak_cost_regression":0.02,"max_error_rate_regression":0.005,"max_p95_latency_regression":0.2,"canary_percent":5,"supplier_affinity_ttl_seconds":86400,"account_affinity_ttl_seconds":1800,"automatic_actions_enabled":false,"evaluation_interval_minutes":60,"promotion_window_count":3,"degradation_window_count":2,"probe_enabled":true,"probe_daily_token_budget":100000,"probe_daily_cost_budget_micros":100000,"probe_cooldown_seconds":3600}`)) + valid.Header.Set("Content-Type", "application/json") + validRecorder := httptest.NewRecorder() + handler.ServeHTTP(validRecorder, valid) + if validRecorder.Code != http.StatusOK || !bytes.Contains(validRecorder.Body.Bytes(), []byte(`"mode":"recommend"`)) || !bytes.Contains(validRecorder.Body.Bytes(), []byte(`"window_hours":48`)) { + t.Fatalf("update policy status=%d body=%s", validRecorder.Code, validRecorder.Body.String()) + } request := httptest.NewRequest(http.MethodPut, "/api/v1/console/effective-pricing/policy", bytes.NewBufferString(`{"mode":"canary","window_hours":24,"min_sample_count":0,"min_metrics_coverage":0.8,"min_billing_consistency":0.95,"min_cost_improvement":0.08,"max_error_rate_regression":0.005,"max_p95_latency_regression":0.2,"canary_percent":5,"supplier_affinity_ttl_seconds":86400,"account_affinity_ttl_seconds":1800}`)) request.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() @@ -260,6 +291,12 @@ func TestProviderCacheProbeEndpointRunsControlledSequenceAndRejectsMissingConfir if response.Data.Status != controlplane.CacheProbeStatusSucceeded || response.Data.ReuseCacheReadTokens != 240 || response.Data.ReuseUpstreamRequestID != "route-probe-2" || calls.Load() != 3 { t.Fatalf("response=%+v calls=%d", response.Data, calls.Load()) } + list := httptest.NewRequest(http.MethodGet, "/api/v1/console/provider-cache-probes?limit=20", nil) + listRecorder := httptest.NewRecorder() + handler.ServeHTTP(listRecorder, list) + if listRecorder.Code != http.StatusOK || !bytes.Contains(listRecorder.Body.Bytes(), []byte(response.Data.ID)) { + t.Fatalf("probe list status=%d body=%s", listRecorder.Code, listRecorder.Body.String()) + } } func pricingMicrosValue(value int64) *int64 { diff --git a/backend/internal/server/email_settings_test.go b/backend/internal/server/email_settings_test.go index bce8135..5b01c50 100644 --- a/backend/internal/server/email_settings_test.go +++ b/backend/internal/server/email_settings_test.go @@ -1,12 +1,23 @@ package server import ( + "bufio" "bytes" + "crypto/tls" + "encoding/base64" "encoding/json" + "encoding/pem" + "fmt" + "io" + "mime" + "net" "net/http" "net/http/httptest" + "net/mail" + "os" "strings" "testing" + "time" "github.com/astercloud/asterrouter/backend/internal/httpx" "github.com/astercloud/asterrouter/backend/internal/settings" @@ -60,6 +71,14 @@ func TestEmailTemplateRoutesExposeLowercaseCatalogAndIndependentUpdates(t *testi if invalid.Code != http.StatusBadRequest { t.Fatalf("invalid status=%d body=%s", invalid.Code, invalid.Body.String()) } + preview := request(http.MethodPost, "/api/v1/console/settings/email-templates/preview", `{"subject":"Hello {{.UserName}}","html":"

{{.SiteName}} {{.ActionURL}}

"}`) + if preview.Code != http.StatusOK || !strings.Contains(preview.Body.String(), "Hello Enterprise User") || !strings.Contains(preview.Body.String(), "https://example.test/action") { + t.Fatalf("preview status=%d body=%s", preview.Code, preview.Body.String()) + } + invalidPreview := request(http.MethodPost, "/api/v1/console/settings/email-templates/preview", `{"subject":"{{.Unknown}}","html":"

body

"}`) + if invalidPreview.Code != http.StatusBadRequest || !strings.Contains(invalidPreview.Body.String(), `"code":1420`) { + t.Fatalf("invalid preview status=%d body=%s", invalidPreview.Code, invalidPreview.Body.String()) + } } func TestEmailTemplateRoutesRequireAuthentication(t *testing.T) { @@ -91,3 +110,227 @@ func TestSMTPConnectionRouteUsesRequestConfiguration(t *testing.T) { t.Fatalf("connection test response=%+v", response) } } + +type capturedSMTPMessage struct { + from string + to string + raw string +} + +func TestSMTPRoutesDeliverPlainAndRenderedMessagesOverSTARTTLS(t *testing.T) { + port, messages := startTestSMTPServer(t) + handler := newTestHandler(t, RuntimeConfig{AdminToken: "secret"}) + post := func(path string, body map[string]any) *httptest.ResponseRecorder { + payload, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(payload)) + req.Header.Set("Authorization", "Bearer secret") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec + } + config := map[string]any{ + "smtp_host": "127.0.0.1", "smtp_port": port, "smtp_from": "sender@example.test", + "smtp_from_name": "AsterRouter Test", "smtp_use_tls": false, + } + + connected := post("/api/v1/console/settings/smtp/test-connection", config) + if connected.Code != http.StatusOK || !strings.Contains(connected.Body.String(), `"connected":true`) { + t.Fatalf("connection status=%d body=%s", connected.Code, connected.Body.String()) + } + + plainConfig := cloneSMTPRequest(config) + plainConfig["recipient"] = "plain@example.test" + plain := post("/api/v1/console/settings/smtp/test", plainConfig) + if plain.Code != http.StatusOK || !strings.Contains(plain.Body.String(), `"sent":true`) { + t.Fatalf("plain status=%d body=%s", plain.Code, plain.Body.String()) + } + assertSMTPMessage(t, nextSMTPMessage(t, messages), "sender@example.test", "plain@example.test", "AsterRouter SMTP test", "SMTP configuration is working.") + + templateConfig := cloneSMTPRequest(config) + templateConfig["recipient"] = "template@example.test" + templateConfig["subject"] = "Hello {{.UserName}}" + templateConfig["html"] = "

{{.SiteName}} / {{.ActionURL}}

" + template := post("/api/v1/console/settings/email-templates/test", templateConfig) + if template.Code != http.StatusOK || !strings.Contains(template.Body.String(), `"sent":true`) { + t.Fatalf("template status=%d body=%s", template.Code, template.Body.String()) + } + assertSMTPMessage(t, nextSMTPMessage(t, messages), "sender@example.test", "template@example.test", "Hello Enterprise User", "

AsterRouter / https://example.test/action

") +} + +func TestSMTPRoutesRejectInvalidRecipientsAndTemplates(t *testing.T) { + handler := newTestHandler(t, RuntimeConfig{AdminToken: "secret"}) + request := func(path, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer secret") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec + } + invalidRecipient := request("/api/v1/console/settings/smtp/test", `{"recipient":"not-an-email"}`) + if invalidRecipient.Code != http.StatusBadRequest || !strings.Contains(invalidRecipient.Body.String(), `"code":1402`) { + t.Fatalf("invalid recipient status=%d body=%s", invalidRecipient.Code, invalidRecipient.Body.String()) + } + invalidTemplate := request("/api/v1/console/settings/email-templates/test", `{"recipient":"user@example.test","subject":"{{.Unknown}}","html":"

body

"}`) + if invalidTemplate.Code != http.StatusBadRequest || !strings.Contains(invalidTemplate.Body.String(), `"code":1420`) { + t.Fatalf("invalid template status=%d body=%s", invalidTemplate.Code, invalidTemplate.Body.String()) + } +} + +func startTestSMTPServer(t *testing.T) (int, <-chan capturedSMTPMessage) { + t.Helper() + certificateSource := httptest.NewTLSServer(http.NotFoundHandler()) + serverCertificate := certificateSource.TLS.Certificates[0] + caCertificate := certificateSource.Certificate() + certificateSource.Close() + caFile := t.TempDir() + "/smtp-ca.pem" + if err := os.WriteFile(caFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caCertificate.Raw}), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("SSL_CERT_FILE", caFile) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = listener.Close() }) + messages := make(chan capturedSMTPMessage, 4) + go func() { + for { + connection, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + go serveTestSMTPConnection(connection, serverCertificate, messages) + } + }() + return listener.Addr().(*net.TCPAddr).Port, messages +} + +func serveTestSMTPConnection(connection net.Conn, certificate tls.Certificate, messages chan<- capturedSMTPMessage) { + defer connection.Close() + _ = connection.SetDeadline(time.Now().Add(5 * time.Second)) + reader := bufio.NewReader(connection) + writer := bufio.NewWriter(connection) + write := func(value string) bool { + if _, err := fmt.Fprint(writer, value); err != nil { + return false + } + return writer.Flush() == nil + } + if !write("220 test-smtp ESMTP ready\r\n") { + return + } + tlsActive := false + from, to := "", "" + for { + line, err := reader.ReadString('\n') + if err != nil { + return + } + command := strings.TrimSpace(line) + verb := strings.ToUpper(strings.Fields(command)[0]) + switch verb { + case "EHLO", "HELO": + if tlsActive { + write("250-test-smtp\r\n250 8BITMIME\r\n") + } else { + write("250-test-smtp\r\n250 STARTTLS\r\n") + } + case "STARTTLS": + if tlsActive || !write("220 2.0.0 ready to start TLS\r\n") { + return + } + tlsConnection := tls.Server(connection, &tls.Config{Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS12}) + if err := tlsConnection.Handshake(); err != nil { + return + } + connection = tlsConnection + reader = bufio.NewReader(connection) + writer = bufio.NewWriter(connection) + tlsActive = true + case "MAIL": + from = smtpCommandMailbox(command) + write("250 2.1.0 sender accepted\r\n") + case "RCPT": + to = smtpCommandMailbox(command) + write("250 2.1.5 recipient accepted\r\n") + case "DATA": + if from == "" || to == "" || !write("354 end data with .\r\n") { + return + } + var lines []string + for { + dataLine, readErr := reader.ReadString('\n') + if readErr != nil { + return + } + dataLine = strings.TrimSuffix(strings.TrimSuffix(dataLine, "\n"), "\r") + if dataLine == "." { + break + } + lines = append(lines, strings.TrimPrefix(dataLine, ".")) + } + messages <- capturedSMTPMessage{from: from, to: to, raw: strings.Join(lines, "\r\n")} + write("250 2.0.0 message accepted\r\n") + case "RSET": + from, to = "", "" + write("250 2.0.0 reset\r\n") + case "QUIT": + write("221 2.0.0 bye\r\n") + return + default: + write("502 5.5.2 command not implemented\r\n") + } + } +} + +func cloneSMTPRequest(source map[string]any) map[string]any { + clone := make(map[string]any, len(source)+3) + for key, value := range source { + clone[key] = value + } + return clone +} + +func smtpCommandMailbox(command string) string { + start, end := strings.Index(command, "<"), strings.LastIndex(command, ">") + if start < 0 || end <= start { + return "" + } + return strings.ToLower(strings.TrimSpace(command[start+1 : end])) +} + +func nextSMTPMessage(t *testing.T, messages <-chan capturedSMTPMessage) capturedSMTPMessage { + t.Helper() + select { + case message := <-messages: + return message + case <-time.After(3 * time.Second): + t.Fatal("SMTP message was not delivered") + return capturedSMTPMessage{} + } +} + +func assertSMTPMessage(t *testing.T, captured capturedSMTPMessage, from, to, subject, body string) { + t.Helper() + message, err := mail.ReadMessage(strings.NewReader(captured.raw)) + if err != nil { + t.Fatal(err) + } + decodedSubject, err := new(mime.WordDecoder).DecodeHeader(message.Header.Get("Subject")) + if err != nil { + t.Fatal(err) + } + decodedBody, err := io.ReadAll(base64.NewDecoder(base64.StdEncoding, message.Body)) + if err != nil { + t.Fatal(err) + } + if captured.from != from || captured.to != to || decodedSubject != subject || string(decodedBody) != body { + t.Fatalf("SMTP message envelope=(%q,%q) subject=%q body=%q", captured.from, captured.to, decodedSubject, decodedBody) + } +} diff --git a/backend/internal/server/export_routes_test.go b/backend/internal/server/export_routes_test.go index 640e0b3..3e56e55 100644 --- a/backend/internal/server/export_routes_test.go +++ b/backend/internal/server/export_routes_test.go @@ -73,6 +73,45 @@ func TestAdminRecordExportEndpointsSupportQueryParameters(t *testing.T) { if len(auditRows) != 2 || auditRows[0][2] != "action" || auditRows[1][2] != "invoke" || !strings.Contains(auditRows[1][5], "Export") { t.Fatalf("audit export query not applied: %+v", auditRows) } + + costReq := httptest.NewRequest(http.MethodGet, "/api/v1/console/cost-allocation/export?dimension=api_key&api_key_id="+created.Record.ID+"&limit=10", nil) + costRec := httptest.NewRecorder() + handler.ServeHTTP(costRec, costReq) + costRows := readCSVRows(t, costRec) + if len(costRows) != 2 || costRows[0][0] != "dimension" || costRows[1][0] != controlplane.CostAllocationByAPIKey || costRows[1][3] != created.Record.ID || costRows[1][7] != "2" { + t.Fatalf("cost allocation export query not applied: %+v", costRows) + } + + invalidCostReq := httptest.NewRequest(http.MethodGet, "/api/v1/console/cost-allocation/export?dimension=project", nil) + invalidCostRec := httptest.NewRecorder() + handler.ServeHTTP(invalidCostRec, invalidCostReq) + if invalidCostRec.Code != http.StatusBadRequest || !strings.Contains(invalidCostRec.Body.String(), "invalid cost allocation dimension") { + t.Fatalf("invalid cost allocation export status=%d body=%s", invalidCostRec.Code, invalidCostRec.Body.String()) + } + + emptyUsageReq := httptest.NewRequest(http.MethodGet, "/api/v1/console/usage/export?model=missing&limit=-1&offset=-1", nil) + emptyUsageRec := httptest.NewRecorder() + handler.ServeHTTP(emptyUsageRec, emptyUsageReq) + if rows := readCSVRows(t, emptyUsageRec); len(rows) != 1 { + t.Fatalf("empty usage export should contain only its header: %+v", rows) + } +} + +func TestAdminRecordExportEndpointsRequireAuthentication(t *testing.T) { + handler, _ := newTestRuntime(t, RuntimeConfig{AdminToken: "secret"}) + for _, path := range []string{ + "/api/v1/console/usage/export", + "/api/v1/console/gateway-traces/export", + "/api/v1/console/audit-logs/export", + "/api/v1/console/cost-allocation/export?dimension=api_key", + } { + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("GET %s status=%d body=%s", path, rec.Code, rec.Body.String()) + } + } } func TestAdminAsyncExportJobLifecycle(t *testing.T) { @@ -122,6 +161,19 @@ func TestAdminAsyncExportJobLifecycle(t *testing.T) { if len(rows) != 2 || rows[1][2] != "invoke" || !strings.Contains(rows[1][5], "AsyncExport") { t.Fatalf("async export CSV mismatch: %+v", rows) } + repeatDownloadReq := httptest.NewRequest(http.MethodGet, "/api/v1/console/export-jobs/"+job.ID+"/download", nil) + repeatDownloadRec := httptest.NewRecorder() + handler.ServeHTTP(repeatDownloadRec, repeatDownloadReq) + if repeatedRows := readCSVRows(t, repeatDownloadRec); len(repeatedRows) != len(rows) || repeatedRows[1][5] != rows[1][5] { + t.Fatalf("repeat async export CSV mismatch: first=%+v repeated=%+v", rows, repeatedRows) + } + + missingDownloadReq := httptest.NewRequest(http.MethodGet, "/api/v1/console/export-jobs/export_missing/download", nil) + missingDownloadRec := httptest.NewRecorder() + handler.ServeHTTP(missingDownloadRec, missingDownloadReq) + if missingDownloadRec.Code != http.StatusNotFound || !strings.Contains(missingDownloadRec.Body.String(), `"code":1803`) { + t.Fatalf("missing export download status = %d body=%s", missingDownloadRec.Code, missingDownloadRec.Body.String()) + } listReq := httptest.NewRequest(http.MethodGet, "/api/v1/console/export-jobs?limit=5", nil) listRec := httptest.NewRecorder() diff --git a/backend/internal/server/gateway.go b/backend/internal/server/gateway.go index 804c19c..cebaae8 100644 --- a/backend/internal/server/gateway.go +++ b/backend/internal/server/gateway.go @@ -31,6 +31,14 @@ const ( failureBodyPreviewLimit = 4 << 10 ) +func gatewayAffinityInput(auth gatewaycore.CanonicalAuthContext, request gatewaycore.CanonicalRequest, plan controlplane.GatewayExecutionPlan) controlplane.GatewayAffinityInput { + return controlplane.GatewayAffinityInput{ + ApplicationID: auth.ApplicationID, PrincipalID: auth.PrincipalID, CredentialID: auth.CredentialID, + Model: request.Model, Protocol: string(request.Protocol), RouteGroup: plan.RouteGroup, StickyKey: request.StickyKey, + AccessPolicyVersion: auth.PolicyVersion, RoutingPolicyID: plan.RoutingPolicyID, RoutingPolicyVersion: plan.RoutingPolicyVersion, + } +} + var ( errGatewayRequestTooLarge = errors.New("gateway request body is too large") errUpstreamResponseTooLarge = errors.New("upstream response body is too large") @@ -301,6 +309,16 @@ func attemptGatewayCandidatesForCanonicalRequest(c *gin.Context, control *contro } continue } + var failureBodyPreview string + if isProviderAccountFailureStatus(candidateResp.StatusCode) { + originalBody := candidateResp.Body + bodyPreview, _ := io.ReadAll(io.LimitReader(originalBody, failureBodyPreviewLimit)) + candidateResp.Body = &replayReadCloser{ + Reader: io.MultiReader(bytes.NewReader(bodyPreview), originalBody), + Closer: originalBody, + } + failureBodyPreview = string(bodyPreview) + } if bootstrapErr := prepareCanonicalTextStreamResponse(candidateResp, request, candidate); bootstrapErr != nil { _ = candidateResp.Body.Close() permit.Release() @@ -328,7 +346,7 @@ func attemptGatewayCandidatesForCanonicalRequest(c *gin.Context, control *contro } if isProviderAccountFailureStatus(candidateResp.StatusCode) { if candidate.AccountID != "" { - _ = control.RecordProviderAccountFailure(c.Request.Context(), candidate.AccountID, candidateResp.StatusCode, "") + _ = control.RecordProviderAccountFailure(c.Request.Context(), candidate.AccountID, candidateResp.StatusCode, failureBodyPreview) } } else if candidateResp.StatusCode >= 200 && candidateResp.StatusCode < 400 { _ = control.RecordProviderAccountSuccess(c.Request.Context(), candidate.AccountID) diff --git a/backend/internal/server/gateway_cooldown_test.go b/backend/internal/server/gateway_cooldown_test.go new file mode 100644 index 0000000..e2493d3 --- /dev/null +++ b/backend/internal/server/gateway_cooldown_test.go @@ -0,0 +1,89 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/astercloud/asterrouter/backend/internal/controlplane" +) + +func TestGatewayLastCandidateFailureMatchesCooldownRuleAndPreservesBody(t *testing.T) { + const upstreamError = `{"error":{"type":"upstream_error","message":"synthetic upstream failure"}}` + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(upstreamError)) + })) + defer upstream.Close() + + handler, control := newTestRuntime(t, RuntimeConfig{}) + provider, err := control.CreateProvider(context.Background(), "tester", controlplane.ProviderRequest{ + Name: "cooldown provider", Type: controlplane.ProviderTypeOpenAICompatible, + BaseURL: upstream.URL + "/v1", Status: controlplane.ProviderStatusActive, + }) + if err != nil { + t.Fatalf("CreateProvider(): %v", err) + } + schedulable := true + account, err := control.CreateProviderAccount(context.Background(), "tester", controlplane.ProviderAccountRequest{ + ProviderID: provider.ID, Name: "cooldown account", Platform: controlplane.ProviderTypeOpenAICompatible, + AuthType: controlplane.ProviderAuthAPIKey, Status: controlplane.AccountStatusActive, + Schedulable: &schedulable, Priority: 10, Concurrency: 1, RateMultiplier: 1, + Models: []string{"upstream-model"}, Secret: "cooldown-secret", + TempUnschedulableRules: []controlplane.ProviderAccountTempUnschedulableRule{{ + StatusCode: http.StatusInternalServerError, Keywords: []string{"synthetic upstream failure"}, DurationMinutes: 10, + }}, + }) + if err != nil { + t.Fatalf("CreateProviderAccount(): %v", err) + } + createGatewayTestModelAndRoutes(t, control, "cooldown-model", "default", []gatewayTestRoute{{ + account: account, upstreamModel: "upstream-model", priority: 10, + }}) + key, err := control.CreateAPIKey(context.Background(), "tester", controlplane.APIKeyCreateRequest{ + Name: "cooldown key", ModelAllowlist: []string{"cooldown-model"}, QPSLimit: 10, MonthlyTokenLimit: 1000, + }) + if err != nil { + t.Fatalf("CreateAPIKey(): %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewBufferString(`{"model":"cooldown-model","messages":[{"role":"user","content":"ping"}]}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+key.Key) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("gateway response status=%d body=%q", rec.Code, rec.Body.String()) + } + var responseBody map[string]map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &responseBody); err != nil { + t.Fatalf("decode gateway response: %v", err) + } + if responseBody["error"]["type"] != "upstream_error" || responseBody["error"]["message"] != "synthetic upstream failure" { + t.Fatalf("gateway response body=%q", rec.Body.String()) + } + accounts, err := control.ListProviderAccounts(context.Background()) + if err != nil { + t.Fatalf("ListProviderAccounts(): %v", err) + } + var cooled controlplane.ProviderAccount + for _, candidate := range accounts { + if candidate.ID == account.ID { + cooled = candidate + break + } + } + if cooled.CooldownUntil == nil || time.Until(*cooled.CooldownUntil) < 9*time.Minute { + t.Fatalf("matched cooldown was not applied: %+v", cooled) + } + if !strings.Contains(cooled.TempUnschedulableReason, `keyword="synthetic upstream failure"`) { + t.Fatalf("matched cooldown reason missing: %q", cooled.TempUnschedulableReason) + } +} diff --git a/backend/internal/server/gateway_count_tokens.go b/backend/internal/server/gateway_count_tokens.go index e3ffe2c..058bc6a 100644 --- a/backend/internal/server/gateway_count_tokens.go +++ b/backend/internal/server/gateway_count_tokens.go @@ -55,10 +55,7 @@ func handleGatewayCountTokensRequest(c *gin.Context, control *controlplane.Servi writeGatewayProtocolError(c, request.Protocol, http.StatusBadRequest, "unsupported_feature", err.Error()) return } - affinity := controlplane.GatewayAffinityInput{ - ApplicationID: canonicalAuth.ApplicationID, PrincipalID: canonicalAuth.PrincipalID, CredentialID: canonicalAuth.CredentialID, - Model: request.Model, Protocol: string(request.Protocol), RouteGroup: plan.RouteGroup, StickyKey: request.StickyKey, PolicyVersion: canonicalAuth.PolicyVersion, - } + affinity := gatewayAffinityInput(canonicalAuth, request, plan) cohortKey := control.GatewayEffectivePricingCohortKey(affinity) candidates := control.PreferGatewayCandidatesWithAffinity(c.Request.Context(), affinity, control.OrderGatewayCandidatesByEffectivePricing(c.Request.Context(), request.Model, string(request.Protocol), cohortKey, plan.Candidates)) response, provider, release, attempts, err := attemptGatewayCountTokensCandidates(c, control, candidates, request) diff --git a/backend/internal/server/gateway_images.go b/backend/internal/server/gateway_images.go index 02a830b..ec17e88 100644 --- a/backend/internal/server/gateway_images.go +++ b/backend/internal/server/gateway_images.go @@ -284,11 +284,7 @@ func executeDirectMedia(c *gin.Context, control *controlplane.Service, adapter c return } defer credentialPermit.Release() - affinity := controlplane.GatewayAffinityInput{ - ApplicationID: canonicalAuth.ApplicationID, PrincipalID: canonicalAuth.PrincipalID, CredentialID: canonicalAuth.CredentialID, - Model: request.Model, Protocol: string(request.Protocol), RouteGroup: plan.RouteGroup, StickyKey: request.StickyKey, - PolicyVersion: canonicalAuth.PolicyVersion, - } + affinity := gatewayAffinityInput(canonicalAuth, request, plan) cohortKey := control.GatewayEffectivePricingCohortKey(affinity) candidates := control.PreferGatewayCandidatesWithAffinity(c.Request.Context(), affinity, control.OrderGatewayCandidatesByEffectivePricing(c.Request.Context(), request.Model, string(request.Protocol), cohortKey, plan.Candidates)) diff --git a/backend/internal/server/gateway_protocols.go b/backend/internal/server/gateway_protocols.go index 07ccc26..06d26bf 100644 --- a/backend/internal/server/gateway_protocols.go +++ b/backend/internal/server/gateway_protocols.go @@ -224,7 +224,7 @@ func executeGatewayProtocolDirect(c *gin.Context, control *controlplane.Service, return } defer permit.Release() - affinity := controlplane.GatewayAffinityInput{ApplicationID: canonicalAuth.ApplicationID, PrincipalID: canonicalAuth.PrincipalID, CredentialID: canonicalAuth.CredentialID, Model: request.Model, Protocol: string(request.Protocol), RouteGroup: plan.RouteGroup, StickyKey: request.StickyKey, PolicyVersion: canonicalAuth.PolicyVersion} + affinity := gatewayAffinityInput(canonicalAuth, request, plan) cohortKey := control.GatewayEffectivePricingCohortKey(affinity) candidates := control.PreferGatewayCandidatesWithAffinity(c.Request.Context(), affinity, control.OrderGatewayCandidatesByEffectivePricing(c.Request.Context(), request.Model, string(request.Protocol), cohortKey, plan.Candidates)) resp, provider, release, attempts, attemptErr := attemptGatewayCandidatesForCanonicalRequest(c, control, operation.ID, affinity, candidates, request) diff --git a/backend/internal/server/gateway_realtime.go b/backend/internal/server/gateway_realtime.go index 1a5f1eb..c46110f 100644 --- a/backend/internal/server/gateway_realtime.go +++ b/backend/internal/server/gateway_realtime.go @@ -123,11 +123,7 @@ func handleGatewayRealtime(c *gin.Context, control *controlplane.Service) { } defer credentialPermit.Release() - affinity := controlplane.GatewayAffinityInput{ - ApplicationID: canonicalAuth.ApplicationID, PrincipalID: canonicalAuth.PrincipalID, CredentialID: canonicalAuth.CredentialID, - Model: request.Model, Protocol: string(request.Protocol), RouteGroup: plan.RouteGroup, StickyKey: request.StickyKey, - PolicyVersion: canonicalAuth.PolicyVersion, - } + affinity := gatewayAffinityInput(canonicalAuth, request, plan) cohortKey := control.GatewayEffectivePricingCohortKey(affinity) candidates := control.PreferGatewayCandidatesWithAffinity(c.Request.Context(), affinity, control.OrderGatewayCandidatesByEffectivePricing(c.Request.Context(), request.Model, string(request.Protocol), cohortKey, plan.Candidates)) diff --git a/backend/internal/server/gateway_routes_test.go b/backend/internal/server/gateway_routes_test.go index fe3afbd..d2bb6c7 100644 --- a/backend/internal/server/gateway_routes_test.go +++ b/backend/internal/server/gateway_routes_test.go @@ -568,6 +568,277 @@ func TestGatewayChatCompletionFallsBackToNextAccountAfterUpstreamFailure(t *test } } +func TestRoutingPolicyFailoverToggleControlsRealGatewayAttempts(t *testing.T) { + for _, test := range []struct { + name string + failoverEnabled bool + wantStatus int + wantBackupCalls int32 + }{ + {name: "disabled", failoverEnabled: false, wantStatus: http.StatusUnauthorized, wantBackupCalls: 0}, + {name: "enabled", failoverEnabled: true, wantStatus: http.StatusOK, wantBackupCalls: 1}, + } { + t.Run(test.name, func(t *testing.T) { + var primaryCalls, backupCalls atomic.Int32 + primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + primaryCalls.Add(1) + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":{"type":"invalid_api_key","message":"revoked"}}`)) + })) + defer primary.Close() + backup := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + backupCalls.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"policy-fallback","choices":[{"message":{"content":"fallback-ok"}}],"usage":{"prompt_tokens":3,"completion_tokens":2}}`)) + })) + defer backup.Close() + + handler, control := newTestRuntime(t, RuntimeConfig{}) + primaryProvider, err := control.CreateProvider(context.Background(), "tester", controlplane.ProviderRequest{ + Name: "Policy primary", Type: controlplane.ProviderTypeOpenAICompatible, BaseURL: primary.URL + "/v1", Status: controlplane.ProviderStatusActive, + }) + if err != nil { + t.Fatal(err) + } + backupProvider, err := control.CreateProvider(context.Background(), "tester", controlplane.ProviderRequest{ + Name: "Policy backup", Type: controlplane.ProviderTypeOpenAICompatible, BaseURL: backup.URL + "/v1", Status: controlplane.ProviderStatusActive, + }) + if err != nil { + t.Fatal(err) + } + primaryAccount := createGatewayTestAccount(t, control, primaryProvider, "policy-upstream", "primary-secret", 10, 3) + backupAccount := createGatewayTestAccount(t, control, backupProvider, "policy-upstream", "backup-secret", 20, 3) + createGatewayTestModelAndRoutes(t, control, "policy-failover-model", controlplane.DefaultModelRouteGroup, []gatewayTestRoute{ + {account: primaryAccount, upstreamModel: "policy-upstream", priority: 10}, + {account: backupAccount, upstreamModel: "policy-upstream", priority: 20}, + }) + policy, err := control.CreateRoutingPolicy(context.Background(), "tester", controlplane.RoutingPolicyRequest{ + Name: "Real gateway failover", RouteGroup: controlplane.DefaultModelRouteGroup, Status: controlplane.RoutingPolicyStatusActive, + Strategy: controlplane.RoutingPolicyStrategy{ + Preset: controlplane.RoutingPolicyPresetBalanced, StickyTTLSeconds: 900, + FailoverBeforeFirstByte: test.failoverEnabled, LowPricePoolMode: controlplane.RoutingPolicyLowPriceNone, + }, + }) + if err != nil { + t.Fatal(err) + } + key, err := control.CreateAPIKey(context.Background(), "tester", controlplane.APIKeyCreateRequest{ + Name: "Policy gateway key", ModelAllowlist: []string{"policy-failover-model"}, + }) + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewBufferString(`{"model":"policy-failover-model","messages":[{"role":"user","content":"policy"}]}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+key.Key) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != test.wantStatus || primaryCalls.Load() != 1 || backupCalls.Load() != test.wantBackupCalls { + t.Fatalf("status=%d primary=%d backup=%d body=%s", rec.Code, primaryCalls.Load(), backupCalls.Load(), rec.Body.String()) + } + traces, err := control.ListGatewayTraces(context.Background(), 10) + if err != nil || len(traces) != 1 { + t.Fatalf("traces=%+v err=%v", traces, err) + } + trace := traces[0] + if !strings.Contains(trace.RouteReason, "routing_policy="+policy.ID) || !strings.Contains(trace.RouteReason, "routing_policy_version=1") || !strings.Contains(trace.RouteReason, "preset=balanced") { + t.Fatalf("routing decision evidence missing: %+v", trace) + } + if test.failoverEnabled { + if !strings.Contains(trace.RouteAttempts, `"account_id":"`+primaryAccount.ID+`"`) || !strings.Contains(trace.RouteAttempts, `"outcome":"failed"`) || !strings.Contains(trace.RouteAttempts, `"account_id":"`+backupAccount.ID+`"`) || !strings.Contains(trace.RouteAttempts, `"outcome":"selected"`) { + t.Fatalf("enabled failover attempts=%s", trace.RouteAttempts) + } + } else if !strings.Contains(trace.RouteAttempts, `"account_id":"`+backupAccount.ID+`"`) || !strings.Contains(trace.RouteAttempts, `"outcome":"excluded"`) || !strings.Contains(trace.RouteAttempts, "routing_policy_failover_disabled") { + t.Fatalf("disabled failover evidence=%s", trace.RouteAttempts) + } + }) + } +} + +func TestRoutingPolicyUpdateInvalidatesStickyGatewaySelection(t *testing.T) { + var priorityCalls, cheapCalls atomic.Int32 + priorityUpstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + priorityCalls.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"priority","choices":[{"message":{"content":"priority"}}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`)) + })) + defer priorityUpstream.Close() + cheapUpstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + cheapCalls.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"cheap","choices":[{"message":{"content":"cheap"}}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`)) + })) + defer cheapUpstream.Close() + + handler, control := newTestRuntime(t, RuntimeConfig{}) + priorityProvider, err := control.CreateProvider(context.Background(), "tester", controlplane.ProviderRequest{Name: "Priority", Type: controlplane.ProviderTypeOpenAICompatible, BaseURL: priorityUpstream.URL + "/v1", Status: controlplane.ProviderStatusActive}) + if err != nil { + t.Fatal(err) + } + cheapProvider, err := control.CreateProvider(context.Background(), "tester", controlplane.ProviderRequest{Name: "Cheap", Type: controlplane.ProviderTypeOpenAICompatible, BaseURL: cheapUpstream.URL + "/v1", Status: controlplane.ProviderStatusActive}) + if err != nil { + t.Fatal(err) + } + createAccount := func(provider controlplane.ProviderConnection, name string, priority int, rate float64) controlplane.ProviderAccount { + schedulable := true + account, createErr := control.CreateProviderAccount(context.Background(), "tester", controlplane.ProviderAccountRequest{ + ProviderID: provider.ID, Name: name, Platform: controlplane.ProviderTypeOpenAICompatible, AuthType: controlplane.ProviderAuthAPIKey, + Status: controlplane.AccountStatusActive, Schedulable: &schedulable, Priority: priority, Concurrency: 3, + RateMultiplier: rate, Models: []string{"policy-version-upstream"}, Secret: name + "-secret", + }) + if createErr != nil { + t.Fatal(createErr) + } + return account + } + priorityAccount := createAccount(priorityProvider, "Priority account", 10, 2) + cheapAccount := createAccount(cheapProvider, "Cheap account", 20, 0.5) + createGatewayTestModelAndRoutes(t, control, "policy-version-model", controlplane.DefaultModelRouteGroup, []gatewayTestRoute{ + {account: priorityAccount, upstreamModel: "policy-version-upstream", priority: 10}, + {account: cheapAccount, upstreamModel: "policy-version-upstream", priority: 20}, + }) + policy, err := control.CreateRoutingPolicy(context.Background(), "tester", controlplane.RoutingPolicyRequest{ + Name: "Sticky policy version", RouteGroup: controlplane.DefaultModelRouteGroup, Status: controlplane.RoutingPolicyStatusActive, + Strategy: controlplane.RoutingPolicyStrategy{Preset: controlplane.RoutingPolicyPresetBalanced, StickyRouting: true, StickyTTLSeconds: 900, FailoverBeforeFirstByte: true, LowPricePoolMode: controlplane.RoutingPolicyLowPriceNone}, + }) + if err != nil { + t.Fatal(err) + } + key, err := control.CreateAPIKey(context.Background(), "tester", controlplane.APIKeyCreateRequest{Name: "Sticky policy key", ModelAllowlist: []string{"policy-version-model"}}) + if err != nil { + t.Fatal(err) + } + invoke := func() *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewBufferString(`{"model":"policy-version-model","messages":[{"role":"user","content":"policy version"}]}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+key.Key) + req.Header.Set("X-AsterRouter-Sticky-Key", "stable-enterprise-session") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec + } + if rec := invoke(); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "priority") { + t.Fatalf("version one status=%d body=%s", rec.Code, rec.Body.String()) + } + updated, err := control.UpdateRoutingPolicy(context.Background(), "tester", policy.ID, controlplane.RoutingPolicyRequest{ + Name: policy.Name, RouteGroup: policy.RouteGroup, Status: policy.Status, + Strategy: controlplane.RoutingPolicyStrategy{Preset: controlplane.RoutingPolicyPresetCost, StickyRouting: true, StickyTTLSeconds: 900, FailoverBeforeFirstByte: true, LowPricePoolMode: controlplane.RoutingPolicyLowPriceNone}, + }) + if err != nil || updated.Version != 2 { + t.Fatalf("updated policy=%+v err=%v", updated, err) + } + if rec := invoke(); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "cheap") { + t.Fatalf("version two status=%d body=%s", rec.Code, rec.Body.String()) + } + if priorityCalls.Load() != 1 || cheapCalls.Load() != 1 { + t.Fatalf("priority calls=%d cheap calls=%d", priorityCalls.Load(), cheapCalls.Load()) + } + traces, err := control.ListGatewayTraces(context.Background(), 10) + if err != nil || len(traces) != 2 || !strings.Contains(traces[0].RouteReason, "routing_policy_version=2") || !strings.Contains(traces[0].RouteReason, "preset=cost") { + t.Fatalf("updated routing trace evidence=%+v err=%v", traces, err) + } +} + +func TestRoutingPolicySmartOptimizationReordersRealGatewayRequest(t *testing.T) { + var currentCalls, candidateCalls atomic.Int32 + current := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + currentCalls.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"current","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"current"},"finish_reason":"stop"}]}`)) + })) + defer current.Close() + candidate := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + candidateCalls.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"effective-cost-candidate","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"candidate"},"finish_reason":"stop"}]}`)) + })) + defer candidate.Close() + + handler, control := newTestRuntime(t, RuntimeConfig{}) + currentProvider, err := control.CreateProvider(context.Background(), "tester", controlplane.ProviderRequest{ + Name: "Current cost provider", Type: controlplane.ProviderTypeOpenAICompatible, BaseURL: current.URL + "/v1", Status: controlplane.ProviderStatusActive, + }) + if err != nil { + t.Fatal(err) + } + candidateProvider, err := control.CreateProvider(context.Background(), "tester", controlplane.ProviderRequest{ + Name: "Effective cost candidate", Type: controlplane.ProviderTypeOpenAICompatible, BaseURL: candidate.URL + "/v1", Status: controlplane.ProviderStatusActive, + }) + if err != nil { + t.Fatal(err) + } + currentAccount := createGatewayTestAccount(t, control, currentProvider, "shared-upstream", "current-secret", 10, 2) + candidateAccount := createGatewayTestAccount(t, control, candidateProvider, "shared-upstream", "candidate-secret", 20, 2) + createGatewayTestModelAndRoutes(t, control, "effective-routing", controlplane.DefaultModelRouteGroup, []gatewayTestRoute{ + {account: currentAccount, upstreamModel: "shared-upstream", priority: 10}, + {account: candidateAccount, upstreamModel: "shared-upstream", priority: 20}, + }) + policy, err := control.CreateRoutingPolicy(context.Background(), "tester", controlplane.RoutingPolicyRequest{ + Name: "Smart effective cost", RouteGroup: controlplane.DefaultModelRouteGroup, Status: controlplane.RoutingPolicyStatusActive, + Strategy: controlplane.RoutingPolicyStrategy{ + Preset: controlplane.RoutingPolicyPresetBalanced, SmartOptimization: true, StickyTTLSeconds: 900, + FailoverBeforeFirstByte: true, LowPricePoolMode: controlplane.RoutingPolicyLowPriceNone, + ResourceBatches: []controlplane.RoutingPolicyBatch{{Name: "Production", ProviderAccountIDs: []string{currentAccount.ID, candidateAccount.ID}}}, + }, + }) + if err != nil { + t.Fatal(err) + } + + if _, err := control.UpdateEffectivePricingPolicy(context.Background(), "tester", controlplane.EffectivePricingPolicyRequest{ + Mode: controlplane.EffectivePricingModeRecommend, WindowHours: 24, MinSampleCount: 1, MinMetricsCoverage: 0.5, + MinCostImprovement: 0.08, MaxCacheTiebreakCostRegression: 0.02, MaxErrorRateRegression: 0.01, + MaxP95LatencyRegression: 0.2, CanaryPercent: 100, SupplierAffinityTTLSeconds: 3600, AccountAffinityTTLSeconds: 1800, + }); err != nil { + t.Fatal(err) + } + totalTokens, uncachedTokens := 100, 100 + currentCost, candidateCost := int64(100), int64(50) + for _, input := range []controlplane.GatewayUsageInput{ + {Model: "effective-routing", UpstreamModel: "shared-upstream", Protocol: string(gatewaycore.ProtocolOpenAIChat), ProviderID: currentProvider.ID, ProviderAccountID: currentAccount.ID, Status: "forwarded", LatencyMS: 100, InputTokens: totalTokens, TotalInputTokens: &totalTokens, UncachedInputTokens: &uncachedTokens, CacheFieldsPresent: true, UsageNormalizationStatus: "normalized_openai", ProcurementCostMicros: ¤tCost, ProcurementCostConfidence: controlplane.ProcurementCostConfidenceExact}, + {Model: "effective-routing", UpstreamModel: "shared-upstream", Protocol: string(gatewaycore.ProtocolOpenAIChat), ProviderID: candidateProvider.ID, ProviderAccountID: candidateAccount.ID, Status: "forwarded", LatencyMS: 100, InputTokens: totalTokens, TotalInputTokens: &totalTokens, UncachedInputTokens: &uncachedTokens, CacheFieldsPresent: true, UsageNormalizationStatus: "normalized_openai", ProcurementCostMicros: &candidateCost, ProcurementCostConfidence: controlplane.ProcurementCostConfidenceExact}, + } { + if err := control.RecordGatewayUsage(context.Background(), controlplane.GatewayAuthContext{APIKey: controlplane.APIKeyRecord{ID: "effective-pricing-evidence"}}, input); err != nil { + t.Fatal(err) + } + } + decision, err := control.EvaluateEffectivePricingDecision(context.Background(), "tester", controlplane.EffectivePricingDecisionEvaluationRequest{ + Model: "effective-routing", UpstreamModel: "shared-upstream", Protocol: string(gatewaycore.ProtocolOpenAIChat), + CurrentProviderAccountID: currentAccount.ID, CandidateProviderAccountID: candidateAccount.ID, + }) + if err != nil || decision.Status != controlplane.EffectivePricingDecisionRecommended { + t.Fatalf("effective pricing decision=%+v err=%v", decision, err) + } + decision, err = control.ActOnEffectivePricingDecision(context.Background(), "tester", decision.ID, controlplane.EffectivePricingDecisionActionRequest{Action: "approve_canary", CanaryPercent: 100}) + if err != nil { + t.Fatal(err) + } + decision, err = control.ActOnEffectivePricingDecision(context.Background(), "tester", decision.ID, controlplane.EffectivePricingDecisionActionRequest{Action: "activate"}) + if err != nil || decision.Status != controlplane.EffectivePricingDecisionActive { + t.Fatalf("active effective pricing decision=%+v err=%v", decision, err) + } + key, err := control.CreateAPIKey(context.Background(), "tester", controlplane.APIKeyCreateRequest{Name: "Effective pricing key", ModelAllowlist: []string{"effective-routing"}}) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewBufferString(`{"model":"effective-routing","messages":[{"role":"user","content":"route"}]}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+key.Key) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "effective-cost-candidate") { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if currentCalls.Load() != 0 || candidateCalls.Load() != 1 { + t.Fatalf("current calls=%d candidate calls=%d", currentCalls.Load(), candidateCalls.Load()) + } + traces, err := control.ListGatewayTraces(context.Background(), 10) + if err != nil || len(traces) != 1 || traces[0].ProviderAccountID != candidateAccount.ID || !strings.Contains(traces[0].RouteReason, "effective pricing active decision "+decision.ID) || !strings.Contains(traces[0].RouteReason, "routing_policy="+policy.ID) { + t.Fatalf("effective pricing gateway trace=%+v err=%v", traces, err) + } +} + func TestGatewayChatCompletionFallsBackAfterRateLimitAndServerError(t *testing.T) { for _, test := range []struct { name string diff --git a/backend/internal/server/identity_routes_test.go b/backend/internal/server/identity_routes_test.go index a7750ca..b1b3e6e 100644 --- a/backend/internal/server/identity_routes_test.go +++ b/backend/internal/server/identity_routes_test.go @@ -72,6 +72,12 @@ func TestAdminIdentityUserAndRoleBindingEndpoints(t *testing.T) { if bindingResp.Data.UserID != createResp.Data.ID || bindingResp.Data.ScopeType != controlplane.RoleScopeOrganization || bindingResp.Data.ScopeID != "" { t.Fatalf("role binding mismatch: %+v", bindingResp.Data) } + bindingsReq := httptest.NewRequest(http.MethodGet, "/api/v1/console/role-bindings", nil) + bindingsRec := httptest.NewRecorder() + handler.ServeHTTP(bindingsRec, bindingsReq) + if bindingsRec.Code != http.StatusOK || !strings.Contains(bindingsRec.Body.String(), bindingResp.Data.ID) { + t.Fatalf("list role bindings status = %d body=%s", bindingsRec.Code, bindingsRec.Body.String()) + } usersReq := httptest.NewRequest(http.MethodGet, "/api/v1/console/users", nil) usersRec := httptest.NewRecorder() @@ -102,6 +108,18 @@ func TestAdminIdentityUserAndRoleBindingEndpoints(t *testing.T) { if deleteRec.Code != http.StatusOK { t.Fatalf("delete binding status = %d body=%s", deleteRec.Code, deleteRec.Body.String()) } + emptyBindingsReq := httptest.NewRequest(http.MethodGet, "/api/v1/console/role-bindings", nil) + emptyBindingsRec := httptest.NewRecorder() + handler.ServeHTTP(emptyBindingsRec, emptyBindingsReq) + if emptyBindingsRec.Code != http.StatusOK || !strings.Contains(emptyBindingsRec.Body.String(), `"data":[]`) { + t.Fatalf("empty role bindings status = %d body=%s", emptyBindingsRec.Code, emptyBindingsRec.Body.String()) + } + missingDeleteReq := httptest.NewRequest(http.MethodDelete, "/api/v1/console/role-bindings/missing", nil) + missingDeleteRec := httptest.NewRecorder() + handler.ServeHTTP(missingDeleteRec, missingDeleteReq) + if missingDeleteRec.Code != http.StatusBadRequest || !strings.Contains(missingDeleteRec.Body.String(), "not found") { + t.Fatalf("missing role binding delete status = %d body=%s", missingDeleteRec.Code, missingDeleteRec.Body.String()) + } audit, err := control.ListAuditLogs(context.Background(), 20) if err != nil { @@ -269,10 +287,60 @@ func TestAdminOrganizationGroupLifecycle(t *testing.T) { if listRec.Code != http.StatusOK || !strings.Contains(listRec.Body.String(), createResponse.Data.ID) { t.Fatalf("list status=%d body=%s", listRec.Code, listRec.Body.String()) } + updateReq := httptest.NewRequest(http.MethodPut, "/api/v1/console/organization-groups/"+createResponse.Data.ID, bytes.NewBufferString(`{"name":"AI Platform Updated","description":"Enterprise AI team","status":"disabled","member_ids":["`+user.ID+`","`+user.ID+`"]}`)) + updateReq.Header.Set("Content-Type", "application/json") + updateRec := httptest.NewRecorder() + handler.ServeHTTP(updateRec, updateReq) + if updateRec.Code != http.StatusOK || !strings.Contains(updateRec.Body.String(), `"name":"AI Platform Updated"`) || !strings.Contains(updateRec.Body.String(), `"status":"disabled"`) { + t.Fatalf("update status=%d body=%s", updateRec.Code, updateRec.Body.String()) + } + var updateResponse struct { + Data controlplane.OrganizationGroup `json:"data"` + } + if err := json.Unmarshal(updateRec.Body.Bytes(), &updateResponse); err != nil || len(updateResponse.Data.MemberIDs) != 1 { + t.Fatalf("updated membership status=%d body=%s err=%v", updateRec.Code, updateRec.Body.String(), err) + } + for name, request := range map[string]*http.Request{ + "missing member create": httptest.NewRequest(http.MethodPost, "/api/v1/console/organization-groups", bytes.NewBufferString(`{"name":"Invalid member","status":"active","member_ids":["missing"]}`)), + "missing group update": httptest.NewRequest(http.MethodPut, "/api/v1/console/organization-groups/missing", bytes.NewBufferString(`{"name":"Missing","status":"active"}`)), + } { + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("%s status=%d body=%s", name, recorder.Code, recorder.Body.String()) + } + } deleteReq := httptest.NewRequest(http.MethodDelete, "/api/v1/console/organization-groups/"+createResponse.Data.ID, nil) deleteRec := httptest.NewRecorder() handler.ServeHTTP(deleteRec, deleteReq) if deleteRec.Code != http.StatusOK { t.Fatalf("delete status=%d body=%s", deleteRec.Code, deleteRec.Body.String()) } + missingDeleteReq := httptest.NewRequest(http.MethodDelete, "/api/v1/console/organization-groups/missing", nil) + missingDeleteRec := httptest.NewRecorder() + handler.ServeHTTP(missingDeleteRec, missingDeleteReq) + if missingDeleteRec.Code != http.StatusBadRequest || !strings.Contains(missingDeleteRec.Body.String(), "not found") { + t.Fatalf("missing delete status=%d body=%s", missingDeleteRec.Code, missingDeleteRec.Body.String()) + } +} + +func TestAdminIdentityCollectionsRequirePermission(t *testing.T) { + handler, control := newTestRuntime(t, RuntimeConfig{AdminToken: "secret"}) + user, err := control.CreateWorkspaceUser(t.Context(), "tester", controlplane.WorkspaceUserRequest{ + Email: "identity-reader@example.test", Status: controlplane.WorkspaceUserStatusActive, Role: controlplane.RoleDeveloper, + }) + if err != nil { + t.Fatal(err) + } + for _, path := range []string{"/api/v1/console/role-bindings", "/api/v1/console/organization-groups"} { + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("Authorization", "Bearer secret") + req.Header.Set("X-Actor", user.Email) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("GET %s status=%d body=%s", path, rec.Code, rec.Body.String()) + } + } } diff --git a/backend/internal/server/plugin_routes_test.go b/backend/internal/server/plugin_routes_test.go index c923b95..913ca60 100644 --- a/backend/internal/server/plugin_routes_test.go +++ b/backend/internal/server/plugin_routes_test.go @@ -1,7 +1,9 @@ package server import ( + "archive/tar" "bytes" + "compress/gzip" "context" "crypto/ed25519" "crypto/rand" @@ -44,6 +46,59 @@ func TestAdminPluginsCatalogEndpoint(t *testing.T) { if resp.Data.Summary.Total == 0 || resp.Data.Summary.PaidLocked == 0 { t.Fatalf("unexpected plugin summary: %+v", resp.Data.Summary) } + + statusReq := httptest.NewRequest(http.MethodGet, "/api/v1/console/plugins/catalog-sync/status", nil) + statusRec := httptest.NewRecorder() + handler.ServeHTTP(statusRec, statusReq) + if statusRec.Code != http.StatusOK { + t.Fatalf("catalog status = %d body=%s", statusRec.Code, statusRec.Body.String()) + } + var statusResponse struct { + Data plugins.OfficialCatalogStatus `json:"data"` + } + if err := json.Unmarshal(statusRec.Body.Bytes(), &statusResponse); err != nil { + t.Fatalf("decode catalog status: %v", err) + } + if statusResponse.Data.Status == "" || statusResponse.Data.CatalogVersion != 0 || statusResponse.Data.PluginCount != 0 { + t.Fatalf("initial catalog status mismatch: %+v", statusResponse.Data) + } +} + +func TestPluginRoutesRequireAuthentication(t *testing.T) { + handler := newTestHandler(t, RuntimeConfig{AdminToken: "secret"}) + tests := []struct { + method string + path string + body string + }{ + {method: http.MethodGet, path: "/api/v1/console/plugins"}, + {method: http.MethodGet, path: "/api/v1/console/plugins/catalog-sync/status"}, + {method: http.MethodGet, path: "/api/v1/console/plugins/license/status"}, + {method: http.MethodGet, path: "/api/v1/console/plugins/feeds/client"}, + {method: http.MethodGet, path: "/api/v1/console/plugins/feeds"}, + {method: http.MethodGet, path: "/api/v1/console/plugins/feeds/sync-runs"}, + {method: http.MethodGet, path: "/api/v1/console/plugins/api-tokens"}, + {method: http.MethodGet, path: "/api/v1/console/plugins/com.asterrouter.notification.webhook/config"}, + {method: http.MethodGet, path: "/api/v1/console/plugins/" + plugins.ArtifactS3SinkPluginID + "/artifact-sinks"}, + {method: http.MethodGet, path: "/api/v1/console/plugins/com.asterrouter.notification.webhook/deliveries"}, + {method: http.MethodGet, path: "/api/v1/console/plugins/missing/frontend/workbench"}, + {method: http.MethodGet, path: "/api/v1/console/plugins/missing/frontend/assets/app.js"}, + {method: http.MethodGet, path: "/api/v1/console/plugins/missing/runtime/status"}, + {method: http.MethodDelete, path: "/api/v1/console/plugins/api-tokens/missing"}, + {method: http.MethodPost, path: "/api/v1/console/plugins/license/activate", body: `{}`}, + {method: http.MethodPost, path: "/api/v1/console/plugins/missing/packages/missing/uninstall"}, + } + for _, test := range tests { + req := httptest.NewRequest(test.method, test.path, strings.NewReader(test.body)) + if test.body != "" { + req.Header.Set("Content-Type", "application/json") + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("%s %s status=%d body=%s", test.method, test.path, rec.Code, rec.Body.String()) + } + } } func TestAdminOfficialFeedSyncRecordsDisabledAttempt(t *testing.T) { @@ -52,6 +107,22 @@ func TestAdminOfficialFeedSyncRecordsDisabledAttempt(t *testing.T) { pluginService := plugins.NewService(plugins.NewMemoryRepository()) handler := New(Options{Runtime: RuntimeConfig{}, SettingsService: settingsService, ControlService: controlService, PluginService: pluginService, SystemService: system.NewService(system.Config{Version: "test", BuildType: "source"})}) + feedsReq := httptest.NewRequest(http.MethodGet, "/api/v1/console/plugins/feeds?service_key=provider-intelligence", nil) + feedsRec := httptest.NewRecorder() + handler.ServeHTTP(feedsRec, feedsReq) + if feedsRec.Code != http.StatusOK { + t.Fatalf("feeds status = %d body=%s", feedsRec.Code, feedsRec.Body.String()) + } + var feedsResponse struct { + Data []plugins.OfficialFeedStatus `json:"data"` + } + if err := json.Unmarshal(feedsRec.Body.Bytes(), &feedsResponse); err != nil { + t.Fatalf("decode feeds: %v", err) + } + if feedsResponse.Data == nil || len(feedsResponse.Data) != 0 { + t.Fatalf("unexpected official feed statuses: %+v", feedsResponse.Data) + } + body := bytes.NewBufferString(`{"service_key":"provider-intelligence"}`) syncReq := httptest.NewRequest(http.MethodPost, "/api/v1/console/plugins/feeds/sync", body) syncReq.Header.Set("Content-Type", "application/json") @@ -170,6 +241,84 @@ func TestPluginOpenCatalogUsesScopedAPIToken(t *testing.T) { } } +func TestAdminPluginAPITokenLifecycleAuditsAndRevokesAccess(t *testing.T) { + handler, control := newTestRuntime(t, RuntimeConfig{}) + createBody := bytes.NewBufferString(`{"name":"automation catalog","plugin_id":"com.asterrouter.notification.webhook","scopes":["catalog:read","plugin:action"]}`) + createReq := httptest.NewRequest(http.MethodPost, "/api/v1/console/plugins/api-tokens", createBody) + createReq.Header.Set("Content-Type", "application/json") + createRec := httptest.NewRecorder() + handler.ServeHTTP(createRec, createReq) + if createRec.Code != http.StatusOK { + t.Fatalf("create status=%d body=%s", createRec.Code, createRec.Body.String()) + } + var created struct { + Data plugins.PluginAPITokenCreateResult `json:"data"` + } + if err := json.Unmarshal(createRec.Body.Bytes(), &created); err != nil { + t.Fatalf("decode create response: %v", err) + } + if !strings.HasPrefix(created.Data.Secret, "arpt_") || created.Data.Token.Status != plugins.PluginAPITokenActive { + t.Fatalf("created token=%+v secret prefix valid=%t", created.Data.Token, strings.HasPrefix(created.Data.Secret, "arpt_")) + } + + listRec := httptest.NewRecorder() + handler.ServeHTTP(listRec, httptest.NewRequest(http.MethodGet, "/api/v1/console/plugins/api-tokens", nil)) + if listRec.Code != http.StatusOK || strings.Contains(listRec.Body.String(), created.Data.Secret) { + t.Fatalf("list status=%d leaked=%t body=%s", listRec.Code, strings.Contains(listRec.Body.String(), created.Data.Secret), listRec.Body.String()) + } + + openReq := httptest.NewRequest(http.MethodGet, "/api/v1/open/plugins/catalog", nil) + openReq.Header.Set("Authorization", "Bearer "+created.Data.Secret) + openRec := httptest.NewRecorder() + handler.ServeHTTP(openRec, openReq) + if openRec.Code != http.StatusOK { + t.Fatalf("open catalog status=%d body=%s", openRec.Code, openRec.Body.String()) + } + + revokeReq := httptest.NewRequest(http.MethodDelete, "/api/v1/console/plugins/api-tokens/"+created.Data.Token.ID, nil) + revokeRec := httptest.NewRecorder() + handler.ServeHTTP(revokeRec, revokeReq) + if revokeRec.Code != http.StatusOK || strings.Contains(revokeRec.Body.String(), created.Data.Secret) { + t.Fatalf("revoke status=%d leaked=%t body=%s", revokeRec.Code, strings.Contains(revokeRec.Body.String(), created.Data.Secret), revokeRec.Body.String()) + } + + revokedReq := httptest.NewRequest(http.MethodGet, "/api/v1/open/plugins/catalog", nil) + revokedReq.Header.Set("Authorization", "Bearer "+created.Data.Secret) + revokedRec := httptest.NewRecorder() + handler.ServeHTTP(revokedRec, revokedReq) + if revokedRec.Code != http.StatusUnauthorized { + t.Fatalf("revoked token status=%d body=%s", revokedRec.Code, revokedRec.Body.String()) + } + + missingRevokeReq := httptest.NewRequest(http.MethodDelete, "/api/v1/console/plugins/api-tokens/missing", nil) + missingRevokeRec := httptest.NewRecorder() + handler.ServeHTTP(missingRevokeRec, missingRevokeReq) + if missingRevokeRec.Code != http.StatusNotFound { + t.Fatalf("missing revoke status=%d body=%s", missingRevokeRec.Code, missingRevokeRec.Body.String()) + } + + audit, err := control.ListAuditLogs(context.Background(), 20) + if err != nil { + t.Fatalf("ListAuditLogs(): %v", err) + } + seenCreate := false + seenRevoke := false + for _, event := range audit { + serialized, marshalErr := json.Marshal(event) + if marshalErr != nil { + t.Fatalf("marshal audit event: %v", marshalErr) + } + if strings.Contains(string(serialized), created.Data.Secret) { + t.Fatalf("audit leaked token secret: %s", serialized) + } + seenCreate = seenCreate || event.ResourceType == "plugin" && event.Action == "api_token_create" && event.ResourceID == created.Data.Token.ID + seenRevoke = seenRevoke || event.ResourceType == "plugin" && event.Action == "api_token_revoke" && event.ResourceID == created.Data.Token.ID + } + if !seenCreate || !seenRevoke { + t.Fatalf("token audit missing create=%t revoke=%t audit=%+v", seenCreate, seenRevoke, audit) + } +} + func TestAdminPluginsCatalogSyncEndpoint(t *testing.T) { publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) if err != nil { @@ -256,7 +405,7 @@ func TestAdminPluginPackageDownloadEndpoint(t *testing.T) { t.Fatalf("GenerateKey(): %v", err) } now := time.Date(2026, 7, 11, 2, 45, 0, 0, time.UTC) - content := []byte("router package content") + content := frontendPluginPackage(t, "com.astercloud.catalog.router-sync", "1.0.0") checksumBytes := sha256.Sum256(content) checksum := hex.EncodeToString(checksumBytes[:]) packageID := "pkg_router_darwin_arm64" @@ -350,6 +499,7 @@ func TestAdminPluginPackageDownloadEndpoint(t *testing.T) { PublicKeyBase64: base64.StdEncoding.EncodeToString(publicKey), }, PackageCacheDir: t.TempDir(), + PluginActiveDir: t.TempDir(), CoreVersion: "1.2.0", TargetOS: "darwin", TargetArch: "arm64", @@ -400,6 +550,62 @@ func TestAdminPluginPackageDownloadEndpoint(t *testing.T) { t.Fatalf("install response mismatch: %+v", installResp.Data) } + workbenchReq := httptest.NewRequest(http.MethodGet, "/api/v1/console/plugins/com.astercloud.catalog.router-sync/frontend/workbench", nil) + workbenchRec := httptest.NewRecorder() + handler.ServeHTTP(workbenchRec, workbenchReq) + if workbenchRec.Code != http.StatusOK || workbenchRec.Header().Get("Content-Type") != "application/json; charset=utf-8" { + t.Fatalf("workbench status=%d headers=%v body=%s", workbenchRec.Code, workbenchRec.Header(), workbenchRec.Body.String()) + } + var workbench map[string]any + if err := json.Unmarshal(workbenchRec.Body.Bytes(), &workbench); err != nil { + t.Fatalf("decode workbench: %v", err) + } + if workbench["title"] != "Router Sync" || workbench["entry"] != "app.js" { + t.Fatalf("workbench mismatch: %+v", workbench) + } + + assetReq := httptest.NewRequest(http.MethodGet, "/api/v1/console/plugins/com.astercloud.catalog.router-sync/frontend/assets/app.js", nil) + assetRec := httptest.NewRecorder() + handler.ServeHTTP(assetRec, assetReq) + if assetRec.Code != http.StatusOK || assetRec.Body.String() != "window.AsterRouterPlugin = 'router-sync';\n" { + t.Fatalf("asset status=%d headers=%v body=%q", assetRec.Code, assetRec.Header(), assetRec.Body.String()) + } + + runtimeReq := httptest.NewRequest(http.MethodGet, "/api/v1/console/plugins/com.astercloud.catalog.router-sync/runtime/status", nil) + runtimeRec := httptest.NewRecorder() + handler.ServeHTTP(runtimeRec, runtimeReq) + if runtimeRec.Code != http.StatusOK { + t.Fatalf("runtime status=%d body=%s", runtimeRec.Code, runtimeRec.Body.String()) + } + var runtimeResp struct { + Data plugins.SidecarRuntimeStatus `json:"data"` + } + if err := json.Unmarshal(runtimeRec.Body.Bytes(), &runtimeResp); err != nil { + t.Fatalf("decode runtime status: %v", err) + } + if !runtimeResp.Data.Installed || runtimeResp.Data.Running || runtimeResp.Data.Version != "1.0.0" || runtimeResp.Data.PluginID != "com.astercloud.catalog.router-sync" { + t.Fatalf("runtime projection mismatch: %+v", runtimeResp.Data) + } + + for _, target := range []string{ + "/api/v1/console/plugins/com.astercloud.catalog.router-sync/frontend/assets/missing.js", + "/api/v1/console/plugins/com.astercloud.catalog.router-sync/frontend/assets/%2e%2e%2fplugin.json", + "/api/v1/console/plugins/com.astercloud.catalog.missing/frontend/workbench", + } { + req := httptest.NewRequest(http.MethodGet, target, nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("GET %s status=%d body=%s", target, rec.Code, rec.Body.String()) + } + } + missingRuntimeReq := httptest.NewRequest(http.MethodGet, "/api/v1/console/plugins/com.astercloud.catalog.missing/runtime/status", nil) + missingRuntimeRec := httptest.NewRecorder() + handler.ServeHTTP(missingRuntimeRec, missingRuntimeReq) + if missingRuntimeRec.Code != http.StatusOK || !strings.Contains(missingRuntimeRec.Body.String(), plugins.ErrPluginNotFound.Error()) { + t.Fatalf("missing runtime status=%d body=%s", missingRuntimeRec.Code, missingRuntimeRec.Body.String()) + } + uninstallReq := httptest.NewRequest(http.MethodPost, "/api/v1/console/plugins/com.astercloud.catalog.router-sync/packages/"+packageID+"/uninstall", nil) uninstallRec := httptest.NewRecorder() handler.ServeHTTP(uninstallRec, uninstallReq) @@ -415,6 +621,18 @@ func TestAdminPluginPackageDownloadEndpoint(t *testing.T) { if uninstallResp.Data.Status != plugins.PackageInstallUninstalled || uninstallResp.Data.PackageID != packageID { t.Fatalf("uninstall response mismatch: %+v", uninstallResp.Data) } + repeatUninstallReq := httptest.NewRequest(http.MethodPost, "/api/v1/console/plugins/com.astercloud.catalog.router-sync/packages/"+packageID+"/uninstall", nil) + repeatUninstallRec := httptest.NewRecorder() + handler.ServeHTTP(repeatUninstallRec, repeatUninstallReq) + if repeatUninstallRec.Code != http.StatusNotFound { + t.Fatalf("repeat uninstall status=%d body=%s", repeatUninstallRec.Code, repeatUninstallRec.Body.String()) + } + removedWorkbenchReq := httptest.NewRequest(http.MethodGet, "/api/v1/console/plugins/com.astercloud.catalog.router-sync/frontend/workbench", nil) + removedWorkbenchRec := httptest.NewRecorder() + handler.ServeHTTP(removedWorkbenchRec, removedWorkbenchReq) + if removedWorkbenchRec.Code != http.StatusNotFound { + t.Fatalf("uninstalled workbench status=%d body=%s", removedWorkbenchRec.Code, removedWorkbenchRec.Body.String()) + } audit, err := controlService.ListAuditLogs(context.Background(), 20) if err != nil { @@ -434,6 +652,36 @@ func TestAdminPluginPackageDownloadEndpoint(t *testing.T) { } } +func frontendPluginPackage(t *testing.T, pluginID string, version string) []byte { + t.Helper() + files := []struct { + name string + content []byte + }{ + {name: "plugin.json", content: []byte(`{"id":"` + pluginID + `","version":"` + version + `","runtime":"frontend","entrypoint":{}}`)}, + {name: "frontend/workbench.json", content: []byte(`{"title":"Router Sync","entry":"app.js"}`)}, + {name: "frontend/app.js", content: []byte("window.AsterRouterPlugin = 'router-sync';\n")}, + } + var archive bytes.Buffer + gzipWriter := gzip.NewWriter(&archive) + tarWriter := tar.NewWriter(gzipWriter) + for _, file := range files { + if err := tarWriter.WriteHeader(&tar.Header{Name: file.name, Mode: 0600, Size: int64(len(file.content)), Typeflag: tar.TypeReg}); err != nil { + t.Fatalf("write %s header: %v", file.name, err) + } + if _, err := tarWriter.Write(file.content); err != nil { + t.Fatalf("write %s: %v", file.name, err) + } + } + if err := tarWriter.Close(); err != nil { + t.Fatalf("close plugin tar: %v", err) + } + if err := gzipWriter.Close(); err != nil { + t.Fatalf("close plugin gzip: %v", err) + } + return archive.Bytes() +} + func TestAdminPluginsEnableFreePluginAudits(t *testing.T) { handler, control := newTestRuntime(t, RuntimeConfig{}) @@ -705,6 +953,19 @@ func TestAdminPluginDeliveriesEndpoint(t *testing.T) { if len(resp.Data) != 1 || resp.Data[0].Status != plugins.DeliveryStatusSucceeded || resp.Data[0].HTTPStatus != http.StatusAccepted { t.Fatalf("deliveries mismatch: %+v", resp.Data) } + + emptyReq := httptest.NewRequest(http.MethodGet, "/api/v1/console/plugins/com.asterrouter.notification.webhook/deliveries?status=failed&limit=1&offset=1", nil) + emptyRec := httptest.NewRecorder() + handler.ServeHTTP(emptyRec, emptyReq) + if emptyRec.Code != http.StatusOK { + t.Fatalf("empty deliveries status=%d body=%s", emptyRec.Code, emptyRec.Body.String()) + } + var emptyResponse struct { + Data []plugins.DeliveryAttempt `json:"data"` + } + if err := json.Unmarshal(emptyRec.Body.Bytes(), &emptyResponse); err != nil || emptyResponse.Data == nil || len(emptyResponse.Data) != 0 { + t.Fatalf("empty deliveries=%+v err=%v", emptyResponse.Data, err) + } } func TestAdminPluginLicenseImportEndpointAuditsAndUpdatesStatus(t *testing.T) { diff --git a/backend/internal/server/pricing_rule_routes_test.go b/backend/internal/server/pricing_rule_routes_test.go index 3e981bc..b252c0d 100644 --- a/backend/internal/server/pricing_rule_routes_test.go +++ b/backend/internal/server/pricing_rule_routes_test.go @@ -2,11 +2,17 @@ package server import ( "bytes" + "context" + "encoding/json" + "fmt" "net/http" "net/http/httptest" + "strings" "testing" + "time" "github.com/astercloud/asterrouter/backend/internal/controlplane" + "github.com/astercloud/asterrouter/backend/internal/pricing" "github.com/gin-gonic/gin" ) @@ -37,3 +43,173 @@ func TestPricingRuleRoutesOnlyAcceptEnterpriseUsageCostRules(t *testing.T) { } } } + +func TestPricingRuleHTTPVersionLifecycleAndFailureContracts(t *testing.T) { + gin.SetMode(gin.TestMode) + repository := controlplane.NewMemoryRepository() + service := controlplane.NewService(repository, "/v1") + router := gin.New() + registerPricingRuleRoutes(router.Group("/costs"), service) + + request := func(method, path, body string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, path, strings.NewReader(body)) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec + } + + empty := request(http.MethodGet, "/costs/pricing-rules", "") + if empty.Code != http.StatusOK || !strings.Contains(empty.Body.String(), `"data":[]`) { + t.Fatalf("empty pricing list status=%d body=%s", empty.Code, empty.Body.String()) + } + + create := request(http.MethodPost, "/costs/pricing-rules", `{"name":"Enterprise usage","purpose":"usage_cost","scope_type":"global","scope_id":"","model":"*","currency":"USD","authoring_mode":"raw","expression":"v1: fixed_line(\"request\", \"request\", 10)","test_cases":[]}`) + if create.Code != http.StatusOK { + t.Fatalf("create pricing status=%d body=%s", create.Code, create.Body.String()) + } + var created struct { + Data controlplane.PricingRuleDetail `json:"data"` + } + if err := json.Unmarshal(create.Body.Bytes(), &created); err != nil || created.Data.Draft == nil { + t.Fatalf("decode create pricing: data=%+v err=%v", created.Data, err) + } + ruleID := created.Data.Rule.ID + + listed := request(http.MethodGet, "/costs/pricing-rules?purpose=usage_cost&status=active", "") + if listed.Code != http.StatusOK || !strings.Contains(listed.Body.String(), ruleID) { + t.Fatalf("list pricing status=%d body=%s", listed.Code, listed.Body.String()) + } + detail := request(http.MethodGet, "/costs/pricing-rules/"+ruleID, "") + if detail.Code != http.StatusOK || !strings.Contains(detail.Body.String(), created.Data.Draft.ID) { + t.Fatalf("get pricing rule status=%d body=%s", detail.Code, detail.Body.String()) + } + missingDetail := request(http.MethodGet, "/costs/pricing-rules/missing", "") + if missingDetail.Code != http.StatusNotFound { + t.Fatalf("missing pricing rule status=%d body=%s", missingDetail.Code, missingDetail.Body.String()) + } + + valid := request(http.MethodPost, "/costs/pricing-rules/validate", `{"expression":"v1: fixed_line(\"request\", \"request\", 20)","test_cases":[]}`) + if valid.Code != http.StatusOK || !strings.Contains(valid.Body.String(), `"valid":true`) { + t.Fatalf("validate pricing status=%d body=%s", valid.Code, valid.Body.String()) + } + invalid := request(http.MethodPost, "/costs/pricing-rules/validate", `{"expression":"v1: uncached_input_tokens * 3","test_cases":[]}`) + if invalid.Code != http.StatusUnprocessableEntity || !strings.Contains(invalid.Body.String(), `"valid":false`) { + t.Fatalf("invalid pricing validation status=%d body=%s", invalid.Code, invalid.Body.String()) + } + + simulation := request(http.MethodPost, "/costs/pricing-rules/simulate", `{"expression":"v1: fixed_line(\"request\", \"request\", 20)","currency":"USD","facts":{}}`) + if simulation.Code != http.StatusOK || !strings.Contains(simulation.Body.String(), `"amount_micros":20`) { + t.Fatalf("simulate pricing status=%d body=%s", simulation.Code, simulation.Body.String()) + } + missingSimulation := request(http.MethodPost, "/costs/pricing-rules/simulate", `{"rule_version_id":"missing","currency":"USD","facts":{}}`) + if missingSimulation.Code != http.StatusNotFound { + t.Fatalf("missing simulation version status=%d body=%s", missingSimulation.Code, missingSimulation.Body.String()) + } + + updateBody := fmt.Sprintf(`{"expected_lock_version":%d,"name":"Enterprise usage v1","currency":"USD","authoring_mode":"raw","expression":"v1: fixed_line(\"request\", \"request\", 20)","test_cases":[]}`, created.Data.Rule.LockVersion) + updatedRec := request(http.MethodPut, "/costs/pricing-rules/"+ruleID+"/draft", updateBody) + if updatedRec.Code != http.StatusOK { + t.Fatalf("update pricing draft status=%d body=%s", updatedRec.Code, updatedRec.Body.String()) + } + var updated struct { + Data controlplane.PricingRuleDetail `json:"data"` + } + if err := json.Unmarshal(updatedRec.Body.Bytes(), &updated); err != nil || updated.Data.Draft == nil { + t.Fatalf("decode updated pricing: data=%+v err=%v", updated.Data, err) + } + staleUpdate := request(http.MethodPut, "/costs/pricing-rules/"+ruleID+"/draft", updateBody) + if staleUpdate.Code != http.StatusConflict { + t.Fatalf("stale pricing draft status=%d body=%s", staleUpdate.Code, staleUpdate.Body.String()) + } + + publishBody := fmt.Sprintf(`{"draft_version_id":%q,"expected_lock_version":%d,"expected_active_version_id":"","expression_hash":%q}`, updated.Data.Draft.ID, updated.Data.Rule.LockVersion, updated.Data.Draft.ExpressionHash) + publishedRec := request(http.MethodPost, "/costs/pricing-rules/"+ruleID+"/publish", publishBody) + if publishedRec.Code != http.StatusOK { + t.Fatalf("publish pricing status=%d body=%s", publishedRec.Code, publishedRec.Body.String()) + } + var firstPublished struct { + Data controlplane.PricingRuleDetail `json:"data"` + } + if err := json.Unmarshal(publishedRec.Body.Bytes(), &firstPublished); err != nil || firstPublished.Data.ActiveVersion == nil { + t.Fatalf("decode published pricing: data=%+v err=%v", firstPublished.Data, err) + } + stalePublish := request(http.MethodPost, "/costs/pricing-rules/"+ruleID+"/publish", publishBody) + if stalePublish.Code != http.StatusConflict { + t.Fatalf("stale publish status=%d body=%s", stalePublish.Code, stalePublish.Body.String()) + } + + secondDraftBody := fmt.Sprintf(`{"expected_lock_version":%d,"name":"Enterprise usage v2","currency":"USD","authoring_mode":"raw","expression":"v1: fixed_line(\"request\", \"request\", 30)","test_cases":[]}`, firstPublished.Data.Rule.LockVersion) + secondDraftRec := request(http.MethodPut, "/costs/pricing-rules/"+ruleID+"/draft", secondDraftBody) + var secondDraft struct { + Data controlplane.PricingRuleDetail `json:"data"` + } + if secondDraftRec.Code != http.StatusOK { + t.Fatalf("second pricing draft status=%d body=%s", secondDraftRec.Code, secondDraftRec.Body.String()) + } + if err := json.Unmarshal(secondDraftRec.Body.Bytes(), &secondDraft); err != nil || secondDraft.Data.Draft == nil { + t.Fatalf("decode second draft: data=%+v err=%v", secondDraft.Data, err) + } + secondPublishBody := fmt.Sprintf(`{"draft_version_id":%q,"expected_lock_version":%d,"expected_active_version_id":%q,"expression_hash":%q}`, secondDraft.Data.Draft.ID, secondDraft.Data.Rule.LockVersion, firstPublished.Data.ActiveVersion.ID, secondDraft.Data.Draft.ExpressionHash) + secondPublishedRec := request(http.MethodPost, "/costs/pricing-rules/"+ruleID+"/publish", secondPublishBody) + var secondPublished struct { + Data controlplane.PricingRuleDetail `json:"data"` + } + if secondPublishedRec.Code != http.StatusOK { + t.Fatalf("second publish status=%d body=%s", secondPublishedRec.Code, secondPublishedRec.Body.String()) + } + if err := json.Unmarshal(secondPublishedRec.Body.Bytes(), &secondPublished); err != nil || secondPublished.Data.ActiveVersion == nil { + t.Fatalf("decode second publish: data=%+v err=%v", secondPublished.Data, err) + } + + activatePath := "/costs/pricing-rules/" + ruleID + "/activate/" + firstPublished.Data.ActiveVersion.ID + activateBody := fmt.Sprintf(`{"expected_lock_version":%d}`, secondPublished.Data.Rule.LockVersion) + activated := request(http.MethodPost, activatePath, activateBody) + if activated.Code != http.StatusOK || !strings.Contains(activated.Body.String(), `"status":"active"`) { + t.Fatalf("activate pricing version status=%d body=%s", activated.Code, activated.Body.String()) + } + staleActivate := request(http.MethodPost, activatePath, activateBody) + if staleActivate.Code != http.StatusConflict { + t.Fatalf("stale activate status=%d body=%s", staleActivate.Code, staleActivate.Body.String()) + } + + current := request(http.MethodGet, "/costs/pricing-rules/"+ruleID, "") + var currentDetail struct { + Data controlplane.PricingRuleDetail `json:"data"` + } + if err := json.Unmarshal(current.Body.Bytes(), ¤tDetail); err != nil { + t.Fatalf("decode current pricing detail: %v", err) + } + disableBody := fmt.Sprintf(`{"expected_lock_version":%d}`, currentDetail.Data.Rule.LockVersion) + disabled := request(http.MethodPost, "/costs/pricing-rules/"+ruleID+"/disable", disableBody) + if disabled.Code != http.StatusOK || !strings.Contains(disabled.Body.String(), `"status":"disabled"`) { + t.Fatalf("disable pricing status=%d body=%s", disabled.Code, disabled.Body.String()) + } + staleDisable := request(http.MethodPost, "/costs/pricing-rules/"+ruleID+"/disable", disableBody) + if staleDisable.Code != http.StatusConflict { + t.Fatalf("stale disable status=%d body=%s", staleDisable.Code, staleDisable.Body.String()) + } + + amount := int64(20) + evaluation := controlplane.PricingEvaluation{ + ID: "peval_http", Purpose: controlplane.PricingPurposeUsageCost, Phase: pricing.PhaseSettlement, + OperationID: "operation-http", AttemptID: "attempt-http", UsageVersion: 1, + PricingRuleID: ruleID, PricingRuleVersionID: firstPublished.Data.ActiveVersion.ID, + EngineVersion: pricing.EngineVersionV1, ExpressionHash: firstPublished.Data.ActiveVersion.ExpressionHash, + AmountMicros: &amount, Currency: pricing.CurrencyUSD, Status: controlplane.PricingEvaluationStatusSuccess, CreatedAt: time.Now().UTC(), + } + if err := repository.SavePricingEvaluation(context.Background(), evaluation); err != nil { + t.Fatal(err) + } + evaluationRec := request(http.MethodGet, "/costs/pricing-evaluations/"+evaluation.ID, "") + if evaluationRec.Code != http.StatusOK || !strings.Contains(evaluationRec.Body.String(), evaluation.ID) || !strings.Contains(evaluationRec.Body.String(), `"amount_micros":20`) { + t.Fatalf("get pricing evaluation status=%d body=%s", evaluationRec.Code, evaluationRec.Body.String()) + } + missingEvaluation := request(http.MethodGet, "/costs/pricing-evaluations/missing", "") + if missingEvaluation.Code != http.StatusNotFound { + t.Fatalf("missing pricing evaluation status=%d body=%s", missingEvaluation.Code, missingEvaluation.Body.String()) + } +} diff --git a/backend/internal/server/rbac_test.go b/backend/internal/server/rbac_test.go index d6e2598..30982eb 100644 --- a/backend/internal/server/rbac_test.go +++ b/backend/internal/server/rbac_test.go @@ -106,6 +106,95 @@ func TestAdminRBACBlocksDeveloperButPortalStillWorks(t *testing.T) { } } +func TestAdminRBACRejectsDeveloperSupplyAndPricingOperations(t *testing.T) { + handler, control := newTestRuntime(t, RuntimeConfig{AdminToken: "secret"}) + user, err := control.CreateWorkspaceUser(context.Background(), "tester", controlplane.WorkspaceUserRequest{ + Email: "supply-developer@example.com", Status: controlplane.WorkspaceUserStatusActive, Role: controlplane.RoleDeveloper, + }) + if err != nil { + t.Fatal(err) + } + + tests := []struct { + method string + path string + body string + }{ + {method: http.MethodGet, path: "/api/v1/console/providers"}, + {method: http.MethodPost, path: "/api/v1/console/providers", body: `{}`}, + {method: http.MethodPut, path: "/api/v1/console/providers/provider", body: `{}`}, + {method: http.MethodPost, path: "/api/v1/console/providers/provider/check"}, + {method: http.MethodGet, path: "/api/v1/console/provider-health-checks"}, + {method: http.MethodGet, path: "/api/v1/console/provider-accounts"}, + {method: http.MethodPost, path: "/api/v1/console/provider-accounts", body: `{}`}, + {method: http.MethodPut, path: "/api/v1/console/provider-accounts/account", body: `{}`}, + {method: http.MethodDelete, path: "/api/v1/console/provider-accounts/account"}, + {method: http.MethodPost, path: "/api/v1/console/provider-accounts/account/check"}, + {method: http.MethodPost, path: "/api/v1/console/provider-accounts/account/clear-cooldown"}, + {method: http.MethodGet, path: "/api/v1/console/provider-account-health-checks"}, + {method: http.MethodGet, path: "/api/v1/console/provider-accounts/account/models"}, + {method: http.MethodPost, path: "/api/v1/console/provider-accounts/account/models/discover"}, + {method: http.MethodPost, path: "/api/v1/console/provider-accounts/account/models/sync", body: `{}`}, + {method: http.MethodGet, path: "/api/v1/console/gateway-models"}, + {method: http.MethodPost, path: "/api/v1/console/gateway-models", body: `{}`}, + {method: http.MethodPut, path: "/api/v1/console/gateway-models/model", body: `{}`}, + {method: http.MethodDelete, path: "/api/v1/console/gateway-models/model"}, + {method: http.MethodGet, path: "/api/v1/console/model-routes"}, + {method: http.MethodPost, path: "/api/v1/console/model-routes", body: `{}`}, + {method: http.MethodPost, path: "/api/v1/console/model-routes/bulk", body: `{}`}, + {method: http.MethodPut, path: "/api/v1/console/model-routes/route", body: `{}`}, + {method: http.MethodDelete, path: "/api/v1/console/model-routes/route"}, + {method: http.MethodPost, path: "/api/v1/console/gateway-simulator", body: `{}`}, + {method: http.MethodGet, path: "/api/v1/console/routing-groups"}, + {method: http.MethodPost, path: "/api/v1/console/routing-groups", body: `{}`}, + {method: http.MethodPut, path: "/api/v1/console/routing-groups/group", body: `{}`}, + {method: http.MethodGet, path: "/api/v1/console/routing-policies"}, + {method: http.MethodPost, path: "/api/v1/console/routing-policies", body: `{}`}, + {method: http.MethodPut, path: "/api/v1/console/routing-policies/policy", body: `{}`}, + {method: http.MethodGet, path: "/api/v1/console/pricing-rules"}, + {method: http.MethodPost, path: "/api/v1/console/pricing-rules", body: `{}`}, + {method: http.MethodGet, path: "/api/v1/console/pricing-rules/rule"}, + {method: http.MethodPut, path: "/api/v1/console/pricing-rules/rule/draft", body: `{}`}, + {method: http.MethodPost, path: "/api/v1/console/pricing-rules/validate", body: `{}`}, + {method: http.MethodPost, path: "/api/v1/console/pricing-rules/simulate", body: `{}`}, + {method: http.MethodPost, path: "/api/v1/console/pricing-rules/rule/publish", body: `{}`}, + {method: http.MethodPost, path: "/api/v1/console/pricing-rules/rule/activate/version", body: `{}`}, + {method: http.MethodPost, path: "/api/v1/console/pricing-rules/rule/disable", body: `{}`}, + {method: http.MethodGet, path: "/api/v1/console/pricing-evaluations/evaluation"}, + {method: http.MethodGet, path: "/api/v1/console/effective-pricing/report"}, + {method: http.MethodPut, path: "/api/v1/console/effective-pricing/policy", body: `{}`}, + {method: http.MethodGet, path: "/api/v1/console/procurement-prices"}, + {method: http.MethodPost, path: "/api/v1/console/procurement-prices", body: `{}`}, + {method: http.MethodPost, path: "/api/v1/console/provider-billing-lines", body: `{}`}, + {method: http.MethodPost, path: "/api/v1/console/provider-billing-sources/inspect", body: `{}`}, + {method: http.MethodGet, path: "/api/v1/console/provider-billing-sources"}, + {method: http.MethodPut, path: "/api/v1/console/provider-billing-sources", body: `{}`}, + {method: http.MethodPost, path: "/api/v1/console/provider-billing-sources/source/sync"}, + {method: http.MethodGet, path: "/api/v1/console/provider-billing-sources/source/evidence"}, + {method: http.MethodGet, path: "/api/v1/console/provider-cache-capabilities"}, + {method: http.MethodPut, path: "/api/v1/console/provider-cache-capabilities", body: `{}`}, + {method: http.MethodGet, path: "/api/v1/console/provider-cache-probes"}, + {method: http.MethodPost, path: "/api/v1/console/provider-cache-probes", body: `{}`}, + {method: http.MethodGet, path: "/api/v1/console/effective-pricing/decisions"}, + {method: http.MethodGet, path: "/api/v1/console/effective-pricing/decisions/decision/evaluations"}, + {method: http.MethodPost, path: "/api/v1/console/effective-pricing/decisions/evaluate", body: `{}`}, + {method: http.MethodPost, path: "/api/v1/console/effective-pricing/decisions/decision/action", body: `{}`}, + } + for _, test := range tests { + req := httptest.NewRequest(test.method, test.path, bytes.NewBufferString(test.body)) + req.Header.Set("Authorization", "Bearer secret") + req.Header.Set("X-Actor", user.Email) + if test.body != "" { + req.Header.Set("Content-Type", "application/json") + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("%s %s status=%d body=%s", test.method, test.path, rec.Code, rec.Body.String()) + } + } +} + func TestAdminRBACResourceBindingOnlyGrantsMatchingResource(t *testing.T) { handler, control := newTestRuntime(t, RuntimeConfig{AdminToken: "secret"}) user, err := control.CreateWorkspaceUser(context.Background(), "tester", controlplane.WorkspaceUserRequest{ @@ -424,7 +513,7 @@ func TestAdminRBACProtectsPluginAndSystemWrites(t *testing.T) { t.Fatalf("CreateRoleBinding(): %v", err) } - for _, target := range []string{"/api/v1/console/plugins/catalog-sync", "/api/v1/console/system/update"} { + for _, target := range []string{"/api/v1/console/plugins/catalog-sync", "/api/v1/console/system/update", "/api/v1/console/settings/retention/cleanup"} { req := httptest.NewRequest(http.MethodPost, target, nil) req.Header.Set("Authorization", "Bearer secret") req.Header.Set("X-Actor", "auditor@example.com") diff --git a/backend/internal/server/server_test.go b/backend/internal/server/server_test.go index a456f6e..fbf0325 100644 --- a/backend/internal/server/server_test.go +++ b/backend/internal/server/server_test.go @@ -543,6 +543,44 @@ func TestLoginAgreementIsEnforcedAfterPayloadBinding(t *testing.T) { } } +func TestLegalDocumentEndpointReturnsPublishedDocumentAndRejectsUnknownSlug(t *testing.T) { + settingsService := settings.NewService(settings.NewMemoryRepository(), settings.ServiceOptions{Version: "test", StorageMode: "memory"}) + current, err := settingsService.Admin(t.Context()) + if err != nil { + t.Fatalf("Admin(): %v", err) + } + current.LegalDocuments = []settings.LegalDocument{ + {ID: "privacy", Name: "Privacy Policy", Slug: "privacy-policy", Content: "# Privacy\n\nEnterprise data is isolated."}, + } + if _, err := settingsService.Update(t.Context(), current); err != nil { + t.Fatalf("Update(): %v", err) + } + handler := New(Options{SettingsService: settingsService}) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/legal/privacy-policy", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("published document status=%d body=%s", rec.Code, rec.Body.String()) + } + var response struct { + Data settings.LegalDocument `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatalf("decode legal document: %v", err) + } + if response.Data != current.LegalDocuments[0] { + t.Fatalf("legal document mismatch: %+v", response.Data) + } + + missingReq := httptest.NewRequest(http.MethodGet, "/api/v1/legal/unknown", nil) + missingRec := httptest.NewRecorder() + handler.ServeHTTP(missingRec, missingReq) + if missingRec.Code != http.StatusNotFound || !strings.Contains(missingRec.Body.String(), "legal document not found") { + t.Fatalf("unknown document status=%d body=%s", missingRec.Code, missingRec.Body.String()) + } +} + func TestLegacyCaptchaEndpointDisablesCaptcha(t *testing.T) { handler := newAuthTestHandler(t) diff --git a/backend/internal/settings/service_test.go b/backend/internal/settings/service_test.go index 93d9921..77fd858 100644 --- a/backend/internal/settings/service_test.go +++ b/backend/internal/settings/service_test.go @@ -90,6 +90,26 @@ func TestEmailTemplateUpdateDoesNotOverwriteConcurrentSettings(t *testing.T) { } } +func TestEmailTemplateRestoreDoesNotOverwriteConcurrentSettings(t *testing.T) { + repo := &conflictOnceSettingsRepository{MemoryRepository: NewMemoryRepository()} + stored := `[{"event":"quota_limit","locale":"en-US","subject":"Custom {{.SiteName}}","html":"

{{.Limit}}

"}]` + if err := repo.SetMultiple(t.Context(), map[string]string{KeyEmailTemplates: stored}); err != nil { + t.Fatal(err) + } + svc := NewService(repo, ServiceOptions{Version: "test", StorageMode: "memory"}) + _, err := svc.RestoreEmailTemplate(t.Context(), "quota_limit", "en-US") + if !errors.Is(err, ErrSettingsChanged) { + t.Fatalf("RestoreEmailTemplate() error = %v", err) + } + values, err := repo.GetAll(t.Context()) + if err != nil { + t.Fatal(err) + } + if values[KeyEmailTemplates] != stored { + t.Fatalf("conflicting template restore changed stored value: %s", values[KeyEmailTemplates]) + } +} + func TestLegacyDefaultTemplatesAreNotReportedAsCustomized(t *testing.T) { repo := NewMemoryRepository() legacy, err := json.Marshal(auth.DefaultEmailTemplates()) diff --git a/backend/internal/system/s3_backup.go b/backend/internal/system/s3_backup.go index b27a1de..8b55496 100644 --- a/backend/internal/system/s3_backup.go +++ b/backend/internal/system/s3_backup.go @@ -2,7 +2,6 @@ package system import ( "context" - "errors" "fmt" "io" "path/filepath" @@ -114,7 +113,7 @@ func (s *S3BackupStore) Download(ctx context.Context, key string) (io.ReadCloser if key == "" || filepath.Dir(key) != expectedDir || !strings.HasPrefix(name, backupPrefix) || !strings.HasSuffix(name, ".tar.gz") || strings.Contains(key, "..") { - return nil, errors.New("S3 backup key is invalid") + return nil, fmt.Errorf("%w: S3 backup key is invalid", ErrBackupInvalid) } result, err := s.client.GetObject(ctx, &s3.GetObjectInput{Bucket: aws.String(s.config.Bucket), Key: aws.String(key)}) if err != nil { diff --git a/backend/internal/system/service_test.go b/backend/internal/system/service_test.go index 4368e3c..da86267 100644 --- a/backend/internal/system/service_test.go +++ b/backend/internal/system/service_test.go @@ -11,6 +11,9 @@ import ( "errors" "net/http" "net/http/httptest" + "os" + "os/exec" + "path/filepath" "runtime" "strings" "testing" @@ -283,6 +286,110 @@ func TestPerformUpdateSourceBuildRequiresManualUpdate(t *testing.T) { } } +func TestPerformUpdateReturnsSuccessWhenAlreadyCurrent(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(manifestFile{ + Version: "0.1.0", + Assets: []Asset{{ + OS: runtime.GOOS, Arch: runtime.GOARCH, URL: "https://example.test/asterrouter", SHA256: strings.Repeat("a", 64), + }}, + }) + })) + defer srv.Close() + + svc := NewService(Config{Version: "0.1.0", BuildType: "release", ManifestURL: srv.URL}) + result, err := svc.PerformUpdate(context.Background(), "stable", "update-current") + if err != nil { + t.Fatalf("PerformUpdate(): %v", err) + } + if !result.AlreadyUpToDate || result.NeedRestart || result.OperationID != "update-current" || result.CurrentVersion != "0.1.0" || result.LatestVersion != "0.1.0" { + t.Fatalf("result=%+v", result) + } +} + +func TestRestartExitsIsolatedProcessWhenEnabled(t *testing.T) { + if os.Getenv("ASTERROUTER_TEST_RESTART_CHILD") == "1" { + svc := NewService(Config{AllowRestart: true}) + result, err := svc.Restart("restart-child", time.Millisecond) + if err != nil || result.OperationID != "restart-child" || result.Message == "" { + os.Exit(41) + } + time.Sleep(time.Second) + os.Exit(42) + } + + cmd := exec.Command(os.Args[0], "-test.run=^TestRestartExitsIsolatedProcessWhenEnabled$") + cmd.Env = append(os.Environ(), "ASTERROUTER_TEST_RESTART_CHILD=1", "GORACE=atexit_sleep_ms=0") + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("restart child failed: %v output=%s", err, output) + } +} + +func TestRestartRejectsDisabledMaintenance(t *testing.T) { + result, err := NewService(Config{}).Restart("restart-disabled", time.Millisecond) + if !errors.Is(err, ErrRestartUnsupported) || result.OperationID != "restart-disabled" || result.ManualAction == "" { + t.Fatalf("Restart() result=%+v err=%v", result, err) + } +} + +func TestRollbackReplacesIsolatedExecutableAndPreservesOperation(t *testing.T) { + if os.Getenv("ASTERROUTER_TEST_ROLLBACK_CHILD") == "1" { + result, err := NewService(Config{}).Rollback("rollback-child") + if err != nil || !result.NeedRestart || result.OperationID != "rollback-child" { + os.Exit(51) + } + return + } + + tempDir := t.TempDir() + executable := filepath.Join(tempDir, "asterrouter-system-test") + raw, err := os.ReadFile(os.Args[0]) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(executable, raw, 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(executable+".backup", raw, 0750); err != nil { + t.Fatal(err) + } + cmd := exec.Command(executable, "-test.run=^TestRollbackReplacesIsolatedExecutableAndPreservesOperation$") + cmd.Env = append(os.Environ(), "ASTERROUTER_TEST_ROLLBACK_CHILD=1") + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("rollback child failed: %v output=%s", err, output) + } + if _, err := os.Stat(executable + ".backup"); !os.IsNotExist(err) { + t.Fatalf("rollback backup should be consumed, stat err=%v", err) + } + if _, err := os.Stat(executable); err != nil { + t.Fatalf("restored executable missing: %v", err) + } +} + +func TestRollbackRejectsMissingBackup(t *testing.T) { + if os.Getenv("ASTERROUTER_TEST_ROLLBACK_MISSING_CHILD") != "1" { + tempDir := t.TempDir() + executable := filepath.Join(tempDir, "asterrouter-system-test") + raw, err := os.ReadFile(os.Args[0]) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(executable, raw, 0750); err != nil { + t.Fatal(err) + } + cmd := exec.Command(executable, "-test.run=^TestRollbackRejectsMissingBackup$") + cmd.Env = append(os.Environ(), "ASTERROUTER_TEST_ROLLBACK_MISSING_CHILD=1") + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("missing rollback child failed: %v output=%s", err, output) + } + return + } + + if _, err := NewService(Config{}).Rollback("rollback-missing"); err == nil || !strings.Contains(err.Error(), "no rollback backup") { + os.Exit(61) + } +} + func newOfficialTestKey(t *testing.T) (string, string, ed25519.PrivateKey) { t.Helper() publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) diff --git a/docs/README.md b/docs/README.md index 6f709eb..a3d1a83 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,6 +4,7 @@ > 实现状态:`TARGET`,未完成项不得作为已交付能力对外宣传 > 适用范围:产品、设计、前端、后端、测试、交付与国际化 > 更新日期:2026-08-10 +> 测试事实源:[测试与交付事实源 v1](./test/v1/README.md) · [E2E 场景注册表](./test/v1/scenario-registry.json) ## 1. 一页结论 diff --git a/docs/test/v1/README.md b/docs/test/v1/README.md new file mode 100644 index 0000000..4f2c3eb --- /dev/null +++ b/docs/test/v1/README.md @@ -0,0 +1,130 @@ +# AsterRouter 测试与交付事实源 v1 + +> 状态:`CURRENT` +> 适用范围:后端、前端、数据库、浏览器、发布包与远程验证 +> 产品边界:企业管理控制台、企业服务门户、统一 AI Gateway +> 场景注册表:[`scenario-registry.json`](./scenario-registry.json) +> Owner 证据表:[`owner-evidence.json`](./owner-evidence.json) +> 路由策略算法:[`routing-policy-algorithm-matrix.md`](./routing-policy-algorithm-matrix.md) + +## 1. 完整测试的定义 + +AsterRouter 的测试结论必须声明证明层级和声明边界。低层测试通过,不能替代高层产品闭环。 + +| 层级 | 运行环境 | 能证明什么 | 不能证明什么 | +| --- | --- | --- | --- | +| L0 | 静态检查、场景注册表 | 路由、场景、CI 与文档没有治理漂移 | 运行时行为 | +| L1 | Go/Vitest 单元与组件测试 | 纯规则、组件状态、序列化和边界条件 | 进程、数据库或浏览器集成 | +| L2 | `httptest` + memory repository | HTTP 合同、领域协作、鉴权和副作用 | SQL、迁移和重启持久化 | +| L3 | PostgreSQL 16 | 事务、约束、迁移、并发和重启持久化 | 生产构建与浏览器交互 | +| Gate A | Vite + memory backend + fake upstream + Playwright | 浏览器路由、表单、响应式与产品纵向闭环 | 生产单源构建和 SQL 持久化 | +| Gate B | 生产单源 Go 二进制 + PostgreSQL + fake upstream + Playwright | 候选构建、真实数据库与浏览器的交付闭环 | Linux 包、容器和远程网络 | +| Platform | Docker、Linux artifact、安装/升级/回滚 | 打包、权限、信号、安装和运维合同 | 未授权远程环境 | +| Remote | 明确授权的隔离远程环境 | 部署配置、网络和目标环境可用性 | 其他版本或其他配置类别 | + +“全部功能有完整 E2E”由两个互补合同组成: + +1. 每个用户可见路由至少有一个 surface contract,验证 URL、主标题、授权边界、控制台/网络错误和 `1440x900`、`1280x800`、`390x844` 三个视口。 +2. 每个产品能力至少有一条 vertical journey,验证用户操作、API 结果、持久化刷新、失败路径,以及适用的 RBAC、审计、用量、Trace、告警或导出副作用。 + +Surface contract 不能代替 vertical journey。浏览器也不重复证明所有纯算法分支;算法和策略组合在 L1/L2 完整覆盖,浏览器证明用户能够完成真实闭环。 + +能力证据按风险分层,避免把低风险只读投影机械复制成四套同质测试: + +- 所有 HTTP 操作必须有成功证据; +- 所有命令以及 P0 查询必须有失败和边界证据; +- 所有命令以及 P0/P1 查询必须有浏览器操作或可见投影证据; +- P2 查询只强制 owner 成功证据,但已有的失败、边界或浏览器证据仍会校验引用真实性; +- 一个 journey 只有实际调用或点击了该操作,才能登记为该操作的证据,页面可达或同页其他动作不能代替。 +- browser evidence 由场景的 `operations` 单向生成,并在能力注册表中做双向一致性校验,禁止手工把未触发操作挂到场景上。 +- success、negative 和 boundary evidence 由 `owner-evidence.json` 单向生成;Browser journey 不得冒充 Owner 成功证据。 + +## 2. 不可违反的测试规则 + +- `scenario-registry.json` 是浏览器覆盖的机器事实源。新增、删除或移动产品路由时必须同步更新注册表和场景。 +- `owner-evidence.json` 是 Owner 测试到 API 操作的机器事实源,只能登记已核验的 `文件#TestName` 及其实际证明类型。 +- 每个 Playwright 测试必须有唯一稳定的 `@e2e-*` 场景 ID;禁止用文件名、行号或自然语言标题充当稳定标识。 +- 禁止固定等待。使用 web-first assertion、`expect.poll`、响应事件或可观察的 DOM 状态。 +- 禁止连接真实模型供应商、生产身份源、生产邮件、生产对象存储或生产数据库。默认使用仓库内 fake upstream 和隔离 fixture。 +- 禁止共享数据库、端口、用户目录和可变测试身份。测试数据必须带唯一 run ID,并只清理自身数据。 +- 不允许生产 mock fallback。浏览器无法支持的能力只能放在显式 test-only fixture 中。 +- 断言完整 typed object、事件或 read model 的关键字段,不只断言 HTTP 状态码或成功文案。 +- 每个缺陷在根因 owner 层补回归;跨进程或用户可见缺陷再补一条 vertical slice。 +- auth、RBAC、Gateway 策略、计费、迁移、备份恢复和插件信任变更必须包含 negative path。 +- 测试失败必须保留 trace、截图、视频或 JUnit 证据;不得通过提高重试次数掩盖 flaky。 + +## 3. 浏览器场景模型 + +注册场景必须包含: + +- `id`:唯一 `@e2e-*` 标签; +- `kind`:`surface`、`journey` 或 `setup`; +- `owner`:根因归属模块; +- `proofLevels`:实际可运行的证明层; +- `fixture`:身份、存储和外部依赖; +- `gates`:`pr`、`nightly`、`release`; +- `claim`:场景通过后允许声明的边界; +- `routes`:该场景直接验证的产品路由。 +- `operations`:仅列出该 vertical journey 通过可见交互或页面投影实际触发的前端产品 API;surface 不得声明操作。 + +路由合同必须分别引用 `surface` 和一个或多个 `journeys`。`npm run check:e2e-coverage` 使用 TypeScript AST 读取 Vue Router 和 Playwright 测试,校验: + +- 所有产品叶子路由都已登记,没有陈旧路由; +- 场景 ID 唯一,测试标题与 spec 文件一致; +- spec 文件存在,owner、fixture、proof level、gate 和 claim 完整; +- 每个路由同时有 surface 和 vertical journey; +- 注册表中的 Gate 可生成非空、可执行的 Playwright 筛选表达式。 + +## 4. 环境与门禁 + +| 门禁 | 必跑内容 | 失败处理 | +| --- | --- | --- | +| PR | L0、L1、L2、L3、`pr` 浏览器场景、首次安装 | 阻止合并 | +| Nightly | 全量 Go/Vitest、race/benchmark、全部 Chromium/Firefox/WebKit 场景 | 建立问题并阻止发布候选 | +| Release | Gate B P0 场景、首次安装、容器、HA、Linux artifact、checksum | 阻止发布 | +| Remote | 只读 health/version 起步,再运行授权的隔离 canary | 不回写默认门禁 | + +本地从窄到宽执行: + +```bash +cd frontend +npm run check:e2e-coverage +npm run typecheck +npm run test:unit +npm run test:e2e:pr +npm run test:e2e:full + +cd .. +bash scripts/test-single-origin.sh +``` + +PostgreSQL、候选包与远程验证遵循 `.codex/skills/remote-tests/SKILL.md`,必须使用专用数据库和唯一证据目录。 + +## 5. 测试数据与身份 + +| Fixture | 用途 | 默认身份 | 存储 | +| --- | --- | --- | --- | +| `demo-enterprise` | Gate A 快速浏览器闭环 | `demo/demo`,企业管理员 | 独立 memory runtime | +| `registered-developer` | Portal、RBAC 与会话隔离 | 每场景唯一邮箱 | 独立 memory 或 PostgreSQL | +| `first-install` | 空实例初始化 | 每次唯一管理员 | 专用空数据库 | +| `release-enterprise` | Gate B 候选包 | `admin` + 测试密码 | `asterrouter_release_test_*` | +| `fake-openai` | Gateway 正常、流式、429、5xx、超时和断连 | 无真实凭据 | 独立本地端口 | + +APIRequestContext 只用于准备前置数据和读取副作用。主要用户动作必须通过可见控件完成;测试结束后仅删除带本次 run ID 的记录。 + +## 6. 证据与声明 + +失败证据最少包括:场景 ID、URL、项目/视口、身份、locale/theme、失败交互、控制台错误、失败请求、trace 和截图路径。Gate B 还必须记录版本、commit、数据库类别、候选包和单源 URL。 + +测试报告只允许声明实际跑过的最高证明层。例如 Gate A 通过只能声明“开发构建浏览器闭环通过”,不能声明“PostgreSQL 候选包可发布”。环境不兼容或未执行必须显式写明原因、owner 和后续动作。 + +## 7. 新功能完成条件 + +功能只有同时满足以下条件才算完成: + +1. 在根因 owner 层有成功、失败和边界测试; +2. 用户可见路由登记 surface contract; +3. 产品能力登记 vertical journey,并验证刷新持久化和适用副作用; +4. 场景进入正确 PR/Nightly/Release gate; +5. `npm run check:e2e-coverage`、相关单元测试和目标 E2E 全部通过; +6. 文档中的 claim 与实际证明层一致。 diff --git a/docs/test/v1/capability-registry.json b/docs/test/v1/capability-registry.json new file mode 100644 index 0000000..0daf06d --- /dev/null +++ b/docs/test/v1/capability-registry.json @@ -0,0 +1,5363 @@ +{ + "schemaVersion": 1, + "generatedFrom": "frontend Vue @/api imports and exported apiClient call graph", + "coveragePolicy": { + "success": "Required for every product HTTP operation.", + "negative": "Required for every command and every P0 query.", + "boundary": "Required for every command and every P0 query.", + "browser": "Required for every command and every P0 or P1 query." + }, + "proofContract": { + "success": "The owner or product chain returns the intended result and persists applicable state.", + "negative": "Invalid identity, authorization, dependency, or state is rejected with the public error contract.", + "boundary": "At least one material edge such as empty, limit, idempotency, concurrency, pagination, or restart is verified.", + "browser": "A visible product interaction or projection reaches the public HTTP operation in a registered Playwright journey." + }, + "capabilities": [ + { + "id": "account:beginAccountIdentityBinding", + "owner": "account", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/AccountProfileView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/oauth_state_cookie_test.go#TestExternalAccountBindingStartsBindStateToBrowserCookie" + ], + "negative": [ + "backend/internal/server/auth_rate_limit_test.go#TestAccountBindingStartIsLimitedPerAccountAndClient" + ], + "boundary": [ + "backend/internal/server/oauth_state_cookie_test.go#TestExternalAccountBindingStartsBindStateToBrowserCookie" + ], + "browser": [ + "@e2e-account-identity-001" + ] + }, + "notes": "" + }, + { + "id": "account:beginTOTPSetup", + "owner": "account", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/AccountProfileView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/account_routes_test.go#TestTOTPChangesImmediatelyRevokeExistingBearerTokens" + ], + "negative": [ + "backend/internal/server/account_routes_test.go#TestTOTPChangesImmediatelyRevokeExistingBearerTokens" + ], + "boundary": [ + "backend/internal/controlplane/totp_service_test.go#TestTOTPSetupExpiresAndCanBeRestarted" + ], + "browser": [ + "@e2e-auth-001" + ] + }, + "notes": "" + }, + { + "id": "account:changeAccountPassword", + "owner": "account", + "risk": "P0", + "interaction": "command", + "methods": [ + "put" + ], + "views": [ + "frontend/src/views/AccountProfileView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/account_routes_test.go#TestPasswordChangeRevokesExistingBearerToken" + ], + "negative": [ + "backend/internal/server/account_error_boundary_test.go#TestAccountRoutesPreservePublicValidationErrors" + ], + "boundary": [ + "backend/internal/server/account_routes_test.go#TestPasswordChangeRevokesExistingBearerToken" + ], + "browser": [ + "@e2e-auth-001" + ] + }, + "notes": "" + }, + { + "id": "account:confirmTOTP", + "owner": "account", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/AccountProfileView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/account_routes_test.go#TestTOTPChangesImmediatelyRevokeExistingBearerTokens" + ], + "negative": [ + "backend/internal/server/account_routes_test.go#TestTOTPManagementAttemptsAreLimitedPerAccountAcrossRoutes" + ], + "boundary": [ + "backend/internal/controlplane/totp_service_test.go#TestTOTPSetupExpiresAndCanBeRestarted" + ], + "browser": [ + "@e2e-auth-001" + ] + }, + "notes": "" + }, + { + "id": "account:disableTOTP", + "owner": "account", + "risk": "P0", + "interaction": "command", + "methods": [ + "delete" + ], + "views": [ + "frontend/src/views/AccountProfileView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/account_routes_test.go#TestTOTPChangesImmediatelyRevokeExistingBearerTokens" + ], + "negative": [ + "backend/internal/controlplane/account_service_test.go#TestRecoveryCodeCanDisableTOTP" + ], + "boundary": [ + "backend/internal/controlplane/account_service_test.go#TestRecoveryCodeCanDisableTOTP" + ], + "browser": [ + "@e2e-auth-001" + ] + }, + "notes": "" + }, + { + "id": "account:generateTOTPRecoveryCodes", + "owner": "account", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/AccountProfileView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/account_routes_test.go#TestTOTPChangesImmediatelyRevokeExistingBearerTokens" + ], + "negative": [ + "backend/internal/server/account_routes_test.go#TestTOTPChangesImmediatelyRevokeExistingBearerTokens" + ], + "boundary": [ + "backend/internal/controlplane/totp_service_test.go#TestTOTPRecoveryCodeIsConsumedAtomically" + ], + "browser": [ + "@e2e-auth-001" + ] + }, + "notes": "" + }, + { + "id": "account:getAccountProfile", + "owner": "account", + "risk": "P0", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/AccountProfileView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/account_routes_test.go#TestWorkspaceAccountProfileEndpoints" + ], + "negative": [ + "backend/internal/server/account_error_boundary_test.go#TestAccountRoutesDoNotExposeRepositoryErrors" + ], + "boundary": [ + "backend/internal/server/account_routes_test.go#TestLocalAdministratorAccountIsFullyMutable" + ], + "browser": [ + "@e2e-a11y-session-001", + "@e2e-auth-001", + "@e2e-portal-001" + ] + }, + "notes": "" + }, + { + "id": "account:revokeOtherAccountSessions", + "owner": "account", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/AccountProfileView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/account_routes_test.go#TestRevokeOtherSessionsReturnsReplacementBearerToken" + ], + "negative": [ + "backend/internal/server/account_error_boundary_test.go#TestAccountRoutesDoNotExposeRepositoryErrors" + ], + "boundary": [ + "backend/internal/server/account_routes_test.go#TestRevokeOtherSessionsReturnsReplacementBearerToken" + ], + "browser": [ + "@e2e-auth-001" + ] + }, + "notes": "" + }, + { + "id": "account:unbindAccountIdentity", + "owner": "account", + "risk": "P0", + "interaction": "command", + "methods": [ + "delete" + ], + "views": [ + "frontend/src/views/AccountProfileView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/oidc_provision_test.go#TestUnbindCurrentAuthIdentityProtectsLastLoginMethod" + ], + "negative": [ + "backend/internal/controlplane/oidc_provision_test.go#TestUnbindCurrentAuthIdentityProtectsLastLoginMethod" + ], + "boundary": [ + "backend/internal/controlplane/oidc_provision_test.go#TestUnbindCurrentAuthIdentityProtectsLastLoginMethod", + "backend/internal/controlplane/oidc_provision_test.go#TestUnbindCurrentAuthIdentityWithPasswordDoesNotAffectOtherUsers" + ], + "browser": [ + "@e2e-account-identity-001" + ] + }, + "notes": "" + }, + { + "id": "account:updateAccountProfile", + "owner": "account", + "risk": "P0", + "interaction": "command", + "methods": [ + "put" + ], + "views": [ + "frontend/src/views/AccountProfileView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/account_routes_test.go#TestWorkspaceAccountProfileEndpoints" + ], + "negative": [ + "backend/internal/server/account_error_boundary_test.go#TestAccountRoutesPreservePublicValidationErrors" + ], + "boundary": [ + "backend/internal/controlplane/account_service_test.go#TestCurrentAccountProfileUpdateAndPasswordChange" + ], + "browser": [ + "@e2e-auth-001" + ] + }, + "notes": "" + }, + { + "id": "auth:forgotPassword", + "owner": "auth", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/LoginView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/public_auth_routes_test.go#TestAuthenticationEmailQueueRejectionRollsBackIssuedTokens", + "backend/internal/server/public_auth_routes_test.go#TestPublicAuthenticationWorkflowSecurityContract" + ], + "negative": [ + "backend/internal/server/public_auth_routes_test.go#TestAuthenticationEmailQueueRejectionRollsBackIssuedTokens" + ], + "boundary": [ + "backend/internal/controlplane/registration_test.go#TestAuthenticationTokensAreConsumedAtomically", + "backend/internal/server/public_auth_routes_test.go#TestAuthenticationEmailQueueRejectionRollsBackIssuedTokens" + ], + "browser": [ + "@e2e-auth-001" + ] + }, + "notes": "" + }, + { + "id": "auth:register", + "owner": "auth", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/LoginView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/public_auth_routes_test.go#TestPublicAuthenticationWorkflowSecurityContract" + ], + "negative": [ + "backend/internal/controlplane/registration_test.go#TestWorkspaceRegistrationValidatesEmailAndBcryptLimit", + "backend/internal/server/public_auth_routes_test.go#TestPublicAuthenticationWorkflowSecurityContract", + "backend/internal/server/public_auth_routes_test.go#TestRegistrationRepositoryFailureIsSanitizedServerError" + ], + "boundary": [ + "backend/internal/controlplane/registration_test.go#TestConcurrentAliasRegistrationCreatesOneAccount" + ], + "browser": [ + "@e2e-auth-001" + ] + }, + "notes": "" + }, + { + "id": "auth:resendVerification", + "owner": "auth", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/LoginView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/public_auth_routes_test.go#TestAuthenticationEmailQueueRejectionRollsBackIssuedTokens", + "backend/internal/server/public_auth_routes_test.go#TestPublicAuthenticationWorkflowSecurityContract" + ], + "negative": [ + "backend/internal/server/public_auth_routes_test.go#TestAuthenticationEmailQueueRejectionRollsBackIssuedTokens" + ], + "boundary": [ + "backend/internal/controlplane/registration_test.go#TestInitialEmailVerificationStartsResendCooldown", + "backend/internal/server/public_auth_routes_test.go#TestAuthenticationEmailQueueRejectionRollsBackIssuedTokens" + ], + "browser": [ + "@e2e-auth-001" + ] + }, + "notes": "" + }, + { + "id": "auth:resetPassword", + "owner": "auth", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/LoginView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/public_auth_routes_test.go#TestAuthenticationEmailQueueRejectionRollsBackIssuedTokens" + ], + "negative": [ + "backend/internal/server/public_auth_routes_test.go#TestAuthenticationEmailQueueRejectionRollsBackIssuedTokens", + "backend/internal/server/public_auth_routes_test.go#TestPublicAuthenticationWorkflowSecurityContract" + ], + "boundary": [ + "backend/internal/controlplane/registration_test.go#TestAuthenticationTokensAreConsumedAtomically", + "backend/internal/server/public_auth_routes_test.go#TestAuthenticationEmailQueueRejectionRollsBackIssuedTokens" + ], + "browser": [ + "@e2e-auth-002" + ] + }, + "notes": "" + }, + { + "id": "auth:verifyEmail", + "owner": "auth", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/LoginView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/public_auth_routes_test.go#TestAuthenticationEmailQueueRejectionRollsBackIssuedTokens", + "backend/internal/server/public_auth_routes_test.go#TestPublicAuthenticationWorkflowSecurityContract" + ], + "negative": [ + "backend/internal/server/public_auth_routes_test.go#TestAuthenticationEmailQueueRejectionRollsBackIssuedTokens", + "backend/internal/server/public_auth_routes_test.go#TestPublicAuthenticationWorkflowSecurityContract" + ], + "boundary": [ + "backend/internal/controlplane/registration_test.go#TestAuthenticationTokensAreConsumedAtomically", + "backend/internal/server/public_auth_routes_test.go#TestAuthenticationEmailQueueRejectionRollsBackIssuedTokens" + ], + "browser": [ + "@e2e-auth-001" + ] + }, + "notes": "" + }, + { + "id": "control:acknowledgeAlert", + "owner": "alerts", + "risk": "P1", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminAlertsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/alert_routes_test.go#TestAdminAlertEndpoints" + ], + "negative": [ + "backend/internal/server/alert_routes_test.go#TestAdminAlertEndpoints" + ], + "boundary": [ + "backend/internal/server/alert_routes_test.go#TestAdminAlertEndpoints" + ], + "browser": [ + "@e2e-operations-001" + ] + }, + "notes": "" + }, + { + "id": "control:activatePricingRuleVersion", + "owner": "billing/pricing", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/components/pricing/PricingRulesView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/pricing_rule_routes_test.go#TestPricingRuleHTTPVersionLifecycleAndFailureContracts" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/server/pricing_rule_routes_test.go#TestPricingRuleHTTPVersionLifecycleAndFailureContracts" + ], + "browser": [ + "@e2e-pricing-001" + ] + }, + "notes": "" + }, + { + "id": "control:actOnEffectivePricingDecision", + "owner": "billing/pricing", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminEffectivePricingView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/effective_pricing_service_test.go#TestEffectivePricingDecisionCanaryOrdersCandidateAndRollbackStopsIt" + ], + "negative": [ + "backend/internal/controlplane/effective_pricing_service_test.go#TestEffectivePricingDecisionCanaryOrdersCandidateAndRollbackStopsIt", + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/controlplane/effective_pricing_service_test.go#TestEffectivePricingDecisionCanaryOrdersCandidateAndRollbackStopsIt" + ], + "browser": [ + "@e2e-effective-pricing-001" + ] + }, + "notes": "" + }, + { + "id": "control:bulkCreateModelRoutes", + "owner": "gateway/supply", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminModelRoutesView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/provider_account_model_service_test.go#TestBulkCreateModelRoutesValidatesWholeBatchBeforeWrite", + "backend/internal/server/admin_routes_test.go#TestAdminGatewayModelAndRouteEndpoints" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/controlplane/provider_account_model_service_test.go#TestBulkCreateModelRoutesValidatesWholeBatchBeforeWrite" + ], + "browser": [ + "@e2e-model-inventory-001" + ] + }, + "notes": "" + }, + { + "id": "control:cancelAIJob", + "owner": "jobs", + "risk": "P1", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminAIJobsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/ai_job_admin_routes_test.go#TestAdminAIJobEndpointsProvideRuntimeDetailAndSafeActions" + ], + "negative": [ + "backend/internal/server/ai_job_artifact_admin_contract_test.go#TestAdminAIJobHTTPFailureAndIdempotencyContracts" + ], + "boundary": [ + "backend/internal/server/ai_job_artifact_admin_contract_test.go#TestAdminAIJobHTTPFailureAndIdempotencyContracts" + ], + "browser": [ + "@e2e-operations-001" + ] + }, + "notes": "" + }, + { + "id": "control:checkProvider", + "owner": "gateway/supply", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminProvidersView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/service_test.go#TestProviderConnectionOwnsEndpointStateOnly", + "backend/internal/server/admin_routes_test.go#TestCheckProviderEndpoint" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/controlplane/service_test.go#TestProviderConnectionOwnsEndpointStateOnly", + "backend/internal/server/admin_routes_test.go#TestCheckProviderEndpoint" + ], + "browser": [ + "@e2e-model-supply-lifecycle-001" + ] + }, + "notes": "" + }, + { + "id": "control:checkProviderAccount", + "owner": "gateway/supply", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminProviderAccountsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/service_test.go#TestCheckProviderAccountProbesModelsAndPersistsHealth", + "backend/internal/server/admin_routes_test.go#TestAdminRoutingGroupsAndProviderAccountsEndpoints" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/controlplane/service_test.go#TestCheckProviderAccountProbesModelsAndPersistsHealth", + "backend/internal/server/admin_routes_test.go#TestAdminProviderAccountMissingResourceContracts" + ], + "browser": [ + "@e2e-model-supply-lifecycle-001" + ] + }, + "notes": "" + }, + { + "id": "control:clearProviderAccountCooldown", + "owner": "gateway/supply", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminProviderAccountsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/service_test.go#TestClearProviderAccountCooldownMakesAccountImmediatelyEligible" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/controlplane/service_test.go#TestClearProviderAccountCooldownMakesAccountImmediatelyEligible", + "backend/internal/server/admin_routes_test.go#TestAdminProviderAccountMissingResourceContracts" + ], + "browser": [ + "@e2e-provider-cooldown-001" + ] + }, + "notes": "" + }, + { + "id": "control:createAPIKey", + "owner": "applications/credentials", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminApiKeysView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/api_key_policy_test.go#TestCreateAPIKeyNormalizesPrincipalAndExtendedPolicy", + "backend/internal/controlplane/service_test.go#TestCreateAPIKeyReturnsSecretOnceAndStoresHash", + "backend/internal/server/admin_routes_test.go#TestCreateAPIKeyEndpoint" + ], + "negative": [ + "backend/internal/controlplane/api_key_policy_test.go#TestCreateAPIKeyRejectsInvalidExtendedPolicy" + ], + "boundary": [ + "backend/internal/controlplane/api_key_policy_test.go#TestCreateAPIKeyNormalizesPrincipalAndExtendedPolicy", + "backend/internal/controlplane/service_test.go#TestCreateAPIKeyReturnsSecretOnceAndStoresHash" + ], + "browser": [ + "@e2e-application-001" + ] + }, + "notes": "" + }, + { + "id": "control:createApplication", + "owner": "applications/credentials", + "risk": "P2", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminOnboardingView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/application_service_test.go#TestPlatformCredentialRequiresApplicationPrincipalAndSnapshotsGatewayEvidence" + ], + "negative": [ + "backend/internal/controlplane/application_service_test.go#TestApplicationRejectsNegativeConcurrencyLimit" + ], + "boundary": [ + "backend/internal/controlplane/application_service_test.go#TestPlatformDomainDoesNotCreateWorkspaceUsers" + ], + "browser": [ + "@e2e-application-001" + ] + }, + "notes": "" + }, + { + "id": "control:createDepartment", + "owner": "identity/rbac", + "risk": "P1", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminDepartmentsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/department_routes_test.go#TestAdminDepartmentEndpoints" + ], + "negative": [ + "backend/internal/server/department_routes_test.go#TestAdminDepartmentEndpoints" + ], + "boundary": [ + "backend/internal/server/department_routes_test.go#TestAdminDepartmentEndpoints" + ], + "browser": [ + "@e2e-identity-001" + ] + }, + "notes": "" + }, + { + "id": "control:createExportJob", + "owner": "exports", + "risk": "P1", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminExportJobsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/export_routes_test.go#TestAdminAsyncExportJobLifecycle" + ], + "negative": [ + "backend/internal/server/export_routes_test.go#TestAdminAsyncExportJobLifecycle" + ], + "boundary": [ + "backend/internal/server/export_routes_test.go#TestAdminAsyncExportJobLifecycle" + ], + "browser": [ + "@e2e-operations-001" + ] + }, + "notes": "" + }, + { + "id": "control:createGatewayModel", + "owner": "gateway/supply", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminGatewayModelsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/admin_routes_test.go#TestAdminGatewayModelAndRouteEndpoints" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/server/admin_routes_test.go#TestAdminSupplyCollectionsExposeEmptyArraysAndFirstModelDefaults" + ], + "browser": [ + "@e2e-model-supply-lifecycle-001" + ] + }, + "notes": "" + }, + { + "id": "control:createGovernancePolicy", + "owner": "controlplane", + "risk": "P2", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminPoliciesView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/admin_routes_test.go#TestAdminGovernancePolicyEndpoints" + ], + "negative": [ + "backend/internal/server/admin_routes_test.go#TestAdminGovernancePolicyEndpoints" + ], + "boundary": [ + "backend/internal/server/admin_routes_test.go#TestAdminGovernancePolicyEndpoints" + ], + "browser": [ + "@e2e-application-001" + ] + }, + "notes": "" + }, + { + "id": "control:createModelRoute", + "owner": "gateway/supply", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminModelRoutesView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/admin_routes_test.go#TestAdminGatewayModelAndRouteEndpoints" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/controlplane/multi_cloud_routing_test.go#TestModelRouteRequiresProviderCompatibleUpstreamFormat", + "backend/internal/server/admin_routes_test.go#TestAdminGatewayModelAndRouteEndpoints" + ], + "browser": [ + "@e2e-model-supply-lifecycle-001" + ] + }, + "notes": "" + }, + { + "id": "control:createOrganizationGroup", + "owner": "identity/rbac", + "risk": "P1", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminOrganizationGroupsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/identity_routes_test.go#TestAdminOrganizationGroupLifecycle" + ], + "negative": [ + "backend/internal/server/identity_routes_test.go#TestAdminOrganizationGroupLifecycle" + ], + "boundary": [ + "backend/internal/server/identity_routes_test.go#TestAdminOrganizationGroupLifecycle" + ], + "browser": [ + "@e2e-identity-001" + ] + }, + "notes": "" + }, + { + "id": "control:createPortalAPIKey", + "owner": "applications/credentials", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/components/account/AccountAccessConfig.vue", + "frontend/src/views/portal/PortalHomeView.vue", + "frontend/src/views/portal/PortalKeysView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/portal_service_test.go#TestPortalKeyManagementRequiresKeyManagerRole", + "backend/internal/server/portal_routes_test.go#TestPortalWorkspaceAndAPIKeyRoutes" + ], + "negative": [ + "backend/internal/controlplane/portal_service_test.go#TestPortalAPIKeyCreationRejectsReadOnlyPrincipal" + ], + "boundary": [ + "backend/internal/controlplane/portal_service_test.go#TestPortalKeyManagementRequiresKeyManagerRole" + ], + "browser": [ + "@e2e-portal-001" + ] + }, + "notes": "" + }, + { + "id": "control:createPricingRule", + "owner": "billing/pricing", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/components/pricing/PricingRulesView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/pricing_service_test.go#TestPricingRuleLifecycleValidationAndCAS", + "backend/internal/server/pricing_rule_routes_test.go#TestPricingRuleHTTPVersionLifecycleAndFailureContracts" + ], + "negative": [ + "backend/internal/controlplane/pricing_service_test.go#TestPricingRuleRejectsUnsupportedCurrencyAndLegacySlots", + "backend/internal/server/pricing_rule_routes_test.go#TestPricingRuleRoutesOnlyAcceptEnterpriseUsageCostRules", + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/server/pricing_rule_routes_test.go#TestPricingRuleRoutesOnlyAcceptEnterpriseUsageCostRules" + ], + "browser": [ + "@e2e-pricing-001" + ] + }, + "notes": "" + }, + { + "id": "control:createProcurementPrice", + "owner": "billing/pricing", + "risk": "P2", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminEffectivePricingView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/effective_pricing_service_test.go#TestCreateProcurementPriceRequiresExplicitCacheComponents", + "backend/internal/server/effective_pricing_routes_test.go#TestEffectivePricingAdminEndpointsCreatePriceAndReconcileBilling" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/controlplane/effective_pricing_service_test.go#TestCreateProcurementPriceRequiresExplicitCacheComponents", + "backend/internal/server/effective_pricing_routes_test.go#TestEffectivePricingAdminEndpointsCreatePriceAndReconcileBilling" + ], + "browser": [ + "@e2e-effective-pricing-001" + ] + }, + "notes": "" + }, + { + "id": "control:createProvider", + "owner": "gateway/supply", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminProvidersView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/service_test.go#TestProviderConnectionOwnsEndpointStateOnly", + "backend/internal/server/admin_routes_test.go#TestAdminRoutingGroupsAndProviderAccountsEndpoints", + "backend/internal/server/admin_routes_test.go#TestProviderEndpointRejectsLegacyCredentialAndModelFields" + ], + "negative": [ + "backend/internal/controlplane/service_test.go#TestCreateProviderRequiresAbsoluteURL", + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/controlplane/service_test.go#TestCreateProviderRequiresAbsoluteURL", + "backend/internal/controlplane/service_test.go#TestProviderConnectionOwnsEndpointStateOnly", + "backend/internal/server/admin_routes_test.go#TestProviderEndpointRejectsLegacyCredentialAndModelFields" + ], + "browser": [ + "@e2e-model-supply-lifecycle-001" + ] + }, + "notes": "" + }, + { + "id": "control:createProviderAccount", + "owner": "gateway/supply", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminProviderAccountsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/service_test.go#TestProviderAccountLifecyclePreservesEncryptedSecretAndUpdatesGroupCounts", + "backend/internal/server/admin_routes_test.go#TestAdminRoutingGroupsAndProviderAccountsEndpoints" + ], + "negative": [ + "backend/internal/controlplane/service_test.go#TestCreateProviderAccountRejectsLegacyAuthTypes", + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/controlplane/provider_account_model_service_test.go#TestCreateProviderAccountAllowsEmptyInventoryBeforeDiscovery", + "backend/internal/controlplane/service_test.go#TestProviderAccountLifecyclePreservesEncryptedSecretAndUpdatesGroupCounts" + ], + "browser": [ + "@e2e-model-account-001", + "@e2e-model-supply-lifecycle-001" + ] + }, + "notes": "" + }, + { + "id": "control:createProviderBillingLine", + "owner": "gateway/supply", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminEffectivePricingView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/effective_pricing_service_test.go#TestImportProviderBillingLineReconcilesUsageByUpstreamRequestID", + "backend/internal/server/effective_pricing_routes_test.go#TestEffectivePricingAdminEndpointsCreatePriceAndReconcileBilling" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/server/effective_pricing_routes_test.go#TestEffectivePricingAdminEndpointsCreatePriceAndReconcileBilling" + ], + "browser": [ + "@e2e-effective-pricing-001" + ] + }, + "notes": "" + }, + { + "id": "control:createRoleBinding", + "owner": "identity/rbac", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminUsersView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/identity_routes_test.go#TestAdminIdentityUserAndRoleBindingEndpoints" + ], + "negative": [ + "backend/internal/server/identity_routes_test.go#TestAdminIdentityUserAndRoleBindingEndpoints" + ], + "boundary": [ + "backend/internal/server/identity_routes_test.go#TestAdminIdentityUserAndRoleBindingEndpoints" + ], + "browser": [ + "@e2e-identity-001" + ] + }, + "notes": "" + }, + { + "id": "control:createRoutingGroup", + "owner": "gateway/routing", + "risk": "P1", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminRoutingGroupsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/admin_routes_test.go#TestAdminRoutingGroupsAndProviderAccountsEndpoints" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/server/admin_routes_test.go#TestAdminRoutingGroupBoundaryContracts" + ], + "browser": [ + "@e2e-routing-resources-001" + ] + }, + "notes": "" + }, + { + "id": "control:createRoutingPolicy", + "owner": "gateway/routing", + "risk": "P1", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminRoutingPolicyView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/admin_routes_test.go#TestAdminRoutingPolicyEndpoints" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/server/admin_routes_test.go#TestAdminRoutingPolicyEndpoints" + ], + "browser": [ + "@e2e-routing-policy-001" + ] + }, + "notes": "" + }, + { + "id": "control:createWorkspaceUser", + "owner": "identity/rbac", + "risk": "P2", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminUsersView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/identity_routes_test.go#TestAdminIdentityUserAndRoleBindingEndpoints" + ], + "negative": [ + "backend/internal/server/identity_routes_test.go#TestAdminIdentityUserAndRoleBindingEndpoints", + "backend/internal/server/identity_routes_test.go#TestAdminUserDepartmentAssignmentValidationAndSessionRevocation" + ], + "boundary": [ + "backend/internal/server/identity_routes_test.go#TestAdminIdentityUserAndRoleBindingEndpoints", + "backend/internal/server/identity_routes_test.go#TestAdminUserDepartmentAssignmentValidationAndSessionRevocation" + ], + "browser": [ + "@e2e-identity-001" + ] + }, + "notes": "" + }, + { + "id": "control:deleteGatewayModel", + "owner": "gateway/supply", + "risk": "P0", + "interaction": "command", + "methods": [ + "delete" + ], + "views": [ + "frontend/src/views/admin/AdminGatewayModelsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/admin_routes_test.go#TestAdminGatewayModelAndRouteEndpoints" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/server/admin_routes_test.go#TestAdminGatewayModelAndRouteEndpoints", + "backend/internal/server/admin_routes_test.go#TestAdminGatewayModelAndRouteMissingResourceContracts" + ], + "browser": [ + "@e2e-model-supply-lifecycle-001" + ] + }, + "notes": "" + }, + { + "id": "control:deleteModelRoute", + "owner": "gateway/supply", + "risk": "P0", + "interaction": "command", + "methods": [ + "delete" + ], + "views": [ + "frontend/src/views/admin/AdminModelRoutesView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/admin_routes_test.go#TestAdminGatewayModelAndRouteEndpoints" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/server/admin_routes_test.go#TestAdminGatewayModelAndRouteMissingResourceContracts" + ], + "browser": [ + "@e2e-model-supply-lifecycle-001" + ] + }, + "notes": "" + }, + { + "id": "control:deleteOrganizationGroup", + "owner": "identity/rbac", + "risk": "P1", + "interaction": "command", + "methods": [ + "delete" + ], + "views": [ + "frontend/src/views/admin/AdminOrganizationGroupsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/identity_routes_test.go#TestAdminOrganizationGroupLifecycle" + ], + "negative": [ + "backend/internal/server/identity_routes_test.go#TestAdminOrganizationGroupLifecycle" + ], + "boundary": [ + "backend/internal/server/identity_routes_test.go#TestAdminOrganizationGroupLifecycle" + ], + "browser": [ + "@e2e-identity-001" + ] + }, + "notes": "" + }, + { + "id": "control:deleteProviderAccount", + "owner": "gateway/supply", + "risk": "P0", + "interaction": "command", + "methods": [ + "delete" + ], + "views": [ + "frontend/src/views/admin/AdminProviderAccountsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/admin_routes_test.go#TestAdminRoutingGroupsAndProviderAccountsEndpoints" + ], + "negative": [ + "backend/internal/server/admin_routes_test.go#TestAdminProviderAccountDeleteProtectsModelRoutes", + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/controlplane/provider_account_runtime_settings_test.go#TestDeleteProviderAccountProtectsModelRoutes", + "backend/internal/server/admin_routes_test.go#TestAdminProviderAccountMissingResourceContracts" + ], + "browser": [ + "@e2e-model-supply-lifecycle-001" + ] + }, + "notes": "" + }, + { + "id": "control:deleteRoleBinding", + "owner": "identity/rbac", + "risk": "P0", + "interaction": "command", + "methods": [ + "delete" + ], + "views": [ + "frontend/src/views/admin/AdminUsersView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/identity_routes_test.go#TestAdminIdentityUserAndRoleBindingEndpoints" + ], + "negative": [ + "backend/internal/server/identity_routes_test.go#TestAdminIdentityUserAndRoleBindingEndpoints" + ], + "boundary": [ + "backend/internal/server/identity_routes_test.go#TestAdminIdentityUserAndRoleBindingEndpoints" + ], + "browser": [ + "@e2e-identity-001" + ] + }, + "notes": "" + }, + { + "id": "control:disableAPIKey", + "owner": "applications/credentials", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminApiKeysView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/api_key_policy_test.go#TestRevalidateCanonicalGatewayRequestUsesCurrentCredentialAndPolicy" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestDepartmentScopedAdministratorOnlySeesDepartmentUsersAndKeys" + ], + "boundary": [ + "backend/internal/controlplane/api_key_policy_test.go#TestRevalidateCanonicalGatewayRequestUsesCurrentCredentialAndPolicy", + "backend/internal/server/rbac_test.go#TestDepartmentScopedAdministratorOnlySeesDepartmentUsersAndKeys" + ], + "browser": [ + "@e2e-application-001" + ] + }, + "notes": "" + }, + { + "id": "control:disablePortalAPIKey", + "owner": "applications/credentials", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/portal/PortalHomeView.vue", + "frontend/src/views/portal/PortalKeysView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/portal_service_test.go#TestPortalKeyManagementRequiresKeyManagerRole", + "backend/internal/server/portal_routes_test.go#TestPortalWorkspaceAndAPIKeyRoutes" + ], + "negative": [ + "backend/internal/controlplane/portal_isolation_test.go#TestPortalWorkspaceIsolatesKeysUsageTracesAndMutationsByOwner", + "backend/internal/server/portal_routes_test.go#TestPortalForeignAPIKeyUsesNotFoundSemantics" + ], + "boundary": [ + "backend/internal/controlplane/portal_isolation_test.go#TestPortalWorkspaceIsolatesKeysUsageTracesAndMutationsByOwner", + "backend/internal/controlplane/portal_service_test.go#TestPortalKeyManagementRequiresKeyManagerRole" + ], + "browser": [ + "@e2e-portal-001" + ] + }, + "notes": "" + }, + { + "id": "control:disablePricingRule", + "owner": "billing/pricing", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/components/pricing/PricingRulesView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/pricing_rule_routes_test.go#TestPricingRuleHTTPVersionLifecycleAndFailureContracts" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/server/pricing_rule_routes_test.go#TestPricingRuleHTTPVersionLifecycleAndFailureContracts" + ], + "browser": [ + "@e2e-pricing-001" + ] + }, + "notes": "" + }, + { + "id": "control:discoverProviderAccountModels", + "owner": "gateway/supply", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/components/provider/ProviderAccountModelEditor.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/provider_account_model_service_test.go#TestProviderAccountModelSyncTracksDiffAndAffectedRoutes", + "backend/internal/server/admin_routes_test.go#TestAdminProviderAccountModelEndpoints" + ], + "negative": [ + "backend/internal/controlplane/provider_account_model_service_test.go#TestProviderModelDiscoveryRejectsRedirectsBeforeForwardingSecret", + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/controlplane/provider_account_model_service_test.go#TestCreateProviderAccountAllowsEmptyInventoryBeforeDiscovery", + "backend/internal/server/admin_routes_test.go#TestAdminProviderAccountModelEndpointsRejectMissingAccount" + ], + "browser": [ + "@e2e-model-account-001", + "@e2e-model-inventory-001" + ] + }, + "notes": "" + }, + { + "id": "control:downloadExportJob", + "owner": "exports", + "risk": "P1", + "interaction": "command", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminExportJobsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/export_routes_test.go#TestAdminAsyncExportJobLifecycle" + ], + "negative": [ + "backend/internal/server/export_routes_test.go#TestAdminAsyncExportJobLifecycle" + ], + "boundary": [ + "backend/internal/server/export_routes_test.go#TestAdminAsyncExportJobLifecycle" + ], + "browser": [ + "@e2e-operations-001" + ] + }, + "notes": "" + }, + { + "id": "control:evaluateEffectivePricingDecision", + "owner": "billing/pricing", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminEffectivePricingView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/effective_pricing_service_test.go#TestEffectivePricingDecisionKeepsGatewayAndUpstreamModelEvidenceSeparate" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/controlplane/effective_pricing_service_test.go#TestEffectivePricingDecisionKeepsGatewayAndUpstreamModelEvidenceSeparate", + "backend/internal/controlplane/effective_pricing_service_test.go#TestEvaluateEffectivePricingDecisionRequiresUpstreamModel" + ], + "browser": [ + "@e2e-effective-pricing-001" + ] + }, + "notes": "" + }, + { + "id": "control:exportAuditLogsCSV", + "owner": "exports", + "risk": "P1", + "interaction": "command", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminAuditView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/export_routes_test.go#TestAdminRecordExportEndpointsSupportQueryParameters" + ], + "negative": [ + "backend/internal/server/export_routes_test.go#TestAdminRecordExportEndpointsRequireAuthentication" + ], + "boundary": [ + "backend/internal/server/export_routes_test.go#TestAdminRecordExportEndpointsSupportQueryParameters" + ], + "browser": [ + "@e2e-record-csv-exports-001" + ] + }, + "notes": "" + }, + { + "id": "control:exportCostAllocationCSV", + "owner": "exports", + "risk": "P1", + "interaction": "command", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminCostAllocationView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/export_routes_test.go#TestAdminRecordExportEndpointsSupportQueryParameters" + ], + "negative": [ + "backend/internal/server/export_routes_test.go#TestAdminRecordExportEndpointsRequireAuthentication" + ], + "boundary": [ + "backend/internal/server/export_routes_test.go#TestAdminRecordExportEndpointsSupportQueryParameters" + ], + "browser": [ + "@e2e-record-csv-exports-001" + ] + }, + "notes": "" + }, + { + "id": "control:exportGatewayTracesCSV", + "owner": "exports", + "risk": "P1", + "interaction": "command", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminGatewayTracesView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/export_routes_test.go#TestAdminRecordExportEndpointsSupportQueryParameters" + ], + "negative": [ + "backend/internal/server/export_routes_test.go#TestAdminRecordExportEndpointsRequireAuthentication" + ], + "boundary": [ + "backend/internal/server/export_routes_test.go#TestAdminRecordExportEndpointsSupportQueryParameters" + ], + "browser": [ + "@e2e-record-csv-exports-001" + ] + }, + "notes": "" + }, + { + "id": "control:exportUsageCSV", + "owner": "exports", + "risk": "P1", + "interaction": "command", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminUsageView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/export_routes_test.go#TestAdminRecordExportEndpointsSupportQueryParameters" + ], + "negative": [ + "backend/internal/server/export_routes_test.go#TestAdminRecordExportEndpointsRequireAuthentication" + ], + "boundary": [ + "backend/internal/server/export_routes_test.go#TestAdminRecordExportEndpointsSupportQueryParameters" + ], + "browser": [ + "@e2e-record-csv-exports-001" + ] + }, + "notes": "" + }, + { + "id": "control:getAIJob", + "owner": "jobs", + "risk": "P1", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminAIJobsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/ai_job_admin_routes_test.go#TestAdminAIJobEndpointsProvideRuntimeDetailAndSafeActions" + ], + "negative": [], + "boundary": [], + "browser": [ + "@e2e-ai-job-reconciliation-001", + "@e2e-operations-001" + ] + }, + "notes": "" + }, + { + "id": "control:getAIJobRuntime", + "owner": "jobs", + "risk": "P1", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminAIJobsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/ai_job_admin_routes_test.go#TestAdminAIJobEndpointsProvideRuntimeDetailAndSafeActions" + ], + "negative": [], + "boundary": [], + "browser": [ + "@e2e-operations-001" + ] + }, + "notes": "" + }, + { + "id": "control:getAIJobs", + "owner": "jobs", + "risk": "P1", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminAIJobsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/ai_job_admin_routes_test.go#TestAdminAIJobEndpointsProvideRuntimeDetailAndSafeActions" + ], + "negative": [], + "boundary": [], + "browser": [ + "@e2e-ai-job-reconciliation-001", + "@e2e-operations-001" + ] + }, + "notes": "" + }, + { + "id": "control:getAIJobSummary", + "owner": "jobs", + "risk": "P1", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminAIJobsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/ai_job_artifact_admin_contract_test.go#TestAdminAIJobHTTPFailureAndIdempotencyContracts" + ], + "negative": [], + "boundary": [], + "browser": [ + "@e2e-operations-001" + ] + }, + "notes": "" + }, + { + "id": "control:getAlerts", + "owner": "alerts", + "risk": "P1", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminAlertsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/alert_routes_test.go#TestAdminAlertEndpoints" + ], + "negative": [], + "boundary": [], + "browser": [ + "@e2e-gateway-budget-001", + "@e2e-operations-001" + ] + }, + "notes": "" + }, + { + "id": "control:getAlertSummary", + "owner": "alerts", + "risk": "P1", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminAlertsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/alert_routes_test.go#TestAdminAlertEndpoints" + ], + "negative": [], + "boundary": [], + "browser": [ + "@e2e-gateway-budget-001", + "@e2e-operations-001" + ] + }, + "notes": "" + }, + { + "id": "control:getAPIKeyPolicyExplanation", + "owner": "applications/credentials", + "risk": "P0", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminApiKeysView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/admin_routes_test.go#TestAPIKeyPolicyExplanationEndpoint" + ], + "negative": [ + "backend/internal/server/admin_routes_test.go#TestAPIKeyPolicyExplanationEndpoint" + ], + "boundary": [ + "backend/internal/server/admin_routes_test.go#TestAPIKeyPolicyExplanationEndpoint" + ], + "browser": [ + "@e2e-application-001" + ] + }, + "notes": "" + }, + { + "id": "control:getAPIKeys", + "owner": "applications/credentials", + "risk": "P0", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminApiKeysView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/service_test.go#TestCreateAPIKeyReturnsSecretOnceAndStoresHash" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestDepartmentScopedAdministratorOnlySeesDepartmentUsersAndKeys" + ], + "boundary": [ + "backend/internal/controlplane/service_test.go#TestCreateAPIKeyReturnsSecretOnceAndStoresHash", + "backend/internal/server/rbac_test.go#TestDepartmentScopedAdministratorOnlySeesDepartmentUsersAndKeys" + ], + "browser": [ + "@e2e-application-001", + "@e2e-credential-boundary-001", + "@e2e-gateway-budget-001" + ] + }, + "notes": "" + }, + { + "id": "control:getApplications", + "owner": "applications/credentials", + "risk": "P2", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminOnboardingView.vue", + "frontend/src/views/admin/AdminUsersView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/application_routes_test.go#TestApplicationEndpointsPersistLifecycleAndRejectInvalidUpdates" + ], + "negative": [], + "boundary": [], + "browser": [ + "@e2e-application-001", + "@e2e-identity-001", + "@e2e-list-contract-001" + ] + }, + "notes": "" + }, + { + "id": "control:getArtifact", + "owner": "artifacts", + "risk": "P1", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminArtifactsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/artifact_admin_routes_test.go#TestAdminArtifactEndpointsReturnFilteredRedactedRecords" + ], + "negative": [], + "boundary": [], + "browser": [ + "@e2e-artifact-delivery-retry-001", + "@e2e-operations-001" + ] + }, + "notes": "" + }, + { + "id": "control:getArtifactContent", + "owner": "artifacts", + "risk": "P1", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminArtifactsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/artifact_admin_routes_test.go#TestAdminArtifactEndpointsReturnFilteredRedactedRecords" + ], + "negative": [], + "boundary": [], + "browser": [ + "@e2e-operations-001" + ] + }, + "notes": "" + }, + { + "id": "control:getArtifactRuntimes", + "owner": "artifacts", + "risk": "P1", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminArtifactsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/artifact_admin_routes_test.go#TestAdminArtifactEndpointsReturnFilteredRedactedRecords" + ], + "negative": [], + "boundary": [], + "browser": [ + "@e2e-artifact-delivery-retry-001", + "@e2e-operations-001" + ] + }, + "notes": "" + }, + { + "id": "control:getArtifacts", + "owner": "artifacts", + "risk": "P1", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminArtifactsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/artifact_admin_routes_test.go#TestAdminArtifactEndpointsReturnFilteredRedactedRecords" + ], + "negative": [], + "boundary": [], + "browser": [ + "@e2e-artifact-delivery-retry-001", + "@e2e-operations-001" + ] + }, + "notes": "" + }, + { + "id": "control:getArtifactSummary", + "owner": "artifacts", + "risk": "P1", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminArtifactsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/artifact_admin_routes_test.go#TestAdminArtifactEndpointsReturnFilteredRedactedRecords" + ], + "negative": [], + "boundary": [], + "browser": [ + "@e2e-operations-001" + ] + }, + "notes": "" + }, + { + "id": "control:getAuditLogs", + "owner": "operations/observability", + "risk": "P2", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminAuditView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/admin_routes_test.go#TestAdminRecordEndpointsSupportQueryParameters" + ], + "negative": [], + "boundary": [], + "browser": [ + "@e2e-ai-job-reconciliation-001", + "@e2e-application-001", + "@e2e-artifact-delivery-retry-001", + "@e2e-identity-001", + "@e2e-operations-001", + "@e2e-record-csv-exports-001", + "@e2e-system-diagnostic-001" + ] + }, + "notes": "" + }, + { + "id": "control:getAuditLogSummary", + "owner": "operations/observability", + "risk": "P2", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminAuditView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/admin_routes_test.go#TestAdminRecordEndpointsSupportQueryParameters" + ], + "negative": [], + "boundary": [], + "browser": [ + "@e2e-application-001", + "@e2e-identity-001", + "@e2e-operations-001", + "@e2e-record-csv-exports-001" + ] + }, + "notes": "" + }, + { + "id": "control:getCapacityRecommendations", + "owner": "operations/observability", + "risk": "P2", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminSupplyView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/supply_routes_test.go#TestSupplyRoutesRequireAuthenticationAndValidateWindow" + ], + "negative": [], + "boundary": [], + "browser": [ + "@e2e-operations-001" + ] + }, + "notes": "" + }, + { + "id": "control:getCostAllocationReport", + "owner": "controlplane", + "risk": "P2", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminCostAllocationView.vue", + "frontend/src/views/admin/AdminUsageView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/admin_routes_test.go#TestAdminRecordEndpointsSupportQueryParameters" + ], + "negative": [], + "boundary": [], + "browser": [ + "@e2e-gateway-001", + "@e2e-gateway-protocols-001", + "@e2e-operations-001", + "@e2e-record-csv-exports-001" + ] + }, + "notes": "" + }, + { + "id": "control:getDashboard", + "owner": "controlplane", + "risk": "P2", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminDashboardView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/admin_routes_test.go#TestAdminDashboardEndpoint" + ], + "negative": [], + "boundary": [], + "browser": [ + "@e2e-a11y-console-001", + "@e2e-a11y-session-001", + "@e2e-login-001", + "@e2e-preferences-001" + ] + }, + "notes": "" + }, + { + "id": "control:getDepartments", + "owner": "identity/rbac", + "risk": "P1", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminDepartmentsView.vue", + "frontend/src/views/admin/AdminUsersView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/department_routes_test.go#TestAdminDepartmentEndpoints" + ], + "negative": [], + "boundary": [], + "browser": [ + "@e2e-identity-001" + ] + }, + "notes": "" + }, + { + "id": "control:getEffectivePricingDecisionEvaluations", + "owner": "billing/pricing", + "risk": "P0", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminEffectivePricingView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/effective_pricing_monitor_test.go#TestEffectivePricingDecisionMonitorPromotesAfterConsecutiveHealthyWindows", + "backend/internal/server/effective_pricing_routes_test.go#TestEffectivePricingDecisionEvaluationsEndpointReturnsEmptyHistory" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/controlplane/effective_pricing_monitor_test.go#TestEffectivePricingDecisionMonitorPromotesAfterConsecutiveHealthyWindows", + "backend/internal/server/effective_pricing_routes_test.go#TestEffectivePricingDecisionEvaluationsEndpointReturnsEmptyHistory" + ], + "browser": [ + "@e2e-effective-pricing-001" + ] + }, + "notes": "" + }, + { + "id": "control:getEffectivePricingDecisions", + "owner": "billing/pricing", + "risk": "P0", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminEffectivePricingView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/effective_pricing_service_test.go#TestEffectivePricingDecisionKeepsGatewayAndUpstreamModelEvidenceSeparate" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/controlplane/effective_pricing_service_test.go#TestEffectivePricingDecisionKeepsGatewayAndUpstreamModelEvidenceSeparate" + ], + "browser": [ + "@e2e-effective-pricing-001" + ] + }, + "notes": "" + }, + { + "id": "control:getEffectivePricingReport", + "owner": "billing/pricing", + "risk": "P0", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminEffectivePricingView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/effective_pricing_service_test.go#TestEffectivePricingDecisionKeepsGatewayAndUpstreamModelEvidenceSeparate", + "backend/internal/controlplane/effective_pricing_service_test.go#TestEffectivePricingReportRanksRealCostInsteadOfQuotedMultiplier", + "backend/internal/server/effective_pricing_routes_test.go#TestEffectivePricingAdminEndpointsCreatePriceAndReconcileBilling" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/controlplane/effective_pricing_service_test.go#TestEffectivePricingDecisionKeepsGatewayAndUpstreamModelEvidenceSeparate", + "backend/internal/controlplane/effective_pricing_service_test.go#TestEffectivePricingReportDistinguishesZeroCostFromMissingEvidence", + "backend/internal/server/effective_pricing_routes_test.go#TestEffectivePricingAdminEndpointsCreatePriceAndReconcileBilling" + ], + "browser": [ + "@e2e-effective-pricing-001" + ] + }, + "notes": "" + }, + { + "id": "control:getExportJobs", + "owner": "exports", + "risk": "P1", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminExportJobsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/export_routes_test.go#TestAdminAsyncExportJobLifecycle" + ], + "negative": [], + "boundary": [], + "browser": [ + "@e2e-operations-001" + ] + }, + "notes": "" + }, + { + "id": "control:getGatewayModels", + "owner": "gateway/supply", + "risk": "P0", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminApiKeysView.vue", + "frontend/src/views/admin/AdminEffectivePricingView.vue", + "frontend/src/views/admin/AdminGatewayModelsView.vue", + "frontend/src/views/admin/AdminGatewaySimulatorView.vue", + "frontend/src/views/admin/AdminModelRoutesView.vue", + "frontend/src/views/admin/AdminPoliciesView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/admin_routes_test.go#TestAdminGatewayModelAndRouteEndpoints" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/server/admin_routes_test.go#TestAdminGatewayModelAndRouteEndpoints", + "backend/internal/server/admin_routes_test.go#TestAdminSupplyCollectionsExposeEmptyArraysAndFirstModelDefaults" + ], + "browser": [ + "@e2e-application-001", + "@e2e-credential-boundary-001", + "@e2e-effective-pricing-001", + "@e2e-gateway-budget-001", + "@e2e-gateway-failover-001", + "@e2e-list-contract-001", + "@e2e-model-inventory-001", + "@e2e-model-supply-lifecycle-001", + "@e2e-routing-policy-001", + "@e2e-routing-resources-001" + ] + }, + "notes": "" + }, + { + "id": "control:getGatewayTraces", + "owner": "operations/observability", + "risk": "P2", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminApiKeysView.vue", + "frontend/src/views/admin/AdminGatewayTracesView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/admin_routes_test.go#TestAdminRecordEndpointsSupportQueryParameters" + ], + "negative": [], + "boundary": [], + "browser": [ + "@e2e-application-001", + "@e2e-credential-boundary-001", + "@e2e-gateway-001", + "@e2e-gateway-budget-001", + "@e2e-gateway-failover-001", + "@e2e-gateway-protocols-001", + "@e2e-operations-001", + "@e2e-provider-cooldown-001", + "@e2e-record-csv-exports-001" + ] + }, + "notes": "" + }, + { + "id": "control:getGatewayTraceSummary", + "owner": "operations/observability", + "risk": "P2", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminGatewayTracesView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/admin_routes_test.go#TestAdminRecordEndpointsSupportQueryParameters" + ], + "negative": [], + "boundary": [], + "browser": [ + "@e2e-gateway-001", + "@e2e-gateway-failover-001", + "@e2e-gateway-protocols-001", + "@e2e-operations-001", + "@e2e-record-csv-exports-001" + ] + }, + "notes": "" + }, + { + "id": "control:getGovernancePolicies", + "owner": "controlplane", + "risk": "P2", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminApiKeysView.vue", + "frontend/src/views/admin/AdminPoliciesView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/admin_routes_test.go#TestAdminGovernancePolicyEndpoints" + ], + "negative": [], + "boundary": [], + "browser": [ + "@e2e-application-001", + "@e2e-credential-boundary-001", + "@e2e-gateway-budget-001", + "@e2e-list-contract-001" + ] + }, + "notes": "" + }, + { + "id": "control:getModelRoutes", + "owner": "gateway/supply", + "risk": "P0", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminModelRoutesView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/admin_routes_test.go#TestAdminGatewayModelAndRouteEndpoints" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/server/admin_routes_test.go#TestAdminGatewayModelAndRouteEndpoints" + ], + "browser": [ + "@e2e-gateway-failover-001", + "@e2e-model-inventory-001", + "@e2e-model-supply-lifecycle-001" + ] + }, + "notes": "" + }, + { + "id": "control:getOrganizationGroups", + "owner": "identity/rbac", + "risk": "P1", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminOrganizationGroupsView.vue", + "frontend/src/views/admin/AdminUsersView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/identity_routes_test.go#TestAdminOrganizationGroupLifecycle" + ], + "negative": [ + "backend/internal/server/identity_routes_test.go#TestAdminIdentityCollectionsRequirePermission" + ], + "boundary": [], + "browser": [ + "@e2e-identity-001" + ] + }, + "notes": "" + }, + { + "id": "control:getPortalWorkspace", + "owner": "controlplane", + "risk": "P2", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/components/account/AccountAccessConfig.vue", + "frontend/src/views/portal/PortalHomeView.vue", + "frontend/src/views/portal/PortalKeysView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/portal_routes_test.go#TestPortalWorkspaceAndAPIKeyRoutes" + ], + "negative": [], + "boundary": [], + "browser": [ + "@e2e-portal-001" + ] + }, + "notes": "" + }, + { + "id": "control:getPricingEvaluation", + "owner": "billing/pricing", + "risk": "P0", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/components/pricing/PricingRulesView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/pricing_rule_routes_test.go#TestPricingRuleHTTPVersionLifecycleAndFailureContracts" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/server/pricing_rule_routes_test.go#TestPricingRuleHTTPVersionLifecycleAndFailureContracts" + ], + "browser": [ + "@e2e-pricing-001" + ] + }, + "notes": "" + }, + { + "id": "control:getPricingRule", + "owner": "billing/pricing", + "risk": "P0", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/components/pricing/PricingRulesView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/pricing_rule_routes_test.go#TestPricingRuleHTTPVersionLifecycleAndFailureContracts" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/server/pricing_rule_routes_test.go#TestPricingRuleHTTPVersionLifecycleAndFailureContracts" + ], + "browser": [ + "@e2e-pricing-001" + ] + }, + "notes": "" + }, + { + "id": "control:getPricingRules", + "owner": "billing/pricing", + "risk": "P0", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/components/pricing/PricingRulesView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/pricing_rule_routes_test.go#TestPricingRuleHTTPVersionLifecycleAndFailureContracts" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/server/pricing_rule_routes_test.go#TestPricingRuleHTTPVersionLifecycleAndFailureContracts" + ], + "browser": [ + "@e2e-pricing-001" + ] + }, + "notes": "" + }, + { + "id": "control:getProcurementPrices", + "owner": "billing/pricing", + "risk": "P2", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminRoutingPolicyView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/effective_pricing_routes_test.go#TestEffectivePricingAdminEndpointsCreatePriceAndReconcileBilling" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [], + "browser": [ + "@e2e-effective-pricing-001", + "@e2e-routing-policy-001" + ] + }, + "notes": "" + }, + { + "id": "control:getProviderAccountHealthChecks", + "owner": "gateway/supply", + "risk": "P0", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminProviderAccountsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/service_test.go#TestCheckProviderAccountProbesModelsAndPersistsHealth", + "backend/internal/server/admin_routes_test.go#TestAdminRoutingGroupsAndProviderAccountsEndpoints" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/controlplane/service_test.go#TestCheckProviderAccountProbesModelsAndPersistsHealth" + ], + "browser": [ + "@e2e-model-account-001", + "@e2e-model-inventory-001", + "@e2e-model-supply-lifecycle-001" + ] + }, + "notes": "" + }, + { + "id": "control:getProviderAccountModelInventory", + "owner": "gateway/supply", + "risk": "P0", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/components/provider/ProviderAccountModelEditor.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/service_test.go#TestCheckProviderAccountProbesModelsAndPersistsHealth", + "backend/internal/server/admin_routes_test.go#TestAdminProviderAccountModelEndpoints" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/controlplane/provider_account_model_service_test.go#TestCreateProviderAccountAllowsEmptyInventoryBeforeDiscovery", + "backend/internal/controlplane/service_test.go#TestCheckProviderAccountProbesModelsAndPersistsHealth", + "backend/internal/server/admin_routes_test.go#TestAdminProviderAccountModelEndpointsRejectMissingAccount" + ], + "browser": [ + "@e2e-model-inventory-001" + ] + }, + "notes": "" + }, + { + "id": "control:getProviderAccounts", + "owner": "gateway/supply", + "risk": "P0", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminEffectivePricingView.vue", + "frontend/src/views/admin/AdminModelRoutesView.vue", + "frontend/src/views/admin/AdminProviderAccountsView.vue", + "frontend/src/views/admin/AdminRoutingPolicyView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/service_test.go#TestCheckProviderAccountProbesModelsAndPersistsHealth", + "backend/internal/controlplane/service_test.go#TestProviderAccountLifecyclePreservesEncryptedSecretAndUpdatesGroupCounts", + "backend/internal/server/admin_routes_test.go#TestAdminRoutingGroupsAndProviderAccountsEndpoints" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/controlplane/service_test.go#TestCheckProviderAccountProbesModelsAndPersistsHealth", + "backend/internal/controlplane/service_test.go#TestProviderAccountLifecyclePreservesEncryptedSecretAndUpdatesGroupCounts" + ], + "browser": [ + "@e2e-effective-pricing-001", + "@e2e-effective-pricing-002", + "@e2e-gateway-failover-001", + "@e2e-model-account-001", + "@e2e-model-inventory-001", + "@e2e-model-supply-lifecycle-001", + "@e2e-provider-cooldown-001", + "@e2e-routing-policy-001" + ] + }, + "notes": "" + }, + { + "id": "control:getProviderBillingSourceEvidence", + "owner": "gateway/supply", + "risk": "P0", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminEffectivePricingView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/provider_billing_sync_service_test.go#TestProviderBillingSourceServicePersistsAggregateEvidenceWithoutBalance", + "backend/internal/server/effective_pricing_routes_test.go#TestProviderBillingSourceInspectionEndpointDetectsSub2APIWithoutInventingLines" + ], + "negative": [ + "backend/internal/controlplane/provider_billing_sync_service_test.go#TestProviderBillingSourceServicePersistsStableFailureCodeWithoutUpstreamBody", + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/controlplane/provider_billing_sync_service_test.go#TestProviderBillingSourceServicePersistsStableFailureCodeWithoutUpstreamBody", + "backend/internal/server/effective_pricing_routes_test.go#TestProviderBillingSourceInspectionEndpointDetectsSub2APIWithoutInventingLines" + ], + "browser": [ + "@e2e-effective-pricing-002" + ] + }, + "notes": "" + }, + { + "id": "control:getProviderBillingSources", + "owner": "gateway/supply", + "risk": "P0", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminEffectivePricingView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/effective_pricing_routes_test.go#TestProviderBillingSourceInspectionEndpointDetectsSub2APIWithoutInventingLines" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/server/admin_routes_test.go#TestAdminSupplyCollectionsExposeEmptyArraysAndFirstModelDefaults" + ], + "browser": [ + "@e2e-effective-pricing-001", + "@e2e-effective-pricing-002" + ] + }, + "notes": "" + }, + { + "id": "control:getProviderCacheCapabilities", + "owner": "gateway/supply", + "risk": "P0", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminEffectivePricingView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/effective_pricing_routes_test.go#TestEffectivePricingAdminEndpointsCreatePriceAndReconcileBilling" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/server/admin_routes_test.go#TestAdminSupplyCollectionsExposeEmptyArraysAndFirstModelDefaults" + ], + "browser": [ + "@e2e-effective-pricing-001" + ] + }, + "notes": "" + }, + { + "id": "control:getProviderCacheProbeRuns", + "owner": "gateway/supply", + "risk": "P0", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminEffectivePricingView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/effective_pricing_routes_test.go#TestProviderCacheProbeEndpointRunsControlledSequenceAndRejectsMissingConfirmation" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/server/effective_pricing_routes_test.go#TestProviderCacheProbeEndpointRunsControlledSequenceAndRejectsMissingConfirmation" + ], + "browser": [ + "@e2e-effective-pricing-001" + ] + }, + "notes": "" + }, + { + "id": "control:getProviderHealthChecks", + "owner": "gateway/supply", + "risk": "P0", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminProvidersView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/service_test.go#TestProviderConnectionOwnsEndpointStateOnly", + "backend/internal/server/admin_routes_test.go#TestCheckProviderEndpoint" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/controlplane/service_test.go#TestProviderConnectionOwnsEndpointStateOnly" + ], + "browser": [ + "@e2e-model-inventory-001", + "@e2e-model-supply-lifecycle-001" + ] + }, + "notes": "" + }, + { + "id": "control:getProviders", + "owner": "gateway/supply", + "risk": "P0", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminProviderAccountsView.vue", + "frontend/src/views/admin/AdminProvidersView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/admin_routes_test.go#TestProviderEndpointRejectsLegacyCredentialAndModelFields" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/server/admin_routes_test.go#TestAdminSupplyCollectionsExposeEmptyArraysAndFirstModelDefaults" + ], + "browser": [ + "@e2e-model-account-001", + "@e2e-model-inventory-001", + "@e2e-model-supply-lifecycle-001" + ] + }, + "notes": "" + }, + { + "id": "control:getRoleBindings", + "owner": "identity/rbac", + "risk": "P0", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminUsersView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/identity_routes_test.go#TestAdminIdentityUserAndRoleBindingEndpoints" + ], + "negative": [ + "backend/internal/server/identity_routes_test.go#TestAdminIdentityCollectionsRequirePermission", + "backend/internal/server/identity_routes_test.go#TestAdminIdentityUserAndRoleBindingEndpoints" + ], + "boundary": [ + "backend/internal/server/identity_routes_test.go#TestAdminIdentityUserAndRoleBindingEndpoints" + ], + "browser": [ + "@e2e-identity-001" + ] + }, + "notes": "" + }, + { + "id": "control:getRoutingGroups", + "owner": "gateway/routing", + "risk": "P1", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminProviderAccountsView.vue", + "frontend/src/views/admin/AdminRoutingGroupsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/admin_routes_test.go#TestAdminRoutingGroupsAndProviderAccountsEndpoints" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [], + "browser": [ + "@e2e-routing-resources-001" + ] + }, + "notes": "" + }, + { + "id": "control:getRoutingPolicies", + "owner": "gateway/routing", + "risk": "P1", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminRoutingPolicyView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/admin_routes_test.go#TestAdminRoutingPolicyEndpoints" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [], + "browser": [ + "@e2e-routing-policy-001" + ] + }, + "notes": "" + }, + { + "id": "control:getSupplyUtilization", + "owner": "operations/observability", + "risk": "P2", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminSupplyView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/supply_routes_test.go#TestSupplyRoutesRequireAuthenticationAndValidateWindow" + ], + "negative": [], + "boundary": [], + "browser": [ + "@e2e-operations-001" + ] + }, + "notes": "" + }, + { + "id": "control:getUsageReport", + "owner": "operations/observability", + "risk": "P2", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminApiKeysView.vue", + "frontend/src/views/admin/AdminUsageView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/admin_routes_test.go#TestAdminRecordEndpointsSupportQueryParameters" + ], + "negative": [], + "boundary": [], + "browser": [ + "@e2e-gateway-001", + "@e2e-gateway-protocols-001", + "@e2e-operations-001", + "@e2e-record-csv-exports-001" + ] + }, + "notes": "" + }, + { + "id": "control:getWorkspaceUsers", + "owner": "identity/rbac", + "risk": "P2", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminApiKeysView.vue", + "frontend/src/views/admin/AdminOrganizationGroupsView.vue", + "frontend/src/views/admin/AdminUsersView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/identity_routes_test.go#TestAdminIdentityUserAndRoleBindingEndpoints" + ], + "negative": [], + "boundary": [], + "browser": [ + "@e2e-identity-001", + "@e2e-session-002" + ] + }, + "notes": "" + }, + { + "id": "control:inspectProviderBillingSource", + "owner": "gateway/supply", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminEffectivePricingView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/provider_billing_adapter_test.go#TestInspectProviderBillingSourceUsesProcurementAccountSecretAndAudits", + "backend/internal/server/effective_pricing_routes_test.go#TestProviderBillingSourceInspectionEndpointDetectsSub2APIWithoutInventingLines" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/controlplane/provider_billing_adapter_test.go#TestInspectProviderBillingSourceUsesProcurementAccountSecretAndAudits", + "backend/internal/server/effective_pricing_routes_test.go#TestProviderBillingSourceInspectionEndpointDetectsSub2APIWithoutInventingLines" + ], + "browser": [ + "@e2e-effective-pricing-002" + ] + }, + "notes": "" + }, + { + "id": "control:publishPricingRule", + "owner": "billing/pricing", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/components/pricing/PricingRulesView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/pricing_service_test.go#TestPricingRuleLifecycleValidationAndCAS", + "backend/internal/server/pricing_rule_routes_test.go#TestPricingRuleHTTPVersionLifecycleAndFailureContracts" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/controlplane/pricing_service_test.go#TestPricingRuleLifecycleValidationAndCAS", + "backend/internal/server/pricing_rule_routes_test.go#TestPricingRuleHTTPVersionLifecycleAndFailureContracts" + ], + "browser": [ + "@e2e-pricing-001" + ] + }, + "notes": "" + }, + { + "id": "control:resolveAlert", + "owner": "alerts", + "risk": "P1", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminAlertsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/alert_routes_test.go#TestAdminAlertEndpoints" + ], + "negative": [ + "backend/internal/server/alert_routes_test.go#TestAdminAlertEndpoints" + ], + "boundary": [ + "backend/internal/server/alert_routes_test.go#TestAdminAlertEndpoints" + ], + "browser": [ + "@e2e-operations-001" + ] + }, + "notes": "" + }, + { + "id": "control:retryArtifactDelivery", + "owner": "artifacts", + "risk": "P1", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminArtifactsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/artifact_admin_test.go#TestRetryArtifactDeliverySchedulesReconciliationAndAudits" + ], + "negative": [ + "backend/internal/server/ai_job_artifact_admin_contract_test.go#TestAdminArtifactHTTPFailureAndRangeContracts" + ], + "boundary": [ + "backend/internal/controlplane/artifact_admin_test.go#TestRetryArtifactDeliverySchedulesReconciliationAndAudits", + "backend/internal/server/ai_job_artifact_admin_contract_test.go#TestAdminArtifactHTTPFailureAndRangeContracts" + ], + "browser": [ + "@e2e-artifact-delivery-retry-001" + ] + }, + "notes": "" + }, + { + "id": "control:rotateAPIKey", + "owner": "applications/credentials", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminApiKeysView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/api_key_rotation_routes_test.go#TestAdminAPIKeyRotationAcceptsGracePeriod" + ], + "negative": [ + "backend/internal/server/api_key_rotation_routes_test.go#TestAdminAPIKeyRotationRejectsInvalidGracePeriodWithoutMutation", + "backend/internal/server/rbac_test.go#TestDepartmentScopedAdministratorOnlySeesDepartmentUsersAndKeys" + ], + "boundary": [ + "backend/internal/controlplane/api_key_rotation_test.go#TestAPIKeyRotationCreatesOneReplacementUnderConcurrency", + "backend/internal/server/api_key_rotation_routes_test.go#TestAdminAPIKeyRotationRejectsInvalidGracePeriodWithoutMutation", + "backend/internal/server/rbac_test.go#TestDepartmentScopedAdministratorOnlySeesDepartmentUsersAndKeys" + ], + "browser": [ + "@e2e-application-001" + ] + }, + "notes": "" + }, + { + "id": "control:rotatePortalAPIKey", + "owner": "applications/credentials", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/components/account/AccountAccessConfig.vue", + "frontend/src/views/portal/PortalHomeView.vue", + "frontend/src/views/portal/PortalKeysView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/portal_service_test.go#TestPortalKeyManagementRequiresKeyManagerRole", + "backend/internal/server/portal_routes_test.go#TestPortalWorkspaceAndAPIKeyRoutes" + ], + "negative": [ + "backend/internal/controlplane/portal_isolation_test.go#TestPortalWorkspaceIsolatesKeysUsageTracesAndMutationsByOwner", + "backend/internal/server/portal_routes_test.go#TestPortalForeignAPIKeyUsesNotFoundSemantics" + ], + "boundary": [ + "backend/internal/controlplane/portal_isolation_test.go#TestPortalWorkspaceIsolatesKeysUsageTracesAndMutationsByOwner", + "backend/internal/controlplane/portal_service_test.go#TestPortalKeyManagementRequiresKeyManagerRole" + ], + "browser": [ + "@e2e-portal-001" + ] + }, + "notes": "" + }, + { + "id": "control:runProviderCacheProbe", + "owner": "gateway/supply", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminEffectivePricingView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/effective_pricing_probe_test.go#TestRunProviderCacheProbeObservesReuseAndCapturesEvidence", + "backend/internal/server/effective_pricing_routes_test.go#TestProviderCacheProbeEndpointRunsControlledSequenceAndRejectsMissingConfirmation" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/controlplane/effective_pricing_probe_test.go#TestRunProviderCacheProbeSkipsWithoutSpendingWhenDisabledOrBudgetExhausted", + "backend/internal/server/effective_pricing_routes_test.go#TestProviderCacheProbeEndpointRunsControlledSequenceAndRejectsMissingConfirmation" + ], + "browser": [ + "@e2e-effective-pricing-001" + ] + }, + "notes": "" + }, + { + "id": "control:scheduleAIJobAttemptReconciliation", + "owner": "jobs", + "risk": "P1", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminAIJobsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/ai_job_admin_test.go#TestAIJobAdminQueriesRedactSecretsAndSupportsSafeActions" + ], + "negative": [ + "backend/internal/server/ai_job_artifact_admin_contract_test.go#TestAdminAIJobHTTPFailureAndIdempotencyContracts" + ], + "boundary": [ + "backend/internal/controlplane/ai_job_admin_test.go#TestAIJobAdminQueriesRedactSecretsAndSupportsSafeActions", + "backend/internal/server/ai_job_artifact_admin_contract_test.go#TestAdminAIJobHTTPFailureAndIdempotencyContracts" + ], + "browser": [ + "@e2e-ai-job-reconciliation-001" + ] + }, + "notes": "" + }, + { + "id": "control:simulateGatewayRouting", + "owner": "gateway/routing", + "risk": "P1", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminGatewaySimulatorView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/admin_routes_test.go#TestAdminGatewaySimulatorContracts" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/server/admin_routes_test.go#TestAdminGatewaySimulatorContracts" + ], + "browser": [ + "@e2e-routing-policy-001", + "@e2e-routing-resources-001" + ] + }, + "notes": "" + }, + { + "id": "control:simulatePricingRule", + "owner": "billing/pricing", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/components/pricing/PricingRulesView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/pricing_rule_routes_test.go#TestPricingRuleHTTPVersionLifecycleAndFailureContracts" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/server/pricing_rule_routes_test.go#TestPricingRuleHTTPVersionLifecycleAndFailureContracts" + ], + "browser": [ + "@e2e-pricing-001" + ] + }, + "notes": "" + }, + { + "id": "control:syncProviderAccountModels", + "owner": "gateway/supply", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/components/provider/ProviderAccountModelEditor.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/provider_account_model_service_test.go#TestProviderAccountModelSyncTracksDiffAndAffectedRoutes", + "backend/internal/server/admin_routes_test.go#TestAdminProviderAccountModelEndpoints" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/controlplane/provider_account_model_service_test.go#TestProviderAccountModelSyncAllowsDisablingEveryModel", + "backend/internal/server/admin_routes_test.go#TestAdminProviderAccountModelEndpointsRejectMissingAccount" + ], + "browser": [ + "@e2e-model-account-001" + ] + }, + "notes": "" + }, + { + "id": "control:syncProviderBillingSource", + "owner": "gateway/supply", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminEffectivePricingView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/provider_billing_sync_service_test.go#TestProviderBillingSourceServicePersistsAggregateEvidenceWithoutBalance", + "backend/internal/server/effective_pricing_routes_test.go#TestProviderBillingSourceInspectionEndpointDetectsSub2APIWithoutInventingLines" + ], + "negative": [ + "backend/internal/controlplane/provider_billing_sync_service_test.go#TestProviderBillingSourceServiceCASAndDisabledManualSync", + "backend/internal/controlplane/provider_billing_sync_service_test.go#TestProviderBillingSourceServicePersistsStableFailureCodeWithoutUpstreamBody", + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/controlplane/provider_billing_sync_service_test.go#TestProviderBillingSourceServiceCASAndDisabledManualSync", + "backend/internal/controlplane/provider_billing_sync_service_test.go#TestProviderBillingSourceServicePersistsStableFailureCodeWithoutUpstreamBody" + ], + "browser": [ + "@e2e-effective-pricing-002" + ] + }, + "notes": "" + }, + { + "id": "control:updateAPIKey", + "owner": "applications/credentials", + "risk": "P0", + "interaction": "command", + "methods": [ + "put" + ], + "views": [ + "frontend/src/views/admin/AdminApiKeysView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/api_key_policy_test.go#TestCreateAPIKeyNormalizesPrincipalAndExtendedPolicy", + "backend/internal/controlplane/api_key_policy_test.go#TestRevalidateCanonicalGatewayRequestUsesCurrentCredentialAndPolicy" + ], + "negative": [ + "backend/internal/controlplane/application_service_test.go#TestPlatformCredentialRequiresApplicationPrincipalAndSnapshotsGatewayEvidence" + ], + "boundary": [ + "backend/internal/controlplane/api_key_policy_test.go#TestCreateAPIKeyNormalizesPrincipalAndExtendedPolicy", + "backend/internal/controlplane/api_key_policy_test.go#TestRevalidateCanonicalGatewayRequestUsesCurrentCredentialAndPolicy" + ], + "browser": [ + "@e2e-application-001" + ] + }, + "notes": "" + }, + { + "id": "control:updateApplication", + "owner": "applications/credentials", + "risk": "P0", + "interaction": "command", + "methods": [ + "put" + ], + "views": [ + "frontend/src/views/admin/AdminOnboardingView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/application_routes_test.go#TestApplicationEndpointsPersistLifecycleAndRejectInvalidUpdates" + ], + "negative": [ + "backend/internal/server/application_routes_test.go#TestApplicationEndpointsPersistLifecycleAndRejectInvalidUpdates" + ], + "boundary": [ + "backend/internal/server/application_routes_test.go#TestApplicationEndpointsPersistLifecycleAndRejectInvalidUpdates" + ], + "browser": [ + "@e2e-application-001" + ] + }, + "notes": "" + }, + { + "id": "control:updateDepartment", + "owner": "identity/rbac", + "risk": "P0", + "interaction": "command", + "methods": [ + "put" + ], + "views": [ + "frontend/src/views/admin/AdminDepartmentsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/department_routes_test.go#TestAdminDepartmentEndpoints" + ], + "negative": [ + "backend/internal/server/department_routes_test.go#TestAdminDepartmentEndpoints" + ], + "boundary": [ + "backend/internal/server/department_routes_test.go#TestAdminDepartmentEndpoints" + ], + "browser": [ + "@e2e-identity-001" + ] + }, + "notes": "" + }, + { + "id": "control:updateEffectivePricingPolicy", + "owner": "billing/pricing", + "risk": "P0", + "interaction": "command", + "methods": [ + "put" + ], + "views": [ + "frontend/src/views/admin/AdminEffectivePricingView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/effective_pricing_routes_test.go#TestEffectivePricingPolicyEndpointRejectsUnsafeValues" + ], + "negative": [ + "backend/internal/server/effective_pricing_routes_test.go#TestEffectivePricingPolicyEndpointRejectsUnsafeValues", + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/server/effective_pricing_routes_test.go#TestEffectivePricingPolicyEndpointRejectsUnsafeValues" + ], + "browser": [ + "@e2e-effective-pricing-001" + ] + }, + "notes": "" + }, + { + "id": "control:updateGatewayModel", + "owner": "gateway/supply", + "risk": "P0", + "interaction": "command", + "methods": [ + "put" + ], + "views": [ + "frontend/src/views/admin/AdminGatewayModelsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/admin_routes_test.go#TestAdminGatewayModelAndRouteEndpoints" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/server/admin_routes_test.go#TestAdminGatewayModelAndRouteMissingResourceContracts" + ], + "browser": [ + "@e2e-model-supply-lifecycle-001" + ] + }, + "notes": "" + }, + { + "id": "control:updateGovernancePolicy", + "owner": "controlplane", + "risk": "P0", + "interaction": "command", + "methods": [ + "put" + ], + "views": [ + "frontend/src/views/admin/AdminPoliciesView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/admin_routes_test.go#TestAdminGovernancePolicyEndpoints" + ], + "negative": [ + "backend/internal/server/admin_routes_test.go#TestAdminGovernancePolicyEndpoints" + ], + "boundary": [ + "backend/internal/server/admin_routes_test.go#TestAdminGovernancePolicyEndpoints" + ], + "browser": [ + "@e2e-application-001" + ] + }, + "notes": "" + }, + { + "id": "control:updateModelRoute", + "owner": "gateway/supply", + "risk": "P0", + "interaction": "command", + "methods": [ + "put" + ], + "views": [ + "frontend/src/views/admin/AdminModelRoutesView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/admin_routes_test.go#TestAdminGatewayModelAndRouteEndpoints" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/server/admin_routes_test.go#TestAdminGatewayModelAndRouteMissingResourceContracts" + ], + "browser": [ + "@e2e-model-supply-lifecycle-001" + ] + }, + "notes": "" + }, + { + "id": "control:updateOrganizationGroup", + "owner": "identity/rbac", + "risk": "P0", + "interaction": "command", + "methods": [ + "put" + ], + "views": [ + "frontend/src/views/admin/AdminOrganizationGroupsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/identity_routes_test.go#TestAdminOrganizationGroupLifecycle" + ], + "negative": [ + "backend/internal/server/identity_routes_test.go#TestAdminOrganizationGroupLifecycle" + ], + "boundary": [ + "backend/internal/server/identity_routes_test.go#TestAdminOrganizationGroupLifecycle" + ], + "browser": [ + "@e2e-identity-001" + ] + }, + "notes": "" + }, + { + "id": "control:updatePricingRuleDraft", + "owner": "billing/pricing", + "risk": "P0", + "interaction": "command", + "methods": [ + "put" + ], + "views": [ + "frontend/src/components/pricing/PricingRulesView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/pricing_service_test.go#TestPricingRuleLifecycleValidationAndCAS", + "backend/internal/server/pricing_rule_routes_test.go#TestPricingRuleHTTPVersionLifecycleAndFailureContracts" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/controlplane/pricing_service_test.go#TestPricingRuleLifecycleValidationAndCAS", + "backend/internal/server/pricing_rule_routes_test.go#TestPricingRuleHTTPVersionLifecycleAndFailureContracts" + ], + "browser": [ + "@e2e-pricing-001" + ] + }, + "notes": "" + }, + { + "id": "control:updateProvider", + "owner": "gateway/supply", + "risk": "P0", + "interaction": "command", + "methods": [ + "put" + ], + "views": [ + "frontend/src/views/admin/AdminProvidersView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/admin_routes_test.go#TestProviderEndpointRejectsLegacyCredentialAndModelFields" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/server/admin_routes_test.go#TestProviderEndpointRejectsLegacyCredentialAndModelFields" + ], + "browser": [ + "@e2e-model-supply-lifecycle-001" + ] + }, + "notes": "" + }, + { + "id": "control:updateProviderAccount", + "owner": "gateway/supply", + "risk": "P0", + "interaction": "command", + "methods": [ + "put" + ], + "views": [ + "frontend/src/views/admin/AdminProviderAccountsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/service_test.go#TestProviderAccountLifecyclePreservesEncryptedSecretAndUpdatesGroupCounts", + "backend/internal/server/admin_routes_test.go#TestAdminRoutingGroupsAndProviderAccountsEndpoints" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/controlplane/service_test.go#TestProviderAccountLifecyclePreservesEncryptedSecretAndUpdatesGroupCounts", + "backend/internal/server/admin_routes_test.go#TestAdminProviderAccountMissingResourceContracts" + ], + "browser": [ + "@e2e-model-supply-lifecycle-001" + ] + }, + "notes": "" + }, + { + "id": "control:updateProviderBillingSource", + "owner": "gateway/supply", + "risk": "P0", + "interaction": "command", + "methods": [ + "put" + ], + "views": [ + "frontend/src/views/admin/AdminEffectivePricingView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/provider_billing_sync_service_test.go#TestProviderBillingSourceServicePersistsAggregateEvidenceWithoutBalance", + "backend/internal/server/effective_pricing_routes_test.go#TestProviderBillingSourceInspectionEndpointDetectsSub2APIWithoutInventingLines" + ], + "negative": [ + "backend/internal/controlplane/provider_billing_sync_service_test.go#TestProviderBillingSourceServiceCASAndDisabledManualSync", + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/controlplane/provider_billing_sync_service_test.go#TestProviderBillingSourceServiceCASAndDisabledManualSync", + "backend/internal/server/effective_pricing_routes_test.go#TestProviderBillingSourceInspectionEndpointDetectsSub2APIWithoutInventingLines" + ], + "browser": [ + "@e2e-effective-pricing-002" + ] + }, + "notes": "" + }, + { + "id": "control:updateProviderCacheCapability", + "owner": "gateway/supply", + "risk": "P0", + "interaction": "command", + "methods": [ + "put" + ], + "views": [ + "frontend/src/views/admin/AdminEffectivePricingView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/effective_pricing_service_test.go#TestUpsertProviderCacheCapabilityProtectsObservedEvidence", + "backend/internal/server/effective_pricing_routes_test.go#TestEffectivePricingAdminEndpointsCreatePriceAndReconcileBilling" + ], + "negative": [ + "backend/internal/controlplane/effective_pricing_service_test.go#TestUpsertProviderCacheCapabilityProtectsObservedEvidence", + "backend/internal/controlplane/effective_pricing_service_test.go#TestUpsertProviderCacheCapabilityRejectsReservedAffinityField", + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/controlplane/effective_pricing_service_test.go#TestUpsertProviderCacheCapabilityProtectsObservedEvidence" + ], + "browser": [ + "@e2e-effective-pricing-001" + ] + }, + "notes": "" + }, + { + "id": "control:updateRoutingGroup", + "owner": "gateway/routing", + "risk": "P0", + "interaction": "command", + "methods": [ + "put" + ], + "views": [ + "frontend/src/views/admin/AdminRoutingGroupsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/admin_routes_test.go#TestAdminRoutingGroupsAndProviderAccountsEndpoints" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/server/admin_routes_test.go#TestAdminRoutingGroupBoundaryContracts" + ], + "browser": [ + "@e2e-routing-resources-001" + ] + }, + "notes": "" + }, + { + "id": "control:updateRoutingPolicy", + "owner": "gateway/routing", + "risk": "P0", + "interaction": "command", + "methods": [ + "put" + ], + "views": [ + "frontend/src/views/admin/AdminRoutingPolicyView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/admin_routes_test.go#TestAdminRoutingPolicyEndpoints" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/server/admin_routes_test.go#TestAdminRoutingPolicyEndpoints" + ], + "browser": [ + "@e2e-routing-policy-001" + ] + }, + "notes": "" + }, + { + "id": "control:updateWorkspaceUser", + "owner": "identity/rbac", + "risk": "P0", + "interaction": "command", + "methods": [ + "put" + ], + "views": [ + "frontend/src/views/admin/AdminUsersView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/identity_routes_test.go#TestAdminIdentityUserAndRoleBindingEndpoints" + ], + "negative": [ + "backend/internal/server/identity_routes_test.go#TestAdminUserDepartmentAssignmentValidationAndSessionRevocation" + ], + "boundary": [ + "backend/internal/server/identity_routes_test.go#TestAdminUserDepartmentAssignmentValidationAndSessionRevocation" + ], + "browser": [ + "@e2e-session-002" + ] + }, + "notes": "" + }, + { + "id": "control:validatePricingRule", + "owner": "billing/pricing", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/components/pricing/PricingRulesView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/pricing_service_test.go#TestPricingRuleLifecycleValidationAndCAS", + "backend/internal/server/pricing_rule_routes_test.go#TestPricingRuleHTTPVersionLifecycleAndFailureContracts" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations" + ], + "boundary": [ + "backend/internal/controlplane/pricing_service_test.go#TestPricingRuleLifecycleValidationAndCAS", + "backend/internal/server/pricing_rule_routes_test.go#TestPricingRuleHTTPVersionLifecycleAndFailureContracts" + ], + "browser": [ + "@e2e-pricing-001" + ] + }, + "notes": "" + }, + { + "id": "plugins:activateOfficialLicense", + "owner": "plugins", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminPluginsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/plugins/catalog_sync_test.go#TestServiceBootstrapOnlySyncDownloadAndActivateLicense" + ], + "negative": [ + "backend/internal/server/plugin_routes_test.go#TestPluginRoutesRequireAuthentication" + ], + "boundary": [ + "backend/internal/plugins/catalog_sync_test.go#TestServiceBootstrapOnlySyncDownloadAndActivateLicense" + ], + "browser": [ + "@e2e-plugin-trust-chain-001" + ] + }, + "notes": "" + }, + { + "id": "plugins:createPluginAPIToken", + "owner": "plugins", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminPluginsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/plugin_routes_test.go#TestAdminPluginAPITokenLifecycleAuditsAndRevokesAccess" + ], + "negative": [ + "backend/internal/plugins/api_token_service_test.go#TestPluginAPITokenRequiresPluginBindingForActions" + ], + "boundary": [ + "backend/internal/plugins/api_token_service_test.go#TestPluginAPITokenIsHashedScopedAndRevocable" + ], + "browser": [ + "@e2e-plugin-management-001" + ] + }, + "notes": "" + }, + { + "id": "plugins:deleteArtifactSinkDestination", + "owner": "plugins", + "risk": "P0", + "interaction": "command", + "methods": [ + "delete" + ], + "views": [ + "frontend/src/views/admin/ArtifactSinkDestinationsPanel.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/plugin_routes_test.go#TestAdminArtifactSinkDestinationEndpointsAuditAndMaskSecrets" + ], + "negative": [ + "backend/internal/server/plugin_routes_test.go#TestAdminArtifactSinkDestinationRBACAndInvalidPayloads" + ], + "boundary": [ + "backend/internal/server/plugin_routes_test.go#TestAdminArtifactSinkDestinationEndpointsAuditAndMaskSecrets" + ], + "browser": [ + "@e2e-artifact-sink-001" + ] + }, + "notes": "" + }, + { + "id": "plugins:disablePlugin", + "owner": "plugins", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminPluginsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/plugins/service_test.go#TestServiceFreePluginEnableDisableLifecycle" + ], + "negative": [ + "backend/internal/plugins/service_test.go#TestServiceRejectsDisablingCorePlugin" + ], + "boundary": [ + "backend/internal/plugins/service_test.go#TestServiceFreePluginEnableDisableLifecycle" + ], + "browser": [ + "@e2e-plugin-management-001" + ] + }, + "notes": "" + }, + { + "id": "plugins:downloadPluginPackage", + "owner": "plugins", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminPluginsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/plugins/catalog_sync_test.go#TestServiceDownloadOfficialPackageVerifiesAndCaches", + "backend/internal/server/plugin_routes_test.go#TestAdminPluginPackageDownloadEndpoint" + ], + "negative": [ + "backend/internal/plugins/catalog_sync_test.go#TestServiceDownloadOfficialPackageRejectsChecksumMismatch" + ], + "boundary": [ + "backend/internal/plugins/catalog_sync_test.go#TestServiceDownloadOfficialPackageVerifiesAndCaches", + "backend/internal/plugins/catalog_sync_test.go#TestServiceSyncOfficialCatalogBlocksRevokedAdvisoryVersion" + ], + "browser": [ + "@e2e-plugin-trust-chain-001" + ] + }, + "notes": "" + }, + { + "id": "plugins:enablePlugin", + "owner": "plugins", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminPluginsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/plugins/service_test.go#TestServiceFreePluginEnableDisableLifecycle", + "backend/internal/server/plugin_routes_test.go#TestAdminPluginsEnableFreePluginAudits" + ], + "negative": [ + "backend/internal/server/plugin_routes_test.go#TestAdminPluginsRejectLockedPaidPlugin" + ], + "boundary": [ + "backend/internal/plugins/service_test.go#TestServiceFreePluginEnableDisableLifecycle" + ], + "browser": [ + "@e2e-plugin-management-001" + ] + }, + "notes": "" + }, + { + "id": "plugins:getArtifactSinkDestinations", + "owner": "plugins", + "risk": "P0", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/ArtifactSinkDestinationsPanel.vue" + ], + "evidence": { + "success": [ + "backend/internal/plugins/artifact_sink_s3_test.go#TestArtifactSinkDestinationsSortAndProtectSecrets", + "backend/internal/server/plugin_routes_test.go#TestAdminArtifactSinkDestinationEndpointsAuditAndMaskSecrets" + ], + "negative": [ + "backend/internal/server/plugin_routes_test.go#TestPluginRoutesRequireAuthentication" + ], + "boundary": [ + "backend/internal/plugins/artifact_sink_s3_test.go#TestArtifactSinkDestinationsSortAndProtectSecrets", + "backend/internal/server/plugin_routes_test.go#TestAdminArtifactSinkDestinationEndpointsAuditAndMaskSecrets" + ], + "browser": [ + "@e2e-artifact-sink-001" + ] + }, + "notes": "" + }, + { + "id": "plugins:getOfficialCatalogStatus", + "owner": "plugins", + "risk": "P0", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminPluginsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/plugins/catalog_sync_test.go#TestServiceSyncOfficialCatalogVerifiesCachesAndMapsPlugins", + "backend/internal/server/plugin_routes_test.go#TestAdminPluginsCatalogEndpoint" + ], + "negative": [ + "backend/internal/server/plugin_routes_test.go#TestPluginRoutesRequireAuthentication" + ], + "boundary": [ + "backend/internal/server/plugin_routes_test.go#TestAdminPluginsCatalogEndpoint" + ], + "browser": [ + "@e2e-artifact-sink-001", + "@e2e-plugin-management-001" + ] + }, + "notes": "" + }, + { + "id": "plugins:getOfficialFeedClientInfo", + "owner": "plugins", + "risk": "P0", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminPluginsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/plugins/feed_service_test.go#TestOfficialFeedImportDecryptsAndEncryptsLocalCache" + ], + "negative": [ + "backend/internal/server/plugin_routes_test.go#TestPluginRoutesRequireAuthentication" + ], + "boundary": [ + "backend/internal/plugins/feed_service_test.go#TestOfficialFeedImportDecryptsAndEncryptsLocalCache" + ], + "browser": [ + "@e2e-plugin-trust-chain-001" + ] + }, + "notes": "" + }, + { + "id": "plugins:getOfficialFeedStatuses", + "owner": "plugins", + "risk": "P0", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminPluginsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/plugin_routes_test.go#TestAdminOfficialFeedSyncRecordsDisabledAttempt" + ], + "negative": [ + "backend/internal/server/plugin_routes_test.go#TestPluginRoutesRequireAuthentication" + ], + "boundary": [ + "backend/internal/plugins/feed_service_test.go#TestOfficialFeedImportAppliesRevocationsAndFallsBackToUsableCache", + "backend/internal/server/plugin_routes_test.go#TestAdminOfficialFeedSyncRecordsDisabledAttempt" + ], + "browser": [ + "@e2e-artifact-sink-001", + "@e2e-plugin-management-001" + ] + }, + "notes": "" + }, + { + "id": "plugins:getOfficialFeedSyncRuns", + "owner": "plugins", + "risk": "P0", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminPluginsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/plugins/feed_service_test.go#TestOfficialFeedSyncUsesSignedMetadataAndShortLivedDownloadGrant", + "backend/internal/server/plugin_routes_test.go#TestAdminOfficialFeedSyncRecordsDisabledAttempt" + ], + "negative": [ + "backend/internal/server/plugin_routes_test.go#TestPluginRoutesRequireAuthentication" + ], + "boundary": [ + "backend/internal/plugins/feed_service_test.go#TestOfficialFeedSyncUsesSignedMetadataAndShortLivedDownloadGrant", + "backend/internal/server/plugin_routes_test.go#TestAdminOfficialFeedSyncRecordsDisabledAttempt" + ], + "browser": [ + "@e2e-artifact-sink-001", + "@e2e-plugin-management-001" + ] + }, + "notes": "" + }, + { + "id": "plugins:getOfficialLicenseStatus", + "owner": "plugins", + "risk": "P0", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminPluginsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/plugin_routes_test.go#TestAdminPluginLicenseImportEndpointAuditsAndUpdatesStatus" + ], + "negative": [ + "backend/internal/server/plugin_routes_test.go#TestPluginRoutesRequireAuthentication" + ], + "boundary": [ + "backend/internal/server/plugin_routes_test.go#TestAdminPluginLicenseImportEndpointAuditsAndUpdatesStatus" + ], + "browser": [ + "@e2e-artifact-sink-001", + "@e2e-plugin-management-001" + ] + }, + "notes": "" + }, + { + "id": "plugins:getPluginAPITokens", + "owner": "plugins", + "risk": "P0", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminPluginsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/plugin_routes_test.go#TestAdminPluginAPITokenLifecycleAuditsAndRevokesAccess" + ], + "negative": [ + "backend/internal/server/plugin_routes_test.go#TestPluginRoutesRequireAuthentication" + ], + "boundary": [ + "backend/internal/plugins/api_token_service_test.go#TestPluginAPITokenIsHashedScopedAndRevocable" + ], + "browser": [ + "@e2e-artifact-sink-001", + "@e2e-plugin-management-001" + ] + }, + "notes": "" + }, + { + "id": "plugins:getPluginCatalog", + "owner": "plugins", + "risk": "P0", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/components/ProductShell.vue", + "frontend/src/views/admin/AdminPluginsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/plugin_routes_test.go#TestAdminPluginsCatalogEndpoint" + ], + "negative": [ + "backend/internal/server/plugin_routes_test.go#TestPluginRoutesRequireAuthentication" + ], + "boundary": [ + "backend/internal/plugins/service_test.go#TestServiceCatalogIncludesEnterprisePlugins", + "backend/internal/server/plugin_routes_test.go#TestAdminPluginsCatalogEndpoint" + ], + "browser": [ + "@e2e-artifact-sink-001", + "@e2e-plugin-management-001" + ] + }, + "notes": "" + }, + { + "id": "plugins:getPluginConfig", + "owner": "plugins", + "risk": "P0", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminPluginsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/plugin_routes_test.go#TestAdminPluginConfigEndpointsAuditAndMaskSecrets" + ], + "negative": [ + "backend/internal/server/plugin_routes_test.go#TestPluginRoutesRequireAuthentication" + ], + "boundary": [ + "backend/internal/server/plugin_routes_test.go#TestAdminPluginConfigEndpointsAuditAndMaskSecrets" + ], + "browser": [ + "@e2e-plugin-management-001" + ] + }, + "notes": "" + }, + { + "id": "plugins:getPluginDeliveries", + "owner": "plugins", + "risk": "P0", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminPluginsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/plugin_routes_test.go#TestAdminPluginDeliveriesEndpoint" + ], + "negative": [ + "backend/internal/server/plugin_routes_test.go#TestPluginRoutesRequireAuthentication" + ], + "boundary": [ + "backend/internal/server/plugin_routes_test.go#TestAdminPluginDeliveriesEndpoint" + ], + "browser": [ + "@e2e-plugin-management-001" + ] + }, + "notes": "" + }, + { + "id": "plugins:getPluginFrontendAsset", + "owner": "plugins", + "risk": "P0", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/PluginFrontendView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/plugin_routes_test.go#TestAdminPluginPackageDownloadEndpoint" + ], + "negative": [ + "backend/internal/server/plugin_routes_test.go#TestAdminPluginPackageDownloadEndpoint", + "backend/internal/server/plugin_routes_test.go#TestPluginRoutesRequireAuthentication" + ], + "boundary": [ + "backend/internal/server/plugin_routes_test.go#TestAdminPluginPackageDownloadEndpoint" + ], + "browser": [ + "@e2e-plugin-trust-chain-001" + ] + }, + "notes": "" + }, + { + "id": "plugins:getPluginWorkbench", + "owner": "plugins", + "risk": "P0", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/components/ProductShell.vue", + "frontend/src/views/admin/PluginFrontendView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/plugin_routes_test.go#TestAdminPluginPackageDownloadEndpoint" + ], + "negative": [ + "backend/internal/server/plugin_routes_test.go#TestAdminPluginPackageDownloadEndpoint", + "backend/internal/server/plugin_routes_test.go#TestPluginRoutesRequireAuthentication" + ], + "boundary": [ + "backend/internal/server/plugin_routes_test.go#TestAdminPluginPackageDownloadEndpoint" + ], + "browser": [ + "@e2e-plugin-trust-chain-001" + ] + }, + "notes": "" + }, + { + "id": "plugins:getSidecarRuntimeStatus", + "owner": "plugins", + "risk": "P0", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminPluginsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/plugin_routes_test.go#TestAdminPluginPackageDownloadEndpoint" + ], + "negative": [ + "backend/internal/server/plugin_routes_test.go#TestAdminPluginPackageDownloadEndpoint", + "backend/internal/server/plugin_routes_test.go#TestPluginRoutesRequireAuthentication" + ], + "boundary": [ + "backend/internal/server/plugin_routes_test.go#TestAdminPluginPackageDownloadEndpoint" + ], + "browser": [ + "@e2e-plugin-trust-chain-001" + ] + }, + "notes": "" + }, + { + "id": "plugins:importOfficialFeed", + "owner": "plugins", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminPluginsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/plugins/feed_service_test.go#TestOfficialFeedImportDecryptsAndEncryptsLocalCache" + ], + "negative": [ + "backend/internal/plugins/feed_service_test.go#TestOfficialFeedImportRejectsWrongBindingAndEntitlement" + ], + "boundary": [ + "backend/internal/plugins/feed_service_test.go#TestOfficialFeedImportAppliesRevocationsAndFallsBackToUsableCache", + "backend/internal/plugins/feed_service_test.go#TestOfficialFeedImportDecryptsAndEncryptsLocalCache" + ], + "browser": [ + "@e2e-plugin-trust-chain-001" + ] + }, + "notes": "" + }, + { + "id": "plugins:importOfficialLicense", + "owner": "plugins", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminPluginsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/plugins/catalog_sync_test.go#TestServiceImportLicenseUnlocksPaidPackageDownload", + "backend/internal/server/plugin_routes_test.go#TestAdminPluginLicenseImportEndpointAuditsAndUpdatesStatus" + ], + "negative": [ + "backend/internal/plugins/catalog_sync_test.go#TestServiceImportLicenseRejectsTamperedEnvelope" + ], + "boundary": [ + "backend/internal/plugins/catalog_sync_test.go#TestServiceImportLicenseUnlocksPaidPackageDownload", + "backend/internal/server/plugin_routes_test.go#TestAdminPluginLicenseImportEndpointAuditsAndUpdatesStatus" + ], + "browser": [ + "@e2e-plugin-trust-chain-001" + ] + }, + "notes": "" + }, + { + "id": "plugins:importPluginPackage", + "owner": "plugins", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminPluginsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/plugins/catalog_sync_test.go#TestServiceImportOfflinePackageVerifiesAndCaches" + ], + "negative": [ + "backend/internal/plugins/catalog_sync_test.go#TestServiceImportOfflinePackageRejectsChecksumMismatch" + ], + "boundary": [ + "backend/internal/plugins/catalog_sync_test.go#TestServiceImportOfflinePackageVerifiesAndCaches" + ], + "browser": [ + "@e2e-plugin-trust-chain-001" + ] + }, + "notes": "" + }, + { + "id": "plugins:installPluginPackage", + "owner": "plugins", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminPluginsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/plugins/catalog_sync_test.go#TestServiceDownloadOfficialPackageVerifiesAndCaches", + "backend/internal/plugins/catalog_sync_test.go#TestServiceImportOfflinePackageVerifiesAndCaches", + "backend/internal/server/plugin_routes_test.go#TestAdminPluginPackageDownloadEndpoint" + ], + "negative": [ + "backend/internal/plugins/catalog_sync_test.go#TestServiceInstallPackageRequiresCachedPackage" + ], + "boundary": [ + "backend/internal/plugins/catalog_sync_test.go#TestServiceDownloadOfficialPackageVerifiesAndCaches", + "backend/internal/plugins/catalog_sync_test.go#TestServiceImportOfflinePackageVerifiesAndCaches", + "backend/internal/plugins/catalog_sync_test.go#TestServiceSyncOfficialCatalogBlocksRevokedAdvisoryVersion", + "backend/internal/plugins/package_installation_test.go#TestInstallPackageRollsBackActiveDirectoryWhenRepositoryWriteFails" + ], + "browser": [ + "@e2e-plugin-trust-chain-001" + ] + }, + "notes": "" + }, + { + "id": "plugins:redeemOfficialLicense", + "owner": "plugins", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminPluginsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/plugins/catalog_sync_test.go#TestServiceRedeemLicenseUsesStableIdempotencyAndInstanceBinding" + ], + "negative": [ + "backend/internal/plugins/catalog_sync_test.go#TestServiceRedeemLicenseRejectsDifferentInstance" + ], + "boundary": [ + "backend/internal/plugins/catalog_sync_test.go#TestServiceRedeemLicenseUsesStableIdempotencyAndInstanceBinding" + ], + "browser": [ + "@e2e-plugin-trust-chain-001" + ] + }, + "notes": "" + }, + { + "id": "plugins:revokePluginAPIToken", + "owner": "plugins", + "risk": "P0", + "interaction": "command", + "methods": [ + "delete" + ], + "views": [ + "frontend/src/views/admin/AdminPluginsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/plugin_routes_test.go#TestAdminPluginAPITokenLifecycleAuditsAndRevokesAccess" + ], + "negative": [ + "backend/internal/server/plugin_routes_test.go#TestAdminPluginAPITokenLifecycleAuditsAndRevokesAccess", + "backend/internal/server/plugin_routes_test.go#TestPluginRoutesRequireAuthentication" + ], + "boundary": [ + "backend/internal/plugins/api_token_service_test.go#TestPluginAPITokenIsHashedScopedAndRevocable" + ], + "browser": [ + "@e2e-plugin-management-001" + ] + }, + "notes": "" + }, + { + "id": "plugins:syncOfficialCatalog", + "owner": "plugins", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminPluginsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/plugins/catalog_sync_test.go#TestServiceSyncOfficialCatalogVerifiesCachesAndMapsPlugins", + "backend/internal/server/plugin_routes_test.go#TestAdminPluginPackageDownloadEndpoint" + ], + "negative": [ + "backend/internal/plugins/catalog_sync_test.go#TestServiceSyncOfficialCatalogRejectsTamperedPayload", + "backend/internal/server/rbac_test.go#TestAdminRBACProtectsPluginAndSystemWrites" + ], + "boundary": [ + "backend/internal/plugins/catalog_sync_test.go#TestServiceSyncOfficialCatalogBlocksRevokedAdvisoryVersion" + ], + "browser": [ + "@e2e-plugin-trust-chain-001" + ] + }, + "notes": "" + }, + { + "id": "plugins:syncOfficialFeed", + "owner": "plugins", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminPluginsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/plugins/feed_service_test.go#TestOfficialFeedSyncUsesSignedMetadataAndShortLivedDownloadGrant" + ], + "negative": [ + "backend/internal/plugins/feed_service_test.go#TestOfficialFeedSyncAppliesSignedRevocationBeforeFailedDownload", + "backend/internal/server/plugin_routes_test.go#TestAdminOfficialFeedSyncRecordsDisabledAttempt" + ], + "boundary": [ + "backend/internal/plugins/feed_service_test.go#TestOfficialFeedSyncAppliesSignedRevocationBeforeFailedDownload", + "backend/internal/plugins/feed_service_test.go#TestOfficialFeedSyncUsesSignedMetadataAndShortLivedDownloadGrant" + ], + "browser": [ + "@e2e-plugin-trust-chain-001" + ] + }, + "notes": "" + }, + { + "id": "plugins:uninstallPluginPackage", + "owner": "plugins", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminPluginsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/plugins/catalog_sync_test.go#TestServiceDownloadOfficialPackageVerifiesAndCaches", + "backend/internal/server/plugin_routes_test.go#TestAdminPluginPackageDownloadEndpoint" + ], + "negative": [ + "backend/internal/server/plugin_routes_test.go#TestAdminPluginPackageDownloadEndpoint", + "backend/internal/server/plugin_routes_test.go#TestPluginRoutesRequireAuthentication" + ], + "boundary": [ + "backend/internal/plugins/catalog_sync_test.go#TestServiceDownloadOfficialPackageVerifiesAndCaches", + "backend/internal/server/plugin_routes_test.go#TestAdminPluginPackageDownloadEndpoint" + ], + "browser": [ + "@e2e-plugin-trust-chain-001" + ] + }, + "notes": "" + }, + { + "id": "plugins:updatePluginConfig", + "owner": "plugins", + "risk": "P0", + "interaction": "command", + "methods": [ + "put" + ], + "views": [ + "frontend/src/views/admin/AdminPluginsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/plugin_routes_test.go#TestAdminPluginConfigEndpointsAuditAndMaskSecrets" + ], + "negative": [ + "backend/internal/plugins/service_test.go#TestServiceRejectsConfiguringLockedPaidPlugin" + ], + "boundary": [ + "backend/internal/server/plugin_routes_test.go#TestAdminPluginConfigEndpointsAuditAndMaskSecrets" + ], + "browser": [ + "@e2e-plugin-management-001" + ] + }, + "notes": "" + }, + { + "id": "plugins:upsertArtifactSinkDestination", + "owner": "plugins", + "risk": "P0", + "interaction": "command", + "methods": [ + "put" + ], + "views": [ + "frontend/src/views/admin/ArtifactSinkDestinationsPanel.vue" + ], + "evidence": { + "success": [ + "backend/internal/plugins/artifact_sink_s3_test.go#TestArtifactSinkDestinationsSortAndProtectSecrets", + "backend/internal/server/plugin_routes_test.go#TestAdminArtifactSinkDestinationEndpointsAuditAndMaskSecrets" + ], + "negative": [ + "backend/internal/plugins/artifact_sink_s3_test.go#TestArtifactSinkDestinationValidation", + "backend/internal/server/plugin_routes_test.go#TestAdminArtifactSinkDestinationRBACAndInvalidPayloads" + ], + "boundary": [ + "backend/internal/plugins/artifact_sink_s3_test.go#TestArtifactSinkDestinationsSortAndProtectSecrets", + "backend/internal/server/plugin_routes_test.go#TestAdminArtifactSinkDestinationEndpointsAuditAndMaskSecrets" + ], + "browser": [ + "@e2e-artifact-sink-001" + ] + }, + "notes": "" + }, + { + "id": "settings:completeEnterpriseSetup", + "owner": "settings", + "risk": "P2", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/SetupView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/server_test.go#TestSetupEndpointCompletesEnterpriseInitialization" + ], + "negative": [ + "backend/internal/server/server_test.go#TestSetupEndpointRejectsInvalidOrganization", + "backend/internal/server/server_test.go#TestSetupEndpointReturnsSanitizedServerErrorWhenPersistenceFails" + ], + "boundary": [ + "backend/internal/server/server_test.go#TestSetupEndpointRejectsInvalidOrganization", + "backend/internal/server/server_test.go#TestSetupEndpointSerializesConcurrentRequests" + ], + "browser": [ + "@e2e-setup-001" + ] + }, + "notes": "" + }, + { + "id": "settings:getAdminSettings", + "owner": "settings", + "risk": "P2", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminSettingsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/server_test.go#TestLoginAllowsConsoleSettingsAccess", + "backend/internal/settings/service_test.go#TestAuthenticationSettingsRoundTrip" + ], + "negative": [ + "backend/internal/server/server_test.go#TestConsoleSettingsRequiresToken" + ], + "boundary": [ + "backend/internal/settings/service_test.go#TestAuthenticationSettingsRoundTrip" + ], + "browser": [ + "@e2e-email-001", + "@e2e-settings-001" + ] + }, + "notes": "" + }, + { + "id": "settings:getEmailTemplate", + "owner": "settings", + "risk": "P2", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/EmailTemplateEditor.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/email_settings_test.go#TestEmailTemplateRoutesExposeLowercaseCatalogAndIndependentUpdates" + ], + "negative": [], + "boundary": [ + "backend/internal/settings/service_test.go#TestEmailTemplatesListUpdateAndRestore" + ], + "browser": [ + "@e2e-email-001" + ] + }, + "notes": "" + }, + { + "id": "settings:getEmailTemplateCatalog", + "owner": "settings", + "risk": "P2", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/EmailTemplateEditor.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/email_settings_test.go#TestEmailTemplateRoutesExposeLowercaseCatalogAndIndependentUpdates" + ], + "negative": [], + "boundary": [ + "backend/internal/settings/service_test.go#TestEmailTemplatesListUpdateAndRestore" + ], + "browser": [ + "@e2e-email-001" + ] + }, + "notes": "" + }, + { + "id": "settings:getLegalDocument", + "owner": "settings", + "risk": "P2", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/LegalDocumentView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/server_test.go#TestLegalDocumentEndpointReturnsPublishedDocumentAndRejectsUnknownSlug" + ], + "negative": [], + "boundary": [ + "backend/internal/server/server_test.go#TestLegalDocumentEndpointReturnsPublishedDocumentAndRejectsUnknownSlug" + ], + "browser": [ + "@e2e-legal-001" + ] + }, + "notes": "" + }, + { + "id": "settings:previewEmailTemplate", + "owner": "settings", + "risk": "P2", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/EmailTemplateEditor.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/email_settings_test.go#TestEmailTemplateRoutesExposeLowercaseCatalogAndIndependentUpdates" + ], + "negative": [ + "backend/internal/server/email_settings_test.go#TestEmailTemplateRoutesExposeLowercaseCatalogAndIndependentUpdates" + ], + "boundary": [ + "backend/internal/server/email_settings_test.go#TestEmailTemplateRoutesExposeLowercaseCatalogAndIndependentUpdates" + ], + "browser": [ + "@e2e-email-001" + ] + }, + "notes": "" + }, + { + "id": "settings:restoreEmailTemplate", + "owner": "settings", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/EmailTemplateEditor.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/email_settings_test.go#TestEmailTemplateRoutesExposeLowercaseCatalogAndIndependentUpdates" + ], + "negative": [ + "backend/internal/settings/service_test.go#TestEmailTemplateRestoreDoesNotOverwriteConcurrentSettings" + ], + "boundary": [ + "backend/internal/settings/service_test.go#TestEmailTemplateRestoreDoesNotOverwriteConcurrentSettings", + "backend/internal/settings/service_test.go#TestEmailTemplatesListUpdateAndRestore" + ], + "browser": [ + "@e2e-email-001" + ] + }, + "notes": "" + }, + { + "id": "settings:runRetentionCleanup", + "owner": "settings", + "risk": "P2", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminSettingsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/controlplane/retention_service_test.go#TestCleanupRetainedDataDeletesExpiredRecordsButPreservesActiveAlertsAndAuditEvidence" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACProtectsPluginAndSystemWrites" + ], + "boundary": [ + "backend/internal/controlplane/retention_service_test.go#TestCleanupRetainedDataDeletesExpiredRecordsButPreservesActiveAlertsAndAuditEvidence" + ], + "browser": [ + "@e2e-settings-001" + ] + }, + "notes": "" + }, + { + "id": "settings:testEmailTemplate", + "owner": "settings", + "risk": "P2", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/EmailTemplateEditor.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/email_settings_test.go#TestSMTPRoutesDeliverPlainAndRenderedMessagesOverSTARTTLS" + ], + "negative": [ + "backend/internal/server/email_settings_test.go#TestSMTPRoutesRejectInvalidRecipientsAndTemplates" + ], + "boundary": [ + "backend/internal/auth/mailer_test.go#TestSMTPMailerRejectsInjectedHeadersBeforeDial", + "backend/internal/auth/mailer_test.go#TestSMTPTLSConfigAppendsSSL_CERTFile" + ], + "browser": [ + "@e2e-email-001" + ] + }, + "notes": "" + }, + { + "id": "settings:testSMTP", + "owner": "settings", + "risk": "P2", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminSettingsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/email_settings_test.go#TestSMTPRoutesDeliverPlainAndRenderedMessagesOverSTARTTLS" + ], + "negative": [ + "backend/internal/server/email_settings_test.go#TestSMTPRoutesRejectInvalidRecipientsAndTemplates" + ], + "boundary": [ + "backend/internal/auth/mailer_test.go#TestSMTPMailerRejectsInjectedHeadersBeforeDial", + "backend/internal/auth/mailer_test.go#TestSMTPTLSConfigAppendsSSL_CERTFile" + ], + "browser": [ + "@e2e-email-001" + ] + }, + "notes": "" + }, + { + "id": "settings:testSMTPConnection", + "owner": "settings", + "risk": "P2", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminSettingsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/email_settings_test.go#TestSMTPRoutesDeliverPlainAndRenderedMessagesOverSTARTTLS" + ], + "negative": [ + "backend/internal/server/email_settings_test.go#TestSMTPConnectionRouteUsesRequestConfiguration" + ], + "boundary": [ + "backend/internal/auth/mailer_test.go#TestSMTPConnectionTestRequiresSTARTTLS", + "backend/internal/auth/mailer_test.go#TestSMTPTLSConfigAppendsSSL_CERTFile" + ], + "browser": [ + "@e2e-email-001" + ] + }, + "notes": "" + }, + { + "id": "settings:updateAdminSettings", + "owner": "settings", + "risk": "P0", + "interaction": "command", + "methods": [ + "put" + ], + "views": [ + "frontend/src/views/admin/AdminSettingsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/settings/service_test.go#TestAuthenticationSettingsRoundTrip" + ], + "negative": [ + "backend/internal/server/rbac_test.go#TestAdminRBACAllowsGlobalAuditorReadAndBlocksWrites" + ], + "boundary": [ + "backend/internal/settings/service_test.go#TestAuthenticationSettingsRoundTrip" + ], + "browser": [ + "@e2e-settings-001" + ] + }, + "notes": "" + }, + { + "id": "settings:updateEmailTemplate", + "owner": "settings", + "risk": "P0", + "interaction": "command", + "methods": [ + "put" + ], + "views": [ + "frontend/src/views/admin/EmailTemplateEditor.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/email_settings_test.go#TestEmailTemplateRoutesExposeLowercaseCatalogAndIndependentUpdates" + ], + "negative": [ + "backend/internal/server/email_settings_test.go#TestEmailTemplateRoutesExposeLowercaseCatalogAndIndependentUpdates", + "backend/internal/settings/service_test.go#TestEmailTemplateUpdateDoesNotOverwriteConcurrentSettings" + ], + "boundary": [ + "backend/internal/server/email_settings_test.go#TestEmailTemplateRoutesExposeLowercaseCatalogAndIndependentUpdates", + "backend/internal/settings/service_test.go#TestEmailTemplateUpdateDoesNotOverwriteConcurrentSettings" + ], + "browser": [ + "@e2e-email-001" + ] + }, + "notes": "" + }, + { + "id": "system:checkSystemUpdates", + "owner": "system", + "risk": "P0", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminSettingsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/admin_routes_test.go#TestAdminSystemCheckUpdatesEndpoint" + ], + "negative": [ + "backend/internal/server/admin_routes_test.go#TestSystemRoutesRequireAuthentication" + ], + "boundary": [ + "backend/internal/server/admin_routes_test.go#TestAdminSystemCheckUpdatesEndpoint" + ], + "browser": [ + "@e2e-system-update-001", + "@e2e-system-update-lifecycle-001" + ] + }, + "notes": "" + }, + { + "id": "system:createDiagnosticBundle", + "owner": "system", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminSettingsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/admin_routes_test.go#TestSystemDiagnosticRoutesCreateAndDownload" + ], + "negative": [ + "backend/internal/server/admin_routes_test.go#TestSystemRoutesRequireAuthentication" + ], + "boundary": [ + "backend/internal/system/archive_test.go#TestDiagnosticBundleIsRedacted" + ], + "browser": [ + "@e2e-system-diagnostic-001" + ] + }, + "notes": "" + }, + { + "id": "system:createSystemBackup", + "owner": "system", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminSettingsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/system/archive_test.go#TestBackupRestoreIncludesPluginAssetsAndRequiresConfirmation" + ], + "negative": [ + "backend/internal/server/admin_routes_test.go#TestSystemRoutesRequireAuthentication", + "backend/internal/system/archive_test.go#TestBackupRequiresPostgreSQL" + ], + "boundary": [ + "backend/internal/system/archive_test.go#TestBackupRestoreIncludesPluginAssetsAndRequiresConfirmation" + ], + "browser": [ + "@e2e-system-backup-001" + ] + }, + "notes": "" + }, + { + "id": "system:downloadDiagnosticBundle", + "owner": "system", + "risk": "P0", + "interaction": "command", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminSettingsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/admin_routes_test.go#TestSystemDiagnosticRoutesCreateAndDownload" + ], + "negative": [ + "backend/internal/server/admin_routes_test.go#TestSystemRoutesRequireAuthentication" + ], + "boundary": [ + "backend/internal/server/admin_routes_test.go#TestSystemDiagnosticRoutesRejectUnavailableServiceAndInvalidArchiveID" + ], + "browser": [ + "@e2e-system-diagnostic-001" + ] + }, + "notes": "" + }, + { + "id": "system:downloadS3Backup", + "owner": "system", + "risk": "P0", + "interaction": "command", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminSettingsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/admin_routes_test.go#TestSystemS3BackupRoutesTestListDownloadAndRestore" + ], + "negative": [ + "backend/internal/server/admin_routes_test.go#TestSystemBackupEndpointsExposeEmptyListAndRejectMemoryBackup", + "backend/internal/server/admin_routes_test.go#TestSystemRoutesRequireAuthentication", + "backend/internal/server/admin_routes_test.go#TestSystemS3BackupRoutesTestListDownloadAndRestore" + ], + "boundary": [ + "backend/internal/server/admin_routes_test.go#TestSystemBackupEndpointsExposeEmptyListAndRejectMemoryBackup", + "backend/internal/server/admin_routes_test.go#TestSystemS3BackupRoutesTestListDownloadAndRestore" + ], + "browser": [ + "@e2e-system-backup-001" + ] + }, + "notes": "" + }, + { + "id": "system:downloadSystemBackup", + "owner": "system", + "risk": "P0", + "interaction": "command", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminSettingsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/admin_routes_test.go#TestSystemBackupRoutesListAndDownloadStoredArchive" + ], + "negative": [ + "backend/internal/server/admin_routes_test.go#TestSystemRoutesRequireAuthentication" + ], + "boundary": [ + "backend/internal/server/admin_routes_test.go#TestSystemBackupRoutesListAndDownloadStoredArchive" + ], + "browser": [ + "@e2e-system-backup-001" + ] + }, + "notes": "" + }, + { + "id": "system:listS3Backups", + "owner": "system", + "risk": "P0", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminSettingsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/admin_routes_test.go#TestSystemS3BackupRoutesTestListDownloadAndRestore" + ], + "negative": [ + "backend/internal/server/admin_routes_test.go#TestSystemBackupEndpointsExposeEmptyListAndRejectMemoryBackup", + "backend/internal/server/admin_routes_test.go#TestSystemRoutesRequireAuthentication" + ], + "boundary": [ + "backend/internal/server/admin_routes_test.go#TestSystemBackupEndpointsExposeEmptyListAndRejectMemoryBackup", + "backend/internal/server/admin_routes_test.go#TestSystemS3BackupRoutesTestListDownloadAndRestore" + ], + "browser": [ + "@e2e-system-backup-001" + ] + }, + "notes": "" + }, + { + "id": "system:listSystemBackups", + "owner": "system", + "risk": "P0", + "interaction": "query", + "methods": [ + "get" + ], + "views": [ + "frontend/src/views/admin/AdminSettingsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/admin_routes_test.go#TestSystemBackupRoutesListAndDownloadStoredArchive", + "backend/internal/system/archive_test.go#TestBackupRestoreIncludesPluginAssetsAndRequiresConfirmation" + ], + "negative": [ + "backend/internal/server/admin_routes_test.go#TestSystemRoutesRequireAuthentication" + ], + "boundary": [ + "backend/internal/server/admin_routes_test.go#TestSystemBackupRoutesListAndDownloadStoredArchive", + "backend/internal/system/archive_test.go#TestBackupRestoreIncludesPluginAssetsAndRequiresConfirmation" + ], + "browser": [ + "@e2e-system-backup-001" + ] + }, + "notes": "" + }, + { + "id": "system:performSystemUpdate", + "owner": "system", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminSettingsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/system/service_test.go#TestPerformUpdateReturnsSuccessWhenAlreadyCurrent" + ], + "negative": [ + "backend/internal/server/admin_routes_test.go#TestSystemRoutesRequireAuthentication", + "backend/internal/system/service_test.go#TestPerformUpdateSourceBuildRequiresManualUpdate" + ], + "boundary": [ + "backend/internal/system/service_test.go#TestPerformUpdateReturnsSuccessWhenAlreadyCurrent" + ], + "browser": [ + "@e2e-system-update-001", + "@e2e-system-update-lifecycle-001" + ] + }, + "notes": "" + }, + { + "id": "system:restartSystem", + "owner": "system", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminSettingsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/system/service_test.go#TestRestartExitsIsolatedProcessWhenEnabled" + ], + "negative": [ + "backend/internal/server/admin_routes_test.go#TestSystemRoutesRequireAuthentication", + "backend/internal/system/service_test.go#TestRestartRejectsDisabledMaintenance" + ], + "boundary": [ + "backend/internal/system/service_test.go#TestRestartRejectsDisabledMaintenance" + ], + "browser": [ + "@e2e-system-update-001", + "@e2e-system-update-lifecycle-001" + ] + }, + "notes": "" + }, + { + "id": "system:restoreS3Backup", + "owner": "system", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminSettingsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/admin_routes_test.go#TestSystemS3BackupRoutesTestListDownloadAndRestore" + ], + "negative": [ + "backend/internal/server/admin_routes_test.go#TestSystemBackupEndpointsExposeEmptyListAndRejectMemoryBackup", + "backend/internal/server/admin_routes_test.go#TestSystemRoutesRequireAuthentication", + "backend/internal/server/admin_routes_test.go#TestSystemS3BackupRoutesTestListDownloadAndRestore" + ], + "boundary": [ + "backend/internal/server/admin_routes_test.go#TestSystemBackupEndpointsExposeEmptyListAndRejectMemoryBackup", + "backend/internal/server/admin_routes_test.go#TestSystemS3BackupRoutesTestListDownloadAndRestore" + ], + "browser": [ + "@e2e-system-backup-001" + ] + }, + "notes": "" + }, + { + "id": "system:restoreSystemBackup", + "owner": "system", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminSettingsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/system/archive_test.go#TestBackupRestoreIncludesPluginAssetsAndRequiresConfirmation" + ], + "negative": [ + "backend/internal/server/admin_routes_test.go#TestSystemBackupEndpointsExposeEmptyListAndRejectMemoryBackup", + "backend/internal/server/admin_routes_test.go#TestSystemRoutesRequireAuthentication" + ], + "boundary": [ + "backend/internal/server/admin_routes_test.go#TestSystemBackupEndpointsExposeEmptyListAndRejectMemoryBackup", + "backend/internal/system/archive_test.go#TestBackupRestoreIncludesPluginAssetsAndRequiresConfirmation" + ], + "browser": [ + "@e2e-system-backup-001" + ] + }, + "notes": "" + }, + { + "id": "system:rollbackSystemUpdate", + "owner": "system", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminSettingsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/system/service_test.go#TestRollbackReplacesIsolatedExecutableAndPreservesOperation" + ], + "negative": [ + "backend/internal/server/admin_routes_test.go#TestSystemRoutesRequireAuthentication", + "backend/internal/system/service_test.go#TestRollbackRejectsMissingBackup" + ], + "boundary": [ + "backend/internal/system/service_test.go#TestRollbackRejectsMissingBackup" + ], + "browser": [ + "@e2e-system-update-001", + "@e2e-system-update-lifecycle-001" + ] + }, + "notes": "" + }, + { + "id": "system:testBackupS3", + "owner": "system", + "risk": "P0", + "interaction": "command", + "methods": [ + "post" + ], + "views": [ + "frontend/src/views/admin/AdminSettingsView.vue" + ], + "evidence": { + "success": [ + "backend/internal/server/admin_routes_test.go#TestSystemS3BackupRoutesTestListDownloadAndRestore" + ], + "negative": [ + "backend/internal/server/admin_routes_test.go#TestSystemBackupEndpointsExposeEmptyListAndRejectMemoryBackup", + "backend/internal/server/admin_routes_test.go#TestSystemRoutesRequireAuthentication" + ], + "boundary": [ + "backend/internal/server/admin_routes_test.go#TestSystemBackupEndpointsExposeEmptyListAndRejectMemoryBackup", + "backend/internal/server/admin_routes_test.go#TestSystemS3BackupRoutesTestListDownloadAndRestore" + ], + "browser": [ + "@e2e-system-backup-001" + ] + }, + "notes": "" + } + ] +} diff --git a/docs/test/v1/owner-evidence.json b/docs/test/v1/owner-evidence.json new file mode 100644 index 0000000..ed50ac1 --- /dev/null +++ b/docs/test/v1/owner-evidence.json @@ -0,0 +1,2331 @@ +{ + "schemaVersion": 1, + "evidence": [ + { + "reference": "backend/internal/server/oauth_state_cookie_test.go#TestExternalAccountBindingStartsBindStateToBrowserCookie", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "account:beginAccountIdentityBinding" + ] + }, + { + "reference": "backend/internal/server/auth_rate_limit_test.go#TestAccountBindingStartIsLimitedPerAccountAndClient", + "proofs": [ + "negative" + ], + "operations": [ + "account:beginAccountIdentityBinding" + ] + }, + { + "reference": "backend/internal/controlplane/oidc_provision_test.go#TestUnbindCurrentAuthIdentityProtectsLastLoginMethod", + "proofs": [ + "success", + "negative", + "boundary" + ], + "operations": [ + "account:unbindAccountIdentity" + ] + }, + { + "reference": "backend/internal/controlplane/oidc_provision_test.go#TestUnbindCurrentAuthIdentityWithPasswordDoesNotAffectOtherUsers", + "proofs": [ + "boundary" + ], + "operations": [ + "account:unbindAccountIdentity" + ] + }, + { + "reference": "backend/internal/server/admin_routes_test.go#TestAdminDashboardEndpoint", + "proofs": [ + "success" + ], + "operations": [ + "control:getDashboard" + ] + }, + { + "reference": "backend/internal/server/admin_routes_test.go#TestAdminRecordEndpointsSupportQueryParameters", + "proofs": [ + "success" + ], + "operations": [ + "control:getAuditLogs", + "control:getAuditLogSummary", + "control:getCostAllocationReport", + "control:getGatewayTraces", + "control:getGatewayTraceSummary", + "control:getUsageReport" + ] + }, + { + "reference": "backend/internal/server/supply_routes_test.go#TestSupplyRoutesRequireAuthenticationAndValidateWindow", + "proofs": [ + "success" + ], + "operations": [ + "control:getCapacityRecommendations", + "control:getSupplyUtilization" + ] + }, + { + "reference": "backend/internal/server/admin_routes_test.go#TestAdminSystemCheckUpdatesEndpoint", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "system:checkSystemUpdates" + ] + }, + { + "reference": "backend/internal/server/admin_routes_test.go#TestSystemBackupRoutesListAndDownloadStoredArchive", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "system:downloadSystemBackup", + "system:listSystemBackups" + ] + }, + { + "reference": "backend/internal/system/archive_test.go#TestBackupRestoreIncludesPluginAssetsAndRequiresConfirmation", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "system:createSystemBackup", + "system:listSystemBackups", + "system:restoreSystemBackup" + ] + }, + { + "reference": "backend/internal/system/archive_test.go#TestBackupRequiresPostgreSQL", + "proofs": [ + "negative" + ], + "operations": [ + "system:createSystemBackup" + ] + }, + { + "reference": "backend/internal/server/admin_routes_test.go#TestSystemBackupEndpointsExposeEmptyListAndRejectMemoryBackup", + "proofs": [ + "negative", + "boundary" + ], + "operations": [ + "system:downloadS3Backup", + "system:listS3Backups", + "system:restoreS3Backup", + "system:restoreSystemBackup", + "system:testBackupS3" + ] + }, + { + "reference": "backend/internal/server/admin_routes_test.go#TestSystemS3BackupRoutesTestListDownloadAndRestore", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "system:downloadS3Backup", + "system:listS3Backups", + "system:restoreS3Backup", + "system:testBackupS3" + ] + }, + { + "reference": "backend/internal/server/admin_routes_test.go#TestSystemS3BackupRoutesTestListDownloadAndRestore", + "proofs": [ + "negative" + ], + "operations": [ + "system:downloadS3Backup", + "system:restoreS3Backup" + ] + }, + { + "reference": "backend/internal/server/admin_routes_test.go#TestSystemDiagnosticRoutesCreateAndDownload", + "proofs": [ + "success" + ], + "operations": [ + "system:createDiagnosticBundle", + "system:downloadDiagnosticBundle" + ] + }, + { + "reference": "backend/internal/server/admin_routes_test.go#TestSystemDiagnosticRoutesRejectUnavailableServiceAndInvalidArchiveID", + "proofs": [ + "boundary" + ], + "operations": [ + "system:downloadDiagnosticBundle" + ] + }, + { + "reference": "backend/internal/server/admin_routes_test.go#TestSystemRoutesRequireAuthentication", + "proofs": [ + "negative" + ], + "operations": [ + "system:checkSystemUpdates", + "system:createDiagnosticBundle", + "system:createSystemBackup", + "system:downloadDiagnosticBundle", + "system:downloadS3Backup", + "system:downloadSystemBackup", + "system:listS3Backups", + "system:listSystemBackups", + "system:performSystemUpdate", + "system:restartSystem", + "system:restoreS3Backup", + "system:restoreSystemBackup", + "system:rollbackSystemUpdate", + "system:testBackupS3" + ] + }, + { + "reference": "backend/internal/system/service_test.go#TestPerformUpdateReturnsSuccessWhenAlreadyCurrent", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "system:performSystemUpdate" + ] + }, + { + "reference": "backend/internal/system/service_test.go#TestPerformUpdateSourceBuildRequiresManualUpdate", + "proofs": [ + "negative" + ], + "operations": [ + "system:performSystemUpdate" + ] + }, + { + "reference": "backend/internal/system/service_test.go#TestRestartExitsIsolatedProcessWhenEnabled", + "proofs": [ + "success" + ], + "operations": [ + "system:restartSystem" + ] + }, + { + "reference": "backend/internal/system/service_test.go#TestRestartRejectsDisabledMaintenance", + "proofs": [ + "negative", + "boundary" + ], + "operations": [ + "system:restartSystem" + ] + }, + { + "reference": "backend/internal/system/service_test.go#TestRollbackReplacesIsolatedExecutableAndPreservesOperation", + "proofs": [ + "success" + ], + "operations": [ + "system:rollbackSystemUpdate" + ] + }, + { + "reference": "backend/internal/system/service_test.go#TestRollbackRejectsMissingBackup", + "proofs": [ + "negative", + "boundary" + ], + "operations": [ + "system:rollbackSystemUpdate" + ] + }, + { + "reference": "backend/internal/controlplane/account_service_test.go#TestCurrentAccountProfileUpdateAndPasswordChange", + "proofs": [ + "boundary" + ], + "operations": [ + "account:updateAccountProfile" + ] + }, + { + "reference": "backend/internal/controlplane/account_service_test.go#TestRecoveryCodeCanDisableTOTP", + "proofs": [ + "negative", + "boundary" + ], + "operations": [ + "account:disableTOTP" + ] + }, + { + "reference": "backend/internal/controlplane/totp_service_test.go#TestTOTPRecoveryCodeIsConsumedAtomically", + "proofs": [ + "boundary" + ], + "operations": [ + "account:generateTOTPRecoveryCodes" + ] + }, + { + "reference": "backend/internal/controlplane/totp_service_test.go#TestTOTPSetupExpiresAndCanBeRestarted", + "proofs": [ + "boundary" + ], + "operations": [ + "account:beginTOTPSetup", + "account:confirmTOTP" + ] + }, + { + "reference": "backend/internal/server/account_error_boundary_test.go#TestAccountRoutesDoNotExposeRepositoryErrors", + "proofs": [ + "negative" + ], + "operations": [ + "account:getAccountProfile", + "account:revokeOtherAccountSessions" + ] + }, + { + "reference": "backend/internal/server/account_error_boundary_test.go#TestAccountRoutesPreservePublicValidationErrors", + "proofs": [ + "negative" + ], + "operations": [ + "account:changeAccountPassword", + "account:updateAccountProfile" + ] + }, + { + "reference": "backend/internal/server/account_routes_test.go#TestLocalAdministratorAccountIsFullyMutable", + "proofs": [ + "boundary" + ], + "operations": [ + "account:getAccountProfile" + ] + }, + { + "reference": "backend/internal/server/account_routes_test.go#TestPasswordChangeRevokesExistingBearerToken", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "account:changeAccountPassword" + ] + }, + { + "reference": "backend/internal/server/account_routes_test.go#TestRevokeOtherSessionsReturnsReplacementBearerToken", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "account:revokeOtherAccountSessions" + ] + }, + { + "reference": "backend/internal/server/account_routes_test.go#TestTOTPChangesImmediatelyRevokeExistingBearerTokens", + "proofs": [ + "success" + ], + "operations": [ + "account:beginTOTPSetup", + "account:confirmTOTP", + "account:disableTOTP", + "account:generateTOTPRecoveryCodes" + ] + }, + { + "reference": "backend/internal/server/account_routes_test.go#TestTOTPChangesImmediatelyRevokeExistingBearerTokens", + "proofs": [ + "negative" + ], + "operations": [ + "account:beginTOTPSetup", + "account:generateTOTPRecoveryCodes" + ] + }, + { + "reference": "backend/internal/server/account_routes_test.go#TestTOTPManagementAttemptsAreLimitedPerAccountAcrossRoutes", + "proofs": [ + "negative" + ], + "operations": [ + "account:confirmTOTP" + ] + }, + { + "reference": "backend/internal/server/account_routes_test.go#TestWorkspaceAccountProfileEndpoints", + "proofs": [ + "success" + ], + "operations": [ + "account:getAccountProfile", + "account:updateAccountProfile" + ] + }, + { + "reference": "backend/internal/server/alert_routes_test.go#TestAdminAlertEndpoints", + "proofs": [ + "success" + ], + "operations": [ + "control:acknowledgeAlert", + "control:getAlerts", + "control:getAlertSummary", + "control:resolveAlert" + ] + }, + { + "reference": "backend/internal/server/alert_routes_test.go#TestAdminAlertEndpoints", + "proofs": [ + "negative", + "boundary" + ], + "operations": [ + "control:acknowledgeAlert", + "control:resolveAlert" + ] + }, + { + "reference": "backend/internal/server/export_routes_test.go#TestAdminAsyncExportJobLifecycle", + "proofs": [ + "success" + ], + "operations": [ + "control:createExportJob", + "control:downloadExportJob", + "control:getExportJobs" + ] + }, + { + "reference": "backend/internal/server/export_routes_test.go#TestAdminAsyncExportJobLifecycle", + "proofs": [ + "negative", + "boundary" + ], + "operations": [ + "control:createExportJob", + "control:downloadExportJob" + ] + }, + { + "reference": "backend/internal/server/export_routes_test.go#TestAdminRecordExportEndpointsSupportQueryParameters", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "control:exportAuditLogsCSV", + "control:exportCostAllocationCSV", + "control:exportGatewayTracesCSV", + "control:exportUsageCSV" + ] + }, + { + "reference": "backend/internal/server/export_routes_test.go#TestAdminRecordExportEndpointsRequireAuthentication", + "proofs": [ + "negative" + ], + "operations": [ + "control:exportAuditLogsCSV", + "control:exportCostAllocationCSV", + "control:exportGatewayTracesCSV", + "control:exportUsageCSV" + ] + }, + { + "reference": "backend/internal/controlplane/ai_job_admin_test.go#TestAIJobAdminQueriesRedactSecretsAndSupportsSafeActions", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "control:scheduleAIJobAttemptReconciliation" + ] + }, + { + "reference": "backend/internal/controlplane/artifact_admin_test.go#TestRetryArtifactDeliverySchedulesReconciliationAndAudits", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "control:retryArtifactDelivery" + ] + }, + { + "reference": "backend/internal/server/ai_job_admin_routes_test.go#TestAdminAIJobEndpointsProvideRuntimeDetailAndSafeActions", + "proofs": [ + "success" + ], + "operations": [ + "control:cancelAIJob", + "control:getAIJob", + "control:getAIJobRuntime", + "control:getAIJobs" + ] + }, + { + "reference": "backend/internal/server/ai_job_artifact_admin_contract_test.go#TestAdminAIJobHTTPFailureAndIdempotencyContracts", + "proofs": [ + "success" + ], + "operations": [ + "control:getAIJobSummary" + ] + }, + { + "reference": "backend/internal/server/ai_job_artifact_admin_contract_test.go#TestAdminAIJobHTTPFailureAndIdempotencyContracts", + "proofs": [ + "negative", + "boundary" + ], + "operations": [ + "control:cancelAIJob", + "control:scheduleAIJobAttemptReconciliation" + ] + }, + { + "reference": "backend/internal/server/ai_job_artifact_admin_contract_test.go#TestAdminArtifactHTTPFailureAndRangeContracts", + "proofs": [ + "negative", + "boundary" + ], + "operations": [ + "control:retryArtifactDelivery" + ] + }, + { + "reference": "backend/internal/server/artifact_admin_routes_test.go#TestAdminArtifactEndpointsReturnFilteredRedactedRecords", + "proofs": [ + "success" + ], + "operations": [ + "control:getArtifact", + "control:getArtifactContent", + "control:getArtifactRuntimes", + "control:getArtifacts", + "control:getArtifactSummary" + ] + }, + { + "reference": "backend/internal/controlplane/api_key_policy_test.go#TestCreateAPIKeyNormalizesPrincipalAndExtendedPolicy", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "control:createAPIKey", + "control:updateAPIKey" + ] + }, + { + "reference": "backend/internal/controlplane/api_key_policy_test.go#TestCreateAPIKeyRejectsInvalidExtendedPolicy", + "proofs": [ + "negative" + ], + "operations": [ + "control:createAPIKey" + ] + }, + { + "reference": "backend/internal/controlplane/api_key_policy_test.go#TestRevalidateCanonicalGatewayRequestUsesCurrentCredentialAndPolicy", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "control:disableAPIKey", + "control:updateAPIKey" + ] + }, + { + "reference": "backend/internal/controlplane/api_key_rotation_test.go#TestAPIKeyRotationCreatesOneReplacementUnderConcurrency", + "proofs": [ + "boundary" + ], + "operations": [ + "control:rotateAPIKey" + ] + }, + { + "reference": "backend/internal/controlplane/application_service_test.go#TestApplicationRejectsNegativeConcurrencyLimit", + "proofs": [ + "negative" + ], + "operations": [ + "control:createApplication" + ] + }, + { + "reference": "backend/internal/controlplane/application_service_test.go#TestPlatformCredentialRequiresApplicationPrincipalAndSnapshotsGatewayEvidence", + "proofs": [ + "success" + ], + "operations": [ + "control:createApplication" + ] + }, + { + "reference": "backend/internal/controlplane/application_service_test.go#TestPlatformCredentialRequiresApplicationPrincipalAndSnapshotsGatewayEvidence", + "proofs": [ + "negative" + ], + "operations": [ + "control:updateAPIKey" + ] + }, + { + "reference": "backend/internal/controlplane/application_service_test.go#TestPlatformDomainDoesNotCreateWorkspaceUsers", + "proofs": [ + "boundary" + ], + "operations": [ + "control:createApplication" + ] + }, + { + "reference": "backend/internal/server/application_routes_test.go#TestApplicationEndpointsPersistLifecycleAndRejectInvalidUpdates", + "proofs": [ + "success" + ], + "operations": [ + "control:getApplications", + "control:updateApplication" + ] + }, + { + "reference": "backend/internal/server/application_routes_test.go#TestApplicationEndpointsPersistLifecycleAndRejectInvalidUpdates", + "proofs": [ + "negative", + "boundary" + ], + "operations": [ + "control:updateApplication" + ] + }, + { + "reference": "backend/internal/controlplane/portal_isolation_test.go#TestPortalWorkspaceIsolatesKeysUsageTracesAndMutationsByOwner", + "proofs": [ + "negative", + "boundary" + ], + "operations": [ + "control:disablePortalAPIKey", + "control:rotatePortalAPIKey" + ] + }, + { + "reference": "backend/internal/controlplane/portal_service_test.go#TestPortalKeyManagementRequiresKeyManagerRole", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "control:createPortalAPIKey", + "control:disablePortalAPIKey", + "control:rotatePortalAPIKey" + ] + }, + { + "reference": "backend/internal/controlplane/portal_service_test.go#TestPortalAPIKeyCreationRejectsReadOnlyPrincipal", + "proofs": [ + "negative" + ], + "operations": [ + "control:createPortalAPIKey" + ] + }, + { + "reference": "backend/internal/controlplane/registration_test.go#TestAuthenticationTokensAreConsumedAtomically", + "proofs": [ + "boundary" + ], + "operations": [ + "auth:forgotPassword", + "auth:resetPassword", + "auth:verifyEmail" + ] + }, + { + "reference": "backend/internal/controlplane/registration_test.go#TestConcurrentAliasRegistrationCreatesOneAccount", + "proofs": [ + "boundary" + ], + "operations": [ + "auth:register" + ] + }, + { + "reference": "backend/internal/controlplane/registration_test.go#TestInitialEmailVerificationStartsResendCooldown", + "proofs": [ + "boundary" + ], + "operations": [ + "auth:resendVerification" + ] + }, + { + "reference": "backend/internal/controlplane/registration_test.go#TestWorkspaceRegistrationValidatesEmailAndBcryptLimit", + "proofs": [ + "negative" + ], + "operations": [ + "auth:register" + ] + }, + { + "reference": "backend/internal/controlplane/service_test.go#TestCreateAPIKeyReturnsSecretOnceAndStoresHash", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "control:createAPIKey", + "control:getAPIKeys" + ] + }, + { + "reference": "backend/internal/server/admin_routes_test.go#TestAPIKeyPolicyExplanationEndpoint", + "proofs": [ + "success", + "negative", + "boundary" + ], + "operations": [ + "control:getAPIKeyPolicyExplanation" + ] + }, + { + "reference": "backend/internal/server/admin_routes_test.go#TestCreateAPIKeyEndpoint", + "proofs": [ + "success" + ], + "operations": [ + "control:createAPIKey" + ] + }, + { + "reference": "backend/internal/server/api_key_rotation_routes_test.go#TestAdminAPIKeyRotationAcceptsGracePeriod", + "proofs": [ + "success" + ], + "operations": [ + "control:rotateAPIKey" + ] + }, + { + "reference": "backend/internal/server/api_key_rotation_routes_test.go#TestAdminAPIKeyRotationRejectsInvalidGracePeriodWithoutMutation", + "proofs": [ + "negative", + "boundary" + ], + "operations": [ + "control:rotateAPIKey" + ] + }, + { + "reference": "backend/internal/server/department_routes_test.go#TestAdminDepartmentEndpoints", + "proofs": [ + "success" + ], + "operations": [ + "control:createDepartment", + "control:getDepartments", + "control:updateDepartment" + ] + }, + { + "reference": "backend/internal/server/department_routes_test.go#TestAdminDepartmentEndpoints", + "proofs": [ + "negative", + "boundary" + ], + "operations": [ + "control:createDepartment", + "control:updateDepartment" + ] + }, + { + "reference": "backend/internal/server/identity_routes_test.go#TestAdminIdentityUserAndRoleBindingEndpoints", + "proofs": [ + "success" + ], + "operations": [ + "control:createRoleBinding", + "control:createWorkspaceUser", + "control:deleteRoleBinding", + "control:getRoleBindings", + "control:getWorkspaceUsers", + "control:updateWorkspaceUser" + ] + }, + { + "reference": "backend/internal/server/identity_routes_test.go#TestAdminIdentityUserAndRoleBindingEndpoints", + "proofs": [ + "negative", + "boundary" + ], + "operations": [ + "control:createRoleBinding", + "control:createWorkspaceUser", + "control:deleteRoleBinding", + "control:getRoleBindings" + ] + }, + { + "reference": "backend/internal/server/identity_routes_test.go#TestAdminOrganizationGroupLifecycle", + "proofs": [ + "success" + ], + "operations": [ + "control:createOrganizationGroup", + "control:deleteOrganizationGroup", + "control:getOrganizationGroups", + "control:updateOrganizationGroup" + ] + }, + { + "reference": "backend/internal/server/identity_routes_test.go#TestAdminOrganizationGroupLifecycle", + "proofs": [ + "negative", + "boundary" + ], + "operations": [ + "control:createOrganizationGroup", + "control:deleteOrganizationGroup", + "control:updateOrganizationGroup" + ] + }, + { + "reference": "backend/internal/server/identity_routes_test.go#TestAdminIdentityCollectionsRequirePermission", + "proofs": [ + "negative" + ], + "operations": [ + "control:getOrganizationGroups", + "control:getRoleBindings" + ] + }, + { + "reference": "backend/internal/server/identity_routes_test.go#TestAdminUserDepartmentAssignmentValidationAndSessionRevocation", + "proofs": [ + "negative", + "boundary" + ], + "operations": [ + "control:createWorkspaceUser", + "control:updateWorkspaceUser" + ] + }, + { + "reference": "backend/internal/server/portal_routes_test.go#TestPortalForeignAPIKeyUsesNotFoundSemantics", + "proofs": [ + "negative" + ], + "operations": [ + "control:disablePortalAPIKey", + "control:rotatePortalAPIKey" + ] + }, + { + "reference": "backend/internal/server/portal_routes_test.go#TestPortalWorkspaceAndAPIKeyRoutes", + "proofs": [ + "success" + ], + "operations": [ + "control:createPortalAPIKey", + "control:disablePortalAPIKey", + "control:getPortalWorkspace", + "control:rotatePortalAPIKey" + ] + }, + { + "reference": "backend/internal/server/public_auth_routes_test.go#TestAuthenticationEmailQueueRejectionRollsBackIssuedTokens", + "proofs": [ + "success", + "negative", + "boundary" + ], + "operations": [ + "auth:forgotPassword", + "auth:resendVerification", + "auth:resetPassword", + "auth:verifyEmail" + ] + }, + { + "reference": "backend/internal/server/public_auth_routes_test.go#TestPublicAuthenticationWorkflowSecurityContract", + "proofs": [ + "success" + ], + "operations": [ + "auth:forgotPassword", + "auth:register", + "auth:resendVerification", + "auth:verifyEmail" + ] + }, + { + "reference": "backend/internal/server/public_auth_routes_test.go#TestPublicAuthenticationWorkflowSecurityContract", + "proofs": [ + "negative" + ], + "operations": [ + "auth:register", + "auth:resetPassword", + "auth:verifyEmail" + ] + }, + { + "reference": "backend/internal/server/public_auth_routes_test.go#TestRegistrationRepositoryFailureIsSanitizedServerError", + "proofs": [ + "negative" + ], + "operations": [ + "auth:register" + ] + }, + { + "reference": "backend/internal/server/rbac_test.go#TestDepartmentScopedAdministratorOnlySeesDepartmentUsersAndKeys", + "proofs": [ + "negative", + "boundary" + ], + "operations": [ + "control:disableAPIKey", + "control:getAPIKeys", + "control:rotateAPIKey" + ] + }, + { + "reference": "backend/internal/plugins/api_token_service_test.go#TestPluginAPITokenIsHashedScopedAndRevocable", + "proofs": [ + "boundary" + ], + "operations": [ + "plugins:createPluginAPIToken", + "plugins:getPluginAPITokens", + "plugins:revokePluginAPIToken" + ] + }, + { + "reference": "backend/internal/plugins/api_token_service_test.go#TestPluginAPITokenRequiresPluginBindingForActions", + "proofs": [ + "negative" + ], + "operations": [ + "plugins:createPluginAPIToken" + ] + }, + { + "reference": "backend/internal/plugins/artifact_sink_s3_test.go#TestArtifactSinkDestinationValidation", + "proofs": [ + "negative" + ], + "operations": [ + "plugins:upsertArtifactSinkDestination" + ] + }, + { + "reference": "backend/internal/plugins/artifact_sink_s3_test.go#TestArtifactSinkDestinationsSortAndProtectSecrets", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "plugins:getArtifactSinkDestinations", + "plugins:upsertArtifactSinkDestination" + ] + }, + { + "reference": "backend/internal/plugins/catalog_sync_test.go#TestServiceBootstrapOnlySyncDownloadAndActivateLicense", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "plugins:activateOfficialLicense" + ] + }, + { + "reference": "backend/internal/plugins/catalog_sync_test.go#TestServiceDownloadOfficialPackageRejectsChecksumMismatch", + "proofs": [ + "negative" + ], + "operations": [ + "plugins:downloadPluginPackage" + ] + }, + { + "reference": "backend/internal/plugins/catalog_sync_test.go#TestServiceDownloadOfficialPackageVerifiesAndCaches", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "plugins:downloadPluginPackage", + "plugins:installPluginPackage", + "plugins:uninstallPluginPackage" + ] + }, + { + "reference": "backend/internal/plugins/catalog_sync_test.go#TestServiceImportLicenseRejectsTamperedEnvelope", + "proofs": [ + "negative" + ], + "operations": [ + "plugins:importOfficialLicense" + ] + }, + { + "reference": "backend/internal/plugins/catalog_sync_test.go#TestServiceImportLicenseUnlocksPaidPackageDownload", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "plugins:importOfficialLicense" + ] + }, + { + "reference": "backend/internal/plugins/catalog_sync_test.go#TestServiceImportOfflinePackageRejectsChecksumMismatch", + "proofs": [ + "negative" + ], + "operations": [ + "plugins:importPluginPackage" + ] + }, + { + "reference": "backend/internal/plugins/catalog_sync_test.go#TestServiceImportOfflinePackageVerifiesAndCaches", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "plugins:importPluginPackage", + "plugins:installPluginPackage" + ] + }, + { + "reference": "backend/internal/plugins/catalog_sync_test.go#TestServiceInstallPackageRequiresCachedPackage", + "proofs": [ + "negative" + ], + "operations": [ + "plugins:installPluginPackage" + ] + }, + { + "reference": "backend/internal/plugins/catalog_sync_test.go#TestServiceRedeemLicenseRejectsDifferentInstance", + "proofs": [ + "negative" + ], + "operations": [ + "plugins:redeemOfficialLicense" + ] + }, + { + "reference": "backend/internal/plugins/catalog_sync_test.go#TestServiceRedeemLicenseUsesStableIdempotencyAndInstanceBinding", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "plugins:redeemOfficialLicense" + ] + }, + { + "reference": "backend/internal/plugins/catalog_sync_test.go#TestServiceSyncOfficialCatalogBlocksRevokedAdvisoryVersion", + "proofs": [ + "boundary" + ], + "operations": [ + "plugins:downloadPluginPackage", + "plugins:installPluginPackage", + "plugins:syncOfficialCatalog" + ] + }, + { + "reference": "backend/internal/plugins/catalog_sync_test.go#TestServiceSyncOfficialCatalogRejectsTamperedPayload", + "proofs": [ + "negative" + ], + "operations": [ + "plugins:syncOfficialCatalog" + ] + }, + { + "reference": "backend/internal/plugins/catalog_sync_test.go#TestServiceSyncOfficialCatalogVerifiesCachesAndMapsPlugins", + "proofs": [ + "success" + ], + "operations": [ + "plugins:getOfficialCatalogStatus", + "plugins:syncOfficialCatalog" + ] + }, + { + "reference": "backend/internal/plugins/feed_service_test.go#TestOfficialFeedImportAppliesRevocationsAndFallsBackToUsableCache", + "proofs": [ + "boundary" + ], + "operations": [ + "plugins:getOfficialFeedStatuses", + "plugins:importOfficialFeed" + ] + }, + { + "reference": "backend/internal/plugins/feed_service_test.go#TestOfficialFeedImportDecryptsAndEncryptsLocalCache", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "plugins:getOfficialFeedClientInfo", + "plugins:importOfficialFeed" + ] + }, + { + "reference": "backend/internal/plugins/feed_service_test.go#TestOfficialFeedImportRejectsWrongBindingAndEntitlement", + "proofs": [ + "negative" + ], + "operations": [ + "plugins:importOfficialFeed" + ] + }, + { + "reference": "backend/internal/plugins/feed_service_test.go#TestOfficialFeedSyncAppliesSignedRevocationBeforeFailedDownload", + "proofs": [ + "negative", + "boundary" + ], + "operations": [ + "plugins:syncOfficialFeed" + ] + }, + { + "reference": "backend/internal/plugins/feed_service_test.go#TestOfficialFeedSyncUsesSignedMetadataAndShortLivedDownloadGrant", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "plugins:getOfficialFeedSyncRuns", + "plugins:syncOfficialFeed" + ] + }, + { + "reference": "backend/internal/plugins/package_installation_test.go#TestInstallPackageRollsBackActiveDirectoryWhenRepositoryWriteFails", + "proofs": [ + "boundary" + ], + "operations": [ + "plugins:installPluginPackage" + ] + }, + { + "reference": "backend/internal/plugins/service_test.go#TestServiceCatalogIncludesEnterprisePlugins", + "proofs": [ + "boundary" + ], + "operations": [ + "plugins:getPluginCatalog" + ] + }, + { + "reference": "backend/internal/plugins/service_test.go#TestServiceFreePluginEnableDisableLifecycle", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "plugins:disablePlugin", + "plugins:enablePlugin" + ] + }, + { + "reference": "backend/internal/plugins/service_test.go#TestServiceRejectsConfiguringLockedPaidPlugin", + "proofs": [ + "negative" + ], + "operations": [ + "plugins:updatePluginConfig" + ] + }, + { + "reference": "backend/internal/plugins/service_test.go#TestServiceRejectsDisablingCorePlugin", + "proofs": [ + "negative" + ], + "operations": [ + "plugins:disablePlugin" + ] + }, + { + "reference": "backend/internal/server/plugin_routes_test.go#TestAdminArtifactSinkDestinationEndpointsAuditAndMaskSecrets", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "plugins:deleteArtifactSinkDestination", + "plugins:getArtifactSinkDestinations", + "plugins:upsertArtifactSinkDestination" + ] + }, + { + "reference": "backend/internal/server/plugin_routes_test.go#TestAdminArtifactSinkDestinationRBACAndInvalidPayloads", + "proofs": [ + "negative" + ], + "operations": [ + "plugins:deleteArtifactSinkDestination", + "plugins:upsertArtifactSinkDestination" + ] + }, + { + "reference": "backend/internal/server/plugin_routes_test.go#TestAdminOfficialFeedSyncRecordsDisabledAttempt", + "proofs": [ + "negative" + ], + "operations": [ + "plugins:syncOfficialFeed" + ] + }, + { + "reference": "backend/internal/server/plugin_routes_test.go#TestAdminOfficialFeedSyncRecordsDisabledAttempt", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "plugins:getOfficialFeedStatuses", + "plugins:getOfficialFeedSyncRuns" + ] + }, + { + "reference": "backend/internal/server/plugin_routes_test.go#TestAdminPluginAPITokenLifecycleAuditsAndRevokesAccess", + "proofs": [ + "success" + ], + "operations": [ + "plugins:createPluginAPIToken", + "plugins:getPluginAPITokens", + "plugins:revokePluginAPIToken" + ] + }, + { + "reference": "backend/internal/server/plugin_routes_test.go#TestAdminPluginAPITokenLifecycleAuditsAndRevokesAccess", + "proofs": [ + "negative" + ], + "operations": [ + "plugins:revokePluginAPIToken" + ] + }, + { + "reference": "backend/internal/server/plugin_routes_test.go#TestAdminPluginConfigEndpointsAuditAndMaskSecrets", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "plugins:getPluginConfig", + "plugins:updatePluginConfig" + ] + }, + { + "reference": "backend/internal/server/plugin_routes_test.go#TestAdminPluginDeliveriesEndpoint", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "plugins:getPluginDeliveries" + ] + }, + { + "reference": "backend/internal/server/plugin_routes_test.go#TestAdminPluginLicenseImportEndpointAuditsAndUpdatesStatus", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "plugins:getOfficialLicenseStatus", + "plugins:importOfficialLicense" + ] + }, + { + "reference": "backend/internal/server/plugin_routes_test.go#TestAdminPluginPackageDownloadEndpoint", + "proofs": [ + "success" + ], + "operations": [ + "plugins:downloadPluginPackage", + "plugins:getPluginFrontendAsset", + "plugins:getPluginWorkbench", + "plugins:getSidecarRuntimeStatus", + "plugins:installPluginPackage", + "plugins:syncOfficialCatalog", + "plugins:uninstallPluginPackage" + ] + }, + { + "reference": "backend/internal/server/plugin_routes_test.go#TestAdminPluginPackageDownloadEndpoint", + "proofs": [ + "negative", + "boundary" + ], + "operations": [ + "plugins:getPluginFrontendAsset", + "plugins:getPluginWorkbench", + "plugins:getSidecarRuntimeStatus", + "plugins:uninstallPluginPackage" + ] + }, + { + "reference": "backend/internal/server/plugin_routes_test.go#TestAdminPluginsCatalogEndpoint", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "plugins:getOfficialCatalogStatus", + "plugins:getPluginCatalog" + ] + }, + { + "reference": "backend/internal/server/plugin_routes_test.go#TestPluginRoutesRequireAuthentication", + "proofs": [ + "negative" + ], + "operations": [ + "plugins:activateOfficialLicense", + "plugins:getArtifactSinkDestinations", + "plugins:getOfficialCatalogStatus", + "plugins:getOfficialFeedClientInfo", + "plugins:getOfficialFeedStatuses", + "plugins:getOfficialFeedSyncRuns", + "plugins:getOfficialLicenseStatus", + "plugins:getPluginAPITokens", + "plugins:getPluginCatalog", + "plugins:getPluginConfig", + "plugins:getPluginDeliveries", + "plugins:getPluginFrontendAsset", + "plugins:getPluginWorkbench", + "plugins:getSidecarRuntimeStatus", + "plugins:revokePluginAPIToken", + "plugins:uninstallPluginPackage" + ] + }, + { + "reference": "backend/internal/server/plugin_routes_test.go#TestAdminPluginsEnableFreePluginAudits", + "proofs": [ + "success" + ], + "operations": [ + "plugins:enablePlugin" + ] + }, + { + "reference": "backend/internal/server/plugin_routes_test.go#TestAdminPluginsRejectLockedPaidPlugin", + "proofs": [ + "negative" + ], + "operations": [ + "plugins:enablePlugin" + ] + }, + { + "reference": "backend/internal/server/rbac_test.go#TestAdminRBACProtectsPluginAndSystemWrites", + "proofs": [ + "negative" + ], + "operations": [ + "plugins:syncOfficialCatalog" + ] + }, + { + "reference": "backend/internal/controlplane/multi_cloud_routing_test.go#TestModelRouteRequiresProviderCompatibleUpstreamFormat", + "proofs": [ + "boundary" + ], + "operations": [ + "control:createModelRoute" + ] + }, + { + "reference": "backend/internal/controlplane/provider_account_model_service_test.go#TestBulkCreateModelRoutesValidatesWholeBatchBeforeWrite", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "control:bulkCreateModelRoutes" + ] + }, + { + "reference": "backend/internal/controlplane/provider_account_model_service_test.go#TestCreateProviderAccountAllowsEmptyInventoryBeforeDiscovery", + "proofs": [ + "boundary" + ], + "operations": [ + "control:createProviderAccount", + "control:discoverProviderAccountModels", + "control:getProviderAccountModelInventory" + ] + }, + { + "reference": "backend/internal/controlplane/provider_account_model_service_test.go#TestProviderAccountModelSyncAllowsDisablingEveryModel", + "proofs": [ + "boundary" + ], + "operations": [ + "control:syncProviderAccountModels" + ] + }, + { + "reference": "backend/internal/controlplane/provider_account_model_service_test.go#TestProviderAccountModelSyncTracksDiffAndAffectedRoutes", + "proofs": [ + "success" + ], + "operations": [ + "control:discoverProviderAccountModels", + "control:syncProviderAccountModels" + ] + }, + { + "reference": "backend/internal/controlplane/provider_account_model_service_test.go#TestProviderModelDiscoveryRejectsRedirectsBeforeForwardingSecret", + "proofs": [ + "negative" + ], + "operations": [ + "control:discoverProviderAccountModels" + ] + }, + { + "reference": "backend/internal/controlplane/provider_account_runtime_settings_test.go#TestDeleteProviderAccountProtectsModelRoutes", + "proofs": [ + "boundary" + ], + "operations": [ + "control:deleteProviderAccount" + ] + }, + { + "reference": "backend/internal/controlplane/pricing_service_test.go#TestPricingRuleLifecycleValidationAndCAS", + "proofs": [ + "success" + ], + "operations": [ + "control:createPricingRule", + "control:publishPricingRule", + "control:updatePricingRuleDraft", + "control:validatePricingRule" + ] + }, + { + "reference": "backend/internal/controlplane/pricing_service_test.go#TestPricingRuleLifecycleValidationAndCAS", + "proofs": [ + "boundary" + ], + "operations": [ + "control:publishPricingRule", + "control:updatePricingRuleDraft", + "control:validatePricingRule" + ] + }, + { + "reference": "backend/internal/controlplane/pricing_service_test.go#TestPricingRuleRejectsUnsupportedCurrencyAndLegacySlots", + "proofs": [ + "negative" + ], + "operations": [ + "control:createPricingRule" + ] + }, + { + "reference": "backend/internal/controlplane/service_test.go#TestCheckProviderAccountProbesModelsAndPersistsHealth", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "control:checkProviderAccount", + "control:getProviderAccountHealthChecks", + "control:getProviderAccountModelInventory", + "control:getProviderAccounts" + ] + }, + { + "reference": "backend/internal/controlplane/service_test.go#TestClearProviderAccountCooldownMakesAccountImmediatelyEligible", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "control:clearProviderAccountCooldown" + ] + }, + { + "reference": "backend/internal/controlplane/service_test.go#TestCreateProviderAccountRejectsLegacyAuthTypes", + "proofs": [ + "negative" + ], + "operations": [ + "control:createProviderAccount" + ] + }, + { + "reference": "backend/internal/controlplane/service_test.go#TestCreateProviderRequiresAbsoluteURL", + "proofs": [ + "negative", + "boundary" + ], + "operations": [ + "control:createProvider" + ] + }, + { + "reference": "backend/internal/controlplane/service_test.go#TestProviderAccountLifecyclePreservesEncryptedSecretAndUpdatesGroupCounts", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "control:createProviderAccount", + "control:getProviderAccounts", + "control:updateProviderAccount" + ] + }, + { + "reference": "backend/internal/controlplane/service_test.go#TestProviderConnectionOwnsEndpointStateOnly", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "control:checkProvider", + "control:createProvider", + "control:getProviderHealthChecks" + ] + }, + { + "reference": "backend/internal/server/admin_routes_test.go#TestAdminGatewayModelAndRouteEndpoints", + "proofs": [ + "success" + ], + "operations": [ + "control:bulkCreateModelRoutes", + "control:createGatewayModel", + "control:createModelRoute", + "control:deleteGatewayModel", + "control:deleteModelRoute", + "control:getGatewayModels", + "control:getModelRoutes", + "control:updateGatewayModel", + "control:updateModelRoute" + ] + }, + { + "reference": "backend/internal/server/admin_routes_test.go#TestAdminGatewayModelAndRouteEndpoints", + "proofs": [ + "boundary" + ], + "operations": [ + "control:createModelRoute", + "control:deleteGatewayModel", + "control:getGatewayModels", + "control:getModelRoutes" + ] + }, + { + "reference": "backend/internal/server/admin_routes_test.go#TestAdminSupplyCollectionsExposeEmptyArraysAndFirstModelDefaults", + "proofs": [ + "boundary" + ], + "operations": [ + "control:createGatewayModel", + "control:getGatewayModels", + "control:getProviderBillingSources", + "control:getProviderCacheCapabilities", + "control:getProviders" + ] + }, + { + "reference": "backend/internal/server/admin_routes_test.go#TestAdminGatewayModelAndRouteMissingResourceContracts", + "proofs": [ + "boundary" + ], + "operations": [ + "control:deleteGatewayModel", + "control:deleteModelRoute", + "control:updateGatewayModel", + "control:updateModelRoute" + ] + }, + { + "reference": "backend/internal/server/admin_routes_test.go#TestAdminProviderAccountDeleteProtectsModelRoutes", + "proofs": [ + "negative" + ], + "operations": [ + "control:deleteProviderAccount" + ] + }, + { + "reference": "backend/internal/server/admin_routes_test.go#TestAdminProviderAccountMissingResourceContracts", + "proofs": [ + "boundary" + ], + "operations": [ + "control:checkProviderAccount", + "control:clearProviderAccountCooldown", + "control:deleteProviderAccount", + "control:updateProviderAccount" + ] + }, + { + "reference": "backend/internal/server/admin_routes_test.go#TestAdminProviderAccountModelEndpoints", + "proofs": [ + "success" + ], + "operations": [ + "control:discoverProviderAccountModels", + "control:getProviderAccountModelInventory", + "control:syncProviderAccountModels" + ] + }, + { + "reference": "backend/internal/server/admin_routes_test.go#TestAdminProviderAccountModelEndpointsRejectMissingAccount", + "proofs": [ + "boundary" + ], + "operations": [ + "control:discoverProviderAccountModels", + "control:getProviderAccountModelInventory", + "control:syncProviderAccountModels" + ] + }, + { + "reference": "backend/internal/server/admin_routes_test.go#TestAdminRoutingGroupsAndProviderAccountsEndpoints", + "proofs": [ + "success" + ], + "operations": [ + "control:checkProviderAccount", + "control:createProvider", + "control:createProviderAccount", + "control:createRoutingGroup", + "control:deleteProviderAccount", + "control:getProviderAccountHealthChecks", + "control:getProviderAccounts", + "control:getRoutingGroups", + "control:updateProviderAccount", + "control:updateRoutingGroup" + ] + }, + { + "reference": "backend/internal/server/admin_routes_test.go#TestAdminRoutingGroupBoundaryContracts", + "proofs": [ + "boundary" + ], + "operations": [ + "control:createRoutingGroup", + "control:updateRoutingGroup" + ] + }, + { + "reference": "backend/internal/server/admin_routes_test.go#TestAdminRoutingPolicyEndpoints", + "proofs": [ + "success" + ], + "operations": [ + "control:createRoutingPolicy", + "control:getRoutingPolicies", + "control:updateRoutingPolicy" + ] + }, + { + "reference": "backend/internal/server/admin_routes_test.go#TestAdminRoutingPolicyEndpoints", + "proofs": [ + "boundary" + ], + "operations": [ + "control:createRoutingPolicy", + "control:updateRoutingPolicy" + ] + }, + { + "reference": "backend/internal/server/admin_routes_test.go#TestAdminGatewaySimulatorContracts", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "control:simulateGatewayRouting" + ] + }, + { + "reference": "backend/internal/server/admin_routes_test.go#TestAdminGovernancePolicyEndpoints", + "proofs": [ + "success" + ], + "operations": [ + "control:createGovernancePolicy", + "control:getGovernancePolicies", + "control:updateGovernancePolicy" + ] + }, + { + "reference": "backend/internal/server/admin_routes_test.go#TestAdminGovernancePolicyEndpoints", + "proofs": [ + "negative", + "boundary" + ], + "operations": [ + "control:createGovernancePolicy", + "control:updateGovernancePolicy" + ] + }, + { + "reference": "backend/internal/server/admin_routes_test.go#TestCheckProviderEndpoint", + "proofs": [ + "success" + ], + "operations": [ + "control:checkProvider", + "control:getProviderHealthChecks" + ] + }, + { + "reference": "backend/internal/server/admin_routes_test.go#TestCheckProviderEndpoint", + "proofs": [ + "boundary" + ], + "operations": [ + "control:checkProvider" + ] + }, + { + "reference": "backend/internal/server/admin_routes_test.go#TestProviderEndpointRejectsLegacyCredentialAndModelFields", + "proofs": [ + "success" + ], + "operations": [ + "control:createProvider", + "control:getProviders", + "control:updateProvider" + ] + }, + { + "reference": "backend/internal/server/admin_routes_test.go#TestProviderEndpointRejectsLegacyCredentialAndModelFields", + "proofs": [ + "boundary" + ], + "operations": [ + "control:createProvider", + "control:updateProvider" + ] + }, + { + "reference": "backend/internal/server/pricing_rule_routes_test.go#TestPricingRuleHTTPVersionLifecycleAndFailureContracts", + "proofs": [ + "success" + ], + "operations": [ + "control:activatePricingRuleVersion", + "control:createPricingRule", + "control:disablePricingRule", + "control:getPricingEvaluation", + "control:getPricingRule", + "control:getPricingRules", + "control:publishPricingRule", + "control:simulatePricingRule", + "control:updatePricingRuleDraft", + "control:validatePricingRule" + ] + }, + { + "reference": "backend/internal/server/pricing_rule_routes_test.go#TestPricingRuleHTTPVersionLifecycleAndFailureContracts", + "proofs": [ + "boundary" + ], + "operations": [ + "control:activatePricingRuleVersion", + "control:disablePricingRule", + "control:getPricingEvaluation", + "control:getPricingRule", + "control:getPricingRules", + "control:publishPricingRule", + "control:simulatePricingRule", + "control:updatePricingRuleDraft", + "control:validatePricingRule" + ] + }, + { + "reference": "backend/internal/server/pricing_rule_routes_test.go#TestPricingRuleRoutesOnlyAcceptEnterpriseUsageCostRules", + "proofs": [ + "negative", + "boundary" + ], + "operations": [ + "control:createPricingRule" + ] + }, + { + "reference": "backend/internal/controlplane/effective_pricing_monitor_test.go#TestEffectivePricingDecisionMonitorPromotesAfterConsecutiveHealthyWindows", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "control:getEffectivePricingDecisionEvaluations" + ] + }, + { + "reference": "backend/internal/controlplane/effective_pricing_probe_test.go#TestRunProviderCacheProbeObservesReuseAndCapturesEvidence", + "proofs": [ + "success" + ], + "operations": [ + "control:runProviderCacheProbe" + ] + }, + { + "reference": "backend/internal/controlplane/effective_pricing_probe_test.go#TestRunProviderCacheProbeSkipsWithoutSpendingWhenDisabledOrBudgetExhausted", + "proofs": [ + "boundary" + ], + "operations": [ + "control:runProviderCacheProbe" + ] + }, + { + "reference": "backend/internal/controlplane/effective_pricing_service_test.go#TestCreateProcurementPriceRequiresExplicitCacheComponents", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "control:createProcurementPrice" + ] + }, + { + "reference": "backend/internal/controlplane/effective_pricing_service_test.go#TestEffectivePricingDecisionCanaryOrdersCandidateAndRollbackStopsIt", + "proofs": [ + "success", + "negative", + "boundary" + ], + "operations": [ + "control:actOnEffectivePricingDecision" + ] + }, + { + "reference": "backend/internal/controlplane/effective_pricing_service_test.go#TestEffectivePricingDecisionKeepsGatewayAndUpstreamModelEvidenceSeparate", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "control:evaluateEffectivePricingDecision", + "control:getEffectivePricingDecisions", + "control:getEffectivePricingReport" + ] + }, + { + "reference": "backend/internal/controlplane/effective_pricing_service_test.go#TestEffectivePricingReportDistinguishesZeroCostFromMissingEvidence", + "proofs": [ + "boundary" + ], + "operations": [ + "control:getEffectivePricingReport" + ] + }, + { + "reference": "backend/internal/controlplane/effective_pricing_service_test.go#TestEffectivePricingReportRanksRealCostInsteadOfQuotedMultiplier", + "proofs": [ + "success" + ], + "operations": [ + "control:getEffectivePricingReport" + ] + }, + { + "reference": "backend/internal/controlplane/effective_pricing_service_test.go#TestEvaluateEffectivePricingDecisionRequiresUpstreamModel", + "proofs": [ + "boundary" + ], + "operations": [ + "control:evaluateEffectivePricingDecision" + ] + }, + { + "reference": "backend/internal/controlplane/effective_pricing_service_test.go#TestImportProviderBillingLineReconcilesUsageByUpstreamRequestID", + "proofs": [ + "success" + ], + "operations": [ + "control:createProviderBillingLine" + ] + }, + { + "reference": "backend/internal/controlplane/effective_pricing_service_test.go#TestUpsertProviderCacheCapabilityProtectsObservedEvidence", + "proofs": [ + "success", + "negative", + "boundary" + ], + "operations": [ + "control:updateProviderCacheCapability" + ] + }, + { + "reference": "backend/internal/controlplane/effective_pricing_service_test.go#TestUpsertProviderCacheCapabilityRejectsReservedAffinityField", + "proofs": [ + "negative" + ], + "operations": [ + "control:updateProviderCacheCapability" + ] + }, + { + "reference": "backend/internal/controlplane/provider_billing_adapter_test.go#TestInspectProviderBillingSourceUsesProcurementAccountSecretAndAudits", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "control:inspectProviderBillingSource" + ] + }, + { + "reference": "backend/internal/controlplane/provider_billing_sync_service_test.go#TestProviderBillingSourceServiceCASAndDisabledManualSync", + "proofs": [ + "negative", + "boundary" + ], + "operations": [ + "control:syncProviderBillingSource", + "control:updateProviderBillingSource" + ] + }, + { + "reference": "backend/internal/controlplane/provider_billing_sync_service_test.go#TestProviderBillingSourceServicePersistsAggregateEvidenceWithoutBalance", + "proofs": [ + "success" + ], + "operations": [ + "control:getProviderBillingSourceEvidence", + "control:syncProviderBillingSource", + "control:updateProviderBillingSource" + ] + }, + { + "reference": "backend/internal/controlplane/provider_billing_sync_service_test.go#TestProviderBillingSourceServicePersistsStableFailureCodeWithoutUpstreamBody", + "proofs": [ + "negative", + "boundary" + ], + "operations": [ + "control:getProviderBillingSourceEvidence", + "control:syncProviderBillingSource" + ] + }, + { + "reference": "backend/internal/server/effective_pricing_routes_test.go#TestEffectivePricingAdminEndpointsCreatePriceAndReconcileBilling", + "proofs": [ + "success" + ], + "operations": [ + "control:createProcurementPrice", + "control:createProviderBillingLine", + "control:getEffectivePricingReport", + "control:getProcurementPrices", + "control:getProviderCacheCapabilities", + "control:updateProviderCacheCapability" + ] + }, + { + "reference": "backend/internal/server/effective_pricing_routes_test.go#TestEffectivePricingAdminEndpointsCreatePriceAndReconcileBilling", + "proofs": [ + "boundary" + ], + "operations": [ + "control:createProcurementPrice", + "control:createProviderBillingLine", + "control:getEffectivePricingReport" + ] + }, + { + "reference": "backend/internal/server/effective_pricing_routes_test.go#TestEffectivePricingDecisionEvaluationsEndpointReturnsEmptyHistory", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "control:getEffectivePricingDecisionEvaluations" + ] + }, + { + "reference": "backend/internal/server/effective_pricing_routes_test.go#TestEffectivePricingPolicyEndpointRejectsUnsafeValues", + "proofs": [ + "success", + "negative", + "boundary" + ], + "operations": [ + "control:updateEffectivePricingPolicy" + ] + }, + { + "reference": "backend/internal/server/effective_pricing_routes_test.go#TestProviderBillingSourceInspectionEndpointDetectsSub2APIWithoutInventingLines", + "proofs": [ + "success" + ], + "operations": [ + "control:getProviderBillingSourceEvidence", + "control:getProviderBillingSources", + "control:inspectProviderBillingSource", + "control:syncProviderBillingSource", + "control:updateProviderBillingSource" + ] + }, + { + "reference": "backend/internal/server/effective_pricing_routes_test.go#TestProviderBillingSourceInspectionEndpointDetectsSub2APIWithoutInventingLines", + "proofs": [ + "boundary" + ], + "operations": [ + "control:getProviderBillingSourceEvidence", + "control:inspectProviderBillingSource", + "control:updateProviderBillingSource" + ] + }, + { + "reference": "backend/internal/server/effective_pricing_routes_test.go#TestProviderCacheProbeEndpointRunsControlledSequenceAndRejectsMissingConfirmation", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "control:getProviderCacheProbeRuns", + "control:runProviderCacheProbe" + ] + }, + { + "reference": "backend/internal/server/rbac_test.go#TestAdminRBACRejectsDeveloperSupplyAndPricingOperations", + "proofs": [ + "negative" + ], + "operations": [ + "control:activatePricingRuleVersion", + "control:actOnEffectivePricingDecision", + "control:bulkCreateModelRoutes", + "control:checkProvider", + "control:checkProviderAccount", + "control:clearProviderAccountCooldown", + "control:createGatewayModel", + "control:createModelRoute", + "control:createPricingRule", + "control:createProcurementPrice", + "control:createProvider", + "control:createProviderAccount", + "control:createProviderBillingLine", + "control:createRoutingGroup", + "control:createRoutingPolicy", + "control:deleteGatewayModel", + "control:deleteModelRoute", + "control:deleteProviderAccount", + "control:disablePricingRule", + "control:discoverProviderAccountModels", + "control:evaluateEffectivePricingDecision", + "control:getEffectivePricingDecisionEvaluations", + "control:getEffectivePricingDecisions", + "control:getEffectivePricingReport", + "control:getGatewayModels", + "control:getModelRoutes", + "control:getPricingEvaluation", + "control:getPricingRule", + "control:getPricingRules", + "control:getProcurementPrices", + "control:getProviderAccountHealthChecks", + "control:getProviderAccountModelInventory", + "control:getProviderAccounts", + "control:getProviderHealthChecks", + "control:getProviderBillingSourceEvidence", + "control:getProviderBillingSources", + "control:getProviderCacheCapabilities", + "control:getProviderCacheProbeRuns", + "control:getProviders", + "control:getRoutingGroups", + "control:getRoutingPolicies", + "control:inspectProviderBillingSource", + "control:publishPricingRule", + "control:runProviderCacheProbe", + "control:simulateGatewayRouting", + "control:simulatePricingRule", + "control:syncProviderAccountModels", + "control:syncProviderBillingSource", + "control:updateEffectivePricingPolicy", + "control:updateGatewayModel", + "control:updateModelRoute", + "control:updatePricingRuleDraft", + "control:updateProvider", + "control:updateProviderAccount", + "control:updateProviderBillingSource", + "control:updateProviderCacheCapability", + "control:updateRoutingGroup", + "control:updateRoutingPolicy", + "control:validatePricingRule" + ] + }, + { + "reference": "backend/internal/server/server_test.go#TestLoginAllowsConsoleSettingsAccess", + "proofs": [ + "success" + ], + "operations": [ + "settings:getAdminSettings" + ] + }, + { + "reference": "backend/internal/server/server_test.go#TestConsoleSettingsRequiresToken", + "proofs": [ + "negative" + ], + "operations": [ + "settings:getAdminSettings" + ] + }, + { + "reference": "backend/internal/server/server_test.go#TestSetupEndpointCompletesEnterpriseInitialization", + "proofs": [ + "success" + ], + "operations": [ + "settings:completeEnterpriseSetup" + ] + }, + { + "reference": "backend/internal/server/server_test.go#TestSetupEndpointSerializesConcurrentRequests", + "proofs": [ + "boundary" + ], + "operations": [ + "settings:completeEnterpriseSetup" + ] + }, + { + "reference": "backend/internal/server/server_test.go#TestSetupEndpointReturnsSanitizedServerErrorWhenPersistenceFails", + "proofs": [ + "negative" + ], + "operations": [ + "settings:completeEnterpriseSetup" + ] + }, + { + "reference": "backend/internal/server/server_test.go#TestSetupEndpointRejectsInvalidOrganization", + "proofs": [ + "negative", + "boundary" + ], + "operations": [ + "settings:completeEnterpriseSetup" + ] + }, + { + "reference": "backend/internal/server/server_test.go#TestLegalDocumentEndpointReturnsPublishedDocumentAndRejectsUnknownSlug", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "settings:getLegalDocument" + ] + }, + { + "reference": "backend/internal/settings/service_test.go#TestAuthenticationSettingsRoundTrip", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "settings:getAdminSettings", + "settings:updateAdminSettings" + ] + }, + { + "reference": "backend/internal/server/rbac_test.go#TestAdminRBACAllowsGlobalAuditorReadAndBlocksWrites", + "proofs": [ + "negative" + ], + "operations": [ + "settings:updateAdminSettings" + ] + }, + { + "reference": "backend/internal/controlplane/retention_service_test.go#TestCleanupRetainedDataDeletesExpiredRecordsButPreservesActiveAlertsAndAuditEvidence", + "proofs": [ + "success", + "boundary" + ], + "operations": [ + "settings:runRetentionCleanup" + ] + }, + { + "reference": "backend/internal/server/rbac_test.go#TestAdminRBACProtectsPluginAndSystemWrites", + "proofs": [ + "negative" + ], + "operations": [ + "settings:runRetentionCleanup" + ] + }, + { + "reference": "backend/internal/server/email_settings_test.go#TestEmailTemplateRoutesExposeLowercaseCatalogAndIndependentUpdates", + "proofs": [ + "success" + ], + "operations": [ + "settings:getEmailTemplate", + "settings:getEmailTemplateCatalog", + "settings:previewEmailTemplate", + "settings:restoreEmailTemplate", + "settings:updateEmailTemplate" + ] + }, + { + "reference": "backend/internal/server/email_settings_test.go#TestEmailTemplateRoutesExposeLowercaseCatalogAndIndependentUpdates", + "proofs": [ + "negative", + "boundary" + ], + "operations": [ + "settings:previewEmailTemplate", + "settings:updateEmailTemplate" + ] + }, + { + "reference": "backend/internal/settings/service_test.go#TestEmailTemplateRestoreDoesNotOverwriteConcurrentSettings", + "proofs": [ + "negative", + "boundary" + ], + "operations": [ + "settings:restoreEmailTemplate" + ] + }, + { + "reference": "backend/internal/settings/service_test.go#TestEmailTemplateUpdateDoesNotOverwriteConcurrentSettings", + "proofs": [ + "negative", + "boundary" + ], + "operations": [ + "settings:updateEmailTemplate" + ] + }, + { + "reference": "backend/internal/settings/service_test.go#TestEmailTemplatesListUpdateAndRestore", + "proofs": [ + "boundary" + ], + "operations": [ + "settings:getEmailTemplate", + "settings:getEmailTemplateCatalog", + "settings:restoreEmailTemplate" + ] + }, + { + "reference": "backend/internal/server/email_settings_test.go#TestSMTPRoutesDeliverPlainAndRenderedMessagesOverSTARTTLS", + "proofs": [ + "success" + ], + "operations": [ + "settings:testEmailTemplate", + "settings:testSMTP", + "settings:testSMTPConnection" + ] + }, + { + "reference": "backend/internal/server/email_settings_test.go#TestSMTPRoutesRejectInvalidRecipientsAndTemplates", + "proofs": [ + "negative" + ], + "operations": [ + "settings:testEmailTemplate", + "settings:testSMTP" + ] + }, + { + "reference": "backend/internal/server/email_settings_test.go#TestSMTPConnectionRouteUsesRequestConfiguration", + "proofs": [ + "negative" + ], + "operations": [ + "settings:testSMTPConnection" + ] + }, + { + "reference": "backend/internal/auth/mailer_test.go#TestSMTPConnectionTestRequiresSTARTTLS", + "proofs": [ + "boundary" + ], + "operations": [ + "settings:testSMTPConnection" + ] + }, + { + "reference": "backend/internal/auth/mailer_test.go#TestSMTPTLSConfigAppendsSSL_CERTFile", + "proofs": [ + "boundary" + ], + "operations": [ + "settings:testEmailTemplate", + "settings:testSMTP", + "settings:testSMTPConnection" + ] + }, + { + "reference": "backend/internal/auth/mailer_test.go#TestSMTPMailerRejectsInjectedHeadersBeforeDial", + "proofs": [ + "boundary" + ], + "operations": [ + "settings:testEmailTemplate", + "settings:testSMTP" + ] + }, + { + "reference": "backend/internal/system/archive_test.go#TestDiagnosticBundleIsRedacted", + "proofs": [ + "boundary" + ], + "operations": [ + "system:createDiagnosticBundle" + ] + } + ] +} diff --git a/docs/test/v1/performance-baseline.ubuntu-24.04.json b/docs/test/v1/performance-baseline.ubuntu-24.04.json new file mode 100644 index 0000000..a0cad08 --- /dev/null +++ b/docs/test/v1/performance-baseline.ubuntu-24.04.json @@ -0,0 +1,12 @@ +{ + "schema_version": 1, + "status": "bootstrap", + "environment": "GitHub Actions ubuntu-24.04", + "source": "Run the nightly benchmark on the protected default branch, review five samples, then replace this bootstrap file with confirmed medians.", + "thresholds": { + "ns_per_op_ratio": 1.2, + "bytes_per_op_ratio": 1.1, + "allocs_per_op_ratio": 1.1 + }, + "benchmarks": {} +} diff --git a/docs/test/v1/routing-policy-algorithm-matrix.md b/docs/test/v1/routing-policy-algorithm-matrix.md new file mode 100644 index 0000000..5846d57 --- /dev/null +++ b/docs/test/v1/routing-policy-algorithm-matrix.md @@ -0,0 +1,115 @@ +# 路由策略算法验收矩阵 + +> 状态:`CURRENT` +> 适用版本:`v0.24.0` +> 事实源:[`README.md`](./README.md)、[`scenario-registry.json`](./scenario-registry.json) +> 产品边界:企业 AI Gateway 的静态策略、运行时调度、路由试算与执行证据 + +## 1. 决策链路 + +路由不是单一排序函数。一次企业请求按以下顺序收敛: + +1. 鉴权与访问策略检查模型、操作、模态、额度和预算。 +2. Planner 解析网关模型和路由组,选择生效中的路由策略。 +3. 静态策略应用模型/协议准入、原生协议、资源批次、四种偏好和价格护栏。 +4. 真实 Gateway 按应用、主体和凭据生成有效成本 cohort,再在当前资源批次内应用已批准的动态优化。 +5. 真实 Gateway 应用会话粘性;粘性作用域同时包含访问策略版本和路由策略 ID/版本。 +6. 调度器逐候选申请熔断、并发、RPM 和 TPM 许可,只允许首字节前故障切换。 +7. Trace 保存最终线路、策略标识、策略版本、偏好、淘汰原因和每次尝试结果。 + +试算器与 Planner 共用第 3 步的硬约束,并只读投影当前容量。试算器不持有最终应用、主体、凭据、sticky key 或有效成本 cohort,因此不能代替第 4 至第 6 步的真实 Gateway 证据。 + +## 2. 验收矩阵 + +| 维度 | 预期合同 | 直接证据 | 证明层 | 状态 | +| --- | --- | --- | --- | --- | +| 策略标识与版本 | 创建为 v1;更新后递增;Planner、Simulator 和 Trace 暴露实际策略 ID、版本与偏好 | `backend/internal/controlplane/service_test.go#TestRoutingPolicyLifecycleNormalizesStrategyAndVersionsUpdates`;`backend/internal/controlplane/routing_policy_runtime_test.go#TestRoutingPolicySimulatorMatchesPlannerHardConstraints`;`backend/internal/server/gateway_routes_test.go#TestRoutingPolicyUpdateInvalidatesStickyGatewaySelection` | L1/L2 | 通过 | +| 模型准入 | allowlist 之外或 denylist 命中的基础/限定模型均阻断,Planner 与 Simulator 原因一致 | `backend/internal/controlplane/routing_policy_runtime_test.go#TestRoutingPolicyModelScopeAndPriceGuardrails`;`backend/internal/controlplane/routing_policy_runtime_test.go#TestRoutingPolicySimulatorMatchesPlannerHardConstraints` | L1/L2 | 通过 | +| 协议准入 | 13 种受支持协议可配置;未知协议拒绝;deny 优先于 allow;Planner 与 Simulator 原因一致 | `backend/internal/controlplane/routing_policy_runtime_test.go#TestRoutingPolicyProtocolMatrixIsCompleteAndRejectsUnknownValues`;`backend/internal/controlplane/routing_policy_runtime_test.go#TestRoutingPolicyProtocolAdmissionAndNativeProtocolAreEnforcedByPlanner`;`backend/internal/controlplane/routing_policy_runtime_test.go#TestRoutingPolicySimulatorMatchesPlannerHardConstraints` | L1/L2 | 通过 | +| 原生协议 | `native_protocol_only` 仅保留无需跨协议转换的上游格式 | `backend/internal/controlplane/routing_policy_runtime_test.go#TestRoutingPolicyProtocolAdmissionAndNativeProtocolAreEnforcedByPlanner` | L2 | 通过 | +| 四种偏好 | 成本优先、速度优先、稳定优先、综合均衡在信号冲突时均确定性选中对应线路 | `backend/internal/controlplane/routing_policy_runtime_test.go#TestRoutingPolicyPresetsResolveConflictingSignalsDeterministically` | L2 | 通过 | +| 输入价格绝对上限 | 超限候选淘汰并记录 `routing_policy_input_price_exceeded` | `backend/internal/controlplane/routing_policy_runtime_test.go#TestRoutingPolicyPriceExclusionsRemainExplainable` | L1 | 通过 | +| 输出价格绝对上限 | 超限候选淘汰并记录 `routing_policy_output_price_exceeded` | `backend/internal/controlplane/routing_policy_runtime_test.go#TestRoutingPolicyPriceExclusionsRemainExplainable` | L1 | 通过 | +| 相对最低价 | 每个资源批次独立计算最低价;超过倍数的同批次候选淘汰并记录 `routing_policy_relative_price_exceeded`;更便宜或零价的备用批次不得提前淘汰主批次 | `backend/internal/controlplane/routing_policy_runtime_test.go#TestRoutingPolicyPriceExclusionsRemainExplainable`;`backend/internal/controlplane/routing_policy_runtime_test.go#TestRoutingPolicyOrdersBatchesAndControlsFailover`;`frontend/e2e/routing-policy.spec.ts#@e2e-routing-policy-001` | L1/L2/Gate A | 通过 | +| 价格事实缺失 | 部分候选有价格事实时,缺失候选记录 `routing_policy_price_fact_missing` | `backend/internal/controlplane/routing_policy_runtime_test.go#TestRoutingPolicyPriceExclusionsRemainExplainable` | L1 | 通过 | +| 低价池 | 自动模式使用归一化比例与最少候选;严格模式只保留最低价;成本优先在池内按价格排序 | `backend/internal/controlplane/routing_policy_runtime_test.go#TestRoutingPolicyAutomaticLowPricePoolUsesNormalizedDefaults` | L1 | 通过 | +| 有序资源批次 | 只使用策略列出的账号并保持批次顺序;粘性和动态有效成本均不得跨越当前批次 | `backend/internal/controlplane/routing_policy_runtime_test.go#TestRoutingPolicyOrdersBatchesAndControlsFailover`;`backend/internal/controlplane/effective_pricing_service_test.go#TestEffectivePricingDecisionCanaryOrdersCandidateAndRollbackStopsIt` | L1/L2 | 通过 | +| 首字节前故障切换 | 关闭时只尝试首选线路;开启时首选失败后尝试备用线路;Trace 区分 excluded、failed、selected | `backend/internal/server/gateway_routes_test.go#TestRoutingPolicyFailoverToggleControlsRealGatewayAttempts`;`backend/internal/server/gateway_routes_test.go#TestGatewayStreamingFallsBackBeforeFirstClientEvent` | L2 | 通过 | +| 首字节后禁止切换 | 流已经向客户端输出后发生断连,不调用备用线路,避免重复响应 | `backend/internal/server/gateway_routes_test.go#TestGatewayStreamingInterruptionRecordsErrorWithoutUnsafeFailover` | L2 | 通过 | +| 粘性路由 | 同一作用域优先复用账号;新会话可复用供应商;不同客户和路由组隔离;TTL 到期失效 | `backend/internal/controlplane/gateway_scheduler_test.go#TestGatewayCandidateAffinityReusesAccountThenSupplierWithinScope` | L1 | 通过 | +| 策略版本使粘性失效 | 路由策略从 v1 更新到 v2 后,不得复用旧绑定,真实请求按 v2 重新选择 | `backend/internal/controlplane/routing_policy_runtime_test.go#TestRoutingPolicyVersionInvalidatesAffinityScope`;`backend/internal/server/gateway_routes_test.go#TestRoutingPolicyUpdateInvalidatesStickyGatewaySelection` | L1/L2 | 通过 | +| 动态有效成本 | 只在 `smart_optimization=true` 且决策为 canary/active 时生效;不跨批次;回滚立即停止;真实 HTTP 请求和 Trace 使用被提升线路 | `backend/internal/controlplane/effective_pricing_service_test.go#TestEffectivePricingDecisionCanaryOrdersCandidateAndRollbackStopsIt`;`backend/internal/server/gateway_routes_test.go#TestRoutingPolicySmartOptimizationReordersRealGatewayRequest` | L1/L2 | 通过 | +| cohort 稳定与隔离 | 同一客户跨会话 cohort 稳定且不暴露原始身份;不同客户隔离;抽样比例近似配置值 | `backend/internal/controlplane/gateway_scheduler_test.go#TestGatewayCandidateAffinityReusesAccountThenSupplierWithinScope`;`backend/internal/controlplane/effective_pricing_service_test.go#TestEffectivePricingCanaryUsesStableCohortDistribution` | L1 | 通过 | +| 路由/账号/供应商状态 | 停用路由、停用账号、停用供应商、账号过期、账号冷却和熔断打开均被 Planner 与 Simulator 淘汰并给出精确原因 | `backend/internal/controlplane/gateway_pipeline_test.go#TestPlanCanonicalGatewayRequestRecordsCandidateExclusions` | L2 | 通过 | +| 熔断恢复 | 达阈值后打开;到期进入半开;只允许一个探测请求;成功后恢复关闭 | `backend/internal/controlplane/gateway_scheduler_test.go#TestProviderAccountCircuitOpensAndHalfOpenProbeIsExclusive` | L1 | 通过 | +| 账号容量 | 达并发上限时不拨号繁忙线路,转到可用线路,并在 Trace 标记 skipped | `backend/internal/server/gateway_routes_test.go#TestGatewayChatCompletionSkipsAccountAtConcurrencyCapacity` | L2 | 通过 | +| RPM/TPM | 许可按滑动窗口拒绝超额请求,明确返回 `rpm_exhausted` 或 `tpm_exhausted` | `backend/internal/controlplane/gateway_scheduler_test.go#TestProviderAccountPermitEnforcesRPMAndTPM` | L1 | 通过 | +| 账单健康阻断 | 仅生效中的强账单证据可阻断;无效密钥和耗尽的 key quota 不进入候选;不健康线路不得被有效成本提升 | `backend/internal/controlplane/provider_billing_routing_health_test.go#TestGatewayRoutingUsesOnlyActiveHardBillingEvidence`;`backend/internal/controlplane/provider_billing_routing_health_test.go#TestGatewayRoutingFiltersExhaustedKeyQuota`;`backend/internal/controlplane/provider_billing_routing_health_test.go#TestEffectivePricingDoesNotPromoteBillingUnhealthyCandidate` | L1/L2 | 通过 | +| 企业预算阻断 | 预算 hold 失败时请求不访问上游,返回 402,并记录 Usage 与 Trace | `backend/internal/server/gateway_routes_test.go#TestGatewayChatCompletionEnforcesWorkspaceKeyBudgetAndRecordsTrace` | L2 | 通过 | +| Planner/Simulator 一致性 | 共用原生协议、价格、低价池和故障切换裁剪;模型/协议全局阻断结果一致 | `backend/internal/controlplane/routing_policy_runtime_test.go#TestRoutingPolicySimulatorMatchesPlannerHardConstraints`;`backend/internal/controlplane/gateway_pipeline_test.go#TestPlanCanonicalGatewayRequestRecordsCandidateExclusions` | L2 | 通过 | +| Simulator 无副作用 | 连续试算不消耗 RPM/TPM 或并发许可;不可用候选仍以原因投影 | `backend/internal/controlplane/gateway_scheduler_test.go#TestGatewaySimulationDoesNotConsumeRateCapacity`;`backend/internal/controlplane/gateway_scheduler_test.go#TestGatewaySimulationIncludesSkippedCircuitCandidate` | L1/L2 | 通过 | +| Trace/attempt/exclusion | 最终 Trace 包含策略 ID、版本、偏好和动态决策;尝试列表包含策略淘汰、调度跳过、上游失败及最终选择 | `backend/internal/server/gateway_routes_test.go#TestRoutingPolicyFailoverToggleControlsRealGatewayAttempts`;`backend/internal/server/gateway_routes_test.go#TestRoutingPolicySmartOptimizationReordersRealGatewayRequest`;`backend/internal/server/gateway_contract_test.go#TestGatewayTraceIncludesPlannerExclusionEvidence` | L2 | 通过 | +| 浏览器策略闭环 | 可见控件创建 v1、更新 v2、刷新持久化、运行试算;三视口、中英文、13 种协议、策略偏好和价格淘汰原因可见且无横向溢出 | `frontend/e2e/routing-policy.spec.ts#@e2e-routing-policy-001` | Gate A | 通过 | + +## 3. 已知边界与产品决策 + +### 3.1 完全缺少可比 USD 价格事实 + +当前行为以资源批次为边界:即使同协议或备用批次存在其他价格,只要当前批次完全没有可比 USD 事实,就保留该批次原候选顺序,不因企业尚未导入采购价而中断 Gateway。这个默认值保证首次部署可用,但管理员目前不能显式选择以下治理方式: + +- 强制阻断无价格候选; +- 只允许已定价候选并在不足时降级; +- 保留候选但产生告警。 + +在产品决策确定前,不能把当前行为描述为“严格成本合规”。直接证据为 `backend/internal/controlplane/routing_policy_runtime_test.go#TestRoutingPolicyAutomaticLowPricePoolUsesNormalizedDefaults`。 + +### 3.2 试算器不声明最终运行时顺序 + +Simulator 没有应用 ID、主体 ID、凭据 ID、sticky key 和有效成本 cohort,也不会真实占用容量。它可以解释静态策略与当前容量投影,但不能声明以下结论: + +- 某一真实客户的最终粘性线路; +- 某一 canary cohort 是否命中动态有效成本决策; +- 从试算到请求发出期间容量没有变化; +- 首字节前后故障切换真实发生。 + +这些结论分别由 `TestRoutingPolicyUpdateInvalidatesStickyGatewaySelection`、`TestRoutingPolicySmartOptimizationReordersRealGatewayRequest`、`TestGatewayChatCompletionSkipsAccountAtConcurrencyCapacity` 和两条 Streaming Gateway 测试证明。 + +## 4. 回归命令 + +```bash +cd backend +GOMAXPROCS=1 go test -p=1 ./internal/controlplane ./internal/server -count=1 + +cd ../frontend +npm run typecheck +npm run test:unit +npm run build +npm run generate:e2e-capabilities +npm run check:e2e-coverage +npm run test:e2e -- e2e/routing-policy.spec.ts +``` + +`check:e2e-coverage` 是浏览器治理门禁;其失败不能用算法单元测试通过来替代。PostgreSQL、生产单源构建和远程环境按 [`README.md`](./README.md) 的 Gate B、Platform 与 Remote 合同单独验收。 + +## 5. 2026-08-13 验证记录 + +本轮在 macOS arm64、本地 memory repository、隔离 fake upstream / OIDC / SMTP / S3 / official services 上完成: + +- 全量 Chromium Browser:`82 passed`、`62 skipped`、`0 failed`。跳过项为重复视口的状态型 journey,以及需要 PostgreSQL 或 release binary 的显式环境合同。 +- 策略与运行时核心 race:`internal/controlplane`、`internal/server`、`internal/plugins`、`internal/system` 全部通过。 +- 后端普通全包:`go test ./...` 通过,包含 migrations 包;聚合语句覆盖率 `62.9%`。 +- 前端:`38` 个测试文件、`138` 个单元测试通过;语句覆盖率 `71.71%`,分支覆盖率 `60.30%`;typecheck 和生产构建通过。 +- 生产单源:编译后的 Go 二进制通过 readiness、公开设置、SPA 深链、官网图片和 `SIGTERM` 关闭验证。 +- E2E 治理:`43` 条产品路由、`49` 个场景、`192` 个 API 操作的 success / negative / boundary / browser 缺口均为 `0`。 + +本轮修复了三个测试基础设施问题: + +1. 应用生命周期场景曾遗留带预算的全局访问策略,导致后续独立 Gateway fixture 在计费保留阶段返回 402;策略现限定到本场景 Workspace Key,并有创建响应和顺序回归断言。 +2. fake OIDC 曾转发逐跳头并关闭上游连接,导致 Chromium 刷新出现 `ERR_TOO_MANY_RETRIES`;现过滤逐跳头并复用 keep-alive 连接。 +3. fake OIDC 与后端 discovery 曾存在启动竞态;E2E 编排现等待 HTTPS listener 发布 ready 信号后再启动后端。 + +未执行项: + +- 本机没有 Docker,且现有 `127.0.0.1:5432` 是 PhpWebStudy 管理的 PostgreSQL 18 运行库,没有明确的专用测试 URL;为避免触碰未知数据,本轮未执行 PostgreSQL 16 Gate B、备份恢复和重启持久化。 +- 当前平台不是 Linux amd64,未执行 Linux release archive、容器、安装、升级和回滚生命周期。 +- 未对远程部署执行验证;Remote 证明必须在再次明确授权后使用隔离测试数据运行。 diff --git a/docs/test/v1/scenario-registry.json b/docs/test/v1/scenario-registry.json new file mode 100644 index 0000000..a037844 --- /dev/null +++ b/docs/test/v1/scenario-registry.json @@ -0,0 +1,107 @@ +{ + "schemaVersion": 1, + "scenarios": [ + { "id": "@e2e-surface-public-001", "kind": "surface", "title": "Public routes remain reachable and correctly projected", "spec": "e2e/surfaces.spec.ts", "owner": "frontend/router", "proofLevels": ["gate-a", "gate-b"], "fixture": "public", "gates": ["pr", "nightly"], "claim": "Public product routes resolve to the intended surface without browser errors.", "routes": ["/", "/login", "/register", "/forgot-password", "/resend-verification", "/reset-password", "/verify-email", "/legal/:slug"] }, + { "id": "@e2e-surface-console-001", "kind": "surface", "title": "Console routes remain reachable across supported viewports", "spec": "e2e/surfaces.spec.ts", "owner": "frontend/console", "proofLevels": ["gate-a", "gate-b"], "fixture": "demo-enterprise", "gates": ["pr", "nightly", "release"], "claim": "Every console route resolves with its canonical URL, heading, responsive bounds, and clean browser diagnostics.", "routes": ["/console/workbench", "/console/applications", "/console/applications/credentials", "/console/model-services", "/console/model-services/providers", "/console/model-services/accounts", "/console/model-services/routes", "/console/model-services/route-groups", "/console/model-services/simulator", "/console/model-services/pricing", "/console/model-services/effective-pricing", "/console/policies/access", "/console/policies/routing", "/console/usage", "/console/usage/supply", "/console/usage/cost-allocation", "/console/usage/traces", "/console/usage/alerts", "/console/usage/artifacts", "/console/usage/jobs", "/console/usage/exports", "/console/organization", "/console/organization/departments", "/console/organization/groups", "/console/system", "/console/system/plugins", "/console/system/plugins/:pluginId/workbench", "/console/system/audit", "/console/account"] }, + { "id": "@e2e-surface-portal-001", "kind": "surface", "title": "Portal routes remain reachable across supported viewports", "spec": "e2e/surfaces.spec.ts", "owner": "frontend/portal", "proofLevels": ["gate-a", "gate-b"], "fixture": "registered-developer", "gates": ["pr", "nightly", "release"], "claim": "Every developer portal route resolves with the intended authorization projection and clean browser diagnostics.", "routes": ["/portal/overview", "/portal/applications", "/portal/access", "/portal/usage", "/portal/account"] }, + + { "id": "@e2e-setup-001", "kind": "setup", "title": "First install initializes one enterprise instance", "spec": "e2e/enterprise-setup.spec.ts", "owner": "setup", "proofLevels": ["gate-b"], "fixture": "first-install", "gates": ["pr", "nightly", "release"], "claim": "An empty dedicated runtime can initialize exactly one enterprise administrator and persist setup state.", "routes": ["/setup"], "operations": ["settings:completeEnterpriseSetup"] }, + { "id": "@e2e-marketing-001", "kind": "journey", "title": "Official website is public localized and responsive", "spec": "e2e/marketing-home.spec.ts", "owner": "frontend/marketing", "proofLevels": ["gate-a", "gate-b"], "fixture": "public", "gates": ["pr", "nightly"], "claim": "The official website renders real product content in English and Simplified Chinese with responsive and accessibility checks.", "routes": ["/"], "operations": [] }, + { "id": "@e2e-legal-001", "kind": "journey", "title": "Legal documents are public and selectable", "spec": "e2e/surfaces.spec.ts", "owner": "frontend/legal", "proofLevels": ["gate-a", "gate-b"], "fixture": "public", "gates": ["pr", "nightly"], "claim": "Published legal documents are reachable without authentication and unknown slugs fail visibly.", "routes": ["/legal/:slug"], "operations": ["settings:getLegalDocument"] }, + { "id": "@e2e-auth-001", "kind": "journey", "title": "Registration verification and account security lifecycle", "spec": "e2e/auth.spec.ts", "owner": "auth", "proofLevels": ["gate-a", "gate-b"], "fixture": "registered-developer", "gates": ["nightly", "release"], "claim": "A developer registers, verifies email, requests recovery and verification messages, updates the profile and password, enables TOTP, rotates recovery codes, signs in with both factors, revokes another session, disables TOTP, and retains the persisted account projection.", "routes": ["/register", "/forgot-password", "/resend-verification", "/verify-email", "/console/account", "/portal/account"], "operations": ["auth:register", "auth:forgotPassword", "auth:resendVerification", "auth:verifyEmail", "account:beginTOTPSetup", "account:changeAccountPassword", "account:confirmTOTP", "account:disableTOTP", "account:generateTOTPRecoveryCodes", "account:getAccountProfile", "account:revokeOtherAccountSessions", "account:updateAccountProfile"] }, + { "id": "@e2e-account-identity-001", "kind": "journey", "title": "OIDC account identity binding and unbinding lifecycle", "spec": "e2e/account-identity.spec.ts", "owner": "auth/identity", "proofLevels": ["gate-a", "gate-b"], "fixture": "registered-developer+fake-oidc", "gates": ["nightly"], "claim": "A registered member starts OIDC binding through the visible account action, authorizes at an isolated HTTPS issuer, completes the real PKCE callback and JWT verification, reloads the persisted bound identity, confirms unbinding, reloads the unbound projection, and exposes exact bound and unbound audit evidence for that member.", "routes": ["/portal/account", "/console/system/audit"], "operations": ["account:beginAccountIdentityBinding", "account:unbindAccountIdentity"] }, + { "id": "@e2e-auth-002", "kind": "journey", "title": "Public authentication pages reject invalid links", "spec": "e2e/auth.spec.ts", "owner": "auth", "proofLevels": ["gate-a", "gate-b"], "fixture": "public", "gates": ["pr", "nightly"], "claim": "Public account recovery and verification surfaces expose validation failures without accepting invalid tokens.", "routes": ["/register", "/forgot-password", "/resend-verification", "/reset-password", "/verify-email"], "operations": ["auth:resetPassword"] }, + { "id": "@e2e-session-001", "kind": "journey", "title": "Logout immediately revokes a user session", "spec": "e2e/session.spec.ts", "owner": "auth/session", "proofLevels": ["gate-a", "gate-b"], "fixture": "registered-developer", "gates": ["pr", "nightly", "release"], "claim": "Logout revokes the server session and protected APIs reject the old token.", "routes": ["/login", "/portal/account"], "operations": [] }, + { "id": "@e2e-session-002", "kind": "journey", "title": "Role changes and disabling revoke user sessions", "spec": "e2e/session.spec.ts", "owner": "auth/session", "proofLevels": ["gate-a", "gate-b"], "fixture": "registered-developer", "gates": ["nightly", "release"], "claim": "Identity role and status changes invalidate existing sessions and enforce the new surface boundary.", "routes": ["/console/organization", "/portal/account"], "operations": ["control:getWorkspaceUsers", "control:updateWorkspaceUser"] }, + + { "id": "@e2e-platform-001", "kind": "journey", "title": "Backend health and public settings are ready", "spec": "e2e/smoke.spec.ts", "owner": "server/readiness", "proofLevels": ["gate-a", "gate-b"], "fixture": "demo-enterprise", "gates": ["pr", "nightly", "release"], "claim": "Health, readiness, and public settings agree that the isolated enterprise runtime is ready.", "routes": ["/login"], "operations": [] }, + { "id": "@e2e-authz-001", "kind": "journey", "title": "Anonymous protected navigation redirects to login", "spec": "e2e/smoke.spec.ts", "owner": "auth/routing", "proofLevels": ["gate-a", "gate-b"], "fixture": "public", "gates": ["pr", "nightly", "release"], "claim": "Anonymous console navigation preserves the intended redirect without disclosing protected content.", "routes": ["/console/workbench"], "operations": [] }, + { "id": "@e2e-login-001", "kind": "journey", "title": "Login persists and opens the management console", "spec": "e2e/smoke.spec.ts", "owner": "auth/frontend", "proofLevels": ["gate-a", "gate-b"], "fixture": "demo-enterprise", "gates": ["pr", "nightly", "release"], "claim": "An enterprise administrator signs in, reloads, and remains on the canonical console entry.", "routes": ["/login", "/console/workbench"], "operations": ["control:getDashboard"] }, + { "id": "@e2e-credential-boundary-001", "kind": "journey", "title": "Credential editor exposes enterprise ownership only", "spec": "e2e/smoke.spec.ts", "owner": "applications/credentials", "proofLevels": ["gate-a", "gate-b"], "fixture": "demo-enterprise", "gates": ["pr", "nightly"], "claim": "Workspace credentials expose only enterprise key types and no retired customer ownership model.", "routes": ["/console/applications/credentials"], "operations": ["control:getAPIKeys", "control:getGatewayModels", "control:getGovernancePolicies", "control:getGatewayTraces"] }, + { "id": "@e2e-list-contract-001", "kind": "journey", "title": "Application and policy lists remain usable", "spec": "e2e/smoke.spec.ts", "owner": "frontend/lists", "proofLevels": ["gate-a", "gate-b"], "fixture": "demo-enterprise", "gates": ["pr", "nightly"], "claim": "Core enterprise list pages render their data table contracts without overflow.", "routes": ["/console/applications", "/console/policies/access"], "operations": ["control:getApplications", "control:getGatewayModels", "control:getGovernancePolicies"] }, + { "id": "@e2e-preferences-001", "kind": "journey", "title": "Locale theme and responsive state persist", "spec": "e2e/smoke.spec.ts", "owner": "frontend/shell", "proofLevels": ["gate-a", "gate-b"], "fixture": "demo-enterprise", "gates": ["pr", "nightly"], "claim": "Locale and theme persist after reload and remain operable at all supported viewports.", "routes": ["/console/workbench"], "operations": ["control:getDashboard"] }, + + { "id": "@e2e-application-001", "kind": "journey", "title": "Application and workspace key lifecycle is auditable", "spec": "e2e/enterprise-capabilities.spec.ts", "owner": "applications/credentials", "proofLevels": ["gate-a", "gate-b"], "fixture": "demo-enterprise", "gates": ["pr", "nightly", "release"], "claim": "An administrator creates and updates an application and governance policy, issues then edits a one-time Workspace Key to bind that policy, verifies the selected policy explanation, reloads secrets masked and state persisted, rotates and disables the key, and observes exact audit evidence.", "routes": ["/console/applications", "/console/applications/credentials", "/console/policies/access", "/console/system/audit"], "operations": ["control:createAPIKey", "control:createApplication", "control:createGovernancePolicy", "control:disableAPIKey", "control:getAPIKeyPolicyExplanation", "control:getAPIKeys", "control:getApplications", "control:getAuditLogs", "control:getAuditLogSummary", "control:getGatewayModels", "control:getGatewayTraces", "control:getGovernancePolicies", "control:rotateAPIKey", "control:updateAPIKey", "control:updateApplication", "control:updateGovernancePolicy"] }, + { "id": "@e2e-identity-001", "kind": "journey", "title": "Enterprise identity and organization lifecycle remains scoped", "spec": "e2e/enterprise-capabilities.spec.ts", "owner": "identity/rbac", "proofLevels": ["gate-a", "gate-b"], "fixture": "demo-enterprise", "gates": ["pr", "nightly", "release"], "claim": "An administrator creates and updates a department, creates a member, creates updates and deletes a group, grants a scoped role assignment, reloads each read model, revokes the assignment, and observes audit evidence.", "routes": ["/console/organization", "/console/organization/departments", "/console/organization/groups", "/console/system/audit"], "operations": ["control:createDepartment", "control:createOrganizationGroup", "control:createRoleBinding", "control:createWorkspaceUser", "control:deleteOrganizationGroup", "control:deleteRoleBinding", "control:getApplications", "control:getAuditLogs", "control:getAuditLogSummary", "control:getDepartments", "control:getOrganizationGroups", "control:getRoleBindings", "control:getWorkspaceUsers", "control:updateDepartment", "control:updateOrganizationGroup"] }, + { "id": "@e2e-portal-001", "kind": "journey", "title": "Developer portal key and usage projection is isolated", "spec": "e2e/enterprise-capabilities.spec.ts", "owner": "portal", "proofLevels": ["gate-a", "gate-b"], "fixture": "registered-developer", "gates": ["pr", "nightly", "release"], "claim": "A developer sees only the portal projection, creates and rotates an allowed key, inspects access and usage, and cannot enter the console.", "routes": ["/portal/overview", "/portal/applications", "/portal/access", "/portal/usage", "/portal/account"], "operations": ["account:getAccountProfile", "control:createPortalAPIKey", "control:disablePortalAPIKey", "control:getPortalWorkspace", "control:rotatePortalAPIKey"] }, + { "id": "@e2e-operations-001", "kind": "journey", "title": "Gateway evidence reaches enterprise operations views", "spec": "e2e/enterprise-capabilities.spec.ts", "owner": "operations/evidence", "proofLevels": ["gate-a", "gate-b"], "fixture": "fake-openai", "gates": ["pr", "nightly", "release"], "claim": "Gateway traffic produces usage, supply, allocation, and Trace read models; a real durable image job requeues under provider capacity, is canceled through the UI with reload and audit persistence, then another job succeeds with a persisted image Artifact whose preview pixels and downloaded PNG bytes are verified; quota alerts and asynchronous usage exports complete their lifecycle.", "routes": ["/console/usage", "/console/usage/supply", "/console/usage/cost-allocation", "/console/usage/traces", "/console/usage/alerts", "/console/usage/artifacts", "/console/usage/jobs", "/console/usage/exports", "/console/system/audit"], "operations": ["control:acknowledgeAlert", "control:cancelAIJob", "control:createExportJob", "control:downloadExportJob", "control:getAIJob", "control:getAIJobRuntime", "control:getAIJobs", "control:getAIJobSummary", "control:getAlerts", "control:getAlertSummary", "control:getArtifact", "control:getArtifactContent", "control:getArtifactRuntimes", "control:getArtifacts", "control:getArtifactSummary", "control:getAuditLogs", "control:getAuditLogSummary", "control:getCapacityRecommendations", "control:getCostAllocationReport", "control:getExportJobs", "control:getGatewayTraces", "control:getGatewayTraceSummary", "control:getSupplyUtilization", "control:getUsageReport", "control:resolveAlert"] }, + { "id": "@e2e-record-csv-exports-001", "kind": "journey", "title": "Filtered operational records export exact CSV rows", "spec": "e2e/record-exports.spec.ts", "owner": "operations/exports", "proofLevels": ["gate-a", "gate-b"], "fixture": "fake-openai", "gates": ["pr", "nightly", "release"], "claim": "One real gateway request is filtered through the Usage, Cost Allocation, Gateway Trace, and Audit views; each visible Export action downloads a typed CSV containing exactly the target record with matching model, key, provider, token, status, and audit evidence.", "routes": ["/console/usage", "/console/usage/cost-allocation", "/console/usage/traces", "/console/system/audit"], "operations": ["control:exportAuditLogsCSV", "control:exportCostAllocationCSV", "control:exportGatewayTracesCSV", "control:exportUsageCSV", "control:getAuditLogs", "control:getAuditLogSummary", "control:getCostAllocationReport", "control:getGatewayTraces", "control:getGatewayTraceSummary", "control:getUsageReport"] }, + { "id": "@e2e-ai-job-reconciliation-001", "kind": "journey", "title": "Unknown provider attempts can be scheduled for reconciliation", "spec": "e2e/operations-recovery.spec.ts", "owner": "operations/jobs", "proofLevels": ["gate-a", "gate-b"], "fixture": "fake-openai", "gates": ["pr", "nightly", "release"], "claim": "A real durable image dispatch enters running/unknown after an upstream 500; an administrator opens its details, schedules immediate reconciliation through the UI, reloads the persisted state, and verifies the exact attempt audit event without claiming provider recovery.", "routes": ["/console/usage/jobs", "/console/system/audit"], "operations": ["control:getAIJob", "control:getAIJobs", "control:getAuditLogs", "control:scheduleAIJobAttemptReconciliation"] }, + { "id": "@e2e-artifact-delivery-retry-001", "kind": "journey", "title": "Failed customer artifact delivery retries to stored bytes", "spec": "e2e/operations-recovery.spec.ts", "owner": "operations/artifacts", "proofLevels": ["gate-a", "gate-b"], "fixture": "fake-openai+synthetic-s3", "gates": ["pr", "nightly", "release"], "claim": "A durable image job reaches a real customer-sink delivery failure through the AWS SDK, the administrator retries it through the UI after the sink recovers, and the Artifact reloads as delivered with exact object bytes, media type, signed destination request, lifecycle events, and retry audit evidence.", "routes": ["/console/usage/artifacts", "/console/system/audit"], "operations": ["control:getArtifact", "control:getArtifactRuntimes", "control:getArtifacts", "control:getAuditLogs", "control:retryArtifactDelivery"] }, + + { "id": "@e2e-model-account-001", "kind": "journey", "title": "Provider account discovery requires explicit apply", "spec": "e2e/models.spec.ts", "owner": "model-supply/accounts", "proofLevels": ["gate-a", "gate-b"], "fixture": "fake-openai", "gates": ["nightly"], "claim": "A provider account persists empty inventory until discovery and explicit application.", "routes": ["/console/model-services/accounts"], "operations": ["control:createProviderAccount", "control:discoverProviderAccountModels", "control:getProviderAccountHealthChecks", "control:getProviderAccounts", "control:getProviders", "control:syncProviderAccountModels"] }, + { "id": "@e2e-model-supply-lifecycle-001", "kind": "journey", "title": "Provider supply lifecycle persists and protects dependencies", "spec": "e2e/models.spec.ts", "owner": "model-supply", "proofLevels": ["gate-a", "gate-b"], "fixture": "fake-openai", "gates": ["pr", "nightly", "release"], "claim": "An administrator creates updates and health-checks a provider and account, toggles scheduling, creates and updates a gateway model and route, observes referenced-account deletion fail closed, then deletes the route model and account in dependency order with audit evidence.", "routes": ["/console/model-services", "/console/model-services/providers", "/console/model-services/accounts", "/console/model-services/routes", "/console/system/audit"], "operations": ["control:checkProvider", "control:checkProviderAccount", "control:createGatewayModel", "control:createModelRoute", "control:createProvider", "control:createProviderAccount", "control:deleteGatewayModel", "control:deleteModelRoute", "control:deleteProviderAccount", "control:getGatewayModels", "control:getModelRoutes", "control:getProviderAccountHealthChecks", "control:getProviderAccounts", "control:getProviderHealthChecks", "control:getProviders", "control:updateGatewayModel", "control:updateModelRoute", "control:updateProvider", "control:updateProviderAccount"] }, + { "id": "@e2e-model-inventory-001", "kind": "journey", "title": "Model inventory and bulk routes stay auditable", "spec": "e2e/models.spec.ts", "owner": "model-supply", "proofLevels": ["gate-a", "gate-b"], "fixture": "fake-openai", "gates": ["pr", "nightly", "release"], "claim": "Providers, accounts, published models, and bulk routes remain connected and auditable across responsive layouts.", "routes": ["/console/model-services", "/console/model-services/providers", "/console/model-services/accounts", "/console/model-services/routes"], "operations": ["control:bulkCreateModelRoutes", "control:discoverProviderAccountModels", "control:getGatewayModels", "control:getModelRoutes", "control:getProviderAccountHealthChecks", "control:getProviderAccountModelInventory", "control:getProviderAccounts", "control:getProviderHealthChecks", "control:getProviders"] }, + { "id": "@e2e-routing-policy-001", "kind": "journey", "title": "Routing policy lifecycle persists and remains explainable", "spec": "e2e/routing-policy.spec.ts", "owner": "routing/policy", "proofLevels": ["gate-a", "gate-b"], "fixture": "fake-openai", "gates": ["pr", "nightly", "release"], "claim": "An enterprise routing policy is created and updated through visible controls, advances to version two, survives reload, and is consumed by the routing simulator to expose the applied preference, version, eligible route, and exact price exclusion across responsive projections.", "routes": ["/console/policies/routing", "/console/model-services/simulator"], "operations": ["control:createRoutingPolicy", "control:getGatewayModels", "control:getProcurementPrices", "control:getProviderAccounts", "control:getRoutingPolicies", "control:simulateGatewayRouting", "control:updateRoutingPolicy"] }, + { "id": "@e2e-routing-resources-001", "kind": "journey", "title": "Route groups and simulator use the published routing contract", "spec": "e2e/enterprise-capabilities.spec.ts", "owner": "routing/resources", "proofLevels": ["gate-a", "gate-b"], "fixture": "fake-openai", "gates": ["pr", "nightly", "release"], "claim": "A route group is created, updated with persisted cost and capacity settings after reload, and consumed by the simulator to explain a decision using published model supply.", "routes": ["/console/model-services/route-groups", "/console/model-services/simulator"], "operations": ["control:createRoutingGroup", "control:getGatewayModels", "control:getRoutingGroups", "control:simulateGatewayRouting", "control:updateRoutingGroup"] }, + { "id": "@e2e-pricing-001", "kind": "journey", "title": "Expression pricing lifecycle remains usable", "spec": "e2e/pricing.spec.ts", "owner": "billing/pricing", "proofLevels": ["gate-a", "gate-b"], "fixture": "fake-openai", "gates": ["nightly", "release"], "claim": "A pricing rule is created, validated, simulated, published as two versions, switched back to version one, applied to a real Gateway settlement whose persisted evaluation is queried through the UI, then disabled and reloaded with exact version and cost evidence.", "routes": ["/console/model-services/pricing"], "operations": ["control:activatePricingRuleVersion", "control:createPricingRule", "control:disablePricingRule", "control:getPricingEvaluation", "control:getPricingRule", "control:getPricingRules", "control:publishPricingRule", "control:simulatePricingRule", "control:updatePricingRuleDraft", "control:validatePricingRule"] }, + { "id": "@e2e-effective-pricing-001", "kind": "journey", "title": "Measured provider switch reaches canary with persisted evidence", "spec": "e2e/effective-pricing.spec.ts", "owner": "billing/effective-pricing", "proofLevels": ["gate-a", "gate-b"], "fixture": "fake-openai-cache-probe", "gates": ["nightly"], "claim": "Two independently routed Gateway requests create comparable usage for the same upstream model; UI-created exact procurement prices and an imported provider bill reconcile the upstream request back to persisted usage, a persisted cache capability drives a real three-request warm/reuse/control probe, and the resulting lower-cost recommendation enters a 25% canary with refresh, empty-window history, and audit evidence.", "routes": ["/console/model-services/effective-pricing"], "operations": ["control:actOnEffectivePricingDecision", "control:createProcurementPrice", "control:createProviderBillingLine", "control:evaluateEffectivePricingDecision", "control:getEffectivePricingDecisionEvaluations", "control:getEffectivePricingDecisions", "control:getEffectivePricingReport", "control:getGatewayModels", "control:getProcurementPrices", "control:getProviderAccounts", "control:getProviderBillingSources", "control:getProviderCacheCapabilities", "control:getProviderCacheProbeRuns", "control:runProviderCacheProbe", "control:updateEffectivePricingPolicy", "control:updateProviderCacheCapability"] }, + { "id": "@e2e-effective-pricing-002", "kind": "journey", "title": "Billing evidence remains distinct from bill lines", "spec": "e2e/effective-pricing.spec.ts", "owner": "billing/evidence", "proofLevels": ["gate-a"], "fixture": "test-only-billing-source", "gates": ["nightly"], "claim": "Aggregate provider evidence cannot be presented as precise billing lines or automatic routing eligibility.", "routes": ["/console/model-services/effective-pricing"], "operations": ["control:getProviderAccounts", "control:getProviderBillingSourceEvidence", "control:getProviderBillingSources", "control:inspectProviderBillingSource", "control:syncProviderBillingSource", "control:updateProviderBillingSource"] }, + + { "id": "@e2e-gateway-001", "kind": "journey", "title": "Provider to gateway request records evidence", "spec": "e2e/gateway.spec.ts", "owner": "gateway", "proofLevels": ["gate-a", "gate-b"], "fixture": "fake-openai", "gates": ["pr", "nightly", "release"], "claim": "A successful provider route returns a response and records typed usage, Trace, pricing, audit, and policy evidence.", "routes": ["/console/usage", "/console/usage/traces"], "operations": ["control:getCostAllocationReport", "control:getGatewayTraces", "control:getGatewayTraceSummary", "control:getUsageReport"] }, + { "id": "@e2e-gateway-protocols-001", "kind": "journey", "title": "Native text protocols cross the complete gateway evidence pipeline", "spec": "e2e/gateway-protocols.spec.ts", "owner": "gateway/protocols", "proofLevels": ["gate-a", "gate-b"], "fixture": "fake-openai", "gates": ["pr", "nightly", "release"], "claim": "OpenAI Responses, Anthropic Messages, and Gemini GenerateContent JSON and streaming calls cross authentication, routing, protocol translation, usage, trace, audit, model rejection, and upstream-rate-limit boundaries.", "routes": ["/console/usage", "/console/usage/traces"], "operations": ["control:getCostAllocationReport", "control:getGatewayTraces", "control:getGatewayTraceSummary", "control:getUsageReport"] }, + { "id": "@e2e-gateway-budget-001", "kind": "journey", "title": "Quota and budget warn escalate and reject with evidence", "spec": "e2e/gateway.spec.ts", "owner": "gateway/policy", "proofLevels": ["gate-a", "gate-b"], "fixture": "fake-openai", "gates": ["pr", "nightly", "release"], "claim": "Quota and budget thresholds warn once, escalate, reject fail-closed, and preserve evidence.", "routes": ["/console/policies/access", "/console/usage/alerts"], "operations": ["control:getAlerts", "control:getAlertSummary", "control:getAPIKeys", "control:getGatewayModels", "control:getGatewayTraces", "control:getGovernancePolicies"] }, + { "id": "@e2e-gateway-failover-001", "kind": "journey", "title": "Failed primary route falls back and records attempts", "spec": "e2e/gateway.spec.ts", "owner": "gateway/routing", "proofLevels": ["gate-a", "gate-b"], "fixture": "fake-openai", "gates": ["pr", "nightly", "release"], "claim": "A retryable primary failure selects an eligible fallback and records every route attempt.", "routes": ["/console/model-services/routes", "/console/usage/traces"], "operations": ["control:getGatewayModels", "control:getGatewayTraces", "control:getGatewayTraceSummary", "control:getModelRoutes", "control:getProviderAccounts"] }, + { "id": "@e2e-provider-cooldown-001", "kind": "journey", "title": "Provider cooldown can be cleared and traffic recovers", "spec": "e2e/gateway.spec.ts", "owner": "gateway/supply", "proofLevels": ["gate-a", "gate-b"], "fixture": "fake-openai", "gates": ["pr", "nightly", "release"], "claim": "A real upstream 500 matches the account's configured long cooldown rule, opens the circuit, blocks the next Gateway request, projects the cooldown in the provider-account UI, clears every failure state through the visible action, persists the cleared state across reload, restores successful Gateway traffic, and records trace plus audit evidence.", "routes": ["/console/model-services/accounts", "/console/usage/traces", "/console/system/audit"], "operations": ["control:clearProviderAccountCooldown", "control:getGatewayTraces", "control:getProviderAccounts"] }, + { "id": "@e2e-isolation-001", "kind": "journey", "title": "Department and owner isolation covers reads writes and exports", "spec": "e2e/isolation.spec.ts", "owner": "identity/rbac", "proofLevels": ["gate-a", "gate-b"], "fixture": "registered-developer", "gates": ["pr", "nightly", "release"], "claim": "Department and ownership scopes protect list, mutation, detail, evidence, and export paths without disclosure.", "routes": ["/console/organization/departments", "/console/usage/exports", "/portal/overview"], "operations": [] }, + + { "id": "@e2e-plugin-management-001", "kind": "journey", "title": "Plugin management lifecycle is governed and auditable", "spec": "e2e/plugin-management.spec.ts", "owner": "plugins/platform", "proofLevels": ["gate-a", "gate-b"], "fixture": "demo-enterprise", "gates": ["pr", "nightly", "release"], "claim": "An administrator reaches the canonical Plugin Center, observes locked entitlements fail closed, enables and configures a free plugin without secret disclosure, persists state across reload, inspects delivery records, issues and revokes a scoped API token, and observes plugin audit evidence.", "routes": ["/console/system/plugins"], "operations": ["plugins:createPluginAPIToken", "plugins:disablePlugin", "plugins:enablePlugin", "plugins:getOfficialCatalogStatus", "plugins:getOfficialFeedStatuses", "plugins:getOfficialFeedSyncRuns", "plugins:getOfficialLicenseStatus", "plugins:getPluginAPITokens", "plugins:getPluginCatalog", "plugins:getPluginConfig", "plugins:getPluginDeliveries", "plugins:revokePluginAPIToken", "plugins:updatePluginConfig"] }, + { "id": "@e2e-plugin-trust-chain-001", "kind": "journey", "title": "Signed official plugin trust chain crosses the browser and runtime", "spec": "e2e/plugin-management.spec.ts", "owner": "plugins/trust-chain", "proofLevels": ["gate-a", "gate-b"], "fixture": "demo-enterprise+fake-official", "gates": ["pr", "nightly", "release"], "claim": "An administrator verifies signed catalog, License, encrypted Feed, online and offline package delivery, installation, runtime projection, frontend workbench assets, and uninstall through the product UI without a production trust bypass.", "routes": ["/console/system/plugins", "/console/system/plugins/:pluginId/workbench"], "operations": ["plugins:activateOfficialLicense", "plugins:downloadPluginPackage", "plugins:getOfficialFeedClientInfo", "plugins:getPluginFrontendAsset", "plugins:getPluginWorkbench", "plugins:getSidecarRuntimeStatus", "plugins:importOfficialFeed", "plugins:importOfficialLicense", "plugins:importPluginPackage", "plugins:installPluginPackage", "plugins:redeemOfficialLicense", "plugins:syncOfficialCatalog", "plugins:syncOfficialFeed", "plugins:uninstallPluginPackage"] }, + { "id": "@e2e-artifact-sink-001", "kind": "journey", "title": "Customer-owned object storage secrets stay masked", "spec": "e2e/artifact-sink.spec.ts", "owner": "plugins/artifact-sink", "proofLevels": ["gate-a", "gate-b"], "fixture": "synthetic-object-storage", "gates": ["nightly"], "claim": "Object-storage destinations can be managed without disclosing stored access, secret, or session credentials.", "routes": ["/console/system/plugins", "/console/system/plugins/:pluginId/workbench", "/console/usage/artifacts"], "operations": ["plugins:deleteArtifactSinkDestination", "plugins:getArtifactSinkDestinations", "plugins:getOfficialCatalogStatus", "plugins:getOfficialFeedStatuses", "plugins:getOfficialFeedSyncRuns", "plugins:getOfficialLicenseStatus", "plugins:getPluginAPITokens", "plugins:getPluginCatalog", "plugins:upsertArtifactSinkDestination"] }, + { "id": "@e2e-email-001", "kind": "journey", "title": "Email settings and localized templates remain manageable", "spec": "e2e/email-settings.spec.ts", "owner": "settings/email", "proofLevels": ["gate-a", "gate-b"], "fixture": "fake-mail", "gates": ["nightly"], "claim": "An administrator verifies a STARTTLS connection with unsaved credentials, delivers and inspects SMTP and rendered template messages, then customizes, reloads, restores, and localizes the template without exposing or persisting the test SMTP configuration.", "routes": ["/console/system"], "operations": ["settings:getAdminSettings", "settings:getEmailTemplate", "settings:getEmailTemplateCatalog", "settings:previewEmailTemplate", "settings:restoreEmailTemplate", "settings:testEmailTemplate", "settings:testSMTP", "settings:testSMTPConnection", "settings:updateEmailTemplate"] }, + { "id": "@e2e-settings-001", "kind": "journey", "title": "Admin settings persist and retention cleanup is evidenced", "spec": "e2e/settings.spec.ts", "owner": "settings/governance", "proofLevels": ["gate-a", "gate-b"], "fixture": "demo-enterprise", "gates": ["nightly", "release"], "claim": "An administrator changes data governance settings, saves and reloads the persisted values, confirms retention cleanup through the product UI with deletion evidence, and restores the original values.", "routes": ["/console/system"], "operations": ["settings:getAdminSettings", "settings:runRetentionCleanup", "settings:updateAdminSettings"] }, + { "id": "@e2e-system-diagnostic-001", "kind": "journey", "title": "Diagnostic bundle is downloadable, redacted, and audited", "spec": "e2e/system-maintenance.spec.ts", "owner": "system/maintenance", "proofLevels": ["gate-a", "gate-b"], "fixture": "demo-enterprise", "gates": ["pr", "nightly", "release"], "claim": "An administrator creates and downloads a real diagnostic archive through the product UI, verifies its exact byte size and typed runtime health payload, confirms secrets and database URLs are absent, and observes the persisted system audit event.", "routes": ["/console/system", "/console/system/audit"], "operations": ["control:getAuditLogs", "system:createDiagnosticBundle", "system:downloadDiagnosticBundle"] }, + { "id": "@e2e-system-update-001", "kind": "journey", "title": "Signed system update metadata and maintenance commands fail closed", "spec": "e2e/system-maintenance.spec.ts", "owner": "system/maintenance", "proofLevels": ["gate-a", "gate-b"], "fixture": "demo-enterprise+fake-official", "gates": ["pr", "nightly", "release"], "claim": "An administrator checks a real signed official release, sees the source-build manual-update boundary, receives controlled rejection for one-click update and restart, receives an explicit missing-backup rollback failure, and observes audit evidence for every action.", "routes": ["/console/system", "/console/system/audit"], "operations": ["system:checkSystemUpdates", "system:performSystemUpdate", "system:restartSystem", "system:rollbackSystemUpdate"] }, + { "id": "@e2e-system-update-lifecycle-001", "kind": "journey", "title": "Managed release binary updates, restarts, rolls back, and restarts", "spec": "e2e/system-maintenance.spec.ts", "owner": "system/maintenance", "proofLevels": ["gate-a", "gate-b"], "fixture": "dedicated-postgresql+release-binaries+supervisor+fake-official", "gates": ["nightly", "release"], "claim": "An administrator uses the browser to apply a signed matching release asset to an isolated executable, proves the on-disk SHA and rollback backup, restarts through a dedicated supervisor into the new version, rolls the executable back, restarts into the old version, and verifies persistent audit evidence across all three process generations.", "routes": ["/console/system", "/console/system/audit"], "operations": ["system:checkSystemUpdates", "system:performSystemUpdate", "system:restartSystem", "system:rollbackSystemUpdate"] }, + { "id": "@e2e-system-backup-001", "kind": "journey", "title": "PostgreSQL backup restores local and S3 state", "spec": "e2e/system-maintenance.spec.ts", "owner": "system/maintenance", "proofLevels": ["gate-a", "gate-b"], "fixture": "dedicated-postgresql+synthetic-s3", "gates": ["pr", "nightly", "release"], "claim": "An administrator verifies the configured S3 store through the UI, creates a real pg_dump backup, reloads local and remote listings, downloads byte-identical local and S3 archives, writes database markers after the snapshot, restores locally and from S3 through confirmed visible actions, proves each marker is removed by pg_restore, and observes exact backup and restore audit evidence.", "routes": ["/console/system", "/console/system/audit"], "operations": ["system:createSystemBackup", "system:downloadS3Backup", "system:downloadSystemBackup", "system:listS3Backups", "system:listSystemBackups", "system:restoreS3Backup", "system:restoreSystemBackup", "system:testBackupS3"] }, + { "id": "@e2e-a11y-console-001", "kind": "journey", "title": "Console overview has no serious accessibility violations", "spec": "e2e/accessibility.spec.ts", "owner": "frontend/accessibility", "proofLevels": ["gate-a", "gate-b"], "fixture": "demo-enterprise", "gates": ["pr", "nightly", "release"], "claim": "The primary console entry has no serious or critical automated accessibility violations.", "routes": ["/console/workbench"], "operations": ["control:getDashboard"] }, + { "id": "@e2e-a11y-session-001", "kind": "journey", "title": "Enterprise member sessions are isolated and keyboard operable", "spec": "e2e/accessibility.spec.ts", "owner": "frontend/accessibility", "proofLevels": ["gate-a", "gate-b"], "fixture": "registered-developer", "gates": ["nightly", "release"], "claim": "Concurrent member/admin browser contexts remain isolated and core controls are keyboard operable.", "routes": ["/portal/account", "/console/workbench"], "operations": ["account:getAccountProfile", "control:getDashboard"] } + ], + "routes": [ + { "path": "/", "surface": "@e2e-surface-public-001", "journeys": ["@e2e-marketing-001"] }, + { "path": "/login", "surface": "@e2e-surface-public-001", "journeys": ["@e2e-platform-001", "@e2e-login-001", "@e2e-session-001"] }, + { "path": "/register", "surface": "@e2e-surface-public-001", "journeys": ["@e2e-auth-001", "@e2e-auth-002"] }, + { "path": "/forgot-password", "surface": "@e2e-surface-public-001", "journeys": ["@e2e-auth-001", "@e2e-auth-002"] }, + { "path": "/resend-verification", "surface": "@e2e-surface-public-001", "journeys": ["@e2e-auth-001", "@e2e-auth-002"] }, + { "path": "/reset-password", "surface": "@e2e-surface-public-001", "journeys": ["@e2e-auth-002"] }, + { "path": "/verify-email", "surface": "@e2e-surface-public-001", "journeys": ["@e2e-auth-001", "@e2e-auth-002"] }, + { "path": "/setup", "surface": "@e2e-setup-001", "journeys": ["@e2e-setup-001"] }, + { "path": "/legal/:slug", "surface": "@e2e-surface-public-001", "journeys": ["@e2e-legal-001"] }, + + { "path": "/console/workbench", "surface": "@e2e-surface-console-001", "journeys": ["@e2e-authz-001", "@e2e-login-001", "@e2e-preferences-001", "@e2e-a11y-console-001", "@e2e-a11y-session-001"] }, + { "path": "/console/applications", "surface": "@e2e-surface-console-001", "journeys": ["@e2e-application-001", "@e2e-list-contract-001"] }, + { "path": "/console/applications/credentials", "surface": "@e2e-surface-console-001", "journeys": ["@e2e-application-001", "@e2e-credential-boundary-001"] }, + { "path": "/console/model-services", "surface": "@e2e-surface-console-001", "journeys": ["@e2e-model-supply-lifecycle-001", "@e2e-model-inventory-001"] }, + { "path": "/console/model-services/providers", "surface": "@e2e-surface-console-001", "journeys": ["@e2e-model-supply-lifecycle-001", "@e2e-model-inventory-001"] }, + { "path": "/console/model-services/accounts", "surface": "@e2e-surface-console-001", "journeys": ["@e2e-model-account-001", "@e2e-model-supply-lifecycle-001", "@e2e-model-inventory-001", "@e2e-provider-cooldown-001"] }, + { "path": "/console/model-services/routes", "surface": "@e2e-surface-console-001", "journeys": ["@e2e-model-supply-lifecycle-001", "@e2e-model-inventory-001", "@e2e-gateway-failover-001"] }, + { "path": "/console/model-services/route-groups", "surface": "@e2e-surface-console-001", "journeys": ["@e2e-routing-resources-001"] }, + { "path": "/console/model-services/simulator", "surface": "@e2e-surface-console-001", "journeys": ["@e2e-routing-policy-001", "@e2e-routing-resources-001"] }, + { "path": "/console/model-services/pricing", "surface": "@e2e-surface-console-001", "journeys": ["@e2e-pricing-001"] }, + { "path": "/console/model-services/effective-pricing", "surface": "@e2e-surface-console-001", "journeys": ["@e2e-effective-pricing-001", "@e2e-effective-pricing-002"] }, + { "path": "/console/policies/access", "surface": "@e2e-surface-console-001", "journeys": ["@e2e-application-001", "@e2e-list-contract-001", "@e2e-gateway-budget-001"] }, + { "path": "/console/policies/routing", "surface": "@e2e-surface-console-001", "journeys": ["@e2e-routing-policy-001"] }, + { "path": "/console/usage", "surface": "@e2e-surface-console-001", "journeys": ["@e2e-gateway-001", "@e2e-gateway-protocols-001", "@e2e-operations-001", "@e2e-record-csv-exports-001"] }, + { "path": "/console/usage/supply", "surface": "@e2e-surface-console-001", "journeys": ["@e2e-operations-001"] }, + { "path": "/console/usage/cost-allocation", "surface": "@e2e-surface-console-001", "journeys": ["@e2e-operations-001", "@e2e-record-csv-exports-001"] }, + { "path": "/console/usage/traces", "surface": "@e2e-surface-console-001", "journeys": ["@e2e-gateway-001", "@e2e-gateway-protocols-001", "@e2e-gateway-failover-001", "@e2e-provider-cooldown-001", "@e2e-operations-001", "@e2e-record-csv-exports-001"] }, + { "path": "/console/usage/alerts", "surface": "@e2e-surface-console-001", "journeys": ["@e2e-gateway-budget-001", "@e2e-operations-001"] }, + { "path": "/console/usage/artifacts", "surface": "@e2e-surface-console-001", "journeys": ["@e2e-artifact-delivery-retry-001", "@e2e-artifact-sink-001", "@e2e-operations-001"] }, + { "path": "/console/usage/jobs", "surface": "@e2e-surface-console-001", "journeys": ["@e2e-ai-job-reconciliation-001", "@e2e-operations-001"] }, + { "path": "/console/usage/exports", "surface": "@e2e-surface-console-001", "journeys": ["@e2e-isolation-001", "@e2e-operations-001"] }, + { "path": "/console/organization", "surface": "@e2e-surface-console-001", "journeys": ["@e2e-identity-001", "@e2e-session-002"] }, + { "path": "/console/organization/departments", "surface": "@e2e-surface-console-001", "journeys": ["@e2e-identity-001", "@e2e-isolation-001"] }, + { "path": "/console/organization/groups", "surface": "@e2e-surface-console-001", "journeys": ["@e2e-identity-001"] }, + { "path": "/console/system", "surface": "@e2e-surface-console-001", "journeys": ["@e2e-email-001", "@e2e-settings-001", "@e2e-system-backup-001", "@e2e-system-diagnostic-001", "@e2e-system-update-001", "@e2e-system-update-lifecycle-001"] }, + { "path": "/console/system/plugins", "surface": "@e2e-surface-console-001", "journeys": ["@e2e-plugin-management-001", "@e2e-plugin-trust-chain-001", "@e2e-artifact-sink-001"] }, + { "path": "/console/system/plugins/:pluginId/workbench", "surface": "@e2e-surface-console-001", "journeys": ["@e2e-plugin-trust-chain-001", "@e2e-artifact-sink-001"] }, + { "path": "/console/system/audit", "surface": "@e2e-surface-console-001", "journeys": ["@e2e-account-identity-001", "@e2e-ai-job-reconciliation-001", "@e2e-application-001", "@e2e-artifact-delivery-retry-001", "@e2e-identity-001", "@e2e-model-supply-lifecycle-001", "@e2e-operations-001", "@e2e-provider-cooldown-001", "@e2e-record-csv-exports-001", "@e2e-system-backup-001", "@e2e-system-diagnostic-001", "@e2e-system-update-001", "@e2e-system-update-lifecycle-001"] }, + { "path": "/console/account", "surface": "@e2e-surface-console-001", "journeys": ["@e2e-auth-001"] }, + + { "path": "/portal/overview", "surface": "@e2e-surface-portal-001", "journeys": ["@e2e-portal-001", "@e2e-isolation-001"] }, + { "path": "/portal/applications", "surface": "@e2e-surface-portal-001", "journeys": ["@e2e-portal-001"] }, + { "path": "/portal/access", "surface": "@e2e-surface-portal-001", "journeys": ["@e2e-portal-001"] }, + { "path": "/portal/usage", "surface": "@e2e-surface-portal-001", "journeys": ["@e2e-portal-001"] }, + { "path": "/portal/account", "surface": "@e2e-surface-portal-001", "journeys": ["@e2e-account-identity-001", "@e2e-auth-001", "@e2e-session-001", "@e2e-session-002", "@e2e-portal-001", "@e2e-a11y-session-001"] } + ] +} diff --git a/frontend/e2e/accessibility.spec.ts b/frontend/e2e/accessibility.spec.ts index 597b109..1c3f01f 100644 --- a/frontend/e2e/accessibility.spec.ts +++ b/frontend/e2e/accessibility.spec.ts @@ -18,7 +18,7 @@ async function focusWithTab(page: Page, target: ReturnType): await expect(target).toBeFocused() } -test('@smoke @j09 console overview has no serious accessibility violations', async ({ page }, testInfo) => { +test('@e2e-a11y-console-001 console overview has no serious accessibility violations', async ({ page }, testInfo) => { test.skip(testInfo.project.name !== 'chromium-desktop', 'The semantic audit runs once; layout coverage runs in every Chromium viewport.') await loginDemo(page) @@ -27,7 +27,7 @@ test('@smoke @j09 console overview has no serious accessibility violations', asy expect(blocking, JSON.stringify(blocking, null, 2)).toEqual([]) }) -test('@smoke @j09 enterprise member sessions are isolated and keyboard-operable', async ({ browser, page }, testInfo) => { +test('@e2e-a11y-session-001 enterprise member sessions are isolated and keyboard-operable', async ({ browser, page }, testInfo) => { test.skip(testInfo.project.name !== 'chromium-desktop', 'The cross-session workflow is viewport-independent and runs once on desktop.') const errors = captureBrowserErrors(page) diff --git a/frontend/e2e/account-identity.spec.ts b/frontend/e2e/account-identity.spec.ts new file mode 100644 index 0000000..30637cf --- /dev/null +++ b/frontend/e2e/account-identity.spec.ts @@ -0,0 +1,73 @@ +import { expect, test } from '@playwright/test' +import { captureBrowserErrors, envelope, loginTestPrincipal, loginUser, registerUsers } from './fixtures' + +type AccountProfile = { + auth_identities: Array<{ issuer: string; subject: string; email: string }> + login_methods: Array<{ id: string; label: string; available: boolean; bound: boolean }> +} + +test('@e2e-account-identity-001 OIDC identity binding and unbinding complete the real callback lifecycle', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'chromium-desktop', 'The stateful identity lifecycle runs once on desktop.') + test.skip(process.env.ASTER_E2E_OIDC_AVAILABLE !== '1', 'This journey requires the isolated fake OIDC runtime.') + test.setTimeout(60_000) + + const browserErrors = captureBrowserErrors(page) + const adminToken = await loginTestPrincipal(page) + const adminHeaders = { Authorization: `Bearer ${adminToken}` } + const password = 'e2e-identity-password-1' + const email = `e2e-identity-${Date.now()}@example.test` + const [{ id: userID }] = await registerUsers(page, adminToken, [{ email, password, displayName: 'E2E Identity User' }]) + const token = await loginUser(page, email, password) + const headers = { Authorization: `Bearer ${token}` } + await page.goto('/login') + await page.getByLabel('Username').fill(email) + await page.locator('#password').fill(password) + await page.getByRole('button', { name: 'Sign in', exact: true }).click() + await expect(page).toHaveURL(/\/portal\/overview$/) + + await page.goto('/portal/account') + await page.getByRole('tab', { name: 'Sign-in methods' }).click() + const oidcMethod = page.locator('.login-method-row').filter({ hasText: 'Fake OIDC' }) + await expect(oidcMethod).toContainText('Available') + + const bindingResponse = page.waitForResponse((response) => response.url().includes('/api/v1/account/identities/oidc/bind') && response.request().method() === 'POST') + await oidcMethod.getByRole('button', { name: 'Bind', exact: true }).click() + expect((await bindingResponse).status()).toBe(200) + await expect(page).toHaveURL(/\/oidc\/authorize\?/) + await expect(page.getByRole('heading', { name: 'Fake OIDC authorization' })).toBeVisible() + await page.getByRole('button', { name: 'Continue' }).click() + + await expect(page).toHaveURL(/\/portal\/account$/) + await expect(page.getByText('Sign-in method bound successfully')).toBeVisible() + await expect(oidcMethod).toContainText('Bound') + const bound = await envelope(await page.request.get('/api/v1/account/profile', { headers })) + expect(bound.auth_identities).toContainEqual(expect.objectContaining({ + issuer: expect.stringMatching(/\/oidc$/), + subject: 'fake-oidc-subject-1', + email: 'e2e-oidc@example.test' + })) + expect(bound.login_methods).toContainEqual(expect.objectContaining({ id: 'oidc', available: true, bound: true })) + + await page.reload() + await page.getByRole('tab', { name: 'Sign-in methods' }).click() + await expect(oidcMethod).toContainText('Bound') + + page.once('dialog', (dialog) => dialog.accept()) + const unbindingResponse = page.waitForResponse((response) => response.url().includes('/api/v1/account/identities/oidc') && response.request().method() === 'DELETE') + await oidcMethod.getByRole('button', { name: 'Unbind', exact: true }).click() + expect((await unbindingResponse).status()).toBe(200) + await expect(page.getByText('Fake OIDC has been unbound')).toBeVisible() + await expect(oidcMethod).toContainText('Available') + + await page.reload() + await page.getByRole('tab', { name: 'Sign-in methods' }).click() + await expect(oidcMethod).toContainText('Available') + const unbound = await envelope(await page.request.get('/api/v1/account/profile', { headers })) + expect(unbound.auth_identities).not.toContainEqual(expect.objectContaining({ subject: 'fake-oidc-subject-1' })) + expect(unbound.login_methods).toContainEqual(expect.objectContaining({ id: 'oidc', available: true, bound: false })) + + const audit = await envelope>>(await page.request.get('/api/v1/console/audit-logs?limit=100', { headers: adminHeaders })) + expect(audit).toContainEqual(expect.objectContaining({ action: 'auth_identity_bound', resource_type: 'workspace_user', resource_id: userID })) + expect(audit).toContainEqual(expect.objectContaining({ action: 'auth_identity_unbound', resource_type: 'workspace_user', resource_id: userID })) + expect(browserErrors).toEqual([]) +}) diff --git a/frontend/e2e/artifact-sink.spec.ts b/frontend/e2e/artifact-sink.spec.ts index c08ece1..601baf8 100644 --- a/frontend/e2e/artifact-sink.spec.ts +++ b/frontend/e2e/artifact-sink.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from '@playwright/test' import { captureBrowserErrors, envelope, expectNoHorizontalOverflow, loginDemo, loginTestPrincipal } from './fixtures' -test('@artifact-sink manages customer-owned object storage without exposing credentials', async ({ page }, testInfo) => { +test('@e2e-artifact-sink-001 manages customer-owned object storage without exposing credentials', async ({ page }, testInfo) => { const errors = captureBrowserErrors(page) await loginDemo(page) const pluginID = 'com.asterrouter.artifact.s3-compatible-sink' @@ -15,7 +15,8 @@ test('@artifact-sink manages customer-owned object storage without exposing cred headers: { Authorization: `Bearer ${token}` } })) } - await page.goto('/console/plugins') + await page.goto('/console/system/plugins') + await expect(page).toHaveURL(/\/console\/system\/plugins$/) const mobileViewport = (page.viewportSize()?.width || 0) <= 640 await page.getByRole('button', { name: 'Plugin registry', exact: true }).click() await page.getByRole('button', { name: /S3-compatible Artifact Delivery/ }).click() diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts index 25e5952..0cb25ee 100644 --- a/frontend/e2e/auth.spec.ts +++ b/frontend/e2e/auth.spec.ts @@ -1,6 +1,6 @@ import { createHmac } from 'node:crypto' -import { expect, test, type Page } from '@playwright/test' -import { captureBrowserErrors, envelope, expectNoHorizontalOverflow } from './fixtures' +import { expect, request as playwrightRequest, test, type Page } from '@playwright/test' +import { captureBrowserErrors, envelope, expectNoHorizontalOverflow, loginTestPrincipal } from './fixtures' function decodeBase32(value: string): Buffer { const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' @@ -39,18 +39,62 @@ async function startPasswordLogin(page: Page, email: string, password: string) { await page.getByRole('button', { name: 'Sign in', exact: true }).click() } -test('@auth registration, email verification, TOTP, and recovery-code sign-in work end to end', async ({ page }, testInfo) => { +async function verificationToken(page: Page, email: string, responseToken?: string): Promise { + if (responseToken) return responseToken + const mailAPI = process.env.ASTER_E2E_MAIL_API_URL + expect(mailAPI, 'Gate B authentication requires the isolated fake SMTP API').toBeTruthy() + let token = '' + await expect.poll(async () => { + const response = await page.request.get(`${mailAPI}/__test/messages?recipient=${encodeURIComponent(email)}`) + expect(response.status()).toBe(200) + const body = await response.json() as { messages: Array<{ body: string }> } + const match = body.messages.at(-1)?.body.match(/\/verify-email\?token=([^"'&<\s]+)/) + token = match?.[1] ? decodeURIComponent(match[1]) : '' + return token + }).not.toBe('') + return token +} + +async function accountProfileStatus(page: Page, token: string): Promise { + const request = await playwrightRequest.newContext({ + baseURL: new URL(page.url()).origin, + extraHTTPHeaders: { Authorization: `Bearer ${token}` } + }) + try { + return (await request.get('/api/v1/account/profile')).status() + } finally { + await request.dispose() + } +} + +async function createTOTPAuthenticatedSession(page: Page, email: string, password: string, code: string): Promise { + const request = await playwrightRequest.newContext({ baseURL: new URL(page.url()).origin }) + try { + const login = await envelope<{ mfa_required: boolean; challenge: string }>(await request.post('/api/v1/auth/login', { + data: { username: email, password, agreement_accepted: true } + })) + expect(login.mfa_required).toBe(true) + const session = await envelope<{ access_token: string }>(await request.post('/api/v1/auth/totp/login', { + data: { challenge: login.challenge, code } + })) + return session.access_token + } finally { + await request.dispose() + } +} + +test('@e2e-auth-001 registration, email verification, TOTP, and recovery-code sign-in work end to end', async ({ page }, testInfo) => { test.skip(testInfo.project.name !== 'chromium-desktop', 'The stateful authentication journey runs once; public layouts run in every viewport.') test.setTimeout(90_000) const browserErrors = captureBrowserErrors(page) - const adminLogin = await envelope<{ access_token: string }>(await page.request.post('/api/v1/auth/login', { - data: { username: 'demo', password: 'demo', agreement_accepted: true } - })) - const adminHeaders = { Authorization: `Bearer ${adminLogin.access_token}` } + const adminToken = await loginTestPrincipal(page) + const adminHeaders = { Authorization: `Bearer ${adminToken}` } const settings = await envelope>(await page.request.get('/api/v1/console/settings', { headers: adminHeaders })) const email = `e2e-auth-${Date.now()}@example.test` const password = 'synthetic-password-123' + const updatedPassword = 'synthetic-password-456' + const updatedDisplayName = 'E2E Authentication User Updated' try { await envelope(await page.request.put('/api/v1/console/settings', { @@ -63,9 +107,10 @@ test('@auth registration, email verification, TOTP, and recovery-code sign-in wo totp_enabled: true, turnstile_enabled: false, public_base_url: 'https://router.example.test', - smtp_host: 'smtp.example.test', - smtp_port: 587, - smtp_from: 'noreply@example.test' + smtp_host: process.env.ASTER_E2E_SMTP_PORT ? '127.0.0.1' : 'smtp.example.test', + smtp_port: Number(process.env.ASTER_E2E_SMTP_PORT || 587), + smtp_from: 'noreply@example.test', + smtp_use_tls: false } })) @@ -79,17 +124,34 @@ test('@auth registration, email verification, TOTP, and recovery-code sign-in wo await page.getByRole('button', { name: 'Create account', exact: true }).click() const registered = await registrationResponse expect(registered.status()).toBe(200) - const registrationBody = await registered.json() as { data: { verification_token?: string; verification_required: boolean } } + const registrationBody = await registered.json() as { data: { verification_token?: string; verification_required: boolean; email_delivery_failed: boolean } } expect(registrationBody.data.verification_required).toBe(true) - expect(registrationBody.data.verification_token).toBeTruthy() + expect(registrationBody.data.email_delivery_failed).toBe(false) + const token = await verificationToken(page, email, registrationBody.data.verification_token) await expect(page.getByText('Your account has been created. Check your email to verify it before signing in.')).toBeVisible() const verificationResponse = page.waitForResponse((response) => response.url().endsWith('/api/v1/auth/verify-email')) - await page.goto(`/verify-email?token=${encodeURIComponent(registrationBody.data.verification_token || '')}`) + await page.goto(`/verify-email?token=${encodeURIComponent(token)}`) expect((await verificationResponse).status()).toBe(200) await expect(page.getByText('Email verified. You can now sign in.')).toBeVisible() await page.getByRole('button', { name: 'Back to sign in' }).click() + await page.goto('/forgot-password') + await page.getByLabel('Email').fill(email) + const forgotPasswordResponse = page.waitForResponse((response) => response.url().endsWith('/api/v1/auth/forgot-password')) + await page.getByRole('button', { name: 'Send reset email', exact: true }).click() + expect((await forgotPasswordResponse).status()).toBe(200) + await expect(page.getByText('If the account exists, a reset email has been sent.')).toBeVisible() + + await page.goto('/resend-verification') + await page.getByLabel('Email').fill(email) + const resendResponse = page.waitForResponse((response) => response.url().endsWith('/api/v1/auth/resend-verification')) + await page.getByRole('button', { name: 'Resend verification email', exact: true }).click() + expect((await resendResponse).status()).toBe(200) + await expect(page.getByText('If the account is awaiting verification, another email has been sent.')).toBeVisible() + + await page.goto('/login') + await page.getByLabel('Username').fill(email) await page.locator('#password').fill(password) await page.getByRole('button', { name: 'Sign in', exact: true }).click() @@ -99,8 +161,23 @@ test('@auth registration, email verification, TOTP, and recovery-code sign-in wo expect(surface).toBeTruthy() await page.goto(`/${surface}/account`) + await page.getByLabel('Display name').fill(updatedDisplayName) + await page.getByRole('button', { name: 'Save profile' }).click() + await expect(page.getByText('Profile updated')).toBeVisible() + await page.reload() + await expect(page.getByLabel('Display name')).toHaveValue(updatedDisplayName) + await page.getByRole('tab', { name: 'Security' }).click() - await page.locator('#account-totp-current-password').fill(password) + const prePasswordChangeToken = await page.evaluate(() => localStorage.getItem('asterrouter_admin_token')) + expect(prePasswordChangeToken).toBeTruthy() + await page.locator('#account-current-password').fill(password) + await page.locator('#account-new-password').fill(updatedPassword) + await page.locator('#account-confirm-password').fill(updatedPassword) + await page.getByRole('button', { name: 'Change password', exact: true }).click() + await expect(page.getByText('Password changed')).toBeVisible() + expect(await accountProfileStatus(page, prePasswordChangeToken!)).toBe(401) + + await page.locator('#account-totp-current-password').fill(updatedPassword) await page.getByRole('button', { name: 'Set up authenticator' }).click() const secret = (await page.locator('.totp-setup-copy > code').first().textContent())?.trim() || '' expect(secret).toMatch(/^[A-Z2-7]+$/) @@ -111,26 +188,57 @@ test('@auth registration, email verification, TOTP, and recovery-code sign-in wo expect(recoveryCode).toMatch(/^[A-Z2-7]{6}-[A-Z2-7]{6}$/) await page.screenshot({ path: testInfo.outputPath('totp-enabled.png'), fullPage: true }) + await page.locator('#account-recovery-totp-code').fill(recoveryCode) + const recoveryResponse = page.waitForResponse((response) => + response.url().endsWith('/api/v1/account/totp/recovery-codes') && response.request().method() === 'POST' + ) + await page.getByRole('button', { name: 'Regenerate recovery codes' }).click() + expect((await recoveryResponse).status()).toBe(200) + await expect(page.locator('.recovery-grid code').first()).not.toHaveText(recoveryCode) + const replacementRecoveryCode = (await page.locator('.recovery-grid code').first().textContent())?.trim() || '' + expect(replacementRecoveryCode).toMatch(/^[A-Z2-7]{6}-[A-Z2-7]{6}$/) + expect(replacementRecoveryCode).not.toBe(recoveryCode) + await signOut(page) - await startPasswordLogin(page, email, password) + await startPasswordLogin(page, email, updatedPassword) await expect(page.getByRole('heading', { level: 2, name: 'Two-factor verification' })).toBeVisible() await page.locator('#mfa-code').fill(currentTOTP(secret)) await page.getByRole('button', { name: 'Verify and sign in' }).click() await expect(page).toHaveURL(new RegExp(`${entryPath}$`)) await signOut(page) - await startPasswordLogin(page, email, password) + await startPasswordLogin(page, email, updatedPassword) await expect(page.locator('#mfa-code')).toHaveAttribute('maxlength', '13') - await page.locator('#mfa-code').fill(recoveryCode) + await page.locator('#mfa-code').fill(replacementRecoveryCode) await page.getByRole('button', { name: 'Verify and sign in' }).click() await expect(page).toHaveURL(new RegExp(`${entryPath}$`)) + + const secondarySessionToken = await createTOTPAuthenticatedSession(page, email, updatedPassword, currentTOTP(secret)) + expect(secondarySessionToken).toBeTruthy() + + await page.goto(`/${surface}/account`) + await page.getByRole('tab', { name: 'Security' }).click() + page.once('dialog', (dialog) => dialog.accept()) + await page.getByRole('button', { name: 'Sign out other devices' }).click() + await expect(page.getByText('Other device sessions have been revoked')).toBeVisible() + expect(await accountProfileStatus(page, secondarySessionToken)).toBe(401) + + await page.locator('#account-disable-totp-code').fill(currentTOTP(secret)) + await page.getByRole('button', { name: 'Disable two-factor authentication' }).click() + await expect(page.getByText('Two-factor authentication disabled')).toBeVisible() + await signOut(page) + await startPasswordLogin(page, email, updatedPassword) + await expect(page).toHaveURL(new RegExp(`${entryPath}$`)) + await expect(page.getByRole('heading', { level: 2, name: 'Two-factor verification' })).toHaveCount(0) + await page.goto(`/${surface}/account`) + await expect(page.getByLabel('Display name')).toHaveValue(updatedDisplayName) expect(browserErrors).toEqual([]) } finally { await envelope(await page.request.put('/api/v1/console/settings', { headers: adminHeaders, data: settings })) } }) -test('@auth public authentication pages remain usable and reject invalid links', async ({ page }, testInfo) => { +test('@e2e-auth-002 public authentication pages remain usable and reject invalid links', async ({ page }, testInfo) => { const browserErrors = captureBrowserErrors(page) await page.goto('/register') diff --git a/frontend/e2e/effective-pricing.spec.ts b/frontend/e2e/effective-pricing.spec.ts index a3173ab..0e5f57a 100644 --- a/frontend/e2e/effective-pricing.spec.ts +++ b/frontend/e2e/effective-pricing.spec.ts @@ -1,29 +1,437 @@ -import { expect, test } from '@playwright/test' -import { captureBrowserErrors, expectNoHorizontalOverflow, loginDemo } from './fixtures' +import { expect, test, type Locator, type Page } from '@playwright/test' +import { adminPost, captureBrowserErrors, controlAPI, envelope, expectNoHorizontalOverflow, loginDemo, loginTestPrincipal } from './fixtures' -test('@effective-pricing automatic window policy remains usable across viewports', async ({ page }, testInfo) => { +type SupplierFixture = { + provider: { id: string } + account: { id: string; provider_id: string; name: string } + model: { id: string; model_id: string } + route: { id: string } + workspaceKey: { key: string; record: { id: string } } +} + +type EffectivePricingReportRow = { + provider_account_id: string + request_count: number + cost_confidence: string + effective_cost_micros_per_1m: number + price_id: string + billing_consistency_rate?: number +} + +type UsageEvidence = { + id: string + provider_account_id: string + upstream_request_id: string + procurement_cost_micros?: number + procurement_cost_source: string + procurement_cost_confidence: string + provider_billing_line_id: string +} + +function dialogField(dialog: Locator, label: string, selector = 'input'): Locator { + return dialog.locator('.field').filter({ hasText: label }).locator(selector).first() +} + +async function createSupplierFixture( + page: Page, + token: string, + runID: string, + label: string, + publicModel: string, + upstreamModel: string +): Promise { + const upstreamPort = process.env.ASTER_E2E_UPSTREAM_PORT || '19000' + const provider = await adminPost<{ id: string }>(page, token, '/providers', { + name: `${label} provider ${runID}`, + type: 'openai_compatible', + base_url: `http://127.0.0.1:${upstreamPort}/v1`, + status: 'active', + priority: 10 + }) + const account = await adminPost<{ id: string; provider_id: string; name: string; secret_configured: boolean }>(page, token, '/provider-accounts', { + provider_id: provider.id, + name: `${label} account ${runID}`, + platform: 'openai_compatible', + auth_type: 'api_key', + status: 'active', + schedulable: true, + priority: 10, + concurrency: 4, + rate_multiplier: 1, + models: [upstreamModel], + group_ids: [], + secret: 'synthetic-effective-pricing-secret' + }) + expect(account.secret_configured).toBe(true) + const model = await adminPost<{ id: string; model_id: string }>(page, token, '/gateway-models', { + model_id: publicModel, + name: `${label} model ${runID}`, + description: 'Synthetic effective-pricing E2E route', + modality: 'chat', + default_route_group: 'default', + status: 'active' + }) + const route = await adminPost<{ id: string }>(page, token, '/model-routes', { + gateway_model_id: model.id, + route_group: 'default', + provider_account_id: account.id, + upstream_model: upstreamModel, + upstream_format: 'openai_chat', + priority: 10, + weight: 100, + status: 'active' + }) + const workspaceKey = await adminPost<{ key: string; record: { id: string } }>(page, token, '/api-keys', { + name: `${label} effective pricing key ${runID}`, + model_allowlist: [publicModel], + qps_limit: 10, + monthly_token_limit: 100000 + }) + return { provider, account, model, route, workspaceKey } +} + +async function addProcurementPrice( + page: Page, + supplier: SupplierFixture, + upstreamModel: string, + inputRate: number, + outputRate: number, + sourceReference: string +) { + await page.getByRole('button', { name: 'Add procurement price' }).click() + const dialog = page.getByRole('dialog', { name: 'Add procurement price' }) + await dialogField(dialog, 'Route Resources', 'select').selectOption(supplier.account.id) + await dialogField(dialog, 'Model', 'select').selectOption(upstreamModel) + await dialogField(dialog, 'Uncached input price').fill(String(inputRate)) + await dialogField(dialog, 'Cache read price').fill(String(Math.floor(inputRate / 10))) + await dialogField(dialog, '5-minute cache write price').fill(String(inputRate)) + await dialogField(dialog, '1-hour cache write price').fill(String(inputRate)) + await dialogField(dialog, 'Output price').fill(String(outputRate)) + await dialogField(dialog, 'Per-request fee').fill('0') + await dialogField(dialog, 'Quoted multiplier').fill(inputRate === 2_000_000 ? '2' : '1') + await dialogField(dialog, 'Recharge paid multiplier').fill('1') + await dialogField(dialog, 'Official input baseline').fill('1000000') + await dialogField(dialog, 'Official output baseline').fill('2000000') + await dialogField(dialog, 'Confidence', 'select').selectOption('exact') + await dialogField(dialog, 'Quote source').fill(sourceReference) + const responsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' && new URL(response.url()).pathname === '/api/v1/console/procurement-prices' + ) + await dialog.getByRole('button', { name: 'Save' }).click() + const response = await responsePromise + expect(response.status()).toBe(200) + const body = await response.json() as { data: Record } + expect(body.data).toMatchObject({ + provider_id: supplier.provider.id, + provider_account_id: supplier.account.id, + upstream_model: upstreamModel, + protocol: 'openai_chat_completions', + confidence: 'exact', + status: 'active', + source_reference: sourceReference + }) + return body.data as { id: string } +} + +test('@e2e-effective-pricing-001 effective pricing executes a measured provider switch through canary', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'chromium-desktop', 'The stateful provider-switch lifecycle runs once; surface coverage verifies responsive layouts.') + test.setTimeout(120_000) const errors = captureBrowserErrors(page) await loginDemo(page) - await page.goto('/console/effective-pricing') + const token = await loginTestPrincipal(page) + const runID = `${testInfo.project.name.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}-${Date.now().toString(36)}` + const upstreamModel = `effective-upstream-${runID}` + const currentModel = `effective-current-${runID}` + const candidateModel = `effective-candidate-${runID}` + const current = await createSupplierFixture(page, token, runID, 'Current expensive', currentModel, upstreamModel) + const candidate = await createSupplierFixture(page, token, runID, 'Candidate efficient', candidateModel, upstreamModel) + + await page.goto('/console/model-services/effective-pricing') + await expect(page).toHaveURL(/\/console\/model-services\/effective-pricing$/) await expect(page.getByRole('heading', { level: 1, name: 'Effective Pricing & Cache Routing' })).toBeVisible() await page.getByRole('button', { name: 'Policy' }).click() - const dialog = page.getByRole('dialog', { name: 'Effective pricing policy' }) - await expect(dialog).toBeVisible() - await expect(dialog.getByLabel('Evaluation interval (minutes)')).toHaveValue('60') - await expect(dialog.getByLabel('Healthy windows before automatic promotion')).toHaveValue('3') - await expect(dialog.getByLabel('Degraded windows before automatic rollback')).toHaveValue('2') - const automaticActions = dialog.getByLabel(/Enable automatic promotion and rollback/) - await automaticActions.scrollIntoViewIfNeeded() - await expect(automaticActions).toBeVisible() - await expect(automaticActions).not.toBeChecked() + let dialog = page.getByRole('dialog', { name: 'Effective pricing policy' }) + await dialogField(dialog, 'Mode', 'select').selectOption('cost_first') + await dialogField(dialog, 'Minimum samples').fill('1') + await dialogField(dialog, 'Minimum metric coverage').fill('0') + await dialogField(dialog, 'Minimum billing consistency').fill('0') + await dialogField(dialog, 'Minimum effective cost improvement').fill('0.1') + await dialogField(dialog, 'Maximum error-rate regression').fill('1') + await dialogField(dialog, 'Maximum P95 latency regression').fill('1') + await dialogField(dialog, 'Canary percent').fill('25') + await dialogField(dialog, 'Daily probe token budget').fill('100000') + await dialogField(dialog, 'Daily probe cost budget').fill('100000') + await dialogField(dialog, 'Per-account probe cooldown').fill('0') + await dialog.getByLabel('Enable controlled probes').check() + const policyResponsePromise = page.waitForResponse((response) => + response.request().method() === 'PUT' && new URL(response.url()).pathname === '/api/v1/console/effective-pricing/policy' + ) + await dialog.getByRole('button', { name: 'Save' }).click() + const policyResponse = await policyResponsePromise + expect(policyResponse.status()).toBe(200) + await expect(policyResponse.json()).resolves.toMatchObject({ + data: { + mode: 'cost_first', min_sample_count: 1, min_metrics_coverage: 0, min_billing_consistency: 0, + min_cost_improvement: 0.1, max_error_rate_regression: 1, max_p95_latency_regression: 1, + canary_percent: 25, probe_enabled: true, probe_cooldown_seconds: 0 + } + }) + + const currentPrice = await addProcurementPrice(page, current, upstreamModel, 2_000_000, 4_000_000, `current-${runID}`) + const candidatePrice = await addProcurementPrice(page, candidate, upstreamModel, 1_000_000, 2_000_000, `candidate-${runID}`) + + const usageByAccount = new Map() + for (const supplier of [current, candidate]) { + const completion = await page.request.post('/v1/chat/completions', { + headers: { Authorization: `Bearer ${supplier.workspaceKey.key}` }, + data: { model: supplier.model.model_id, messages: [{ role: 'user', content: `effective pricing sample ${runID}` }] } + }) + expect(completion.status()).toBe(200) + await expect(completion.json()).resolves.toMatchObject({ + id: 'e2e-completion', usage: { prompt_tokens: 7, completion_tokens: 11 } + }) + await expect.poll(async () => { + const usage = await envelope<{ recent: UsageEvidence[] }>(await page.request.get( + `/api/v1/console/usage?api_key_id=${encodeURIComponent(supplier.workspaceKey.record.id)}&limit=10`, + { headers: { Authorization: `Bearer ${token}` } } + )) + const record = usage.recent.find((item) => item.provider_account_id === supplier.account.id) + if (record) usageByAccount.set(supplier.account.id, record) + return record + }, { message: `persisted Gateway usage for ${supplier.account.name}` }).toMatchObject({ + provider_account_id: supplier.account.id, + procurement_cost_source: 'procurement_price', + procurement_cost_confidence: 'exact', + provider_billing_line_id: '' + }) + expect(usageByAccount.get(supplier.account.id)?.upstream_request_id).not.toBe('') + } + + const reportResponsePromise = page.waitForResponse((response) => + response.request().method() === 'GET' && new URL(response.url()).pathname === '/api/v1/console/effective-pricing/report' + ) + await page.getByRole('button', { name: 'Refresh' }).click() + const reportResponse = await reportResponsePromise + expect(reportResponse.status()).toBe(200) + const reportBody = await reportResponse.json() as { data: { rows: EffectivePricingReportRow[] } } + const currentRow = reportBody.data.rows.find((row) => row.provider_account_id === current.account.id) + const candidateRow = reportBody.data.rows.find((row) => row.provider_account_id === candidate.account.id) + expect(currentRow).toMatchObject({ request_count: 1, cost_confidence: 'exact', price_id: currentPrice.id }) + expect(candidateRow).toMatchObject({ request_count: 1, cost_confidence: 'exact', price_id: candidatePrice.id }) + expect(currentRow!.effective_cost_micros_per_1m).toBeGreaterThan(candidateRow!.effective_cost_micros_per_1m) + await expect(page.locator('.ep-table tbody tr').filter({ hasText: current.account.name })).toContainText(/1 requests/i) + await expect(page.locator('.ep-table tbody tr').filter({ hasText: candidate.account.name })).toContainText(/1 requests/i) + + const currentUsage = usageByAccount.get(current.account.id)! + const externalLineID = `line-${runID}` + await page.getByRole('button', { name: 'Import bill' }).click() + dialog = page.getByRole('dialog', { name: 'Import provider bill' }) + await dialogField(dialog, 'Route Resources', 'select').selectOption(current.account.id) + await dialogField(dialog, 'Model', 'select').selectOption(upstreamModel) + await dialogField(dialog, 'External billing line ID').fill(externalLineID) + await dialogField(dialog, 'Upstream request ID').fill(currentUsage.upstream_request_id) + await dialogField(dialog, 'Billed amount').fill('99') + await dialogField(dialog, 'Confidence', 'select').selectOption('exact') + const billingResponsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' && new URL(response.url()).pathname === '/api/v1/console/provider-billing-lines' + ) + await dialog.getByRole('button', { name: 'Save' }).click() + const billingResponse = await billingResponsePromise + expect(billingResponse.status()).toBe(200) + const billingBody = await billingResponse.json() as { data: Record } + expect(billingBody.data).toMatchObject({ + provider_id: current.provider.id, + provider_account_id: current.account.id, + external_line_id: externalLineID, + external_request_id: currentUsage.upstream_request_id, + usage_record_id: currentUsage.id, + upstream_model: upstreamModel, + amount_micros: 99, + confidence: 'exact', + reconciliation_status: 'matched' + }) + const billingLineID = String(billingBody.data.id) + await expect.poll(async () => { + const usage = await envelope<{ recent: UsageEvidence[] }>(await page.request.get( + `/api/v1/console/usage?api_key_id=${encodeURIComponent(current.workspaceKey.record.id)}&limit=10`, + { headers: { Authorization: `Bearer ${token}` } } + )) + return usage.recent.find((item) => item.id === currentUsage.id) + }, { message: 'Gateway usage reconciled to the imported provider bill' }).toMatchObject({ + id: currentUsage.id, + upstream_request_id: currentUsage.upstream_request_id, + procurement_cost_micros: 99, + procurement_cost_source: 'billing', + procurement_cost_confidence: 'exact', + provider_billing_line_id: billingLineID + }) + const reconciledReport = await envelope<{ rows: EffectivePricingReportRow[] }>(await page.request.get( + `/api/v1/console/effective-pricing/report?model=${encodeURIComponent(upstreamModel)}&protocol=openai_chat_completions&window_hours=24`, + { headers: { Authorization: `Bearer ${token}` } } + )) + expect(reconciledReport.rows.find((row) => row.provider_account_id === current.account.id)).toMatchObject({ + request_count: 1, + cost_confidence: 'exact', + billing_consistency_rate: 1 + }) + + await page.getByRole('button', { name: 'Cache quality' }).click() + let candidateCacheRow = page.locator('.cache-row').filter({ hasText: candidate.account.name }) + await candidateCacheRow.getByRole('button', { name: 'Configure capability' }).click() + dialog = page.getByRole('dialog', { name: 'Configure provider cache capability' }) + await dialogField(dialog, 'Capability status', 'select').selectOption('accepted') + await dialogField(dialog, 'Affinity transport', 'select').selectOption('header') + await dialogField(dialog, 'Affinity field').fill('X-E2E-Session') + await dialogField(dialog, 'Cache control mode', 'select').selectOption('prompt_cache_key') + const capabilityResponsePromise = page.waitForResponse((response) => + response.request().method() === 'PUT' && new URL(response.url()).pathname === '/api/v1/console/provider-cache-capabilities' + ) + await dialog.getByRole('button', { name: 'Save' }).click() + const capabilityResponse = await capabilityResponsePromise + expect(capabilityResponse.status()).toBe(200) + const capabilityBody = await capabilityResponse.json() as { data: Record } + expect(capabilityBody.data).toMatchObject({ + provider_account_id: candidate.account.id, + upstream_model: upstreamModel, + protocol: 'openai_chat_completions', + support_status: 'accepted', + pool_affinity_grade: 'unknown', + affinity_transport: 'header', + affinity_field: 'X-E2E-Session', + cache_control_mode: 'prompt_cache_key' + }) + + await page.reload() + await page.getByRole('button', { name: 'Cache quality' }).click() + candidateCacheRow = page.locator('.cache-row').filter({ hasText: candidate.account.name }) + await candidateCacheRow.getByRole('button', { name: 'Configure capability' }).click() + dialog = page.getByRole('dialog', { name: 'Configure provider cache capability' }) + await expect(dialogField(dialog, 'Capability status', 'select')).toHaveValue('accepted') + await expect(dialogField(dialog, 'Affinity transport', 'select')).toHaveValue('header') + await expect(dialogField(dialog, 'Affinity field')).toHaveValue('X-E2E-Session') + await expect(dialogField(dialog, 'Cache control mode', 'select')).toHaveValue('prompt_cache_key') + await dialog.getByRole('button', { name: 'Close' }).click() + + await page.getByRole('button', { name: 'Probe records' }).click() + await page.getByRole('button', { name: 'Run probe' }).click() + dialog = page.getByRole('dialog', { name: 'Run controlled cache probe' }) + await dialogField(dialog, 'Route Resources', 'select').selectOption(candidate.account.id) + await dialogField(dialog, 'Model', 'select').selectOption(upstreamModel) + await dialogField(dialog, 'Synthetic prefix token estimate').fill('256') + await dialogField(dialog, 'Maximum accepted cost').fill('100000') + await dialog.getByLabel(/I confirm this run will send three synthetic requests/).check() + const probeResponsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' && new URL(response.url()).pathname === '/api/v1/console/provider-cache-probes' + ) + await dialog.getByRole('button', { name: 'Save' }).click() + const probeResponse = await probeResponsePromise + expect(probeResponse.status()).toBe(200) + const probeBody = await probeResponse.json() as { data: Record } + expect(probeBody.data).toMatchObject({ + provider_account_id: candidate.account.id, + upstream_model: upstreamModel, + status: 'succeeded', + warm_cache_read_tokens: 0, + reuse_cache_read_tokens: 240, + control_cache_read_tokens: 0, + cache_fields_present: true + }) + expect(new Set([ + probeBody.data.warm_upstream_request_id, + probeBody.data.reuse_upstream_request_id, + probeBody.data.control_upstream_request_id + ]).size).toBe(3) + const probeRow = page.locator('.probe-row').filter({ hasText: candidate.account.name }) + await expect(probeRow).toContainText('240') + await expect(probeRow).toContainText('succeeded') + + await page.getByRole('button', { name: 'Cache quality' }).click() + candidateCacheRow = page.locator('.cache-row').filter({ hasText: candidate.account.name }) + await expect(candidateCacheRow).toContainText('observed') + await expect(candidateCacheRow).toContainText('probable') + + await page.getByRole('button', { name: 'Switch center' }).click() + await page.getByRole('button', { name: 'New evaluation' }).click() + dialog = page.getByRole('dialog', { name: 'Evaluate provider switch' }) + await dialogField(dialog, 'Gateway model', 'select').selectOption(currentModel) + await dialogField(dialog, 'Provider upstream model', 'select').selectOption(upstreamModel) + await dialogField(dialog, 'Current route', 'select').selectOption(current.account.id) + await dialogField(dialog, 'Candidate route', 'select').selectOption(candidate.account.id) + const decisionResponsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' && new URL(response.url()).pathname === '/api/v1/console/effective-pricing/decisions/evaluate' + ) + await dialog.getByRole('button', { name: 'Save' }).click() + const decisionResponse = await decisionResponsePromise + expect(decisionResponse.status()).toBe(200) + const decisionBody = await decisionResponse.json() as { data: Record } + expect(decisionBody.data).toMatchObject({ + model: currentModel, + upstream_model: upstreamModel, + current_provider_account_id: current.account.id, + candidate_provider_account_id: candidate.account.id, + status: 'recommended', + sample_count: 1, + confidence: 'exact', + reason_codes: [] + }) + expect(Number(decisionBody.data.current_cost_micros_per_1m)).toBeGreaterThan(Number(decisionBody.data.candidate_cost_micros_per_1m)) + const decisionID = String(decisionBody.data.id) + let decisionCard = page.locator('.decision-card').filter({ hasText: decisionID }) + await expect(decisionCard).toContainText('recommended') + + const actionResponsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' && new URL(response.url()).pathname === `/api/v1/console/effective-pricing/decisions/${decisionID}/action` + ) + await decisionCard.getByRole('button', { name: 'Start canary' }).click() + const actionResponse = await actionResponsePromise + expect(actionResponse.status()).toBe(200) + await expect(actionResponse.json()).resolves.toMatchObject({ + data: { id: decisionID, status: 'canary', canary_percent: 25 } + }) + + await page.reload() + await page.getByRole('button', { name: 'Policy' }).click() + dialog = page.getByRole('dialog', { name: 'Effective pricing policy' }) + await expect(dialogField(dialog, 'Mode', 'select')).toHaveValue('cost_first') + await expect(dialogField(dialog, 'Minimum samples')).toHaveValue('1') + await expect(dialogField(dialog, 'Canary percent')).toHaveValue('25') + await expect(dialog.getByLabel('Enable controlled probes')).toBeChecked() + await dialog.getByRole('button', { name: 'Close' }).click() + await page.getByRole('button', { name: 'Switch center' }).click() + decisionCard = page.locator('.decision-card').filter({ hasText: decisionID }) + await expect(decisionCard).toContainText('canary') + await expect(decisionCard.getByRole('button', { name: 'Activate' })).toBeVisible() + const evaluationsResponsePromise = page.waitForResponse((response) => + response.request().method() === 'GET' && new URL(response.url()).pathname === `/api/v1/console/effective-pricing/decisions/${decisionID}/evaluations` + ) + await decisionCard.getByRole('button', { name: 'Window evidence' }).click() + const evaluationsResponse = await evaluationsResponsePromise + expect(evaluationsResponse.status()).toBe(200) + await expect(evaluationsResponse.json()).resolves.toMatchObject({ data: [] }) + const evidenceDialog = page.getByRole('dialog').filter({ hasText: decisionID }) + await expect(evidenceDialog.getByText('No completed evaluation windows yet.')).toBeVisible() + await evidenceDialog.getByRole('button', { name: 'Close' }).click() + + const audits = await envelope>( + await page.request.get(controlAPI('/audit-logs?limit=200'), { headers: { Authorization: `Bearer ${token}` } }) + ) + expect(audits).toEqual(expect.arrayContaining([ + expect.objectContaining({ action: 'create', resource_type: 'procurement_price', resource_id: currentPrice.id }), + expect.objectContaining({ action: 'create', resource_type: 'procurement_price', resource_id: candidatePrice.id }), + expect.objectContaining({ action: 'import', resource_type: 'provider_billing_line', resource_id: billingLineID }), + expect.objectContaining({ action: 'upsert', resource_type: 'provider_cache_capability', resource_id: capabilityBody.data.id }), + expect.objectContaining({ action: 'run', resource_type: 'provider_cache_probe', resource_id: probeBody.data.id }), + expect.objectContaining({ action: 'evaluate', resource_type: 'effective_pricing_decision', resource_id: decisionID }), + expect.objectContaining({ action: 'approve_canary', resource_type: 'effective_pricing_decision', resource_id: decisionID }) + ])) await expectNoHorizontalOverflow(page) - await page.screenshot({ path: testInfo.outputPath('effective-pricing-policy.png'), fullPage: true }) + await page.screenshot({ path: testInfo.outputPath(`effective-pricing-canary-${runID}.png`), fullPage: true }) expect(errors).toEqual([]) }) -test('@effective-pricing billing source inspection keeps aggregate evidence distinct from bill lines', async ({ page }, testInfo) => { +test('@e2e-effective-pricing-002 billing source inspection keeps aggregate evidence distinct from bill lines', async ({ page }, testInfo) => { const errors = captureBrowserErrors(page) const source = { id: 'source-e2e', provider_id: 'provider-source-e2e', provider_account_id: 'account-source-e2e', adapter_id: 'sub2api_compatible', @@ -106,7 +514,8 @@ test('@effective-pricing billing source inspection keeps aggregate evidence dist await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 0, message: 'success', data: { source: { ...source, version: 5 }, run, balance, aggregates: [aggregate] } }) }) }) - await page.goto('/console/effective-pricing') + await page.goto('/console/model-services/effective-pricing') + await expect(page).toHaveURL(/\/console\/model-services\/effective-pricing$/) await page.getByRole('button', { name: 'Billing source' }).click() await expect(page.getByRole('heading', { name: 'Third-party billing source inspection' })).toBeVisible() await expect(page.getByRole('heading', { name: 'Sync run history' })).toBeVisible() diff --git a/frontend/e2e/email-settings.spec.ts b/frontend/e2e/email-settings.spec.ts index b9202a8..3f6bfef 100644 --- a/frontend/e2e/email-settings.spec.ts +++ b/frontend/e2e/email-settings.spec.ts @@ -1,7 +1,27 @@ -import { expect, test } from '@playwright/test' +import { expect, test, type Page } from '@playwright/test' import { captureBrowserErrors, expectNoHorizontalOverflow, loginDemo } from './fixtures' -test('email settings manage SMTP credentials and localized templates', async ({ page }, testInfo) => { +type MailboxMessage = { + from: string + to: string + subject: string + body: string +} + +async function latestMessage(page: Page, recipient: string): Promise { + const mailAPI = process.env.ASTER_E2E_MAIL_API_URL || 'http://127.0.0.1:29002' + let message: MailboxMessage | undefined + await expect.poll(async () => { + const response = await page.request.get(`${mailAPI}/__test/messages?recipient=${encodeURIComponent(recipient)}`) + expect(response.status()).toBe(200) + const body = await response.json() as { messages: MailboxMessage[] } + message = body.messages.at(-1) + return message?.to || '' + }).toBe(recipient) + return message! +} + +test('@e2e-email-001 email settings manage SMTP credentials and localized templates', async ({ page }, testInfo) => { const errors = captureBrowserErrors(page) await loginDemo(page) await page.goto('/console/system') @@ -22,13 +42,65 @@ test('email settings manage SMTP credentials and localized templates', async ({ if (testInfo.project.name === 'chromium-desktop') { const subject = editor.getByLabel('Email subject') + const html = editor.getByLabel('HTML template') const originalSubject = await subject.inputValue() + const originalHTML = await html.inputValue() + const originalSMTP = { + host: await page.locator('input[name="smtp-host"]').inputValue(), + port: await page.locator('input[name="smtp-port"]').inputValue(), + from: await page.locator('input[name="smtp-from"]').inputValue(), + fromName: await page.locator('input[name="smtp-from-name"]').inputValue() + } + const runID = Date.now() + const smtpPort = process.env.ASTER_E2E_SMTP_PORT || '29001' + const sender = `e2e-sender-${runID}@example.test` + const smtpRecipient = `e2e-smtp-${runID}@example.test` + const templateRecipient = `e2e-template-${runID}@example.test` + const smtpPanel = page.locator('.panel').filter({ has: page.getByRole('heading', { name: 'Email settings' }) }) + + await page.locator('input[name="smtp-host"]').fill('127.0.0.1') + await page.locator('input[name="smtp-port"]').fill(smtpPort) + await page.locator('input[name="smtp-from"]').fill(sender) + await page.locator('input[name="smtp-from-name"]').fill('AsterRouter E2E') + await smtpPanel.locator('.auth-provider-header input[type="checkbox"]').uncheck() + await smtpPanel.getByRole('button', { name: 'Test connection' }).click() + await expect(page.getByText('SMTP connection succeeded')).toBeVisible() + + await smtpPanel.locator('.smtp-test-controls input[type="email"]').fill(smtpRecipient) + await smtpPanel.getByRole('button', { name: 'Send test email' }).click() + await expect(page.getByText('SMTP test email sent')).toBeVisible() + expect(await latestMessage(page, smtpRecipient)).toMatchObject({ + from: sender, + to: smtpRecipient, + subject: 'AsterRouter SMTP test', + body: 'SMTP configuration is working.' + }) + + const templateSubject = `E2E ${runID} for {{.UserName}}` + const templateHTML = `

E2E ${runID}

{{.SiteName}} / {{.UserName}} / {{.ActionURL}}

` + await subject.fill(templateSubject) + await html.fill(templateHTML) + await editor.getByLabel('Test recipient').fill(templateRecipient) + await editor.getByRole('button', { name: 'Send test', exact: true }).click() + await expect(editor.getByText('Template test email sent')).toBeVisible() + expect(await latestMessage(page, templateRecipient)).toMatchObject({ + from: sender, + to: templateRecipient, + subject: `E2E ${runID} for Enterprise User`, + body: `

E2E ${runID}

AsterRouter / Enterprise User / https://example.test/action

` + }) + await subject.fill(`${originalSubject} [browser test]`) + await html.fill(originalHTML) await editor.getByRole('button', { name: 'Save', exact: true }).click() await expect(editor.getByText('Customized')).toBeVisible() await page.reload() await page.getByRole('tab', { name: 'Email settings' }).click() await expect(editor.getByLabel('Email subject')).toHaveValue(`${originalSubject} [browser test]`) + await expect(page.locator('input[name="smtp-host"]')).toHaveValue(originalSMTP.host) + await expect(page.locator('input[name="smtp-port"]')).toHaveValue(originalSMTP.port) + await expect(page.locator('input[name="smtp-from"]')).toHaveValue(originalSMTP.from) + await expect(page.locator('input[name="smtp-from-name"]')).toHaveValue(originalSMTP.fromName) page.once('dialog', (dialog) => dialog.accept()) await editor.getByRole('button', { name: 'Restore default' }).click() await expect(editor.getByLabel('Email subject')).toHaveValue(originalSubject) diff --git a/frontend/e2e/enterprise-capabilities.spec.ts b/frontend/e2e/enterprise-capabilities.spec.ts new file mode 100644 index 0000000..dc6ec74 --- /dev/null +++ b/frontend/e2e/enterprise-capabilities.spec.ts @@ -0,0 +1,606 @@ +import { expect, test, type Locator, type Page } from '@playwright/test' +import { readFile } from 'node:fs/promises' +import { + adminPost, + captureBrowserErrors, + createDurableImageGatewayFixture, + createGatewayFixture, + envelope, + expectNoHorizontalOverflow, + loginDemo, + loginTestPrincipal, + registerUsers +} from './fixtures' + +function uniqueID(testName: string, projectName: string): string { + return `${testName}-${projectName}-${Date.now()}`.replace(/[^a-z0-9-]+/gi, '-').toLowerCase() +} + +async function loginThroughPage(page: Page, email: string, password: string): Promise { + await page.goto('/login') + await page.getByLabel('Username').fill(email) + await page.locator('#password').fill(password) + await page.getByRole('button', { name: 'Sign in', exact: true }).click() + await expect(page).toHaveURL(/\/portal\/overview$/) +} + +function rowFor(table: Locator, text: string): Locator { + return table.getByRole('row').filter({ hasText: text }) +} + +function fieldControl(container: Locator, label: string, control = 'input'): Locator { + return container.locator('.field').filter({ hasText: label }).locator(control).first() +} + +test('@e2e-application-001 application and workspace key lifecycle is auditable', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'chromium-desktop', 'The stateful lifecycle runs once; route surfaces cover every supported viewport.') + test.setTimeout(90_000) + + const errors = captureBrowserErrors(page) + const runID = uniqueID('application', testInfo.project.name) + const applicationName = `Browser Application ${runID}` + const updatedName = `${applicationName} Updated` + const keyName = `Browser Key ${runID}` + const updatedKeyName = `${keyName} Updated` + const policyName = `Browser Access Policy ${runID}` + const publicModel = `browser-app-model-${runID}` + await loginDemo(page) + const token = await loginTestPrincipal(page) + await createGatewayFixture(page, token, runID, publicModel) + + await page.goto('/console/applications') + await page.getByRole('button', { name: 'New application' }).click() + const applicationDialog = page.getByRole('dialog', { name: 'New application' }) + await applicationDialog.getByLabel('Application name').fill(applicationName) + await applicationDialog.getByLabel('Application identifier').fill(`browser-app-${runID}`) + await applicationDialog.getByLabel('Concurrency limit').fill('7') + await applicationDialog.getByLabel('Entitlement reference').fill(`contract-${runID}`) + await applicationDialog.getByRole('button', { name: 'Save', exact: true }).click() + await expect(page.getByText('Application created.')).toBeVisible() + const applicationRow = rowFor(page.getByRole('table'), applicationName) + await expect(applicationRow).toContainText('7') + await applicationRow.getByRole('button', { name: `Edit application ${applicationName}` }).click() + const editDialog = page.getByRole('dialog', { name: 'Edit application' }) + await editDialog.getByLabel('Application name').fill(updatedName) + await editDialog.getByRole('button', { name: 'Save', exact: true }).click() + await page.reload() + await expect(rowFor(page.getByRole('table'), updatedName)).toContainText(`contract-${runID}`) + + await page.goto('/console/applications/credentials') + await page.getByRole('button', { name: 'New workspace key' }).click() + const keyDialog = page.locator('.api-key-modal') + await fieldControl(keyDialog, 'Name').fill(keyName) + const targetModel = keyDialog.getByRole('button', { name: publicModel, exact: true }) + await expect(targetModel).toBeVisible() + if (await targetModel.getAttribute('aria-pressed') !== 'true') await targetModel.click() + await expect(targetModel).toHaveAttribute('aria-pressed', 'true') + const createKeyResponse = page.waitForResponse((response) => + response.request().method() === 'POST' && response.url().endsWith('/api/v1/console/api-keys') + ) + await keyDialog.getByRole('button', { name: 'Save', exact: true }).click() + const createdKey = await envelope<{ key: string; record: { id: string } }>(await createKeyResponse) + await expect(page.getByText('API key created')).toBeVisible() + const firstSecret = await page.locator('.notice.success input[readonly]').inputValue() + expect(firstSecret).toBe(createdKey.key) + let keyRow = rowFor(page.getByRole('table'), keyName) + await expect(keyRow).toContainText(publicModel) + + await page.reload() + await expect(page.getByText(firstSecret, { exact: true })).toHaveCount(0) + + await page.goto('/console/policies/access') + const createPolicyResponse = page.waitForResponse((response) => + response.request().method() === 'POST' && response.url().endsWith('/api/v1/console/policies') + ) + await page.getByRole('button', { name: 'New policy' }).click() + let policyDialog = page.getByRole('dialog', { name: 'New policy' }) + await policyDialog.getByLabel('Name').fill(policyName) + await policyDialog.getByLabel('Description').fill('Synthetic application access policy') + await policyDialog.getByLabel('Scope type').selectOption('api_key') + await policyDialog.getByLabel('Scope ID').fill(createdKey.record.id) + await policyDialog.getByLabel('Requests per second (QPS)').fill('8') + await policyDialog.getByLabel('Monthly token limit').fill('75000') + await policyDialog.getByLabel('Monthly budget (micros)').fill('2500000') + await policyDialog.getByRole('button', { name: 'Save', exact: true }).click() + const policy = await envelope<{ id: string; version: number; scope_type: string; scope_id: string }>(await createPolicyResponse) + expect(policy).toMatchObject({ version: 1, scope_type: 'api_key', scope_id: createdKey.record.id }) + await expect(page.getByText('Policy created')).toBeVisible() + let policyRow = rowFor(page.getByRole('table'), policyName) + await expect(policyRow).toContainText('8 QPS') + await policyRow.getByRole('button', { name: 'Edit' }).click() + policyDialog = page.getByRole('dialog', { name: 'Edit policy' }) + await policyDialog.getByLabel('Description').fill('Updated synthetic application access policy') + await policyDialog.getByLabel('Monthly budget (micros)').fill('3000000') + await policyDialog.getByRole('button', { name: 'Save', exact: true }).click() + await expect(page.getByText('Policy updated')).toBeVisible() + await page.reload() + policyRow = rowFor(page.getByRole('table'), policyName) + await expect(policyRow).toContainText('Updated synthetic application access policy') + await expect(policyRow).toContainText('v2') + + await page.goto('/console/applications/credentials') + keyRow = rowFor(page.getByRole('table'), keyName) + await keyRow.getByRole('button', { name: 'Edit', exact: true }).click() + const editKeyDialog = page.locator('.api-key-modal') + await fieldControl(editKeyDialog, 'Name').fill(updatedKeyName) + await fieldControl(editKeyDialog, 'Policy', 'select').selectOption(policy.id) + await fieldControl(editKeyDialog, 'QPS limit').fill('7') + await fieldControl(editKeyDialog, 'Monthly token limit').fill('54321') + await editKeyDialog.getByRole('button', { name: 'Save', exact: true }).click() + await expect(page.getByText('API key updated')).toBeVisible() + await page.reload() + keyRow = rowFor(page.getByRole('table'), updatedKeyName) + await expect(keyRow).toContainText(policyName) + await expect(keyRow).toContainText('54,321') + + const explanationResponse = page.waitForResponse((response) => + response.request().method() === 'GET' && response.url().endsWith(`/api/v1/console/api-keys/${createdKey.record.id}/policy-explanation`) + ) + await keyRow.getByRole('button', { name: 'Details', exact: true }).click() + const explanation = await envelope<{ + api_key_id: string + selected_policy_id: string + selected_policy_name: string + selected_policy_version: number + selected_source: string + candidates: Array<{ policy_id: string; source: string; selected: boolean; reason: string }> + }>(await explanationResponse) + expect(explanation).toEqual(expect.objectContaining({ + api_key_id: createdKey.record.id, + selected_policy_id: policy.id, + selected_policy_name: policyName, + selected_policy_version: 2, + selected_source: 'api_key_explicit' + })) + expect(explanation.candidates).toContainEqual(expect.objectContaining({ + policy_id: policy.id, + source: 'api_key_explicit', + selected: true + })) + const keyDetails = page.locator('.modal-card').filter({ has: page.getByRole('heading', { name: updatedKeyName }) }) + await expect(keyDetails).toContainText(policyName) + await expect(keyDetails).toContainText('api key explicit') + await keyDetails.locator('.modal-header .icon-button').click() + + keyRow = rowFor(page.getByRole('table'), updatedKeyName) + await keyRow.getByRole('button', { name: 'Rotate', exact: true }).click() + const rotationDialog = page.getByRole('dialog', { name: 'Rotate API key' }) + await rotationDialog.locator('#api-key-rotation-grace').selectOption('0') + await rotationDialog.getByRole('button', { name: 'Rotate key' }).click() + await expect(page.getByText('API key rotated. Copy the new key now.')).toBeVisible() + const rotatedSecret = await page.locator('.notice.success input[readonly]').inputValue() + expect(rotatedSecret).toMatch(/^ar_/) + expect(rotatedSecret).not.toBe(firstSecret) + + keyRow = rowFor(page.getByRole('table'), updatedKeyName) + await keyRow.getByRole('button', { name: 'Disable', exact: true }).click() + await expect(page.getByText('API key disabled')).toBeVisible() + await expect(rowFor(page.getByRole('table'), updatedKeyName).filter({ hasText: 'Disabled' }).first()).toBeVisible() + + const audit = await envelope>>(await page.request.get('/api/v1/console/audit-logs?limit=200', { + headers: { Authorization: `Bearer ${token}` } + })) + expect(audit).toEqual(expect.arrayContaining([ + expect.objectContaining({ action: 'create', resource_type: 'application' }), + expect.objectContaining({ action: 'create', resource_type: 'api_key' }), + expect.objectContaining({ action: 'update', resource_type: 'api_key' }), + expect.objectContaining({ action: 'create', resource_type: 'governance_policy' }), + expect.objectContaining({ action: 'update', resource_type: 'governance_policy' }), + expect.objectContaining({ action: 'rotate', resource_type: 'api_key' }), + expect.objectContaining({ action: 'disable', resource_type: 'api_key' }) + ])) + await page.goto('/console/system/audit') + await expect(page.getByRole('heading', { level: 1, name: 'Audit Logs' })).toBeVisible() + await expectNoHorizontalOverflow(page) + expect(errors).toEqual([]) +}) + +test('@e2e-identity-001 enterprise identity and organization lifecycle remains scoped', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'chromium-desktop', 'The stateful lifecycle runs once; route surfaces cover every supported viewport.') + test.setTimeout(90_000) + + const errors = captureBrowserErrors(page) + const runID = uniqueID('identity', testInfo.project.name) + const departmentName = `Finance ${runID}` + const updatedDepartmentName = `${departmentName} Operations` + const email = `member-${runID}@example.test` + const displayName = `Finance Member ${runID}` + const groupName = `Budget Owners ${runID}` + const updatedGroupName = `${groupName} Updated` + await loginDemo(page) + + await page.goto('/console/organization/departments') + await page.getByRole('button', { name: 'New department' }).click() + let modal = page.locator('.modal-card') + await fieldControl(modal, 'Name').fill(departmentName) + await fieldControl(modal, 'Code').fill(`FIN-${runID.slice(-8)}`.toUpperCase()) + await fieldControl(modal, 'Cost center').fill(`CC-${runID.slice(-8)}`.toUpperCase()) + await fieldControl(modal, 'Monthly budget').fill('25000000') + await modal.getByRole('button', { name: 'Save', exact: true }).click() + await expect(page.getByText('Department created')).toBeVisible() + await page.reload() + await expect(rowFor(page.getByRole('table'), departmentName)).toContainText('25.00') + await rowFor(page.getByRole('table'), departmentName).getByRole('button', { name: 'Edit' }).click() + modal = page.locator('.modal-card') + await fieldControl(modal, 'Name').fill(updatedDepartmentName) + await fieldControl(modal, 'Cost center').fill(`CC-UPDATED-${runID.slice(-6)}`.toUpperCase()) + await fieldControl(modal, 'Monthly budget').fill('30000000') + await modal.getByRole('button', { name: 'Save', exact: true }).click() + await expect(page.getByText('Department updated')).toBeVisible() + await page.reload() + await expect(rowFor(page.getByRole('table'), updatedDepartmentName)).toContainText('30.00') + + await page.goto('/console/organization') + await page.getByRole('button', { name: 'New user' }).click() + modal = page.getByRole('dialog', { name: 'New user' }) + await modal.getByLabel('Email').fill(email) + await modal.getByLabel('Display name').fill(displayName) + await modal.getByLabel('Default role').selectOption('developer') + await modal.getByRole('button', { name: 'Save', exact: true }).click() + await expect(page.getByText('Workspace user created')).toBeVisible() + await expect(rowFor(page.getByRole('table'), email)).toContainText('Developer') + + await page.goto('/console/organization/groups') + await page.getByRole('button', { name: 'New organization group' }).click() + modal = page.locator('.modal-card') + await fieldControl(modal, 'Group name').fill(groupName) + await fieldControl(modal, 'Description', 'textarea').fill('Synthetic cross-department budget ownership') + await modal.locator('label').filter({ hasText: email }).getByRole('checkbox').check() + await modal.getByRole('button', { name: 'Save', exact: true }).click() + await expect(rowFor(page.getByRole('table'), groupName)).toContainText('1') + await page.reload() + await expect(rowFor(page.getByRole('table'), groupName)).toContainText(displayName) + await rowFor(page.getByRole('table'), groupName).getByTitle('Edit').click() + modal = page.locator('.modal-card') + await fieldControl(modal, 'Group name').fill(updatedGroupName) + await fieldControl(modal, 'Description', 'textarea').fill('Updated synthetic budget ownership') + await modal.getByRole('button', { name: 'Save', exact: true }).click() + await page.reload() + await expect(rowFor(page.getByRole('table'), updatedGroupName)).toContainText('Updated synthetic budget ownership') + + await page.goto('/console/organization') + const userRow = rowFor(page.getByRole('table'), email) + await userRow.getByRole('button', { name: 'Grant role' }).click() + const bindingDialog = page.getByRole('dialog', { name: 'Grant role' }) + await bindingDialog.getByLabel('Role').selectOption('key_manager') + await bindingDialog.getByLabel('Scope').selectOption('department') + const departmentID = await bindingDialog.getByLabel('Scope target').locator('option').filter({ hasText: updatedDepartmentName }).getAttribute('value') + expect(departmentID).toBeTruthy() + await bindingDialog.getByLabel('Scope target').selectOption(departmentID!) + await bindingDialog.getByRole('button', { name: 'Grant role', exact: true }).click() + await expect(page.getByText('Role binding created')).toBeVisible() + await page.getByRole('button', { name: 'Role assignments' }).click() + const bindingRow = rowFor(page.getByRole('table'), email).filter({ hasText: updatedDepartmentName }) + await expect(bindingRow).toContainText('Key manager') + page.once('dialog', (dialog) => dialog.accept()) + await bindingRow.getByRole('button', { name: 'Revoke' }).click() + await expect(page.getByText('Role binding revoked')).toBeVisible() + + await page.goto('/console/organization/groups') + const groupRow = rowFor(page.getByRole('table'), updatedGroupName) + page.once('dialog', (dialog) => dialog.accept()) + await groupRow.getByTitle('Delete').click() + await page.reload() + await expect(rowFor(page.getByRole('table'), updatedGroupName)).toHaveCount(0) + + await page.goto('/console/system/audit') + await expect(page.getByRole('table')).toContainText('department') + await expect(page.getByRole('table')).toContainText('organization_group') + await expectNoHorizontalOverflow(page) + expect(errors).toEqual([]) +}) + +test('@e2e-routing-resources-001 route groups and simulator use the published routing contract', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'chromium-desktop', 'The stateful lifecycle runs once; route surfaces cover every supported viewport.') + test.setTimeout(60_000) + + const errors = captureBrowserErrors(page) + const runID = uniqueID('routing', testInfo.project.name) + const groupName = `Browser Route Group ${runID}` + const updatedDescription = `Updated routing resource contract ${runID}` + const publicModel = `browser-route-model-${runID}` + await loginDemo(page) + const token = await loginTestPrincipal(page) + await createGatewayFixture(page, token, runID, publicModel) + + await page.goto('/console/model-services/route-groups') + await page.getByRole('button', { name: 'New policy group' }).click() + const modal = page.locator('.modal-card') + await fieldControl(modal, 'Policy group name').fill(groupName) + await fieldControl(modal, 'Platform').fill('openai_compatible') + await fieldControl(modal, 'Description').fill('Synthetic routing resource contract') + await modal.getByRole('button', { name: 'Save', exact: true }).click() + await expect(page.getByText('Routing policy group created')).toBeVisible() + await page.reload() + await expect(rowFor(page.getByRole('table'), groupName)).toContainText('openai_compatible') + const groupRow = rowFor(page.getByRole('table'), groupName) + await groupRow.getByRole('button', { name: 'Edit' }).click() + const editGroupDialog = page.locator('.modal-card') + await fieldControl(editGroupDialog, 'Description').fill(updatedDescription) + await fieldControl(editGroupDialog, 'Cost weight').fill('1.25') + await fieldControl(editGroupDialog, 'RPM limit').fill('321') + await editGroupDialog.getByRole('button', { name: 'Save', exact: true }).click() + await expect(page.getByText('Routing policy group updated')).toBeVisible() + await page.reload() + await expect(rowFor(page.getByRole('table'), groupName)).toContainText(updatedDescription) + await expect(rowFor(page.getByRole('table'), groupName)).toContainText('1.25x') + await expect(rowFor(page.getByRole('table'), groupName)).toContainText('RPM limit 321') + + await page.goto('/console/model-services/simulator') + await page.getByLabel('Requested model').selectOption(publicModel) + await page.getByRole('button', { name: 'Run simulation' }).click() + await expect(page.locator('.simulation-flow')).toContainText(publicModel) + await expect(page.getByRole('table')).toContainText('upstream-model') + await expect(page.locator('.crud-summary')).toContainText('1 / 1') + await expect(page.getByRole('table')).toContainText('eligible') + await page.getByRole('checkbox', { name: 'tools' }).check() + await page.getByRole('button', { name: 'Run simulation' }).click() + await expect(page.locator('.crud-summary')).toContainText('candidates') + await expectNoHorizontalOverflow(page) + expect(errors).toEqual([]) +}) + +test('@e2e-operations-001 gateway evidence reaches enterprise operations views', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'chromium-desktop', 'The evidence workflow runs once; route surfaces cover every supported viewport.') + test.setTimeout(90_000) + + const errors = captureBrowserErrors(page) + const runID = uniqueID('operations', testInfo.project.name) + const publicModel = `browser-ops-model-${runID}` + const imageModel = `browser-image-model-${runID}` + await loginDemo(page) + const token = await loginTestPrincipal(page) + await createGatewayFixture(page, token, runID, publicModel) + const imageFixture = await createDurableImageGatewayFixture(page, token, runID, imageModel) + const workspaceKey = await adminPost<{ key: string; record: { id: string } }>(page, token, '/api-keys', { + name: `Operations Key ${runID}`, + model_allowlist: [publicModel], + qps_limit: 10, + monthly_token_limit: 18 + }) + const completion = await page.request.post('/v1/chat/completions', { + data: { model: publicModel, messages: [{ role: 'user', content: 'synthetic operations evidence request' }] }, + headers: { Authorization: `Bearer ${workspaceKey.key}` } + }) + expect(completion.status()).toBe(200) + + const imageKey = await adminPost<{ key: string; record: { id: string } }>(page, token, '/api-keys', { + name: `Operations Image Key ${runID}`, + scopes: ['gateway:invoke', 'jobs:read', 'jobs:cancel'], + model_allowlist: [imageModel], + allowed_modalities: ['image'], + allowed_operations: ['image_generation'], + lane_policy: 'direct_and_durable', + artifact_policy: 'temporary' + }) + const imageHeaders = { Authorization: `Bearer ${imageKey.key}` } + const directImageResponse = await page.request.post('/v1/images/generations', { + headers: { ...imageHeaders, 'Idempotency-Key': `image-direct-${runID}` }, + data: { model: imageModel, prompt: 'synthetic direct image', delivery_mode: 'inline' } + }) + expect(directImageResponse.status()).toBe(200) + const queuedJobResponse = await page.request.post('/v1/jobs', { + headers: { ...imageHeaders, 'Idempotency-Key': `image-queued-${runID}` }, + data: { model: imageModel, operation: 'image_generation', modality: 'image', input: { prompt: 'synthetic queued image', count: 1 } } + }) + expect(queuedJobResponse.status()).toBe(202) + const queuedJob = await queuedJobResponse.json() as { id: string } + await expect.poll(async () => { + const response = await page.request.get(`/v1/jobs/${queuedJob.id}`, { headers: imageHeaders }) + return (await response.json() as { status: string }).status + }).toBe('queued') + + for (const route of ['/console/usage', '/console/usage/traces']) { + await page.goto(route) + await expect(page.getByRole('heading', { level: 1 })).toBeVisible() + await expect(page.getByRole('main')).toContainText(publicModel) + } + + await page.goto('/console/usage/cost-allocation') + await expect(page.getByRole('heading', { level: 1 })).toBeVisible() + await page.getByRole('button', { name: 'By model' }).click() + await expect(page.getByRole('main')).toContainText(publicModel) + + await page.goto('/console/usage/jobs') + await expect(page.locator('.runtime-heading')).toContainText('Online') + await expect(rowFor(page.getByRole('table'), queuedJob.id)).toContainText('queued') + await rowFor(page.getByRole('table'), queuedJob.id).getByRole('button', { name: 'Details' }).click() + const queuedJobDialog = page.getByRole('dialog') + await expect(queuedJobDialog).toContainText(queuedJob.id) + page.once('dialog', (dialog) => dialog.accept()) + await queuedJobDialog.getByRole('button', { name: 'Cancel job' }).click() + await expect(page.getByText('Job cancellation requested.')).toBeVisible() + await expect(queuedJobDialog).toContainText('canceled') + await queuedJobDialog.locator('.modal-footer').getByRole('button', { name: 'Close', exact: true }).click() + await page.reload() + await expect(rowFor(page.getByRole('table'), queuedJob.id)).toContainText('canceled') + + await envelope(await page.request.put(`/api/v1/console/provider-accounts/${imageFixture.account.id}`, { + headers: { Authorization: `Bearer ${token}` }, + data: { + provider_id: imageFixture.account.provider_id, + name: imageFixture.account.name, + platform: 'openai_compatible', + auth_type: 'api_key', + status: 'active', + schedulable: true, + priority: 10, + weight: 100, + concurrency: 1, + rpm_limit: 0, + tpm_limit: 0, + rate_multiplier: 1, + models: ['upstream-image-model'], + auto_enable_new_models: false, + group_ids: [], + secret: '' + } + })) + const imageJobResponse = await page.request.post('/v1/jobs', { + headers: { ...imageHeaders, 'Idempotency-Key': `image-ready-${runID}` }, + data: { model: imageModel, operation: 'image_generation', modality: 'image', input: { prompt: 'synthetic ready image', count: 1 } } + }) + expect(imageJobResponse.status()).toBe(202) + const imageJob = await imageJobResponse.json() as { id: string } + await expect.poll(async () => { + const response = await page.request.get(`/v1/jobs/${imageJob.id}`, { headers: imageHeaders }) + return (await response.json() as { status: string }).status + }, { timeout: 20_000 }).toBe('succeeded') + const completedJob = await envelope<{ artifacts: Array<{ id: string }> }>( + await page.request.get(`/api/v1/console/ai-jobs/${imageJob.id}`, { headers: { Authorization: `Bearer ${token}` } }) + ) + expect(completedJob.artifacts).toHaveLength(1) + const artifactID = completedJob.artifacts[0].id + + await page.goto('/console/usage/jobs') + const completedJobRow = rowFor(page.getByRole('table'), imageJob.id) + await expect(completedJobRow).toContainText('succeeded') + await completedJobRow.getByRole('button', { name: 'Details' }).click() + const completedJobDialog = page.getByRole('dialog') + await expect(completedJobDialog).toContainText('accepted') + await expect(completedJobDialog).toContainText('image/png') + await completedJobDialog.locator('.modal-footer').getByRole('button', { name: 'Close', exact: true }).click() + + for (const route of ['/console/usage/supply']) { + await page.goto(route) + await expect(page.getByRole('heading', { level: 1 })).toBeVisible() + await expectNoHorizontalOverflow(page) + } + + await page.goto('/console/usage/artifacts') + const artifactRow = rowFor(page.getByRole('table'), artifactID) + await expect(artifactRow).toContainText('ready') + await artifactRow.getByRole('button', { name: 'Details' }).click() + const artifactDialog = page.getByRole('dialog') + const preview = artifactDialog.getByRole('img', { name: /Preview of/ }) + await expect(preview).toBeVisible() + await expect.poll(() => preview.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBeGreaterThan(0) + const artifactDownloadStarted = page.waitForEvent('download') + await artifactDialog.getByRole('button', { name: 'Download' }).click() + const artifactDownload = await artifactDownloadStarted + expect(artifactDownload.suggestedFilename()).toMatch(/^artifact_.+\.png$/) + const artifactDownloadPath = await artifactDownload.path() + expect(artifactDownloadPath).toBeTruthy() + const artifactBytes = await readFile(artifactDownloadPath!) + expect(artifactBytes.subarray(1, 4).toString('ascii')).toBe('PNG') + await artifactDialog.locator('.modal-footer').getByRole('button', { name: 'Close', exact: true }).click() + await page.reload() + await expect(rowFor(page.getByRole('table'), artifactID)).toContainText('ready') + + await page.goto('/console/usage/alerts') + const alertTable = page.getByRole('table') + let alertRow = rowFor(alertTable, workspaceKey.record.id) + await expect(alertRow).toContainText('Critical') + await alertRow.getByRole('button', { name: 'Acknowledge' }).click() + await page.locator('.table-toolbar select').nth(2).selectOption('acknowledged') + alertRow = rowFor(alertTable, workspaceKey.record.id) + await expect(alertRow).toContainText('Acknowledged') + await alertRow.getByRole('button', { name: 'Resolve' }).click() + await page.locator('.table-toolbar select').nth(2).selectOption('resolved') + alertRow = rowFor(alertTable, workspaceKey.record.id) + await expect(alertRow).toContainText('Resolved') + await expect(alertRow.getByText('Closed')).toBeVisible() + await page.reload() + await page.locator('.table-toolbar select').nth(2).selectOption('resolved') + await expect(rowFor(page.getByRole('table'), workspaceKey.record.id)).toContainText('Resolved') + await expectNoHorizontalOverflow(page) + + await page.goto('/console/usage/exports') + await page.getByRole('button', { name: 'Create export job' }).click() + const exportDialog = page.locator('.modal-card') + await exportDialog.getByLabel('Data type').selectOption('usage') + await exportDialog.getByLabel('Model').fill(publicModel) + await exportDialog.getByRole('button', { name: 'Create export job' }).click() + await expect(page.getByText('Export job created')).toBeVisible() + const exportRow = rowFor(page.getByRole('table'), publicModel).first() + await expect(exportRow).toContainText('Succeeded', { timeout: 15_000 }) + await expect(exportRow).toContainText('1 rows') + const downloadStarted = page.waitForEvent('download') + await exportRow.getByRole('button', { name: 'Download' }).click() + const download = await downloadStarted + expect(download.suggestedFilename()).toBe('usage-records.csv') + const downloadPath = await download.path() + expect(downloadPath).toBeTruthy() + const csv = await readFile(downloadPath!, 'utf8') + expect(csv).toContain(publicModel) + expect(csv).toContain(workspaceKey.record.id) + + await page.goto('/console/system/audit') + await expect(page.getByRole('table')).toContainText('gateway_call') + const audit = await envelope>( + await page.request.get('/api/v1/console/audit-logs?limit=200', { headers: { Authorization: `Bearer ${token}` } }) + ) + expect(audit).toContainEqual(expect.objectContaining({ action: 'acknowledge', resource_type: 'alert_event' })) + expect(audit).toContainEqual(expect.objectContaining({ action: 'resolve', resource_type: 'alert_event' })) + expect(audit).toContainEqual(expect.objectContaining({ action: 'create', resource_type: 'export_job' })) + expect(audit).toContainEqual(expect.objectContaining({ action: 'download', resource_type: 'export_job' })) + expect(audit).toContainEqual(expect.objectContaining({ action: 'cancel', resource_type: 'ai_job', resource_id: queuedJob.id })) + await expectNoHorizontalOverflow(page) + expect(errors).toEqual([]) +}) + +test('@e2e-portal-001 developer portal key and usage projection is isolated', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'chromium-desktop', 'The stateful lifecycle runs once; portal surfaces cover every supported viewport.') + test.setTimeout(90_000) + + const errors = captureBrowserErrors(page) + const runID = uniqueID('portal', testInfo.project.name) + const publicModel = `browser-portal-model-${runID}` + const password = 'synthetic-password-123' + await loginDemo(page) + const token = await loginTestPrincipal(page) + await createGatewayFixture(page, token, runID, publicModel) + const [developer] = await registerUsers(page, token, [{ + email: `portal-${runID}@example.test`, + password, + displayName: 'Portal Lifecycle User' + }]) + + await page.context().clearCookies() + await page.evaluate(() => localStorage.clear()) + await loginThroughPage(page, developer.email, password) + await page.goto('/portal/applications') + await page.locator('.portal-keys-heading').getByRole('button', { name: 'Create new Key' }).click() + const createPanel = page.locator('.portal-create-panel') + const keyName = `Portal Key ${runID}` + await createPanel.getByLabel('Name').fill(keyName) + const selectedModels = await createPanel.locator('.chip-list .pill.status-success').allTextContents() + for (const model of selectedModels.filter((model) => model !== publicModel)) { + await createPanel.getByRole('button', { name: model, exact: true }).click() + } + const publicModelButton = createPanel.getByRole('button', { name: publicModel, exact: true }) + if (!selectedModels.includes(publicModel)) await publicModelButton.click() + await expect(createPanel.locator('.chip-list .pill.status-success')).toHaveCount(1) + await expect(publicModelButton).toHaveClass(/status-success/) + await createPanel.getByRole('button', { name: 'Create new Key' }).click() + await expect(page.locator('.notice.success')).toContainText('API key created. The full secret is shown once') + const firstSecret = await page.locator('.global-key-line code').textContent() + expect(firstSecret).toMatch(/^ar_/) + let keyRow = rowFor(page.getByRole('table'), keyName) + await expect(keyRow).toContainText(publicModel) + + page.once('dialog', (dialog) => dialog.accept()) + await keyRow.getByTitle('Rotate key').click() + await expect(page.getByText('API key rotated. The old secret is invalid; copy the new one now.')).toBeVisible() + const rotatedSecret = await page.locator('.global-key-line code').textContent() + expect(rotatedSecret).toMatch(/^ar_/) + expect(rotatedSecret).not.toBe(firstSecret) + + await page.goto('/portal/access') + await expect(page.getByRole('heading', { level: 1 })).toBeVisible() + await expect(page.getByRole('option', { name: publicModel })).toHaveCount(1) + await page.goto('/portal/usage') + await expect(page.getByRole('heading', { level: 1, name: 'Employee Portal' })).toBeVisible() + await page.goto('/portal/account') + await expect(page.getByLabel('Email')).toHaveValue(developer.email) + + await page.goto('/console/workbench') + await expect(page).toHaveURL(/\/portal\/overview$/) + await page.goto('/portal/applications') + keyRow = rowFor(page.getByRole('table'), keyName).filter({ hasText: 'Active' }) + page.once('dialog', (dialog) => dialog.accept()) + await keyRow.getByTitle('Disable key').click() + await expect(page.getByText('API key disabled.')).toBeVisible() + await expect(rowFor(page.getByRole('table'), keyName).filter({ hasText: 'Disabled' }).first()).toBeVisible() + expect(errors).toEqual([]) +}) diff --git a/frontend/e2e/enterprise-setup.spec.ts b/frontend/e2e/enterprise-setup.spec.ts index 7b47894..d567591 100644 --- a/frontend/e2e/enterprise-setup.spec.ts +++ b/frontend/e2e/enterprise-setup.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from '@playwright/test' import { captureBrowserErrors } from './fixtures' -test('@setup setup initializes one enterprise instance', async ({ page }, testInfo) => { +test('@setup @e2e-setup-001 setup initializes one enterprise instance', async ({ page }, testInfo) => { test.skip(testInfo.project.name !== 'chromium-desktop', 'The persistent setup workflow runs once against an isolated empty runtime.') const adminPassword = process.env.ASTER_E2E_PASSWORD || 'setup-browser-test-password' const errors = captureBrowserErrors(page) diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts index 43666c9..59147f2 100644 --- a/frontend/e2e/fixtures.ts +++ b/frontend/e2e/fixtures.ts @@ -1,4 +1,5 @@ import { expect, type APIResponse, type Page } from '@playwright/test' +import { isNavigationCancellationError } from '../src/testing/browser-errors' export type Envelope = { code: number; message: string; data: T } @@ -159,6 +160,53 @@ export async function createGatewayFixture(page: Page, token: string, runID: str return account } +export async function createDurableImageGatewayFixture(page: Page, token: string, runID: string, publicModel: string) { + const upstreamPort = process.env.ASTER_E2E_UPSTREAM_PORT || '19000' + const provider = await adminPost<{ id: string }>(page, token, '/providers', { + name: `E2E Image Provider ${runID}`, + type: 'openai_compatible', + base_url: `http://127.0.0.1:${upstreamPort}/v1`, + status: 'active', + priority: 10 + }) + const account = await adminPost<{ id: string; provider_id: string; name: string; secret_configured: boolean }>(page, token, '/provider-accounts', { + provider_id: provider.id, + name: `E2E Image Account ${runID}`, + platform: 'openai_compatible', + auth_type: 'api_key', + status: 'active', + schedulable: true, + priority: 10, + concurrency: 1, + rpm_limit: 1, + rate_multiplier: 1, + models: ['upstream-image-model'], + group_ids: [], + secret: 'synthetic-image-account-secret' + }) + expect(account.secret_configured).toBe(true) + + const model = await adminPost<{ id: string }>(page, token, '/gateway-models', { + model_id: publicModel, + name: `E2E Image Model ${runID}`, + description: 'Synthetic Playwright durable image contract', + modality: 'image', + default_route_group: 'default', + status: 'active' + }) + const route = await adminPost<{ id: string }>(page, token, '/model-routes', { + gateway_model_id: model.id, + route_group: 'default', + provider_account_id: account.id, + upstream_model: 'upstream-image-model', + upstream_format: 'native_media', + priority: 10, + weight: 100, + status: 'active' + }) + return { account, model, route } +} + export function captureBrowserErrors(page: Page): string[] { const errors: string[] = [] page.on('console', (message) => { @@ -167,7 +215,7 @@ export function captureBrowserErrors(page: Page): string[] { page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`)) page.on('requestfailed', (request) => { const failure = request.failure() - if (failure?.errorText === 'net::ERR_ABORTED') return + if (isNavigationCancellationError(failure?.errorText)) return errors.push(`requestfailed: ${request.method()} ${request.url()} ${failure?.errorText || ''}`.trim()) }) return errors diff --git a/frontend/e2e/gateway-protocols.spec.ts b/frontend/e2e/gateway-protocols.spec.ts new file mode 100644 index 0000000..0d86d8c --- /dev/null +++ b/frontend/e2e/gateway-protocols.spec.ts @@ -0,0 +1,172 @@ +import { expect, test, type APIRequestContext, type APIResponse } from '@playwright/test' +import { adminPost, createGatewayFixture, envelope, loginDemo, loginTestPrincipal } from './fixtures' + +type ProtocolCase = { + name: string + protocol: string + path: (model: string, stream: boolean) => string + headers: (key?: string) => Record + body: (model: string, stream: boolean) => Record + assertJSON: (body: Record) => void + streamMarker: string + unauthorizedMarker: string + forbiddenMarker: string + rateLimitMarker: string +} + +const protocols: ProtocolCase[] = [ + { + name: 'OpenAI Responses', + protocol: 'openai_responses', + path: () => '/v1/responses', + headers: (key) => key ? { Authorization: `Bearer ${key}` } : {}, + body: (model, stream) => ({ model, input: 'synthetic protocol request', stream }), + assertJSON: (body) => { + expect(body).toMatchObject({ object: 'response', status: 'completed', usage: { input_tokens: 7, output_tokens: 11 } }) + expect(body.output?.[0]?.content?.[0]?.text).toBe('e2e-ok') + }, + streamMarker: 'event: response.output_text.delta', + unauthorizedMarker: 'invalid_api_key', + forbiddenMarker: 'model_not_allowed', + rateLimitMarker: 'rate_limit_error' + }, + { + name: 'Anthropic Messages', + protocol: 'anthropic_messages', + path: () => '/v1/messages', + headers: (key) => key ? { 'X-API-Key': key } : {}, + body: (model, stream) => ({ model, max_tokens: 32, messages: [{ role: 'user', content: 'synthetic protocol request' }], stream }), + assertJSON: (body) => { + expect(body).toMatchObject({ type: 'message', usage: { input_tokens: 7, output_tokens: 11 } }) + expect(body.content?.[0]?.text).toBe('e2e-ok') + }, + streamMarker: 'event: content_block_delta', + unauthorizedMarker: 'authentication_error', + forbiddenMarker: 'permission_error', + rateLimitMarker: 'rate_limit_error' + }, + { + name: 'Gemini GenerateContent', + protocol: 'gemini_generate_content', + path: (model, stream) => `/v1beta/models/${model}:${stream ? 'streamGenerateContent' : 'generateContent'}`, + headers: (key) => key ? { 'X-Goog-API-Key': key } : {}, + body: () => ({ contents: [{ role: 'user', parts: [{ text: 'synthetic protocol request' }] }] }), + assertJSON: (body) => { + expect(body.usageMetadata).toMatchObject({ promptTokenCount: 7, candidatesTokenCount: 11 }) + expect(body.candidates?.[0]?.content?.parts?.[0]?.text).toBe('e2e-ok') + }, + streamMarker: '"candidates"', + unauthorizedMarker: 'UNAUTHENTICATED', + forbiddenMarker: 'PERMISSION_DENIED', + rateLimitMarker: 'RESOURCE_EXHAUSTED' + } +] + +async function invoke( + request: APIRequestContext, + protocol: ProtocolCase, + model: string, + key: string | undefined, + stream: boolean, + idempotencyKey: string +): Promise { + return request.post(protocol.path(model, stream), { + data: protocol.body(model, stream), + headers: { ...protocol.headers(key), 'Idempotency-Key': idempotencyKey } + }) +} + +async function setUpstreamMode(request: APIRequestContext, mode: string): Promise { + const upstreamPort = process.env.ASTER_E2E_UPSTREAM_PORT || '19000' + const response = await request.post(`http://127.0.0.1:${upstreamPort}/__test/mode`, { data: { mode } }) + expect(response.status()).toBe(200) +} + +test('@e2e-gateway-protocols-001 native text protocols cross auth routing translation usage and error boundaries', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'chromium-desktop', 'The API protocol matrix is viewport-independent and runs once on desktop.') + + await loginDemo(page) + const token = await loginTestPrincipal(page) + const runID = `${testInfo.project.name}-${Date.now()}` + const fixtures = new Map() + for (const protocol of protocols) { + const model = `e2e-${protocol.protocol.replaceAll('_', '-')}-${runID}` + const account = await createGatewayFixture(page, token, `${runID}-${protocol.protocol}`, model) + fixtures.set(protocol.protocol, { model, accountID: account.id }) + } + const workspaceKey = await adminPost<{ key: string; record: { id: string } }>(page, token, '/api-keys', { + name: `E2E Protocol Key ${runID}`, + model_allowlist: [...fixtures.values()].map((fixture) => fixture.model), + qps_limit: 100, + monthly_token_limit: 100000 + }) + + await setUpstreamMode(page.request, 'normal') + try { + for (const protocol of protocols) { + const fixture = fixtures.get(protocol.protocol)! + const jsonResponse = await invoke(page.request, protocol, fixture.model, workspaceKey.key, false, `${runID}-${protocol.protocol}-json`) + expect(jsonResponse.status(), `${protocol.name} JSON: ${await jsonResponse.text()}`).toBe(200) + protocol.assertJSON(await jsonResponse.json()) + + const streamResponse = await invoke(page.request, protocol, fixture.model, workspaceKey.key, true, `${runID}-${protocol.protocol}-stream`) + expect(streamResponse.status(), `${protocol.name} stream: ${await streamResponse.text()}`).toBe(200) + expect(streamResponse.headers()['content-type']).toContain('text/event-stream') + const streamBody = await streamResponse.text() + expect(streamBody).toContain(protocol.streamMarker) + expect(streamBody).toContain('hello') + + const unauthorized = await invoke(page.request, protocol, fixture.model, undefined, false, `${runID}-${protocol.protocol}-unauthorized`) + expect(unauthorized.status()).toBe(401) + expect(await unauthorized.text()).toContain(protocol.unauthorizedMarker) + + const forbidden = await invoke(page.request, protocol, `${fixture.model}-denied`, workspaceKey.key, false, `${runID}-${protocol.protocol}-forbidden`) + expect(forbidden.status()).toBe(403) + expect(await forbidden.text()).toContain(protocol.forbiddenMarker) + } + + await setUpstreamMode(page.request, '429') + for (const protocol of protocols) { + const fixture = fixtures.get(protocol.protocol)! + const limited = await invoke(page.request, protocol, fixture.model, workspaceKey.key, false, `${runID}-${protocol.protocol}-limited`) + expect(limited.status()).toBe(429) + const body = await limited.text() + expect(body).toContain(protocol.rateLimitMarker) + expect(body).toContain('synthetic rate limit') + } + } finally { + await setUpstreamMode(page.request, 'normal') + } + + const usage = await envelope<{ recent: Array> }>(await page.request.get('/api/v1/console/usage?limit=100', { + headers: { Authorization: `Bearer ${token}` } + })) + for (const protocol of protocols) { + const fixture = fixtures.get(protocol.protocol)! + const records = usage.recent.filter((item) => + item.api_key_id === workspaceKey.record.id && + item.model === fixture.model && + item.protocol === protocol.protocol + ) + expect(records.filter((item) => item.status === 'forwarded'), `${protocol.name} successful usage`).toHaveLength(2) + expect(records).toContainEqual(expect.objectContaining({ + protocol: protocol.protocol, + provider_account_id: fixture.accountID, + upstream_model: 'upstream-model', + status: 'upstream_error', + error_type: 'upstream_status' + })) + } + + const traces = await envelope>>(await page.request.get('/api/v1/console/gateway-traces?limit=100', { + headers: { Authorization: `Bearer ${token}` } + })) + const protocolTraces = traces.filter((item) => item.api_key_id === workspaceKey.record.id) + expect(protocolTraces.filter((item) => item.status === 'forwarded' && item.http_status === 200)).toHaveLength(6) + expect(protocolTraces.filter((item) => item.status === 'upstream_error' && item.http_status === 429 && item.error_type === 'upstream_status')).toHaveLength(3) + + const audit = await envelope>>(await page.request.get('/api/v1/console/audit-logs?limit=100', { + headers: { Authorization: `Bearer ${token}` } + })) + expect(audit).toContainEqual(expect.objectContaining({ action: 'invoke', resource_type: 'gateway_call' })) +}) diff --git a/frontend/e2e/gateway.spec.ts b/frontend/e2e/gateway.spec.ts index d956a00..85bb5c8 100644 --- a/frontend/e2e/gateway.spec.ts +++ b/frontend/e2e/gateway.spec.ts @@ -1,5 +1,5 @@ -import { expect, test, type Page } from '@playwright/test' -import { adminPost, createGatewayFixture, createPublishedPricingRule, envelope, loginDemo, loginTestPrincipal } from './fixtures' +import { expect, test, type APIRequestContext, type Page } from '@playwright/test' +import { adminPost, captureBrowserErrors, controlAPI, createGatewayFixture, createPublishedPricingRule, envelope, loginDemo, loginTestPrincipal } from './fixtures' type PolicyAlert = { id: string @@ -40,7 +40,13 @@ async function expectUsageCost(page: Page, adminToken: string, keyID: string, ex }, { message: `usage cost for ${keyID}` }).toBe(expectedCostMicros) } -test('@smoke @j01 provider-to-gateway request records evidence', async ({ page }, testInfo) => { +async function setUpstreamMode(request: APIRequestContext, mode: string): Promise { + const upstreamPort = process.env.ASTER_E2E_UPSTREAM_PORT || '19000' + const response = await request.post(`http://127.0.0.1:${upstreamPort}/__test/mode`, { data: { mode } }) + expect(response.status()).toBe(200) +} + +test('@e2e-gateway-001 provider-to-gateway request records evidence', async ({ page }, testInfo) => { test.skip(testInfo.project.name !== 'chromium-desktop', 'The API workflow is viewport-independent and runs once on desktop.') await loginDemo(page) @@ -116,7 +122,7 @@ test('@smoke @j01 provider-to-gateway request records evidence', async ({ page } expect(audit).toContainEqual(expect.objectContaining({ action: 'invoke', resource_type: 'gateway_call' })) }) -test('@smoke @j05 quota and budget warn, deduplicate, escalate, and reject with evidence', async ({ page }, testInfo) => { +test('@e2e-gateway-budget-001 quota and budget warn, deduplicate, escalate, and reject with evidence', async ({ page }, testInfo) => { test.skip(testInfo.project.name !== 'chromium-desktop', 'The API workflow is viewport-independent and runs once on desktop.') await loginDemo(page) @@ -216,7 +222,7 @@ test('@smoke @j05 quota and budget warn, deduplicate, escalate, and reject with expect(audit).toContainEqual(expect.objectContaining({ action: 'invoke', resource_type: 'gateway_call', summary: expect.stringContaining('status=policy_rejected') })) }) -test('@smoke @j04 failed primary route falls back and records attempts', async ({ page }, testInfo) => { +test('@e2e-gateway-failover-001 failed primary route falls back and records attempts', async ({ page }, testInfo) => { test.skip(testInfo.project.name !== 'chromium-desktop', 'The API workflow is viewport-independent and runs once on desktop.') await loginDemo(page) @@ -297,3 +303,160 @@ test('@smoke @j04 failed primary route falls back and records attempts', async ( expect(String(trace?.route_attempts)).toContain('"account_id":"' + fallback.id + '"') expect(String(trace?.route_attempts)).toContain('"outcome":"selected"') }) + +test('@e2e-provider-cooldown-001 upstream failure cooldown is cleared in the UI and restores gateway traffic', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'chromium-desktop', 'The stateful cooldown lifecycle runs once; surface tests cover responsive projections.') + test.setTimeout(60_000) + + const browserErrors = captureBrowserErrors(page) + await loginDemo(page) + const token = await loginTestPrincipal(page) + const runID = `${testInfo.project.name}-${Date.now()}` + const upstreamPort = process.env.ASTER_E2E_UPSTREAM_PORT || '19000' + const publicModel = `e2e-cooldown-${runID}` + const accountName = `E2E Cooldown Account ${runID}` + const provider = await adminPost<{ id: string }>(page, token, '/providers', { + name: `E2E Cooldown Provider ${runID}`, + type: 'openai_compatible', + base_url: `http://127.0.0.1:${upstreamPort}/v1`, + status: 'active', + priority: 10 + }) + const account = await adminPost<{ id: string }>(page, token, '/provider-accounts', { + provider_id: provider.id, + name: accountName, + platform: 'openai_compatible', + auth_type: 'api_key', + status: 'active', + schedulable: true, + priority: 10, + weight: 100, + concurrency: 1, + rpm_limit: 0, + tpm_limit: 0, + rate_multiplier: 1, + models: ['upstream-model'], + auto_enable_new_models: false, + group_ids: [], + secret: 'synthetic-cooldown-secret', + circuit_failure_threshold: 1, + circuit_open_seconds: 600, + temp_unschedulable_rules: [{ status_code: 500, keywords: ['synthetic upstream failure'], duration_minutes: 10 }] + }) + const model = await adminPost<{ id: string }>(page, token, '/gateway-models', { + model_id: publicModel, + name: `E2E Cooldown Model ${runID}`, + modality: 'chat', + default_route_group: 'default', + status: 'active' + }) + await adminPost(page, token, '/model-routes', { + gateway_model_id: model.id, + route_group: 'default', + provider_account_id: account.id, + upstream_model: 'upstream-model', + upstream_format: 'openai_chat', + priority: 10, + weight: 100, + status: 'active' + }) + const workspaceKey = await adminPost<{ key: string; record: { id: string } }>(page, token, '/api-keys', { + name: `E2E Cooldown Key ${runID}`, + model_allowlist: [publicModel], + qps_limit: 10, + monthly_token_limit: 100000 + }) + const invoke = () => page.request.post('/v1/chat/completions', { + data: { model: publicModel, messages: [{ role: 'user', content: 'synthetic cooldown lifecycle request' }] }, + headers: { Authorization: `Bearer ${workspaceKey.key}` } + }) + const accountState = async () => { + const accounts = await envelope>>(await page.request.get(controlAPI('/provider-accounts'), { + headers: { Authorization: `Bearer ${token}` } + })) + const current = accounts.find((candidate) => candidate.id === account.id) + expect(current, `missing provider account ${account.id}`).toBeTruthy() + return current! + } + + await setUpstreamMode(page.request, 'normal') + try { + await setUpstreamMode(page.request, '500') + const failed = await invoke() + expect(failed.status()).toBe(500) + expect(await failed.text()).toContain('synthetic upstream failure') + + await expect.poll(async () => accountState()).toMatchObject({ + circuit_state: 'open', + consecutive_failures: 1, + temp_unschedulable_reason: expect.stringContaining('keyword="synthetic upstream failure"') + }) + const cooled = await accountState() + expect(new Date(String(cooled.cooldown_until)).getTime()).toBeGreaterThan(Date.now() + 9 * 60_000) + expect(new Date(String(cooled.circuit_opened_until)).getTime()).toBeGreaterThan(Date.now() + 9 * 60_000) + + const blocked = await invoke() + expect(blocked.status()).toBe(503) + await expect(blocked.json()).resolves.toMatchObject({ error: { type: 'route_unavailable' } }) + + await page.goto('/console/model-services/accounts') + let accountRow = page.locator(`tr[data-account-id="${account.id}"]`) + await expect(accountRow).toContainText(accountName) + await expect(accountRow).toContainText('cooldown') + await accountRow.getByRole('button', { name: 'More actions' }).click() + const clearResponsePromise = page.waitForResponse((response) => + response.url().endsWith(`/api/v1/console/provider-accounts/${account.id}/clear-cooldown`) && + response.request().method() === 'POST' + ) + await accountRow.getByRole('button', { name: 'Clear cooldown' }).click() + const clearResponse = await clearResponsePromise + expect(clearResponse.status()).toBe(200) + const clearBody = await clearResponse.json() as { data: Record } + expect(clearBody.data).toMatchObject({ + id: account.id, + circuit_state: 'closed', + consecutive_failures: 0, + temp_unschedulable_reason: '' + }) + expect(clearBody.data.cooldown_until).toBeUndefined() + expect(clearBody.data.circuit_opened_until).toBeUndefined() + await expect(page.getByText('Cooldown cleared')).toBeVisible() + await expect(accountRow).not.toContainText('cooldown') + + await page.reload() + accountRow = page.locator(`tr[data-account-id="${account.id}"]`) + await expect(accountRow).toContainText(accountName) + await expect(accountRow).not.toContainText('cooldown') + await expect(accountState()).resolves.toMatchObject({ + circuit_state: 'closed', + consecutive_failures: 0, + temp_unschedulable_reason: '' + }) + + await setUpstreamMode(page.request, 'normal') + const recovered = await invoke() + expect(recovered.status(), await recovered.text()).toBe(200) + + const traces = await envelope>>(await page.request.get('/api/v1/console/gateway-traces?limit=100', { + headers: { Authorization: `Bearer ${token}` } + })) + const lifecycleTraces = traces.filter((trace) => trace.api_key_id === workspaceKey.record.id && trace.model === publicModel) + expect(lifecycleTraces).toContainEqual(expect.objectContaining({ provider_account_id: account.id, status: 'upstream_error', http_status: 500 })) + expect(lifecycleTraces).toContainEqual(expect.objectContaining({ status: 'error', http_status: 503, error_type: 'route_unavailable' })) + expect(lifecycleTraces).toContainEqual(expect.objectContaining({ provider_account_id: account.id, status: 'forwarded', http_status: 200 })) + + const audit = await envelope>>(await page.request.get('/api/v1/console/audit-logs?limit=100', { + headers: { Authorization: `Bearer ${token}` } + })) + expect(audit).toContainEqual(expect.objectContaining({ + action: 'clear_cooldown', + resource_type: 'provider_account', + resource_id: account.id, + summary: expect.stringContaining(accountName) + })) + await testInfo.attach('provider-cooldown-cleared', { body: await page.screenshot({ fullPage: true }), contentType: 'image/png' }) + expect(browserErrors).toEqual([]) + } finally { + await setUpstreamMode(page.request, 'normal') + } +}) diff --git a/frontend/e2e/isolation.spec.ts b/frontend/e2e/isolation.spec.ts index 50e097c..73abe34 100644 --- a/frontend/e2e/isolation.spec.ts +++ b/frontend/e2e/isolation.spec.ts @@ -35,7 +35,7 @@ async function expectForbiddenOrHidden(response: APIResponse): Promise { expect([403, 404]).toContain(response.status()) } -test('@smoke @j03 department and owner isolation covers reads, writes, and exports', async ({ page }, testInfo) => { +test('@e2e-isolation-001 department and owner isolation covers reads, writes, and exports', async ({ page }, testInfo) => { test.skip(testInfo.project.name !== 'chromium-desktop', 'The isolation workflow is viewport-independent and runs once on desktop.') await loginDemo(page) diff --git a/frontend/e2e/marketing-home.spec.ts b/frontend/e2e/marketing-home.spec.ts index 7762c7f..cfa1ca6 100644 --- a/frontend/e2e/marketing-home.spec.ts +++ b/frontend/e2e/marketing-home.spec.ts @@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test' import AxeBuilder from '@axe-core/playwright' import { captureBrowserErrors, expectNoHorizontalOverflow } from './fixtures' -test('@marketing official website is public, localized, and responsive', async ({ page }, testInfo) => { +test('@e2e-marketing-001 official website is public, localized, and responsive', async ({ page }, testInfo) => { const errors = captureBrowserErrors(page) const captureDesignEvidence = !process.env.ASTER_E2E_EXTERNAL_URL await page.goto('/', { waitUntil: 'commit' }) @@ -11,6 +11,10 @@ test('@marketing official website is public, localized, and responsive', async ( await expect(page.getByRole('heading', { level: 1, name: 'AsterRouter' })).toBeVisible() await expect(page.locator('.hero-category')).toHaveText('Enterprise AI access and routing infrastructure') await expect(page.getByRole('heading', { level: 2, name: 'Every model request follows the same enterprise decision chain' })).toBeVisible() + await expect(page.getByRole('region', { name: 'AsterRouter live routing decision preview' })).toBeVisible() + await expect(page.getByText('Request entered the preferred route')).toBeVisible() + const nextSectionSignal = await page.locator('.decision-section .section-heading > span').boundingBox() + expect(nextSectionSignal?.y).toBeLessThan(page.viewportSize()?.height || 0) const productImage = page.getByRole('img', { name: 'Actual AsterRouter routing policy workbench' }) await expect(productImage).toBeVisible() await expect.poll(() => productImage.evaluate((image: HTMLImageElement) => image.complete && image.naturalWidth > 0)).toBe(true) @@ -29,6 +33,7 @@ test('@marketing official website is public, localized, and responsive', async ( await expect(page.locator('html')).toHaveAttribute('lang', 'zh-CN') await expect(page.locator('.hero-category')).toHaveText('企业 AI 访问与路由基础设施') await expect(page.getByRole('heading', { level: 2, name: '策略不是一个权重,而是一份完整路由合同' })).toBeVisible() + await expect(page.getByRole('region', { name: 'AsterRouter 实时路由决策预览' })).toBeVisible() await expectNoHorizontalOverflow(page) if (captureDesignEvidence) { await page.screenshot({ path: testInfo.outputPath('marketing-home-zh.png'), fullPage: true, animations: 'disabled' }) diff --git a/frontend/e2e/models.spec.ts b/frontend/e2e/models.spec.ts index 9355274..3ada9ae 100644 --- a/frontend/e2e/models.spec.ts +++ b/frontend/e2e/models.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test' +import { expect, test, type Locator } from '@playwright/test' import { adminPost, captureBrowserErrors, controlAPI, envelope, expectNoHorizontalOverflow, loginDemo, loginTestPrincipal } from './fixtures' const modelPaths = { @@ -7,7 +7,11 @@ const modelPaths = { routes: '/console/model-services/routes' } -test('new provider account persists empty before automatic discovery and explicit apply', async ({ page }, testInfo) => { +function fieldControl(container: Locator, label: string, control = 'input'): Locator { + return container.locator('.field').filter({ hasText: label }).locator(control).first() +} + +test('@e2e-model-account-001 new provider account persists empty before automatic discovery and explicit apply', async ({ page }, testInfo) => { test.skip(testInfo.project.name !== 'chromium-desktop', 'Lifecycle is covered once; the responsive inventory flow is covered separately.') const browserErrors = captureBrowserErrors(page) @@ -55,7 +59,186 @@ test('new provider account persists empty before automatic discovery and explici expect(browserErrors).toEqual([]) }) -test('model inventory and bulk routes stay auditable across responsive layouts', async ({ page }, testInfo) => { +test('@e2e-model-supply-lifecycle-001 provider supply updates, rejects unsafe deletion, and tears down cleanly', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'chromium-desktop', 'The stateful supply lifecycle runs once; surface tests cover responsive projections.') + test.setTimeout(120_000) + + const browserErrors = captureBrowserErrors(page) + const runID = `${testInfo.project.name}-${Date.now()}` + const upstreamPort = process.env.ASTER_E2E_UPSTREAM_PORT || '19000' + const providerName = `Browser supply provider ${runID}` + const updatedProviderName = `${providerName} updated` + const accountName = `Browser supply account ${runID}` + const updatedAccountName = `${accountName} updated` + const publicModel = `browser-supply-${runID}` + const updatedModelName = `Browser supply model ${runID} updated` + + await loginDemo(page) + const token = await loginTestPrincipal(page) + + await page.goto(modelPaths.providers) + await page.getByRole('button', { name: 'New provider' }).click() + let modal = page.getByRole('dialog', { name: 'New provider connection' }) + await modal.getByLabel('Connection name').fill(providerName) + await modal.getByLabel('Base URL').fill(`http://127.0.0.1:${upstreamPort}/v1`) + await modal.getByLabel('Priority').fill('15') + await modal.getByRole('button', { name: 'Create connection' }).click() + await expect(page.getByText('Provider created')).toBeVisible() + + let providerRow = page.getByRole('row').filter({ hasText: providerName }) + await providerRow.getByRole('button', { name: 'Edit' }).click() + modal = page.getByRole('dialog', { name: 'Edit provider connection' }) + await modal.getByLabel('Connection name').fill(updatedProviderName) + await modal.getByLabel('Priority').fill('12') + await modal.getByRole('button', { name: 'Update connection' }).click() + await expect(page.getByText('Provider updated')).toBeVisible() + await page.reload() + providerRow = page.getByRole('row').filter({ hasText: updatedProviderName }) + await expect(providerRow).toContainText('12') + await providerRow.getByRole('button', { name: 'Check' }).click() + await expect(providerRow).toContainText('Provider endpoint configuration is ready; credentials are validated on provider accounts') + await expect(providerRow).toContainText('ok') + + await page.goto(modelPaths.accounts) + await page.getByRole('button', { name: 'New route resource' }).click() + modal = page.getByRole('dialog', { name: 'New route resource' }) + await modal.getByLabel('Provider').selectOption({ label: `${updatedProviderName} · openai_compatible` }) + await modal.getByLabel('Resource name').fill(accountName) + await modal.getByLabel('API key', { exact: true }).fill('synthetic-account-secret') + await modal.getByPlaceholder('Enter an upstream model ID').fill('upstream-model') + await modal.getByRole('button', { name: 'Add custom model' }).click() + await modal.getByRole('button', { name: 'Save', exact: true }).click() + modal = page.getByRole('dialog', { name: 'Edit route resource' }) + await expect(modal).toBeVisible() + await expect(page.getByText('Route resource created')).toBeVisible() + await modal.getByLabel('Resource name').fill(updatedAccountName) + await fieldControl(modal, 'Concurrency').fill('4') + await modal.getByRole('button', { name: 'Save', exact: true }).click() + await expect(page.getByText('Route resource updated')).toBeVisible() + await modal.getByRole('button', { name: 'Close' }).click() + await page.reload() + + let accountRow = page.getByRole('row').filter({ hasText: updatedAccountName }) + await expect(accountRow).toContainText('4') + await accountRow.getByRole('switch', { name: `Toggle scheduling for ${updatedAccountName}` }).click() + await expect(accountRow).toContainText('not schedulable') + accountRow = page.getByRole('row').filter({ hasText: updatedAccountName }) + await accountRow.getByRole('switch', { name: `Toggle scheduling for ${updatedAccountName}` }).click() + await expect(accountRow).toContainText('schedulable') + await accountRow.getByRole('button', { name: 'More actions' }).click() + await accountRow.getByRole('button', { name: 'Check' }).click() + await expect(page.getByText('Provider account is reachable; discovered 1 models')).toBeVisible() + const accountID = await accountRow.getAttribute('data-account-id') + expect(accountID).toBeTruthy() + const accountHealth = await envelope>( + await page.request.get(controlAPI('/provider-account-health-checks'), { headers: { Authorization: `Bearer ${token}` } }) + ) + expect(accountHealth).toContainEqual(expect.objectContaining({ account_id: accountID, status: 'ok' })) + + await page.goto('/console/model-services') + await page.getByRole('button', { name: 'New gateway model' }).click() + modal = page.locator('.modal-card') + await fieldControl(modal, 'External model ID').fill(publicModel) + await fieldControl(modal, 'Display name').fill(`Browser supply model ${runID}`) + await fieldControl(modal, 'Description', 'textarea').fill('Browser-created supply lifecycle model') + await modal.getByRole('button', { name: 'Save', exact: true }).click() + await expect(page.getByText('Gateway model created')).toBeVisible() + let modelRow = page.getByRole('row').filter({ hasText: publicModel }) + await modelRow.getByTitle('Edit').click() + modal = page.locator('.modal-card') + await fieldControl(modal, 'Display name').fill(updatedModelName) + await modal.getByLabel('Enable sticky routing for stable session identifiers').check() + await fieldControl(modal, 'Sticky TTL (seconds)').fill('900') + await modal.getByRole('button', { name: 'Save', exact: true }).click() + await expect(page.getByText('Gateway model updated')).toBeVisible() + await page.reload() + modelRow = page.getByRole('row').filter({ hasText: publicModel }) + await expect(modelRow).toContainText(updatedModelName) + + await page.goto(modelPaths.routes) + await page.getByRole('button', { name: 'New model route' }).click() + modal = page.locator('.modal-card') + await fieldControl(modal, 'Gateway model', 'select').selectOption({ label: publicModel }) + await fieldControl(modal, 'Provider account', 'select').selectOption({ label: updatedAccountName }) + await fieldControl(modal, 'Upstream model', 'select').selectOption('upstream-model') + await fieldControl(modal, 'Priority').fill('10') + await fieldControl(modal, 'Weight').fill('100') + await modal.getByRole('button', { name: 'Save', exact: true }).click() + await expect(page.getByText('Model route created')).toBeVisible() + let routeRow = page.getByRole('row').filter({ hasText: publicModel }).filter({ hasText: updatedAccountName }) + await routeRow.getByTitle('Edit').click() + modal = page.locator('.modal-card') + await fieldControl(modal, 'Priority').fill('20') + await fieldControl(modal, 'Weight').fill('250') + await fieldControl(modal, 'Status', 'select').selectOption('disabled') + await modal.getByRole('button', { name: 'Save', exact: true }).click() + await expect(page.getByText('Model route updated')).toBeVisible() + await page.reload() + routeRow = page.getByRole('row').filter({ hasText: publicModel }).filter({ hasText: updatedAccountName }) + await expect(routeRow).toContainText('P20') + await expect(routeRow).toContainText('W250') + await expect(routeRow).toContainText('disabled') + + await page.goto(modelPaths.accounts) + accountRow = page.getByRole('row').filter({ hasText: updatedAccountName }) + const expectedErrorCount = browserErrors.length + const protectedDeleteResponse = page.waitForResponse((response) => + response.url().endsWith(`/api/v1/console/provider-accounts/${accountID}`) && + response.request().method() === 'DELETE' + ) + page.once('dialog', (dialog) => dialog.accept()) + await accountRow.getByRole('button', { name: 'Delete account' }).click() + const protectedDelete = await protectedDeleteResponse + expect(protectedDelete.status()).toBe(400) + expect(await protectedDelete.json()).toEqual(expect.objectContaining({ + code: 1554, + message: expect.stringContaining('referenced by model route') + })) + await expect(page.locator('.notice').filter({ hasText: 'referenced by model route' })).toBeVisible() + await expect(accountRow).toBeVisible() + expect(browserErrors.splice(expectedErrorCount)).toEqual([ + 'console: Failed to load resource: the server responded with a status of 400 (Bad Request)' + ]) + + await page.goto(modelPaths.routes) + routeRow = page.getByRole('row').filter({ hasText: publicModel }).filter({ hasText: updatedAccountName }) + page.once('dialog', (dialog) => dialog.accept()) + await routeRow.getByTitle('Delete model route').click() + await expect(page.getByText('Model route deleted')).toBeVisible() + await page.reload() + await expect(page.getByRole('row').filter({ hasText: publicModel }).filter({ hasText: updatedAccountName })).toHaveCount(0) + + await page.goto('/console/model-services') + modelRow = page.getByRole('row').filter({ hasText: publicModel }) + page.once('dialog', (dialog) => dialog.accept()) + await modelRow.getByTitle('Delete gateway model').click() + await expect(page.getByText('Gateway model deleted')).toBeVisible() + await page.reload() + await expect(page.getByRole('row').filter({ hasText: publicModel })).toHaveCount(0) + + await page.goto(modelPaths.accounts) + accountRow = page.getByRole('row').filter({ hasText: updatedAccountName }) + page.once('dialog', (dialog) => dialog.accept()) + await accountRow.getByRole('button', { name: 'Delete account' }).click() + await expect(page.getByText('Route resource deleted')).toBeVisible() + await page.reload() + await expect(page.getByRole('row').filter({ hasText: updatedAccountName })).toHaveCount(0) + + const audit = await envelope>>(await page.request.get(controlAPI('/audit-logs?limit=200'), { + headers: { Authorization: `Bearer ${token}` } + })) + for (const [action, resourceType] of [ + ['create', 'provider'], ['update', 'provider'], ['check', 'provider'], + ['create', 'provider_account'], ['update', 'provider_account'], ['check', 'provider_account'], ['delete', 'provider_account'], + ['create', 'gateway_model'], ['update', 'gateway_model'], ['delete', 'gateway_model'], + ['create', 'model_route'], ['update', 'model_route'], ['delete', 'model_route'] + ]) { + expect(audit).toContainEqual(expect.objectContaining({ action, resource_type: resourceType })) + } + expect(browserErrors).toEqual([]) +}) + +test('@e2e-model-inventory-001 model inventory and bulk routes stay auditable across responsive layouts', async ({ page }, testInfo) => { const browserErrors = captureBrowserErrors(page) await loginDemo(page) await page.goto(modelPaths.providers) diff --git a/frontend/e2e/operations-recovery.spec.ts b/frontend/e2e/operations-recovery.spec.ts new file mode 100644 index 0000000..9b9dbf0 --- /dev/null +++ b/frontend/e2e/operations-recovery.spec.ts @@ -0,0 +1,295 @@ +import { expect, test, type APIResponse, type Locator, type Page } from '@playwright/test' +import { + adminPost, + captureBrowserErrors, + createDurableImageGatewayFixture, + envelope, + loginDemo, + loginTestPrincipal +} from './fixtures' + +type Plugin = { id: string; status: string } +type Attempt = { + id: string + status: string + dispatch_state: string + dispatch_version: number + reconcile_after?: string +} +type Artifact = { + id: string + status: string + media_type: string + size_bytes: number + sha256: string +} +type AIJobDetail = { job: { id: string; status: string }; attempts: Attempt[]; artifacts: Artifact[] } +type AuditEvent = { action: string; resource_type: string; resource_id: string } +type S3Request = { + method: string + path: string + content_type: string + content_length: number + authorization_present: boolean + sigv4_valid: boolean + sigv4_errors: string[] + body_base64: string + decode_error: string + outcome: string +} + +const artifactSinkPluginID = 'com.asterrouter.artifact.s3-compatible-sink' +const syntheticPNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64') + +function uniqueID(name: string, project: string): string { + return `${name}-${project}-${Date.now()}`.replace(/[^a-z0-9-]+/gi, '-').toLowerCase() +} + +function rowFor(table: Locator, value: string): Locator { + return table.getByRole('row').filter({ hasText: value }) +} + +async function responseData(response: APIResponse): Promise { + const body = await response.json() as { code: number; message: string; data: T } + expect(response.status(), JSON.stringify(body)).toBe(200) + expect(body.code, JSON.stringify(body)).toBe(0) + return body.data +} + +async function setFixtureMode(page: Page, baseURL: string, mode: string): Promise { + const response = await page.request.post(`${baseURL}/__test/mode`, { data: { mode } }) + expect(response.status()).toBe(200) + expect(await response.json()).toEqual({ mode }) +} + +async function getJob(page: Page, token: string, jobID: string): Promise { + return envelope(await page.request.get(`/api/v1/console/ai-jobs/${jobID}`, { + headers: { Authorization: `Bearer ${token}` } + })) +} + +test('@e2e-ai-job-reconciliation-001 unknown provider attempts can be scheduled for immediate reconciliation', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'chromium-desktop', 'The stateful recovery action runs once.') + test.setTimeout(90_000) + + const errors = captureBrowserErrors(page) + const runID = uniqueID('reconcile', testInfo.project.name) + const modelID = `browser-reconcile-model-${runID}` + const upstreamAPI = `http://127.0.0.1:${process.env.ASTER_E2E_UPSTREAM_PORT || '19000'}` + await loginDemo(page) + const token = await loginTestPrincipal(page) + await createDurableImageGatewayFixture(page, token, runID, modelID) + const key = await adminPost<{ key: string }>(page, token, '/api-keys', { + name: `Reconciliation Key ${runID}`, + scopes: ['gateway:invoke', 'jobs:read'], + model_allowlist: [modelID], + allowed_modalities: ['image'], + allowed_operations: ['image_generation'], + lane_policy: 'durable_only', + artifact_policy: 'temporary' + }) + + try { + await setFixtureMode(page, upstreamAPI, '500') + const response = await page.request.post('/v1/jobs', { + headers: { Authorization: `Bearer ${key.key}`, 'Idempotency-Key': `reconcile-${runID}` }, + data: { model: modelID, operation: 'image_generation', modality: 'image', input: { prompt: 'synthetic unknown image', count: 1 } } + }) + expect(response.status()).toBe(202) + const jobID = (await response.json() as { id: string }).id + + await expect.poll(async () => { + const detail = await getJob(page, token, jobID) + return `${detail.job.status}:${detail.attempts[0]?.status}:${detail.attempts[0]?.dispatch_state}` + }, { timeout: 20_000 }).toBe('unknown:running:unknown') + const before = await getJob(page, token, jobID) + const attempt = before.attempts[0] + expect(attempt).toBeDefined() + expect(attempt.reconcile_after).toBeTruthy() + + await page.goto(`/console/usage/jobs?q=${encodeURIComponent(jobID)}`) + const jobRow = rowFor(page.getByRole('table'), jobID) + await expect(jobRow).toContainText('unknown') + await jobRow.getByRole('button', { name: 'Details' }).click() + const dialog = page.getByRole('dialog') + await expect(dialog).toContainText('unknown') + await expect(dialog.getByRole('button', { name: 'Reconcile now' })).toBeVisible() + + const scheduledResponse = page.waitForResponse((candidate) => + candidate.request().method() === 'POST' && candidate.url().includes(`/ai-jobs/${jobID}/attempts/${attempt.id}/reconcile`) + ) + page.once('dialog', (confirmation) => confirmation.accept()) + await dialog.getByRole('button', { name: 'Reconcile now' }).click() + const scheduled = await responseData<{ job_id: string; attempt_id: string; status: string; scheduled_at: string }>(await scheduledResponse) + expect(scheduled).toEqual(expect.objectContaining({ job_id: jobID, attempt_id: attempt.id, status: 'scheduled' })) + expect(Date.parse(scheduled.scheduled_at)).not.toBeNaN() + await expect(page.getByText('Provider attempt scheduled for reconciliation.')).toBeVisible() + + await page.reload() + const persistedRow = rowFor(page.getByRole('table'), jobID) + await expect(persistedRow).toContainText('unknown') + await persistedRow.getByRole('button', { name: 'Details' }).click() + await expect(page.getByRole('dialog').getByRole('button', { name: 'Reconcile now' })).toBeVisible() + + const audit = await envelope(await page.request.get('/api/v1/console/audit-logs?action=schedule_reconciliation&resource_type=ai_attempt&limit=200', { + headers: { Authorization: `Bearer ${token}` } + })) + expect(audit).toContainEqual(expect.objectContaining({ + action: 'schedule_reconciliation', + resource_type: 'ai_attempt', + resource_id: attempt.id + })) + expect(errors).toEqual([]) + } finally { + await setFixtureMode(page, upstreamAPI, 'normal') + } +}) + +test('@e2e-artifact-delivery-retry-001 failed customer delivery retries through to stored bytes', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'chromium-desktop', 'The stateful recovery action runs once.') + test.setTimeout(120_000) + + const errors = captureBrowserErrors(page) + const runID = uniqueID('artifact-retry', testInfo.project.name) + const modelID = `browser-artifact-model-${runID}` + const sinkID = `browser-retry-${runID}` + const s3Port = process.env.ASTER_E2E_S3_PORT || '29003' + const s3API = process.env.ASTER_E2E_S3_API_URL || 'http://127.0.0.1:29004' + await loginDemo(page) + const token = await loginTestPrincipal(page) + const headers = { Authorization: `Bearer ${token}` } + const catalog = await envelope<{ plugins: Plugin[] }>(await page.request.get('/api/v1/console/plugins', { headers })) + const initialPlugin = catalog.plugins.find((plugin) => plugin.id === artifactSinkPluginID) + expect(initialPlugin).toBeDefined() + + let destinationCreated = false + try { + await envelope(await page.request.put( + `/api/v1/console/plugins/${encodeURIComponent(artifactSinkPluginID)}/artifact-sinks/${encodeURIComponent(sinkID)}`, + { + headers, + data: { + name: `Browser retry sink ${runID}`, + provider: 's3', + endpoint: `https://localhost:${s3Port}`, + region: 'us-east-1', + bucket: 'browser-media', + prefix: 'delivery', + reference_base_url: 'https://media.example.test/delivery', + allowed_application_id: '', + path_style: true, + enabled: true, + secrets: { access_key: `access-${runID}`, secret_key: `secret-${runID}` }, + clear_session_token: false + } + } + )) + destinationCreated = true + if (initialPlugin?.status !== 'enabled') { + await envelope(await page.request.post(`/api/v1/console/plugins/${encodeURIComponent(artifactSinkPluginID)}/enable`, { headers })) + } + + await createDurableImageGatewayFixture(page, token, runID, modelID) + const key = await adminPost<{ key: string }>(page, token, '/api-keys', { + name: `Artifact Retry Key ${runID}`, + scopes: ['gateway:invoke', 'jobs:read'], + model_allowlist: [modelID], + allowed_modalities: ['image'], + allowed_operations: ['image_generation'], + lane_policy: 'durable_only', + artifact_policy: 'customer_sink', + artifact_sink_id: sinkID + }) + + await setFixtureMode(page, s3API, 'fail-put') + const response = await page.request.post('/v1/jobs', { + headers: { Authorization: `Bearer ${key.key}`, 'Idempotency-Key': `artifact-retry-${runID}` }, + data: { model: modelID, operation: 'image_generation', modality: 'image', input: { prompt: 'synthetic customer delivery', count: 1 } } + }) + expect(response.status()).toBe(202) + const jobID = (await response.json() as { id: string }).id + + await expect.poll(async () => { + const detail = await getJob(page, token, jobID) + return detail.artifacts[0]?.status || 'missing' + }, { timeout: 30_000 }).toBe('delivery_failed') + const failedJob = await getJob(page, token, jobID) + const artifact = failedJob.artifacts[0] + expect(artifact).toEqual(expect.objectContaining({ status: 'delivery_failed', media_type: 'image/png' })) + expect(artifact.size_bytes).toBe(syntheticPNG.length) + + await page.goto(`/console/usage/artifacts?q=${encodeURIComponent(artifact.id)}`) + const artifactRow = rowFor(page.getByRole('table'), artifact.id) + await expect(artifactRow).toContainText('delivery failed') + await expect(artifactRow).toContainText(sinkID) + await artifactRow.getByRole('button', { name: 'Details' }).click() + const dialog = page.getByRole('dialog') + await expect(dialog.getByRole('button', { name: 'Retry delivery' })).toBeVisible() + + await setFixtureMode(page, s3API, 'normal') + const retryResponse = page.waitForResponse((candidate) => + candidate.request().method() === 'POST' && candidate.url().includes(`/artifacts/${artifact.id}/retry-delivery`) + ) + page.once('dialog', (confirmation) => confirmation.accept()) + await dialog.getByRole('button', { name: 'Retry delivery' }).click() + const retry = await responseData<{ artifact_id: string; attempt_id: string; status: string; scheduled_at: string }>(await retryResponse) + expect(retry).toEqual(expect.objectContaining({ artifact_id: artifact.id, status: 'scheduled' })) + expect(Date.parse(retry.scheduled_at)).not.toBeNaN() + await expect(page.getByText('Delivery retry scheduled.')).toBeVisible() + + await expect.poll(async () => { + const detail = await envelope<{ artifact: Artifact }>(await page.request.get(`/api/v1/console/artifacts/${artifact.id}`, { headers })) + return detail.artifact.status + }, { timeout: 30_000 }).toBe('delivered') + + await page.reload() + const deliveredRow = rowFor(page.getByRole('table'), artifact.id) + await expect(deliveredRow).toContainText('delivered') + await deliveredRow.getByRole('button', { name: 'Details' }).click() + const deliveredDialog = page.getByRole('dialog') + await expect(deliveredDialog).toContainText('delivery failed') + await expect(deliveredDialog).toContainText('delivered') + await expect(deliveredDialog).toContainText('sink delivery failed') + await expect(deliveredDialog.getByRole('button', { name: 'Retry delivery' })).toHaveCount(0) + + const requestLog = await page.request.get(`${s3API}/__test/requests?artifact_id=${encodeURIComponent(artifact.id)}`) + expect(requestLog.status()).toBe(200) + const s3Requests = (await requestLog.json() as { requests: S3Request[] }).requests + const putRequests = s3Requests.filter((request) => request.method === 'PUT') + const stored = putRequests.filter((request) => request.outcome === 'stored') + expect(putRequests.some((request) => request.outcome === 'rejected')).toBe(true) + expect(stored).toHaveLength(1) + expect(stored[0]).toEqual(expect.objectContaining({ + content_type: 'image/png', + content_length: syntheticPNG.length, + authorization_present: true, + sigv4_valid: true, + sigv4_errors: [], + body_base64: syntheticPNG.toString('base64'), + decode_error: '' + })) + expect(stored[0].path).toMatch(new RegExp(`^/browser-media/delivery/owners/[a-f0-9]{32}/${artifact.id}`)) + + const objectResponse = await page.request.get(`${s3API}/__test/objects`) + const objects = (await objectResponse.json() as { objects: Array<{ path: string; content_type: string; body_base64: string }> }).objects + expect(objects).toContainEqual(expect.objectContaining({ + content_type: 'image/png', + body_base64: syntheticPNG.toString('base64') + })) + + const audit = await envelope(await page.request.get('/api/v1/console/audit-logs?action=retry_delivery&resource_type=artifact&limit=200', { headers })) + expect(audit).toContainEqual(expect.objectContaining({ action: 'retry_delivery', resource_type: 'artifact', resource_id: artifact.id })) + expect(errors).toEqual([]) + } finally { + await setFixtureMode(page, s3API, 'normal') + if (initialPlugin?.status !== 'enabled') { + await page.request.post(`/api/v1/console/plugins/${encodeURIComponent(artifactSinkPluginID)}/disable`, { headers }) + } + if (destinationCreated) { + await page.request.delete( + `/api/v1/console/plugins/${encodeURIComponent(artifactSinkPluginID)}/artifact-sinks/${encodeURIComponent(sinkID)}`, + { headers } + ) + } + } +}) diff --git a/frontend/e2e/plugin-management.spec.ts b/frontend/e2e/plugin-management.spec.ts new file mode 100644 index 0000000..e7336e0 --- /dev/null +++ b/frontend/e2e/plugin-management.spec.ts @@ -0,0 +1,371 @@ +import { expect, test } from '@playwright/test' +import { captureBrowserErrors, envelope, loginDemo, loginTestPrincipal } from './fixtures' + +type Plugin = { id: string; status: string } +type AuditEvent = { action: string; resource_type: string; resource_id: string; summary: string } +type SidecarRuntimeStatus = { plugin_id: string; installed: boolean; enabled: boolean; running: boolean; version: string } +type OfficialRequest = { + kind: string + method: string + path: string + valid: boolean + errors: string[] + headers: Record +} + +const webhookPluginID = 'com.asterrouter.notification.webhook' +const lockedPluginID = 'com.asterrouter.notification.slack' + +test('@e2e-plugin-management-001 plugin management lifecycle is governed and auditable', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'chromium-desktop', 'The stateful lifecycle runs once; the console surface contract covers every supported viewport.') + test.setTimeout(90_000) + + const errors = captureBrowserErrors(page) + await loginDemo(page) + const adminToken = await loginTestPrincipal(page) + const headers = { Authorization: `Bearer ${adminToken}` } + const initialCatalog = await envelope<{ plugins: Plugin[] }>(await page.request.get('/api/v1/console/plugins', { headers })) + const initialWebhook = initialCatalog.plugins.find((plugin) => plugin.id === webhookPluginID) + expect(initialWebhook).toBeDefined() + + let issuedSecret = '' + try { + await page.goto('/console/plugins') + await expect(page).toHaveURL(/\/console\/system\/plugins$/) + await expect(page.getByRole('heading', { level: 1, name: 'Plugin Center' })).toBeVisible() + + await page.getByRole('button', { name: 'Plugin registry', exact: true }).click() + const search = page.getByPlaceholder('Search plugins, vendors, or categories') + await search.fill('Slack Notification') + await page.getByRole('button', { name: /Slack Notification/ }).click() + await expect(page.getByRole('button', { name: 'Enable', exact: true })).toBeDisabled() + const lockedResponse = await page.request.post(`/api/v1/console/plugins/${encodeURIComponent(lockedPluginID)}/enable`, { headers }) + expect(lockedResponse.status()).toBe(409) + expect(await lockedResponse.json()).toEqual(expect.objectContaining({ + code: 1709, + message: expect.stringMatching(/entitlement.*missing/i) + })) + + await search.fill('Generic Webhook Notification') + await page.getByRole('button', { name: /Generic Webhook Notification/ }).click() + if (initialWebhook?.status !== 'enabled') { + await page.getByRole('button', { name: 'Enable', exact: true }).click() + await expect(page.getByText('Plugin enabled', { exact: true })).toBeVisible() + } + await expect(page.getByRole('button', { name: 'Disable', exact: true })).toBeEnabled() + + const deliveriesResponse = page.waitForResponse((response) => + response.request().method() === 'GET' + && response.url().includes(`/api/v1/console/plugins/${encodeURIComponent(webhookPluginID)}/deliveries`) + ) + await page.getByRole('button', { name: 'Deliveries', exact: true }).click() + const loadedDeliveries = await deliveriesResponse + expect(loadedDeliveries.status()).toBe(200) + expect((await loadedDeliveries.json()).data).toEqual([]) + const deliveriesDialog = page.locator('.modal-card').filter({ has: page.getByRole('heading', { name: /Deliveries.*Generic Webhook Notification/ }) }) + await expect(deliveriesDialog.getByText('No delivery attempts match the current filter.')).toBeVisible() + await deliveriesDialog.getByRole('button', { name: 'Cancel', exact: true }).click() + + await page.getByRole('button', { name: 'Configure', exact: true }).click() + const configDialog = page.locator('.modal-card').filter({ has: page.getByRole('heading', { name: /Configure.*Generic Webhook Notification/ }) }) + const webhookURL = configDialog.getByLabel('Webhook URL') + await webhookURL.fill('ftp://invalid.example.test/hook') + const expectedErrorCount = errors.length + await configDialog.getByRole('button', { name: 'Save', exact: true }).click() + await expect(page.getByText(/webhook_url must be an HTTP or HTTPS URL/)).toBeVisible() + expect(errors.splice(expectedErrorCount)).toEqual([ + 'console: Failed to load resource: the server responded with a status of 400 (Bad Request)' + ]) + + const suffix = `${testInfo.project.name}-${Date.now()}` + const secret = `synthetic-plugin-secret-${suffix}` + await webhookURL.fill(`https://hooks.example.test/${suffix}`) + await configDialog.getByLabel('Bearer token').fill(secret) + await configDialog.getByLabel('Minimum severity').selectOption('critical') + await configDialog.getByLabel('Alert types').fill('api_key_quota,gateway_error_rate') + await configDialog.getByRole('button', { name: 'Save', exact: true }).click() + await expect(page.getByText('Plugin configuration saved', { exact: true })).toBeVisible() + await expect(configDialog.getByLabel('Webhook URL')).toHaveValue('') + await expect(configDialog.getByLabel('Bearer token')).toHaveValue('') + await expect(page.getByText(secret, { exact: true })).toHaveCount(0) + await configDialog.getByRole('button', { name: 'Cancel', exact: true }).click() + + await page.reload() + await page.getByRole('button', { name: 'Plugin registry', exact: true }).click() + await page.getByPlaceholder('Search plugins, vendors, or categories').fill('Generic Webhook Notification') + await page.getByRole('button', { name: /Generic Webhook Notification/ }).click() + await expect(page.getByRole('button', { name: 'Disable', exact: true })).toBeEnabled() + await page.getByRole('button', { name: 'Configure', exact: true }).click() + const persistedDialog = page.locator('.modal-card').filter({ has: page.getByRole('heading', { name: /Configure.*Generic Webhook Notification/ }) }) + await expect(persistedDialog.getByLabel('Minimum severity')).toHaveValue('critical') + await expect(persistedDialog.getByLabel('Alert types')).toHaveValue('api_key_quota,gateway_error_rate') + await expect(persistedDialog.getByLabel('Webhook URL')).toHaveValue('') + await expect(persistedDialog.getByLabel('Webhook URL')).not.toHaveAttribute('placeholder', '') + await persistedDialog.getByRole('button', { name: 'Cancel', exact: true }).click() + + await page.getByRole('button', { name: 'Plugin Open API', exact: true }).click() + await page.getByRole('button', { name: 'Create API token', exact: true }).click() + const tokenDialog = page.locator('.modal-card').filter({ has: page.getByRole('heading', { name: 'Create API token' }) }) + const tokenName = `Browser plugin token ${suffix}` + await tokenDialog.getByLabel('Token name').fill(tokenName) + await tokenDialog.getByLabel('Bound plugin').selectOption(webhookPluginID) + await tokenDialog.getByLabel('plugin:action').check() + await tokenDialog.getByRole('button', { name: 'Create API token', exact: true }).click() + await expect(page.getByText('Plugin API token created', { exact: true })).toBeVisible() + issuedSecret = await tokenDialog.locator('.token-secret-panel code').innerText() + expect(issuedSecret).toMatch(/^arpt_/) + + const openCatalog = await page.request.get('/api/v1/open/plugins/catalog', { + headers: { Authorization: `Bearer ${issuedSecret}` } + }) + expect(openCatalog.status()).toBe(200) + await tokenDialog.getByRole('button', { name: 'Cancel', exact: true }).click() + await page.getByRole('button', { name: 'Refresh', exact: true }).click() + const tokenRow = page.getByRole('row').filter({ hasText: tokenName }) + await expect(tokenRow).toContainText('active') + await expect(tokenRow).not.toContainText(issuedSecret) + page.once('dialog', (dialog) => dialog.accept()) + await tokenRow.getByTitle('Revoke token').click() + await expect(page.getByText('Plugin API token revoked', { exact: true })).toBeVisible() + await expect(tokenRow).toContainText('revoked') + + const revokedUse = await page.request.get('/api/v1/open/plugins/catalog', { + headers: { Authorization: `Bearer ${issuedSecret}` } + }) + expect(revokedUse.status()).toBe(401) + + const audit = await envelope(await page.request.get('/api/v1/console/audit-logs?resource_type=plugin&limit=200', { headers })) + expect(audit).toEqual(expect.arrayContaining([ + expect.objectContaining({ action: 'enable', resource_type: 'plugin', resource_id: webhookPluginID }), + expect.objectContaining({ action: 'configure', resource_type: 'plugin', resource_id: webhookPluginID }), + expect.objectContaining({ action: 'api_token_create', resource_type: 'plugin' }), + expect.objectContaining({ action: 'api_token_revoke', resource_type: 'plugin' }) + ])) + expect(JSON.stringify(audit)).not.toContain(issuedSecret) + expect(errors).toEqual([]) + } finally { + if (initialWebhook?.status === 'enabled') { + await page.request.post(`/api/v1/console/plugins/${encodeURIComponent(webhookPluginID)}/enable`, { headers }) + } else { + await page.request.post(`/api/v1/console/plugins/${encodeURIComponent(webhookPluginID)}/disable`, { headers }) + } + } +}) + +test('@e2e-plugin-trust-chain-001 signed official plugin trust chain crosses the browser and runtime', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'chromium-desktop', 'The signed stateful trust chain runs once on desktop.') + test.setTimeout(120_000) + + const officialURL = process.env.ASTER_E2E_OFFICIAL_URL || 'http://127.0.0.1:29006' + const pluginID = 'com.astercloud.catalog.router-sync' + const packageID = `pkg_router_sync_${process.platform}_${process.arch}` + const importPackageID = `pkg_router_sync_import_${process.platform}_${process.arch}` + const errors = captureBrowserErrors(page) + + await loginDemo(page) + const adminToken = await loginTestPrincipal(page) + const headers = { Authorization: `Bearer ${adminToken}` } + await page.goto('/console/system/plugins') + await expect(page.getByRole('heading', { level: 1, name: 'Plugin Center' })).toBeVisible() + + const catalogResponse = page.waitForResponse((response) => + response.request().method() === 'POST' && response.url().endsWith('/api/v1/console/plugins/catalog-sync') + ) + await page.getByRole('button', { name: 'Sync catalog', exact: true }).first().click() + expect((await catalogResponse).status()).toBe(200) + await expect(page.getByText('Official catalog synchronized', { exact: true })).toBeVisible() + + await page.getByRole('button', { name: 'Distribution & licensing', exact: true }).click() + await page.getByRole('button', { name: 'Activate License', exact: true }).click() + const activateDialog = page.locator('.modal-card').filter({ has: page.getByRole('heading', { name: 'Activate License', exact: true }) }) + await activateDialog.getByLabel('License ID').fill('lic_e2e_browser') + await activateDialog.getByLabel('Activation secret').fill('e2e-activation-secret') + await activateDialog.getByLabel('Instance', { exact: true }).fill('inst_e2e_browser') + await activateDialog.getByLabel('Instance fingerprint').fill('sha256:e2e-browser-fingerprint') + await activateDialog.getByRole('button', { name: 'Save', exact: true }).click() + await expect(page.getByText('License activated and locally verified', { exact: true })).toBeVisible() + await expect(page.getByText('lic_e2e_browser', { exact: true })).toBeVisible() + + await page.getByRole('button', { name: 'Redeem code', exact: true }).click() + const redeemDialog = page.locator('.modal-card').filter({ has: page.getByRole('heading', { name: 'Redeem code', exact: true }) }) + await redeemDialog.getByLabel('Redeem code').fill('ASTER-E2E-REDEEM') + await redeemDialog.getByLabel('Instance', { exact: true }).fill('inst_e2e_browser') + await redeemDialog.getByLabel('Instance fingerprint').fill('sha256:e2e-browser-fingerprint') + await redeemDialog.getByRole('button', { name: 'Save', exact: true }).click() + await expect(page.getByText('Redeem code consumed and License locally verified', { exact: true })).toBeVisible() + + const licenseFixture = await (await page.request.get(`${officialURL}/e2e/license-envelope`)).json() + await page.getByRole('button', { name: 'Import License', exact: true }).click() + const importLicenseDialog = page.locator('.modal-card').filter({ has: page.getByRole('heading', { name: 'Import License', exact: true }) }) + await importLicenseDialog.getByLabel('Offline License file').fill(JSON.stringify(licenseFixture)) + await importLicenseDialog.getByLabel('Activation secret (optional)').fill('e2e-activation-secret') + await importLicenseDialog.getByRole('button', { name: 'Save', exact: true }).click() + await expect(page.getByText('License imported and locally verified', { exact: true })).toBeVisible() + + await page.getByRole('button', { name: 'Refresh', exact: true }).first().click() + await page.getByRole('button', { name: 'Data services', exact: true }).click() + await expect(page.getByText('X25519-HKDF-SHA256+A256GCM', { exact: true })).toBeVisible() + const feedPublicKey = await page.locator('.inline-code-row code').innerText() + const feedFixture = await (await page.request.post(`${officialURL}/e2e/feed-envelope`, { + data: { public_key: feedPublicKey } + })).json() + + await page.getByRole('button', { name: 'Import feed', exact: true }).click() + const importFeedDialog = page.locator('.modal-card').filter({ has: page.getByRole('heading', { name: 'Import feed', exact: true }) }) + await importFeedDialog.getByLabel('Encrypted Feed envelope JSON').fill(JSON.stringify(feedFixture)) + await importFeedDialog.getByRole('button', { name: 'Import feed', exact: true }).click() + await expect(page.getByText('Official encrypted Feed imported', { exact: true })).toBeVisible() + await expect(page.getByText('feed_e2e_import', { exact: true })).toBeVisible() + + await page.getByLabel('Service', { exact: true }).selectOption('provider-intelligence') + await page.getByRole('button', { name: 'Sync feed', exact: true }).click() + await expect(page.getByText('provider-intelligence synchronized at 2', { exact: true })).toBeVisible() + await expect(page.getByText('feed_e2e_sync', { exact: true })).toHaveCount(2) + + await page.getByRole('button', { name: 'Plugin registry', exact: true }).click() + const search = page.getByPlaceholder('Search plugins, vendors, or categories') + await search.fill('Signed Router Sync') + await page.getByRole('button', { name: /Signed Router Sync/ }).click() + const importRow = page.locator('.package-row').filter({ hasText: importPackageID }) + const packageFixture = await (await page.request.get(`${officialURL}/e2e/package-import`)).json() + await importRow.getByRole('button', { name: 'Import package', exact: true }).click() + const importPackageDialog = page.locator('.modal-card').filter({ has: page.getByRole('heading', { name: 'Import package', exact: true }) }) + await importPackageDialog.getByLabel('Offline plugin package file JSON').fill(JSON.stringify(packageFixture)) + await importPackageDialog.getByRole('button', { name: 'Import package', exact: true }).click() + await expect(page.getByText('Plugin package imported and verified', { exact: true })).toBeVisible() + + const downloadRow = page.locator('.package-row').filter({ hasText: packageID }).filter({ hasNotText: importPackageID }) + await downloadRow.getByRole('button', { name: 'Download package', exact: true }).click() + await expect(page.getByText('Plugin package downloaded and verified', { exact: true })).toBeVisible() + + const refreshedImportRow = page.locator('.package-row').filter({ hasText: importPackageID }) + await refreshedImportRow.getByRole('button', { name: 'Install', exact: true }).click() + await expect(page.getByText('Plugin package installed', { exact: true })).toBeVisible() + await page.getByRole('button', { name: 'Enable', exact: true }).click() + await expect(page.getByText('Plugin enabled', { exact: true })).toBeVisible() + + await page.reload() + await expect(page.getByRole('heading', { level: 1, name: 'Plugin Center' })).toBeVisible() + + await page.getByRole('button', { name: 'Distribution & licensing', exact: true }).click() + await expect(page.getByText('lic_e2e_browser', { exact: true })).toBeVisible() + await expect(page.getByText('active', { exact: true }).first()).toBeVisible() + + await page.getByRole('button', { name: 'Data services', exact: true }).click() + await expect(page.getByText('feed_e2e_sync', { exact: true })).toHaveCount(2) + await expect(page.locator('.inline-code-row code')).toHaveText(feedPublicKey) + + await page.getByRole('button', { name: 'Plugin registry', exact: true }).click() + await page.getByPlaceholder('Search plugins, vendors, or categories').fill('Signed Router Sync') + const runtimeResponse = page.waitForResponse((response) => + response.request().method() === 'GET' + && response.url().endsWith(`/api/v1/console/plugins/${encodeURIComponent(pluginID)}/runtime/status`) + ) + await page.getByRole('button', { name: /Signed Router Sync/ }).click() + const loadedRuntime = await runtimeResponse + expect(loadedRuntime.status()).toBe(200) + const runtime = (await loadedRuntime.json()).data as SidecarRuntimeStatus + expect(runtime).toEqual(expect.objectContaining({ + plugin_id: pluginID, + installed: true, + enabled: true, + running: false, + version: '1.0.0' + })) + const runtimeSection = page.locator('.plugin-detail-section').filter({ has: page.getByRole('heading', { name: 'Runtime status' }) }) + await expect(runtimeSection.getByText('Installed', { exact: true })).toBeVisible() + await expect(runtimeSection.getByText('Enabled', { exact: true })).toBeVisible() + await expect(runtimeSection.getByText('Yes', { exact: true })).toHaveCount(2) + await expect(page.locator('.package-row').filter({ hasText: importPackageID })).toContainText('installed') + + await page.getByRole('button', { name: 'Workbench', exact: true }).click() + const launcher = page.locator('.plugin-launcher-item').filter({ hasText: 'Signed Router Sync' }) + const workbenchResponse = page.waitForResponse((response) => response.url().endsWith( + `/api/v1/console/plugins/${encodeURIComponent(pluginID)}/frontend/workbench` + )) + const styleResponse = page.waitForResponse((response) => response.url().endsWith( + `/api/v1/console/plugins/${encodeURIComponent(pluginID)}/frontend/assets/app.css` + )) + const scriptResponse = page.waitForResponse((response) => response.url().endsWith( + `/api/v1/console/plugins/${encodeURIComponent(pluginID)}/frontend/assets/app.js` + )) + await launcher.getByRole('button', { name: 'Open workbench', exact: true }).click() + const [loadedWorkbench, loadedStyle, loadedScript] = await Promise.all([workbenchResponse, styleResponse, scriptResponse]) + expect(loadedWorkbench.status()).toBe(200) + expect(loadedWorkbench.headers()['content-type']).toContain('application/json') + expect(loadedStyle.status()).toBe(200) + expect(loadedStyle.headers()['content-type']).toContain('text/css') + expect(loadedScript.status()).toBe(200) + expect(loadedScript.headers()['content-type']).toMatch(/javascript/) + await expect(page).toHaveURL(new RegExp(`/console/system/plugins/${pluginID}/workbench$`)) + await expect(page.getByRole('heading', { level: 1, name: 'Signed Router Sync' })).toBeVisible() + await expect(page.getByRole('heading', { level: 2, name: 'Signed Router Sync Workbench' })).toBeVisible() + await expect(page.getByText('Catalog, package, and frontend assets verified.', { exact: true })).toBeVisible() + + await page.getByRole('button', { name: '返回插件中心' }).click() + await page.getByRole('button', { name: 'Plugin registry', exact: true }).click() + await page.getByPlaceholder('Search plugins, vendors, or categories').fill('Signed Router Sync') + await page.getByRole('button', { name: /Signed Router Sync/ }).click() + await page.locator('.package-row').filter({ hasText: importPackageID }).getByRole('button', { name: 'Uninstall', exact: true }).click() + await expect(page.getByText('Plugin package uninstalled', { exact: true })).toBeVisible() + + const officialRequestsResponse = await page.request.get(`${officialURL}/e2e/requests`) + expect(officialRequestsResponse.status()).toBe(200) + const officialRequests = ((await officialRequestsResponse.json()).requests || []) as OfficialRequest[] + for (const kind of [ + 'catalog', + 'license_activate', + 'license_redeem', + 'feed_metadata', + 'feed_download', + 'package_authorization', + 'package_object' + ]) { + expect(officialRequests).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind, valid: true, errors: [] }) + ])) + } + const packageAuthorization = officialRequests.find((request) => request.kind === 'package_authorization' && request.valid) + expect(packageAuthorization?.headers).toEqual(expect.objectContaining({ + 'x-aster-os': process.platform, + 'x-aster-arch': process.arch, + 'x-aster-license-id': 'lic_e2e_browser', + 'x-aster-activation-secret': '[REDACTED]', + 'x-aster-instance-id': 'inst_e2e_browser' + })) + expect(packageAuthorization?.headers['x-aster-core-version']).toBeTruthy() + for (const kind of ['feed_metadata', 'feed_download']) { + const request = officialRequests.find((candidate) => candidate.kind === kind && candidate.valid) + expect(request?.headers).toEqual(expect.objectContaining({ + 'x-aster-license-id': 'lic_e2e_browser', + 'x-aster-activation-secret': '[REDACTED]', + 'x-aster-instance-id': 'inst_e2e_browser', + 'x-aster-instance-fingerprint': 'sha256:e2e-browser-fingerprint', + 'x-aster-feed-public-key': '[PRESENT]' + })) + expect(request?.headers['x-aster-core-version']).toBeTruthy() + expect(request?.headers['x-aster-request-id']).toBeTruthy() + } + expect(JSON.stringify(officialRequests)).not.toContain('e2e-activation-secret') + + const audit = await envelope(await page.request.get('/api/v1/console/audit-logs?resource_type=plugin&limit=200', { headers })) + for (const action of [ + 'catalog_sync', + 'license_activate', + 'license_redeem', + 'license_import', + 'feed_import', + 'feed_sync', + 'package_import', + 'package_download', + 'package_install', + 'enable', + 'package_uninstall' + ]) { + expect(audit).toEqual(expect.arrayContaining([ + expect.objectContaining({ action, resource_type: 'plugin' }) + ])) + } + expect(JSON.stringify(audit)).not.toContain('e2e-activation-secret') + expect(errors).toEqual([]) +}) diff --git a/frontend/e2e/pricing.spec.ts b/frontend/e2e/pricing.spec.ts index f67f21d..96f8e1a 100644 --- a/frontend/e2e/pricing.spec.ts +++ b/frontend/e2e/pricing.spec.ts @@ -1,34 +1,54 @@ import { expect, test } from '@playwright/test' -import { adminPost, captureBrowserErrors, expectNoHorizontalOverflow, loginDemo, loginTestPrincipal } from './fixtures' +import { adminPost, captureBrowserErrors, createGatewayFixture, envelope, expectNoHorizontalOverflow, loginDemo, loginTestPrincipal } from './fixtures' -test('@pricing expression pricing lifecycle remains usable across viewports', async ({ page }, testInfo) => { +type PricingRuleResponse = { + data: { + rule: { + id: string + status: 'active' | 'disabled' + active_version_id?: string + lock_version: number + } + active_version?: { id: string; revision: number; expression: string } + draft?: { id: string; revision: number; expression: string } + } +} + +test('@e2e-pricing-001 expression pricing lifecycle remains usable across viewports', async ({ page }, testInfo) => { const errors = captureBrowserErrors(page) await loginDemo(page) const token = await loginTestPrincipal(page) const suffix = `${testInfo.project.name.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}-${Date.now().toString(36)}` const model = `pricing-e2e-${suffix}` - await adminPost(page, token, '/gateway-models', { - model_id: model, - name: `Pricing E2E ${suffix}`, - description: 'Synthetic pricing browser model', - modality: 'chat', - default_route_group: 'default', - sticky_enabled: false, - sticky_ttl_seconds: 0, - status: 'active' + const ruleName = `Browser cost ${suffix}` + await createGatewayFixture(page, token, suffix, model) + const workspaceKey = await adminPost<{ key: string; record: { id: string } }>(page, token, '/api-keys', { + name: `Pricing E2E Key ${suffix}`, + model_allowlist: [model], + qps_limit: 10, + monthly_token_limit: 100000 }) - await page.goto('/console/pricing') + await page.goto('/console/model-services/pricing') + await expect(page).toHaveURL(/\/console\/model-services\/pricing$/) await expect(page.getByRole('heading', { level: 1, name: 'Expression Pricing' })).toBeVisible() await page.getByRole('button', { name: 'New rule' }).click() const dialog = page.getByRole('dialog', { name: 'New rule' }) - await dialog.getByLabel('Rule name').fill(`Browser cost ${suffix}`) + await dialog.getByLabel('Rule name').fill(ruleName) await dialog.getByLabel('Model').fill(model) await dialog.getByLabel('v1 expression').fill('v1: fixed_line("request", "request", 125)') + const createResponsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' && new URL(response.url()).pathname === '/api/v1/console/pricing-rules' + ) await dialog.getByRole('button', { name: 'Create rule' }).click() + const createResponse = await createResponsePromise + expect(createResponse.status()).toBe(200) + const created = await createResponse.json() as PricingRuleResponse + expect(created.data.rule.id).not.toBe('') + expect(created.data.draft).toMatchObject({ revision: 0, expression: 'v1: fixed_line("request", "request", 125)' }) await expect(page.getByText('Rule created')).toBeVisible() - await expect(page.getByRole('heading', { level: 2, name: `Browser cost ${suffix}` })).toBeVisible() + await expect(page.getByRole('heading', { level: 2, name: ruleName })).toBeVisible() await page.getByRole('button', { name: 'Validate' }).click() await expect(page.getByText('Validation passed').first()).toBeVisible() @@ -37,10 +57,150 @@ test('@pricing expression pricing lifecycle remains usable across viewports', as await expect(page.getByText('$0.000125').first()).toBeVisible() await page.getByRole('button', { name: 'Rule editor' }).click() + const firstPublishResponsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' && new URL(response.url()).pathname === `/api/v1/console/pricing-rules/${created.data.rule.id}/publish` + ) + await page.getByRole('button', { name: 'Publish' }).click() + const firstPublishResponse = await firstPublishResponsePromise + expect(firstPublishResponse.status()).toBe(200) + const firstPublished = await firstPublishResponse.json() as PricingRuleResponse + expect(firstPublished.data.rule.active_version_id).toBe(firstPublished.data.active_version?.id) + expect(firstPublished.data.active_version).toMatchObject({ revision: 1, expression: 'v1: fixed_line("request", "request", 125)' }) + await expect(page.getByText('Version published and activated')).toBeVisible() + + const secondExpression = 'v1: fixed_line("request", "request", 250)' + await page.getByLabel('v1 expression').fill(secondExpression) + const secondDraftResponsePromise = page.waitForResponse((response) => + response.request().method() === 'PUT' && new URL(response.url()).pathname === `/api/v1/console/pricing-rules/${created.data.rule.id}/draft` + ) + await page.getByRole('button', { name: 'Save draft' }).click() + const secondDraftResponse = await secondDraftResponsePromise + expect(secondDraftResponse.status()).toBe(200) + const secondDraft = await secondDraftResponse.json() as PricingRuleResponse + expect(secondDraft.data.rule.active_version_id).toBe(firstPublished.data.rule.active_version_id) + expect(secondDraft.data.draft).toMatchObject({ revision: 0, expression: secondExpression }) + expect(secondDraft.data.draft?.id).not.toBe(firstPublished.data.active_version?.id) + await expect(page.getByText('Draft saved')).toBeVisible() + await expect(page.getByRole('button', { name: 'Publish' })).toBeEnabled() + + const secondPublishResponsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' && new URL(response.url()).pathname === `/api/v1/console/pricing-rules/${created.data.rule.id}/publish` + ) await page.getByRole('button', { name: 'Publish' }).click() + const secondPublishResponse = await secondPublishResponsePromise + expect(secondPublishResponse.status()).toBe(200) + const secondPublished = await secondPublishResponse.json() as PricingRuleResponse + expect(secondPublished.data.rule.active_version_id).toBe(secondPublished.data.active_version?.id) + expect(secondPublished.data.rule.active_version_id).not.toBe(firstPublished.data.rule.active_version_id) + expect(secondPublished.data.active_version).toMatchObject({ revision: 2, expression: secondExpression }) await expect(page.getByText('Version published and activated')).toBeVisible() + await page.getByRole('button', { name: 'Version history' }).click() - await expect(page.getByText('Active version')).toBeVisible() + let firstVersionRow = page.getByRole('row').filter({ hasText: '#1' }) + let secondVersionRow = page.getByRole('row').filter({ hasText: '#2' }) + await expect(firstVersionRow.getByRole('button', { name: 'Activate' })).toBeVisible() + await expect(secondVersionRow.getByText('Active version')).toBeVisible() + + page.once('dialog', (confirmation) => confirmation.accept()) + const activateResponsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' && new URL(response.url()).pathname === `/api/v1/console/pricing-rules/${created.data.rule.id}/activate/${firstPublished.data.active_version!.id}` + ) + await firstVersionRow.getByRole('button', { name: 'Activate' }).click() + const activateResponse = await activateResponsePromise + expect(activateResponse.status()).toBe(200) + expect(await activateResponse.json()).toMatchObject({ data: { status: 'active' } }) + await expect(page.getByText('Version activated')).toBeVisible() + firstVersionRow = page.getByRole('row').filter({ hasText: '#1' }) + secondVersionRow = page.getByRole('row').filter({ hasText: '#2' }) + await expect(firstVersionRow.getByText('Active version')).toBeVisible() + await expect(secondVersionRow.getByRole('button', { name: 'Activate' })).toBeVisible() + + await page.reload() + await page.locator('.pricing-rule-list').getByRole('button').filter({ hasText: ruleName }).click() + await page.getByRole('button', { name: 'Version history' }).click() + await expect(page.getByRole('row').filter({ hasText: '#1' }).getByText('Active version')).toBeVisible() + + const completion = await page.request.post('/v1/chat/completions', { + data: { model, messages: [{ role: 'user', content: 'synthetic pricing evaluation request' }] }, + headers: { Authorization: `Bearer ${workspaceKey.key}` } + }) + expect(completion.status()).toBe(200) + await expect(completion.json()).resolves.toMatchObject({ + id: 'e2e-completion', + usage: { prompt_tokens: 7, completion_tokens: 11 } + }) + + let pricingEvaluationID = '' + await expect.poll(async () => { + const usage = await envelope<{ recent: Array> }>(await page.request.get( + `/api/v1/console/usage?api_key_id=${encodeURIComponent(workspaceKey.record.id)}&model=${encodeURIComponent(model)}&limit=10`, + { headers: { Authorization: `Bearer ${token}` } } + )) + const record = usage.recent.find((item) => item.api_key_id === workspaceKey.record.id && item.model === model && item.status === 'forwarded') + pricingEvaluationID = String(record?.usage_pricing_evaluation_id || '') + return record + }, { message: 'priced usage record for the real gateway request' }).toMatchObject({ + input_tokens: 7, + output_tokens: 11, + usage_cost_micros: 125, + usage_cost_currency: 'USD', + pricing_status: 'priced' + }) + expect(pricingEvaluationID).not.toBe('') + + await page.getByRole('button', { name: 'Evaluation evidence' }).click() + await page.getByLabel('Evaluation ID').fill(pricingEvaluationID) + const evaluationResponsePromise = page.waitForResponse((response) => + response.request().method() === 'GET' && new URL(response.url()).pathname === `/api/v1/console/pricing-evaluations/${pricingEvaluationID}` + ) + await page.getByRole('button', { name: 'Look up' }).click() + const evaluationResponse = await evaluationResponsePromise + expect(evaluationResponse.status()).toBe(200) + const evaluationBody = await evaluationResponse.json() as { + data: { + id: string + phase: string + pricing_rule_id: string + pricing_rule_version_id: string + amount_micros: number + currency: string + status: string + facts: { total_input_tokens: number; output_tokens: number; normalization_status: string } + } + } + expect(evaluationBody.data).toMatchObject({ + id: pricingEvaluationID, + phase: 'settlement', + pricing_rule_id: created.data.rule.id, + pricing_rule_version_id: firstPublished.data.active_version!.id, + amount_micros: 125, + currency: 'USD', + status: 'succeeded', + facts: { total_input_tokens: 7, output_tokens: 11 } + }) + const evaluationResult = page.locator('.evaluation-result') + await expect(evaluationResult.getByText('succeeded', { exact: true })).toBeVisible() + await expect(evaluationResult.getByText('settlement', { exact: true })).toBeVisible() + await expect(evaluationResult.getByText('$0.000125', { exact: true })).toBeVisible() + await expect(evaluationResult.locator('pre')).toContainText('"total_input_tokens": 7') + await expect(evaluationResult.locator('pre')).toContainText('"output_tokens": 11') + + await page.getByRole('button', { name: 'Rule editor' }).click() + page.once('dialog', (confirmation) => confirmation.accept()) + const disableResponsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' && new URL(response.url()).pathname === `/api/v1/console/pricing-rules/${created.data.rule.id}/disable` + ) + await page.getByRole('button', { name: 'Disable rule' }).click() + const disableResponse = await disableResponsePromise + expect(disableResponse.status()).toBe(200) + expect(await disableResponse.json()).toMatchObject({ data: { status: 'disabled' } }) + await expect(page.getByText('Rule disabled')).toBeVisible() + + await page.reload() + await page.locator('.pricing-rule-list').getByRole('button').filter({ hasText: ruleName }).click() + await expect(page.getByRole('heading', { level: 2, name: ruleName })).toBeVisible() + await expect(page.locator('.pricing-workspace-header').getByText('Disabled', { exact: true })).toBeVisible() + await expect(page.getByRole('button', { name: 'Disable rule' })).toHaveCount(0) await expectNoHorizontalOverflow(page) await page.getByLabel('Language').selectOption('zh-CN') @@ -54,11 +214,9 @@ test('@pricing expression pricing lifecycle remains usable across viewports', as await page.getByRole('button', { name: '关闭导航' }).first().click() await expect(page.locator('.admin-sidebar')).not.toHaveClass(/mobile-open/) await expect(page.locator('.sidebar-overlay')).toHaveCount(0) - await page.waitForTimeout(350) } await expectNoHorizontalOverflow(page) await page.evaluate(() => window.scrollTo(0, 0)) - await page.waitForTimeout(100) await page.screenshot({ path: testInfo.outputPath(`pricing-${suffix}.png`), fullPage: true }) expect(errors).toEqual([]) }) diff --git a/frontend/e2e/record-exports.spec.ts b/frontend/e2e/record-exports.spec.ts new file mode 100644 index 0000000..c231da5 --- /dev/null +++ b/frontend/e2e/record-exports.spec.ts @@ -0,0 +1,184 @@ +import { readFile } from 'node:fs/promises' +import { expect, test, type Download, type Page } from '@playwright/test' +import { + adminPost, + captureBrowserErrors, + createGatewayFixture, + loginDemo, + loginTestPrincipal +} from './fixtures' + +function uniqueID(name: string, project: string): string { + return `${name}-${project}-${Date.now()}`.replace(/[^a-z0-9-]+/gi, '-').toLowerCase() +} + +function parseCSV(input: string): string[][] { + const rows: string[][] = [] + let row: string[] = [] + let field = '' + let quoted = false + for (let index = 0; index < input.length; index += 1) { + const char = input[index] + if (quoted) { + if (char === '"' && input[index + 1] === '"') { + field += '"' + index += 1 + } else if (char === '"') { + quoted = false + } else { + field += char + } + continue + } + if (char === '"') { + quoted = true + } else if (char === ',') { + row.push(field) + field = '' + } else if (char === '\n') { + row.push(field.replace(/\r$/, '')) + rows.push(row) + row = [] + field = '' + } else { + field += char + } + } + if (quoted) throw new Error('unterminated quoted CSV field') + if (field || row.length) { + row.push(field.replace(/\r$/, '')) + rows.push(row) + } + return rows +} + +function asRecord(rows: string[][]): Record { + expect(rows).toHaveLength(2) + expect(rows[1]).toHaveLength(rows[0].length) + return Object.fromEntries(rows[0].map((header, index) => [header, rows[1][index]])) +} + +async function exportedCSV( + page: Page, + endpoint: string, + suggestedFilename: RegExp, + trigger: () => Promise +): Promise> { + const responsePromise = page.waitForResponse((response) => + response.request().method() === 'GET' && response.url().includes(endpoint) + ) + const downloadPromise = page.waitForEvent('download') + await trigger() + const [response, download] = await Promise.all([responsePromise, downloadPromise]) + expect(response.status()).toBe(200) + expect(response.headers()['content-type']).toMatch(/^text\/csv/) + expect(response.headers()['content-disposition']).toMatch(/^attachment; filename="[^"]+\.csv"$/) + expect(download.suggestedFilename()).toMatch(suggestedFilename) + return asRecord(parseCSV(await downloadText(download))) +} + +async function downloadText(download: Download): Promise { + const path = await download.path() + expect(path).toBeTruthy() + return readFile(path!, 'utf8') +} + +test('@e2e-record-csv-exports-001 filtered operational records export exact CSV rows', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'chromium-desktop', 'The stateful export workflow runs once.') + test.setTimeout(90_000) + + const errors = captureBrowserErrors(page) + const runID = uniqueID('record-export', testInfo.project.name) + const publicModel = `browser-export-model-${runID}` + await loginDemo(page) + const token = await loginTestPrincipal(page) + const account = await createGatewayFixture(page, token, runID, publicModel) + const workspaceKey = await adminPost<{ key: string; record: { id: string } }>(page, token, '/api-keys', { + name: `Record Export Key ${runID}`, + model_allowlist: [publicModel], + qps_limit: 10, + monthly_token_limit: 100000 + }) + + const completion = await page.request.post('/v1/chat/completions', { + headers: { Authorization: `Bearer ${workspaceKey.key}` }, + data: { model: publicModel, messages: [{ role: 'user', content: `record export ${runID}` }] } + }) + expect(completion.status()).toBe(200) + await expect(completion.json()).resolves.toMatchObject({ + choices: [{ message: { content: 'e2e-ok' } }], + usage: { prompt_tokens: 7, completion_tokens: 11 } + }) + + await page.goto('/console/usage') + await page.getByRole('button', { name: 'Records', exact: true }).click() + const usageFilters = page.locator('[data-section="usage-filters"]') + await usageFilters.getByLabel('Model').selectOption(publicModel) + await expect(page.getByRole('table')).toContainText(publicModel) + const usage = await exportedCSV(page, '/console/usage/export?', /^usage-\d+\.csv$/, () => + page.locator('.page-header').getByRole('button', { name: 'Export' }).click() + ) + expect(usage).toEqual(expect.objectContaining({ + api_key_id: workspaceKey.record.id, + model: publicModel, + upstream_model: 'upstream-model', + provider_account_id: account.id, + status: 'forwarded', + input_tokens: '7', + output_tokens: '11' + })) + + await page.goto('/console/usage/cost-allocation') + await page.getByRole('button', { name: 'By model', exact: true }).click() + const costToolbar = page.locator('.table-toolbar') + await costToolbar.getByLabel('Model').fill(publicModel) + await costToolbar.getByRole('button', { name: 'Apply' }).click() + await expect(page.getByRole('table')).toContainText(publicModel) + const cost = await exportedCSV(page, '/console/cost-allocation/export?', /^cost-allocation-\d+\.csv$/, () => + page.locator('.page-header').getByRole('button', { name: 'Export' }).click() + ) + expect(cost).toEqual(expect.objectContaining({ + dimension: 'model', + resource_id: publicModel, + resource_name: publicModel, + model: publicModel, + requests: '1', + error_requests: '0', + total_tokens: '18' + })) + + await page.goto('/console/usage/traces') + const traceToolbar = page.locator('.table-toolbar') + await traceToolbar.locator('select').first().selectOption(publicModel) + await expect(page.getByRole('table')).toContainText(publicModel) + const trace = await exportedCSV(page, '/console/gateway-traces/export?', /^gateway-traces-\d+\.csv$/, () => + page.locator('.page-header').getByRole('button', { name: 'Export' }).click() + ) + expect(trace).toEqual(expect.objectContaining({ + api_key_id: workspaceKey.record.id, + model: publicModel, + provider_account_id: account.id, + upstream_model: 'upstream-model', + status: 'forwarded', + http_status: '200', + input_tokens: '7', + output_tokens: '11' + })) + + await page.goto('/console/system/audit') + const auditToolbar = page.locator('.table-toolbar') + await auditToolbar.locator('input[placeholder="Search actor, action, resource, or summary"]').fill(workspaceKey.record.id) + await auditToolbar.getByRole('button', { name: 'Apply' }).click() + await auditToolbar.locator('select').first().selectOption('invoke') + await expect(page.getByRole('table')).toContainText(workspaceKey.record.id) + const audit = await exportedCSV(page, '/console/audit-logs/export?', /^audit-\d+\.csv$/, () => + page.locator('.page-header').getByRole('button', { name: 'Export' }).click() + ) + expect(audit).toEqual(expect.objectContaining({ + action: 'invoke', + resource_type: 'gateway_call' + })) + expect(audit.summary).toContain(`workspace_key=${workspaceKey.record.id}`) + expect(audit.summary).toContain('status=forwarded') + expect(errors).toEqual([]) +}) diff --git a/frontend/e2e/routing-policy.spec.ts b/frontend/e2e/routing-policy.spec.ts index f67c8bb..140fb74 100644 --- a/frontend/e2e/routing-policy.spec.ts +++ b/frontend/e2e/routing-policy.spec.ts @@ -1,10 +1,55 @@ import { expect, test } from '@playwright/test' import AxeBuilder from '@axe-core/playwright' -import { captureBrowserErrors, expectNoHorizontalOverflow, loginDemo } from './fixtures' +import { adminPost, captureBrowserErrors, expectNoHorizontalOverflow, loginDemo, loginTestPrincipal } from './fixtures' -test('@routing-policy enterprise routing policy workbench persists and remains responsive', async ({ page }, testInfo) => { +async function createRoutingPolicyFixture(page: Parameters[0], token: string, runID: string) { + const upstreamPort = process.env.ASTER_E2E_UPSTREAM_PORT || '19000' + const routeGroup = `policy-${runID}` + const publicModel = `policy-model-${runID}` + const createResource = async (label: string, priority: number, rate: number) => { + const provider = await adminPost<{ id: string }>(page, token, '/providers', { + name: `${label} policy provider ${runID}`, type: 'openai_compatible', + base_url: `http://127.0.0.1:${upstreamPort}/v1`, status: 'active', priority + }) + const account = await adminPost<{ id: string; secret_configured: boolean }>(page, token, '/provider-accounts', { + provider_id: provider.id, name: `${label} policy account ${runID}`, platform: 'openai_compatible', + auth_type: 'api_key', status: 'active', schedulable: true, priority, concurrency: 4, + rate_multiplier: rate, models: [`${label.toLowerCase()}-upstream`], group_ids: [], secret: `${label}-synthetic-secret` + }) + expect(account.secret_configured).toBe(true) + return { provider, account, upstreamModel: `${label.toLowerCase()}-upstream`, priority } + } + const expensive = await createResource('Priority', 10, 2) + const cheap = await createResource('Cheap', 20, 0.5) + const model = await adminPost<{ id: string }>(page, token, '/gateway-models', { + model_id: publicModel, name: `Policy model ${runID}`, description: 'Routing policy browser evidence', + modality: 'chat', default_route_group: routeGroup, status: 'active' + }) + for (const resource of [expensive, cheap]) { + await adminPost(page, token, '/model-routes', { + gateway_model_id: model.id, route_group: routeGroup, provider_account_id: resource.account.id, + upstream_model: resource.upstreamModel, upstream_format: 'openai_chat', priority: resource.priority, weight: 100, status: 'active' + }) + } + const createPrice = (resource: typeof cheap, input: number, output: number) => adminPost(page, token, '/procurement-prices', { + provider_id: resource.provider.id, provider_account_id: resource.account.id, upstream_model: resource.upstreamModel, + protocol: 'openai_chat_completions', currency: 'USD', uncached_input_micros_per_1m_tokens: input, + cache_read_micros_per_1m_tokens: 0, cache_write_5m_micros_per_1m_tokens: input, + cache_write_1h_micros_per_1m_tokens: input, output_micros_per_1m_tokens: output, request_micros: 0, + reference_input_micros_per_1m_tokens: input, reference_output_micros_per_1m_tokens: output, + quoted_multiplier: 1, recharge_multiplier: 1, source_kind: 'synthetic_e2e', confidence: 'exact', status: 'active' + }) + await createPrice(expensive, 500_000, 500_000) + await createPrice(cheap, 100_000, 100_000) + return { routeGroup, publicModel, expensive, cheap } +} + +test('@e2e-routing-policy-001 enterprise routing policy workbench persists and remains responsive', async ({ page }, testInfo) => { const errors = captureBrowserErrors(page) await loginDemo(page) + const token = await loginTestPrincipal(page) + const runID = `${testInfo.project.name}-${Date.now()}`.replace(/[^a-z0-9-]+/gi, '-').toLowerCase() + const fixture = await createRoutingPolicyFixture(page, token, runID) await page.goto('/console/policies/routing') await expect(page.getByRole('heading', { level: 1, name: 'Routing Policies' })).toBeVisible() @@ -13,20 +58,83 @@ test('@routing-policy enterprise routing policy workbench persists and remains r await expect(page.getByRole('heading', { level: 2, name: 'How one request is decided' })).toBeVisible() await expectNoHorizontalOverflow(page) - const runID = testInfo.project.name + const policyName = `Enterprise production routing ${runID}` + const updatedDescription = `Cost-optimized production routing ${runID}` await page.getByRole('button', { name: 'New policy' }).click() - await page.getByLabel('Policy name').fill('Enterprise production routing') - await page.getByLabel('Route group').fill(`production-${runID}`) + await page.getByLabel('Policy name').fill(policyName) + await page.getByLabel('Route group').fill(fixture.routeGroup) await page.getByRole('radio', { name: /Stability first/ }).click() + const createResponsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' && new URL(response.url()).pathname === '/api/v1/console/routing-policies' + ) await page.getByRole('button', { name: 'Save policy' }).click() + const createResponse = await createResponsePromise + expect(createResponse.status()).toBe(200) + const created = await createResponse.json() as { data: { id: string; version: number; description: string; strategy: { preset: string } } } + expect(created.data.id).not.toBe('') + expect(created.data.version).toBe(1) + expect(created.data.strategy.preset).toBe('stability') await expect(page.getByText('Routing policy created')).toBeVisible() await page.reload() - await expect(page.getByText('Enterprise production routing', { exact: true }).first()).toBeVisible() - await expect(page.getByText(`production-${runID}`, { exact: true }).first()).toBeVisible() + await expect(page.getByText(policyName, { exact: true }).first()).toBeVisible() + await expect(page.getByText(fixture.routeGroup, { exact: true }).first()).toBeVisible() + await expect(page.getByText('v1 ·', { exact: false }).filter({ hasText: created.data.description }).first()).toBeVisible() + + await page.getByLabel('Description').fill(updatedDescription) + await page.getByRole('radio', { name: /Cost first/ }).click() + const updateResponsePromise = page.waitForResponse((response) => + response.request().method() === 'PUT' && new URL(response.url()).pathname === `/api/v1/console/routing-policies/${created.data.id}` + ) + await page.getByRole('button', { name: 'Save policy' }).click() + const updateResponse = await updateResponsePromise + expect(updateResponse.status()).toBe(200) + const updated = await updateResponse.json() as { data: { id: string; version: number; description: string; strategy: { preset: string } } } + expect(updated.data).toMatchObject({ + id: created.data.id, + version: 2, + description: updatedDescription, + strategy: { preset: 'cost' } + }) + await expect(page.getByText('Routing policy updated')).toBeVisible() + + await page.reload() + const policyRow = page.getByRole('row').filter({ hasText: policyName }) + await expect(policyRow).toContainText(`v2 · ${updatedDescription}`) + await expect(policyRow).toContainText('Cost first') + await expect(page.getByLabel('Description')).toHaveValue(updatedDescription) + await expect(page.getByRole('radio', { name: /Cost first/ })).toHaveAttribute('aria-checked', 'true') await expectNoHorizontalOverflow(page) await page.screenshot({ path: testInfo.outputPath('routing-policy-light-en.png'), fullPage: true }) + await page.goto('/console/model-services/simulator') + await expect(page.getByLabel('Client protocol').locator('option')).toHaveCount(13) + await page.getByLabel('Requested model').selectOption(fixture.publicModel) + const simulationResponsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' && new URL(response.url()).pathname === '/api/v1/console/gateway-simulator' + ) + await page.getByRole('button', { name: 'Run simulation' }).click() + const simulationResponse = await simulationResponsePromise + expect(simulationResponse.status()).toBe(200) + const simulation = await simulationResponse.json() as { data: { routing_policy_id: string; routing_policy_version: number; routing_policy_preset: string; candidates: Array<{ provider_account_id: string; eligible: boolean; reason: string }> } } + expect(simulation.data).toMatchObject({ + routing_policy_id: created.data.id, + routing_policy_version: 2, + routing_policy_preset: 'cost' + }) + expect(simulation.data.candidates).toEqual(expect.arrayContaining([ + expect.objectContaining({ provider_account_id: fixture.cheap.account.id, eligible: true, reason: '' }), + expect.objectContaining({ provider_account_id: fixture.expensive.account.id, eligible: false, reason: 'routing_policy_relative_price_exceeded' }) + ])) + await expect(page.locator('.simulation-flow')).toContainText('Cost first · v2') + await expect(page.getByRole('table')).toContainText(fixture.cheap.upstreamModel) + await expect(page.getByRole('table')).toContainText('Cheapest-price multiple exceeded') + await page.reload() + await page.getByLabel('Requested model').selectOption(fixture.publicModel) + await page.getByRole('button', { name: 'Run simulation' }).click() + await expect(page.locator('.simulation-flow')).toContainText('Cost first · v2') + await expectNoHorizontalOverflow(page) + await page.getByLabel('Language').selectOption('zh-CN') if ((page.viewportSize()?.width || 0) <= 640) { await page.getByRole('button', { name: '打开导航' }).click() @@ -37,8 +145,9 @@ test('@routing-policy enterprise routing policy workbench persists and remains r } await expect(page.locator('html')).toHaveAttribute('lang', 'zh-CN') await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark') - await expect(page.getByRole('heading', { level: 1, name: '路由策略' })).toBeVisible() - await expect(page.getByRole('heading', { level: 2, name: '一次请求如何决策' })).toBeVisible() + await expect(page.getByRole('heading', { level: 1, name: '路由模拟器' })).toBeVisible() + await expect(page.locator('.simulation-flow')).toContainText('成本优先 · v2') + await expect(page.getByRole('table')).toContainText('超过相对最低价上限') await expectNoHorizontalOverflow(page) await page.screenshot({ path: testInfo.outputPath('routing-policy-dark-zh.png'), fullPage: true }) if (testInfo.project.name === 'chromium-desktop') { diff --git a/frontend/e2e/session.spec.ts b/frontend/e2e/session.spec.ts index a06a914..cd68180 100644 --- a/frontend/e2e/session.spec.ts +++ b/frontend/e2e/session.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from '@playwright/test' import { envelope as data, loginDemo, loginTestPrincipal } from './fixtures' -test('@smoke @j02 logout immediately revokes a dedicated user session', async ({ page }, testInfo) => { +test('@e2e-session-001 logout immediately revokes a dedicated user session', async ({ page }, testInfo) => { test.skip(testInfo.project.name !== 'chromium-desktop', 'The session contract is viewport-independent and runs once on desktop.') await loginDemo(page) @@ -44,7 +44,7 @@ test('@smoke @j02 logout immediately revokes a dedicated user session', async ({ })).status()).toBe(200) }) -test('@smoke @j02 role changes and disabling immediately revoke existing user sessions', async ({ page }, testInfo) => { +test('@e2e-session-002 role changes and disabling immediately revoke existing user sessions', async ({ page }, testInfo) => { test.skip(testInfo.project.name !== 'chromium-desktop', 'The session contract is viewport-independent and runs once on desktop.') await loginDemo(page) diff --git a/frontend/e2e/settings.spec.ts b/frontend/e2e/settings.spec.ts new file mode 100644 index 0000000..bb547c8 --- /dev/null +++ b/frontend/e2e/settings.spec.ts @@ -0,0 +1,83 @@ +import { expect, test } from '@playwright/test' +import { captureBrowserErrors, envelope, expectNoHorizontalOverflow, loginDemo } from './fixtures' + +type RetentionCleanupResult = { + before: string + usage_records: number + gateway_traces: number + alert_events: number + audit_logs: number +} + +test('@e2e-settings-001 admin settings persist and retention cleanup returns deletion evidence', async ({ page }, testInfo) => { + const errors = captureBrowserErrors(page) + await loginDemo(page) + await page.goto('/console/system') + await expect(page.getByRole('heading', { level: 1, name: 'System Settings' })).toBeVisible() + await expectNoHorizontalOverflow(page) + + if (testInfo.project.name !== 'chromium-desktop') { + expect(errors).toEqual([]) + return + } + + const token = await page.evaluate(() => localStorage.getItem('asterrouter_admin_token')) + expect(token).toBeTruthy() + const headers = { Authorization: `Bearer ${token}` } + const original = await envelope>(await page.request.get('/api/v1/console/settings', { headers })) + const originalRetentionDays = Number(original.data_retention_days) + const originalLoggingMode = String(original.prompt_logging_mode) + const changedRetentionDays = originalRetentionDays === 31 ? 32 : 31 + const changedLoggingMode = originalLoggingMode === 'metadata_only' ? 'disabled' : 'metadata_only' + + try { + await page.getByRole('tab', { name: 'Data backup' }).click() + const governance = page.locator('.panel').filter({ has: page.getByRole('heading', { name: 'Governance' }) }) + const retentionDays = governance.locator('input[type="number"]') + const loggingMode = governance.locator('select').first() + await expect(retentionDays).toHaveValue(String(originalRetentionDays)) + await expect(loggingMode).toHaveValue(originalLoggingMode) + + await retentionDays.fill(String(changedRetentionDays)) + await loggingMode.selectOption(changedLoggingMode) + const savedResponse = page.waitForResponse((response) => response.url().endsWith('/api/v1/console/settings') && response.request().method() === 'PUT') + await page.getByRole('button', { name: 'Save settings' }).click() + expect((await savedResponse).status()).toBe(200) + await expect(page.getByText('Settings saved', { exact: true })).toBeVisible() + + await page.reload() + await page.getByRole('tab', { name: 'Data backup' }).click() + await expect(retentionDays).toHaveValue(String(changedRetentionDays)) + await expect(loggingMode).toHaveValue(changedLoggingMode) + + page.once('dialog', (dialog) => dialog.accept()) + const cleanupResponse = page.waitForResponse((response) => response.url().endsWith('/api/v1/console/settings/retention/cleanup') && response.request().method() === 'POST') + await governance.getByRole('button', { name: 'Run data cleanup now' }).click() + const cleanup = await envelope(await cleanupResponse) + expect(Number.isNaN(Date.parse(cleanup.before))).toBe(false) + const deleted = cleanup.usage_records + cleanup.gateway_traces + cleanup.alert_events + cleanup.audit_logs + expect(deleted).toBeGreaterThanOrEqual(0) + await expect(page.getByText(`Data cleanup completed. ${deleted} records were deleted.`)).toBeVisible() + + await retentionDays.fill(String(originalRetentionDays)) + await loggingMode.selectOption(originalLoggingMode) + const restoredResponse = page.waitForResponse((response) => response.url().endsWith('/api/v1/console/settings') && response.request().method() === 'PUT') + await page.getByRole('button', { name: 'Save settings' }).click() + expect((await restoredResponse).status()).toBe(200) + await page.reload() + await page.getByRole('tab', { name: 'Data backup' }).click() + await expect(retentionDays).toHaveValue(String(originalRetentionDays)) + await expect(loggingMode).toHaveValue(originalLoggingMode) + } finally { + await page.goto('/console/system') + await page.getByRole('tab', { name: 'Data backup' }).click() + const governance = page.locator('.panel').filter({ has: page.getByRole('heading', { name: 'Governance' }) }) + await governance.locator('input[type="number"]').fill(String(originalRetentionDays)) + await governance.locator('select').first().selectOption(originalLoggingMode) + const restoredResponse = page.waitForResponse((response) => response.url().endsWith('/api/v1/console/settings') && response.request().method() === 'PUT') + await page.getByRole('button', { name: 'Save settings' }).click() + expect((await restoredResponse).status()).toBe(200) + } + + expect(errors).toEqual([]) +}) diff --git a/frontend/e2e/smoke.spec.ts b/frontend/e2e/smoke.spec.ts index 06e792e..3b78500 100644 --- a/frontend/e2e/smoke.spec.ts +++ b/frontend/e2e/smoke.spec.ts @@ -9,7 +9,7 @@ const expectedDemoMode = process.env.ASTER_E2E_EXPECT_DEMO_MODE === undefined : process.env.ASTER_E2E_EXPECT_DEMO_MODE === 'true' const managementEntry = { path: '/console/workbench', heading: 'Overview' } -test('@smoke @enterprise-smoke backend health and public settings are ready', async ({ request }) => { +test('@e2e-platform-001 backend health and public settings are ready', async ({ request }) => { const health = await request.get(`${backendURL}/health`) expect(health.status()).toBe(200) await expect(health.json()).resolves.toMatchObject({ data: { status: 'ok' } }) @@ -24,7 +24,7 @@ test('@smoke @enterprise-smoke backend health and public settings are ready', as expect(settingsBody).toMatchObject({ data: { demo_mode: expectedDemoMode, setup_completed: true } }) }) -test('@smoke @enterprise-smoke anonymous protected navigation redirects to login', async ({ page }) => { +test('@e2e-authz-001 anonymous protected navigation redirects to login', async ({ page }) => { const errors = captureBrowserErrors(page) const protectedPath = `${managementEntry.path}?status=active` await page.goto(protectedPath) @@ -42,18 +42,17 @@ test('@smoke @enterprise-smoke anonymous protected navigation redirects to login expect(errors).toEqual([]) }) -test('@smoke @enterprise-smoke login persists and opens the enterprise management console', async ({ page }) => { +test('@e2e-login-001 login persists and opens the enterprise management console', async ({ page }) => { const errors = captureBrowserErrors(page) await loginDemo(page) await page.reload() - await page.waitForLoadState('networkidle') await expect(page).toHaveURL(new RegExp(`${managementEntry.path}$`)) await expect(page.getByRole('heading', { level: 1, name: managementEntry.heading })).toBeVisible() expect(errors).toEqual([]) }) -test('@enterprise-smoke application credential editor exposes enterprise ownership only', async ({ page }) => { +test('@e2e-credential-boundary-001 application credential editor exposes enterprise ownership only', async ({ page }) => { const errors = captureBrowserErrors(page) await loginDemo(page) await page.goto('/console/applications/credentials') @@ -65,7 +64,7 @@ test('@enterprise-smoke application credential editor exposes enterprise ownersh expect(errors).toEqual([]) }) -test('@smoke @enterprise-smoke application and policy lists remain usable', async ({ page }) => { +test('@e2e-list-contract-001 application and policy lists remain usable', async ({ page }) => { const errors = captureBrowserErrors(page) await loginDemo(page) @@ -81,7 +80,7 @@ test('@smoke @enterprise-smoke application and policy lists remain usable', asyn expect(errors).toEqual([]) }) -test('@smoke @enterprise-smoke locale, theme, and responsive layout remain usable', async ({ page }) => { +test('@e2e-preferences-001 locale, theme, and responsive layout remain usable', async ({ page }) => { const errors = captureBrowserErrors(page) await loginDemo(page) diff --git a/frontend/e2e/surfaces.spec.ts b/frontend/e2e/surfaces.spec.ts new file mode 100644 index 0000000..9975a93 --- /dev/null +++ b/frontend/e2e/surfaces.spec.ts @@ -0,0 +1,152 @@ +import { readFileSync } from 'node:fs' +import { expect, test, type Page } from '@playwright/test' +import { + captureBrowserErrors, + envelope, + expectNoHorizontalOverflow, + loginDemo, + loginTestPrincipal, + registerUsers +} from './fixtures' + +type Surface = { route: string; path?: string; target?: 'heading' | 'alert' } +type RegistryRoute = { path: string; surface: string } + +const registry = JSON.parse(readFileSync(new URL('../../docs/test/v1/scenario-registry.json', import.meta.url), 'utf8')) as { routes: RegistryRoute[] } +const surfaceOverrides: Record> = { + '/': { target: 'heading' }, + '/login': { target: 'heading' }, + '/register': { target: 'heading' }, + '/forgot-password': { target: 'heading' }, + '/resend-verification': { target: 'heading' }, + '/reset-password': { path: '/reset-password?token=surface-contract', target: 'heading' }, + '/verify-email': { target: 'alert' }, + '/legal/:slug': { path: '/legal/surface-contract-missing', target: 'alert' }, + '/console/system/plugins/:pluginId/workbench': { path: '/console/system/plugins/surface-contract-missing/workbench' } +} + +function surfacesFor(scenarioID: string): Surface[] { + return registry.routes + .filter((route) => route.surface === scenarioID) + .map((route) => ({ route: route.path, ...surfaceOverrides[route.path] })) +} + +const publicSurfaces = surfacesFor('@e2e-surface-public-001') +const consoleSurfaces = surfacesFor('@e2e-surface-console-001') +const portalSurfaces = surfacesFor('@e2e-surface-portal-001') + +function escapedPath(path: string): RegExp { + return new RegExp(`${path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`) +} + +async function verifySurface(page: Page, surface: Surface): Promise { + const path = surface.path || surface.route + await page.goto(path) + expect(new URL(page.url()).pathname).toBe(new URL(path, page.url()).pathname) + if (surface.target === 'alert') await expect(page.getByRole('alert')).toBeVisible() + else if (surface.target === 'heading') await expect(page.getByRole('heading').first()).toBeVisible() + else await expect(page.getByRole('heading', { level: 1 })).toBeVisible() + await expectNoHorizontalOverflow(page) +} + +async function loginThroughPage(page: Page, email: string, password: string): Promise { + await page.goto('/login') + await page.getByLabel('Username').fill(email) + await page.locator('#password').fill(password) + await page.getByRole('button', { name: 'Sign in', exact: true }).click() + await expect(page).toHaveURL(/\/portal\/overview$/) +} + +test('@e2e-surface-public-001 public routes remain reachable and correctly projected', async ({ page }) => { + const errors = captureBrowserErrors(page) + for (const surface of publicSurfaces) { + await test.step(surface.route, () => verifySurface(page, surface)) + } + expect(errors.filter((error) => !error.includes('404 (Not Found)'))).toEqual([]) +}) + +test('@e2e-surface-console-001 console routes remain reachable across supported viewports', async ({ page }) => { + test.setTimeout(90_000) + const errors = captureBrowserErrors(page) + await loginDemo(page) + const menuButton = page.getByRole('button', { name: 'Open navigation' }) + if (await menuButton.isVisible()) await menuButton.click() + const pluginCenterLink = page.getByRole('link', { name: 'Plugin Center', exact: true }) + await expect(pluginCenterLink).toBeVisible() + await pluginCenterLink.click() + await expect(page).toHaveURL(escapedPath('/console/system/plugins')) + await expect(page.getByRole('heading', { level: 1, name: 'Plugin Center' })).toBeVisible() + for (const surface of consoleSurfaces) { + await test.step(surface.route, async () => { + const previousErrorCount = errors.length + const missingWorkbenchResponse = surface.route.includes(':pluginId') + ? page.waitForResponse((response) => { + const url = new URL(response.url()) + return response.request().method() === 'GET' && + url.pathname === '/api/v1/console/plugins/surface-contract-missing/frontend/workbench' + }) + : undefined + await verifySurface(page, surface) + if (surface.route.includes(':pluginId')) { + expect((await missingWorkbenchResponse!).status()).toBe(404) + await expect(page.getByRole('alert')).toContainText('not available') + const expectedErrors = errors.splice(previousErrorCount) + for (const error of expectedErrors) { + expect(error).toBe('console: Failed to load resource: the server responded with a status of 404 (Not Found)') + } + } + }) + } + expect(errors).toEqual([]) +}) + +test('@e2e-surface-portal-001 portal routes remain reachable across supported viewports', async ({ page }, testInfo) => { + test.setTimeout(60_000) + const errors = captureBrowserErrors(page) + await loginDemo(page) + const adminToken = await loginTestPrincipal(page) + const password = 'synthetic-password-123' + const [developer] = await registerUsers(page, adminToken, [{ + email: `surface-${testInfo.project.name}-${Date.now()}@example.test`, + password, + displayName: 'Portal Surface User' + }]) + await page.context().clearCookies() + await page.evaluate(() => localStorage.clear()) + await loginThroughPage(page, developer.email, password) + + for (const surface of portalSurfaces) { + await test.step(surface.route, () => verifySurface(page, surface)) + } + await page.goto('/console/workbench') + await expect(page).toHaveURL(/\/portal\/overview$/) + expect(errors).toEqual([]) +}) + +test('@e2e-legal-001 legal documents are public and unknown slugs fail visibly', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'chromium-desktop', 'The legal document lifecycle is viewport-independent; public surface coverage runs in every viewport.') + + await loginDemo(page) + const token = await loginTestPrincipal(page) + const headers = { Authorization: `Bearer ${token}` } + const settings = await envelope>(await page.request.get('/api/v1/console/settings', { headers })) + const slug = `browser-terms-${Date.now()}` + const document = { id: slug, name: 'Browser Terms', slug, content: 'Synthetic enterprise terms.' } + try { + await envelope(await page.request.put('/api/v1/console/settings', { + headers, + data: { ...settings, legal_documents: [...(settings.legal_documents as unknown[] || []), document] } + })) + await page.context().clearCookies() + await page.evaluate(() => localStorage.clear()) + await page.goto(`/legal/${slug}`) + await expect(page).toHaveURL(escapedPath(`/legal/${slug}`)) + await expect(page.getByRole('heading', { level: 1, name: document.name })).toBeVisible() + await expect(page.getByText(document.content)).toBeVisible() + + await page.goto('/legal/unknown-browser-document') + await expect(page.getByRole('alert')).toContainText(/not available/i) + } finally { + await envelope(await page.request.put('/api/v1/console/settings', { headers, data: settings })) + } +}) diff --git a/frontend/e2e/system-maintenance.spec.ts b/frontend/e2e/system-maintenance.spec.ts new file mode 100644 index 0000000..74a244d --- /dev/null +++ b/frontend/e2e/system-maintenance.spec.ts @@ -0,0 +1,434 @@ +import { gunzipSync } from 'node:zlib' +import { createHash } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { expect, test } from '@playwright/test' +import { captureBrowserErrors, envelope, loginDemo, loginTestPrincipal } from './fixtures' + +type SystemArchiveInfo = { + id: string + path: string + size_bytes: number + created_at: string +} + +type Diagnostic = { + schema_version: string + created_at: string + version: string + build_type: string + platform: string + database_configured: boolean + details: { + settings_health: string + control_plane_health: string + settings: { + default_locale: string + enabled_locales: string[] + service_center_mode: string + storage_mode: string + demo_mode: boolean + } + } +} + +type AuditEvent = { action: string; resource_type: string; resource_id: string } +type S3Request = { method: string; path: string; sigv4_valid: boolean; sigv4_errors: string[]; outcome: string } + +type Application = { id: string; name: string; slug: string } + +type SystemApplyResult = { + message: string + operation_id: string + need_restart: boolean + current_version: string + latest_version: string +} + +function sha256(value: Buffer): string { + return createHash('sha256').update(value).digest('hex') +} + +async function waitForSystemVersion( + page: import('@playwright/test').Page, + headers: Record, + version: string +): Promise { + await expect.poll(async () => { + try { + const response = await page.request.get('/api/v1/console/system/version', { headers, timeout: 2_000 }) + if (response.status() !== 200) return '' + return ((await response.json()).data || {}).version || '' + } catch { + return '' + } + }, { timeout: 30_000, intervals: [100, 200, 500, 1_000] }).toBe(version) +} + +async function waitForRuntimeGeneration(path: string, count: number): Promise { + await expect.poll(async () => { + try { + return (await readFile(path, 'utf8')).split('\n').filter((line) => line.startsWith('start ')).length + } catch { + return 0 + } + }, { timeout: 30_000, intervals: [100, 200, 500, 1_000] }).toBeGreaterThanOrEqual(count) +} + +async function createApplicationMarker(page: import('@playwright/test').Page, headers: Record, kind: string): Promise { + const timestamp = Date.now() + return envelope(await page.request.post('/api/v1/applications', { + headers, + data: { + name: `${kind} restore marker ${timestamp}`, + slug: `${kind.toLowerCase()}-restore-marker-${timestamp}`, + entitlement_reference: '', + concurrency_limit: 1, + status: 'active' + } + })) +} + +async function applicationExists(page: import('@playwright/test').Page, headers: Record, id: string): Promise { + const applications = await envelope(await page.request.get('/api/v1/applications', { headers })) + return applications.some((item) => item.id === id) +} + +function readTarFile(archive: Buffer, filename: string): Buffer { + const tar = gunzipSync(archive) + for (let offset = 0; offset + 512 <= tar.length;) { + const header = tar.subarray(offset, offset + 512) + if (header.every((byte) => byte === 0)) break + const name = header.subarray(0, 100).toString('utf8').replace(/\0.*$/, '') + const rawSize = header.subarray(124, 136).toString('ascii').replace(/\0.*$/, '').trim() + const size = Number.parseInt(rawSize || '0', 8) + expect(Number.isSafeInteger(size), `invalid tar size for ${name}`).toBe(true) + const bodyStart = offset + 512 + if (name === filename) return tar.subarray(bodyStart, bodyStart + size) + offset = bodyStart + Math.ceil(size / 512) * 512 + } + throw new Error(`${filename} is missing from diagnostic archive`) +} + +test('@e2e-system-diagnostic-001 diagnostic bundle is created, downloaded, redacted, and audited', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'chromium-desktop', 'The stateful archive workflow runs once.') + test.setTimeout(60_000) + + const errors = captureBrowserErrors(page) + await loginDemo(page) + const token = await loginTestPrincipal(page) + await page.goto('/console/system') + await page.getByRole('tab', { name: 'Data backup' }).click() + + const panel = page.locator('.panel').filter({ has: page.getByRole('heading', { name: 'Backup & diagnostics' }) }) + const createResponse = page.waitForResponse((response) => + response.request().method() === 'POST' && response.url().endsWith('/api/v1/console/system/diagnostics') + ) + const downloadResponse = page.waitForResponse((response) => + response.request().method() === 'GET' && /\/api\/v1\/console\/system\/diagnostics\/[^/]+\/download$/.test(response.url()) + ) + const downloadEvent = page.waitForEvent('download') + await panel.getByRole('button', { name: 'Create diagnostic bundle' }).click() + + const bundle = await envelope(await createResponse) + expect(bundle.id).toMatch(/^asterrouter-diagnostic-[A-Za-z0-9_-]+$/) + expect(bundle.path).toBe(`${bundle.id}.tar.gz`) + expect(bundle.size_bytes).toBeGreaterThan(0) + expect(Date.parse(bundle.created_at)).not.toBeNaN() + + const downloaded = await downloadEvent + expect((await downloadResponse).status()).toBe(200) + expect(downloaded.suggestedFilename()).toBe(bundle.path) + const downloadPath = testInfo.outputPath(bundle.path) + await downloaded.saveAs(downloadPath) + const archive = await readFile(downloadPath) + expect(archive.length).toBe(bundle.size_bytes) + + const diagnosticBytes = readTarFile(archive, 'diagnostic.json') + const diagnostic = JSON.parse(diagnosticBytes.toString('utf8')) as Diagnostic + expect(diagnostic).toEqual(expect.objectContaining({ + schema_version: 'asterrouter.diagnostic.v1', + version: expect.any(String), + build_type: expect.any(String), + platform: expect.stringMatching(/^[^/]+\/[^/]+$/), + database_configured: expect.any(Boolean), + details: expect.objectContaining({ + settings_health: 'ok', + control_plane_health: 'ok', + settings: expect.objectContaining({ + default_locale: expect.any(String), + enabled_locales: expect.any(Array), + service_center_mode: expect.any(String), + storage_mode: expect.any(String), + demo_mode: expect.any(Boolean) + }) + }) + })) + expect(Date.parse(diagnostic.created_at)).not.toBeNaN() + const diagnosticText = diagnosticBytes.toString('utf8') + expect(diagnosticText).not.toContain('postgres://') + expect(diagnosticText).not.toContain('asterrouter-e2e-test-secret') + + await expect(page.getByText('Diagnostic bundle created')).toBeVisible() + const audit = await envelope(await page.request.get('/api/v1/console/audit-logs?action=diagnostic&resource_type=system&limit=200', { + headers: { Authorization: `Bearer ${token}` } + })) + expect(audit).toContainEqual(expect.objectContaining({ + action: 'diagnostic', + resource_type: 'system', + resource_id: bundle.id + })) + expect(errors).toEqual([]) +}) + +test('@e2e-system-update-001 source build maintenance actions fail closed with audit evidence', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'chromium-desktop', 'The maintenance command contract runs once on desktop.') + test.setTimeout(60_000) + + const errors = captureBrowserErrors(page) + await loginDemo(page) + const token = await loginTestPrincipal(page) + await page.goto('/console/system') + await page.getByRole('tab', { name: 'Data backup' }).click() + + const updatePanel = page.locator('.panel').filter({ has: page.getByRole('heading', { name: 'System Update' }) }) + const checkResponse = page.waitForResponse((response) => + response.request().method() === 'GET' && response.url().includes('/api/v1/console/system/check-updates') + ) + await updatePanel.getByRole('button', { name: 'Check updates', exact: true }).click() + expect((await checkResponse).status()).toBe(200) + await expect(page.getByText('Update check completed', { exact: true })).toBeVisible() + await expect(updatePanel.getByText('Update available', { exact: true })).toBeVisible() + await expect(updatePanel.getByText('Signed catalog', { exact: true })).toBeVisible() + await expect(updatePanel.getByText('Signed metadata', { exact: true })).toBeVisible() + await expect(updatePanel.getByLabel('Latest version')).toHaveValue('0.99.0') + await expect(updatePanel.getByText(/not produced as a release artifact.*manual update/i)).toBeVisible() + + for (const action of [ + { button: 'One-click update', endpoint: '/api/v1/console/system/update', status: 409, message: /one-click update is not supported.*download the matching release artifact/i }, + { button: 'Rollback', endpoint: '/api/v1/console/system/rollback', status: 500, message: /no rollback backup found/i }, + { button: 'Restart', endpoint: '/api/v1/console/system/restart', status: 409, message: /service restart is not enabled.*restart the service manually/i } + ]) { + const responsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' && response.url().endsWith(action.endpoint) + ) + await updatePanel.getByRole('button', { name: action.button, exact: true }).click() + expect((await responsePromise).status()).toBe(action.status) + await expect(page.getByText(action.message)).toBeVisible() + } + + const audit = await envelope(await page.request.get('/api/v1/console/audit-logs?resource_type=system&limit=200', { + headers: { Authorization: `Bearer ${token}` } + })) + expect(audit).toEqual(expect.arrayContaining([ + expect.objectContaining({ action: 'check_update', resource_type: 'system' }), + expect.objectContaining({ action: 'update_failed', resource_type: 'system' }), + expect.objectContaining({ action: 'rollback_failed', resource_type: 'system' }), + expect.objectContaining({ action: 'restart_rejected', resource_type: 'system' }) + ])) + expect(errors).toEqual([ + 'console: Failed to load resource: the server responded with a status of 409 (Conflict)', + 'console: Failed to load resource: the server responded with a status of 500 (Internal Server Error)', + 'console: Failed to load resource: the server responded with a status of 409 (Conflict)' + ]) +}) + +test('@e2e-system-update-lifecycle-001 release binary updates, restarts, rolls back, and restarts through the browser', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'chromium-desktop', 'The managed release lifecycle runs once on desktop.') + test.skip(process.env.ASTER_E2E_SYSTEM_UPDATE_LIFECYCLE !== '1', 'The lifecycle requires dedicated release binaries, PostgreSQL, and a supervisor.') + test.setTimeout(120_000) + + const runtimeBinary = process.env.ASTER_E2E_SYSTEM_UPDATE_RUNTIME_BINARY || '' + const generationFile = process.env.ASTER_E2E_SYSTEM_UPDATE_GENERATION_FILE || '' + const oldSHA = process.env.ASTER_E2E_SYSTEM_UPDATE_OLD_SHA256 || '' + const newSHA = process.env.ASTER_E2E_SYSTEM_UPDATE_NEW_SHA256 || '' + const oldVersion = process.env.ASTER_E2E_SYSTEM_UPDATE_OLD_VERSION || '0.24.0' + const newVersion = process.env.ASTER_E2E_SYSTEM_UPDATE_NEW_VERSION || '0.99.0' + const officialURL = process.env.ASTER_E2E_OFFICIAL_URL || '' + expect(runtimeBinary).not.toBe('') + expect(generationFile).not.toBe('') + expect(oldSHA).toMatch(/^[a-f0-9]{64}$/) + expect(newSHA).toMatch(/^[a-f0-9]{64}$/) + + const errors = captureBrowserErrors(page) + await loginDemo(page) + const token = await loginTestPrincipal(page) + const headers = { Authorization: `Bearer ${token}` } + await waitForSystemVersion(page, headers, oldVersion) + expect(sha256(await readFile(runtimeBinary))).toBe(oldSHA) + await waitForRuntimeGeneration(generationFile, 1) + + await page.goto('/console/system') + await page.getByRole('tab', { name: 'Data backup' }).click() + let updatePanel = page.locator('.panel').filter({ has: page.getByRole('heading', { name: 'System Update' }) }) + const checkResponse = page.waitForResponse((response) => + response.request().method() === 'GET' && response.url().includes('/api/v1/console/system/check-updates') + ) + await updatePanel.getByRole('button', { name: 'Check updates', exact: true }).click() + expect((await checkResponse).status()).toBe(200) + await expect(updatePanel.getByText('Update available', { exact: true })).toBeVisible() + await expect(updatePanel.getByText('Signed catalog', { exact: true })).toBeVisible() + await expect(updatePanel.getByText('Signed metadata', { exact: true })).toBeVisible() + await expect(updatePanel.getByLabel('Latest version')).toHaveValue(newVersion) + + const updateResponse = page.waitForResponse((response) => + response.request().method() === 'POST' && response.url().endsWith('/api/v1/console/system/update') + ) + await updatePanel.getByRole('button', { name: 'One-click update', exact: true }).click() + const updateResult = await envelope(await updateResponse) + expect(updateResult).toMatchObject({ need_restart: true, current_version: oldVersion, latest_version: newVersion }) + await expect(page.getByText('Update completed. Restart the service to run the new version.', { exact: true })).toBeVisible() + expect(sha256(await readFile(runtimeBinary))).toBe(newSHA) + expect(sha256(await readFile(`${runtimeBinary}.backup`))).toBe(oldSHA) + + const firstRestartResponse = page.waitForResponse((response) => + response.request().method() === 'POST' && response.url().endsWith('/api/v1/console/system/restart') + ) + await updatePanel.getByRole('button', { name: 'Restart', exact: true }).click() + const firstRestart = await envelope(await firstRestartResponse) + expect(firstRestart.message).toBe('Service restart initiated.') + await waitForRuntimeGeneration(generationFile, 2) + await waitForSystemVersion(page, headers, newVersion) + + await page.goto('/console/system') + await page.getByRole('tab', { name: 'Data backup' }).click() + updatePanel = page.locator('.panel').filter({ has: page.getByRole('heading', { name: 'System Update' }) }) + const rollbackResponse = page.waitForResponse((response) => + response.request().method() === 'POST' && response.url().endsWith('/api/v1/console/system/rollback') + ) + await updatePanel.getByRole('button', { name: 'Rollback', exact: true }).click() + const rollbackResult = await envelope(await rollbackResponse) + expect(rollbackResult.need_restart).toBe(true) + await expect(page.getByText('Rollback completed. Restart the service to run the restored version.', { exact: true })).toBeVisible() + expect(sha256(await readFile(runtimeBinary))).toBe(oldSHA) + await expect(readFile(`${runtimeBinary}.backup`)).rejects.toMatchObject({ code: 'ENOENT' }) + + const secondRestartResponse = page.waitForResponse((response) => + response.request().method() === 'POST' && response.url().endsWith('/api/v1/console/system/restart') + ) + await updatePanel.getByRole('button', { name: 'Restart', exact: true }).click() + const secondRestart = await envelope(await secondRestartResponse) + expect(secondRestart.message).toBe('Service restart initiated.') + await waitForRuntimeGeneration(generationFile, 3) + await waitForSystemVersion(page, headers, oldVersion) + + const audit = await envelope(await page.request.get('/api/v1/console/audit-logs?resource_type=system&limit=200', { headers })) + expect(audit).toEqual(expect.arrayContaining([ + expect.objectContaining({ action: 'check_update', resource_type: 'system' }), + expect.objectContaining({ action: 'update', resource_type: 'system' }), + expect.objectContaining({ action: 'rollback', resource_type: 'system' }) + ])) + expect(audit.filter((event) => event.action === 'restart' && event.resource_type === 'system')).toHaveLength(2) + + const officialRequests = ((await (await page.request.get(`${officialURL}/e2e/requests`)).json()).requests || []) as Array<{ kind: string; valid: boolean }> + expect(officialRequests).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: 'catalog', valid: true }), + expect.objectContaining({ kind: 'core_release_object', valid: true }) + ])) + expect(errors).toEqual([]) +}) + +test('@e2e-system-backup-001 PostgreSQL backup survives local and S3 download and restores database state', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'chromium-desktop', 'The destructive backup lifecycle runs once against its dedicated PostgreSQL database.') + test.skip(process.env.ASTER_E2E_POSTGRES_AVAILABLE !== '1', 'Real backup and restore require the dedicated PostgreSQL E2E runtime.') + test.skip(process.env.ASTER_E2E_ALLOW_DESTRUCTIVE_RESTORE !== '1', 'Destructive restore requires explicit ASTER_E2E_ALLOW_DESTRUCTIVE_RESTORE=1 opt-in.') + const databaseName = process.env.ASTER_E2E_DATABASE_NAME || '' + test.skip(!databaseName.split(/[^a-z0-9]+/i).some((token) => token === 'e2e' || token === 'test'), 'Destructive restore requires a database name with an isolated e2e or test token.') + test.setTimeout(120_000) + + const errors = captureBrowserErrors(page) + await loginDemo(page) + const token = await loginTestPrincipal(page) + const headers = { Authorization: `Bearer ${token}` } + const settings = await envelope>(await page.request.get('/api/v1/console/settings', { headers })) + const s3Port = process.env.ASTER_E2E_S3_PORT || '29003' + const s3API = process.env.ASTER_E2E_S3_API_URL || 'http://127.0.0.1:29004' + await envelope(await page.request.put('/api/v1/console/settings', { + headers, + data: { + ...settings, + backup_s3_enabled: true, + backup_s3_endpoint: `https://127.0.0.1:${s3Port}`, + backup_s3_region: 'auto', + backup_s3_bucket: 'e2e-system-backups', + backup_s3_prefix: 'system-lifecycle', + backup_s3_access_key: 'e2e-access-key', + backup_s3_secret_key: 'e2e-secret-key', + backup_s3_path_style: true, + backup_retention_days: 30, + backup_max_retained: 10 + } + })) + + await page.goto('/console/system') + await page.getByRole('tab', { name: 'Data backup' }).click() + const s3Panel = page.locator('.panel').filter({ has: page.getByRole('heading', { name: 'S3 / R2 对象存储' }) }) + const connectionResponse = page.waitForResponse((response) => response.url().endsWith('/api/v1/console/system/backups/s3/test') && response.request().method() === 'POST') + await s3Panel.getByRole('button', { name: '测试连接' }).click() + expect((await connectionResponse).status()).toBe(200) + await expect(page.getByText('S3 / R2 连接成功')).toBeVisible() + + const backupPanel = page.locator('.panel').filter({ has: page.getByRole('heading', { name: 'Backup & diagnostics' }) }) + const createResponse = page.waitForResponse((response) => response.url().endsWith('/api/v1/console/system/backups') && response.request().method() === 'POST') + await backupPanel.getByRole('button', { name: 'Create backup', exact: true }).click() + const backup = await envelope(await createResponse) + expect(backup.id).toMatch(/^asterrouter-backup-/) + expect(backup.size_bytes).toBeGreaterThan(0) + await expect(backupPanel.getByRole('row').filter({ hasText: backup.id })).toBeVisible() + + const localDownload = page.waitForEvent('download') + await backupPanel.getByRole('row').filter({ hasText: backup.id }).getByTitle('Download').click() + const localFile = await localDownload + const localPath = testInfo.outputPath(`local-${backup.path}`) + await localFile.saveAs(localPath) + const localBytes = await readFile(localPath) + expect(localBytes.length).toBe(backup.size_bytes) + + await page.reload() + await page.getByRole('tab', { name: 'Data backup' }).click() + const remotePanel = page.locator('.panel').filter({ has: page.getByRole('heading', { name: '远端备份记录' }) }) + const remoteRow = remotePanel.getByRole('row').filter({ hasText: backup.id }) + await expect(remoteRow).toContainText(`system-lifecycle/${backup.id}.tar.gz`) + const remoteDownload = page.waitForEvent('download') + await remoteRow.getByRole('button', { name: '下载', exact: true }).click() + const remoteFile = await remoteDownload + const remotePath = testInfo.outputPath(`remote-${backup.path}`) + await remoteFile.saveAs(remotePath) + expect(await readFile(remotePath)).toEqual(localBytes) + + const backupAudit = await envelope(await page.request.get(`/api/v1/console/audit-logs?action=backup&resource_type=system&limit=200`, { headers })) + expect(backupAudit).toContainEqual(expect.objectContaining({ action: 'backup', resource_type: 'system', resource_id: backup.id })) + + const localMarker = await createApplicationMarker(page, headers, 'Local') + expect(await applicationExists(page, headers, localMarker.id)).toBe(true) + + page.once('dialog', (dialog) => dialog.accept()) + const localRestoreResponse = page.waitForResponse((response) => response.url().endsWith('/api/v1/console/system/backups/restore') && response.request().method() === 'POST') + await backupPanel.getByRole('row').filter({ hasText: backup.id }).getByTitle('Restore').click() + const localRestore = await envelope<{ backup_id: string; need_restart: boolean }>(await localRestoreResponse) + expect(localRestore).toMatchObject({ backup_id: backup.id, need_restart: true }) + expect(await applicationExists(page, headers, localMarker.id)).toBe(false) + const restoreAudit = await envelope(await page.request.get('/api/v1/console/audit-logs?action=restore&resource_type=system&limit=200', { headers })) + expect(restoreAudit).toContainEqual(expect.objectContaining({ action: 'restore', resource_type: 'system', resource_id: backup.id })) + + const remoteMarker = await createApplicationMarker(page, headers, 'S3') + expect(await applicationExists(page, headers, remoteMarker.id)).toBe(true) + + page.once('dialog', (dialog) => dialog.accept()) + const remoteRestoreResponse = page.waitForResponse((response) => response.url().endsWith('/api/v1/console/system/backups/s3/restore') && response.request().method() === 'POST') + await remoteRow.getByRole('button', { name: '恢复', exact: true }).click() + const remoteRestore = await envelope<{ backup_id: string; need_restart: boolean }>(await remoteRestoreResponse) + expect(remoteRestore).toMatchObject({ backup_id: backup.id, need_restart: true }) + expect(await applicationExists(page, headers, remoteMarker.id)).toBe(false) + const remoteRestoreAudit = await envelope(await page.request.get('/api/v1/console/audit-logs?action=restore_s3&resource_type=system&limit=200', { headers })) + expect(remoteRestoreAudit).toContainEqual(expect.objectContaining({ action: 'restore_s3', resource_type: 'system', resource_id: backup.id })) + const s3RequestLog = await page.request.get(`${s3API}/__test/requests`) + expect(s3RequestLog.status()).toBe(200) + const s3Requests = ((await s3RequestLog.json()).requests || []) as S3Request[] + expect(s3Requests.length).toBeGreaterThan(0) + expect(s3Requests.every((request) => request.sigv4_valid && request.sigv4_errors.length === 0)).toBe(true) + expect(s3Requests).toEqual(expect.arrayContaining([ + expect.objectContaining({ method: 'PUT', outcome: 'stored' }), + expect.objectContaining({ method: 'GET', outcome: 'downloaded' }) + ])) + expect(errors).toEqual([]) +}) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d641c0b..6ebbf71 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "asterrouter-frontend", - "version": "0.23.1", + "version": "0.24.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "asterrouter-frontend", - "version": "0.23.1", + "version": "0.24.0", "dependencies": { "@lucide/vue": "^1.24.0", "@vitejs/plugin-vue": "^6.0.7", diff --git a/frontend/package.json b/frontend/package.json index 019619c..bbde3b6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "asterrouter-frontend", "private": true, - "version": "0.23.1", + "version": "0.24.0", "type": "module", "overrides": { "nanoid": "^3.3.17" @@ -11,13 +11,20 @@ "build": "vue-tsc -b && vite build", "typecheck": "vue-tsc --noEmit", "check:enterprise-surface": "node scripts/check-enterprise-surface.mjs", + "generate:e2e-capabilities": "node scripts/generate-e2e-capability-registry.mjs", + "check:e2e-coverage": "node --test ../scripts/dev-env.test.mjs ../scripts/fake-official.test.mjs ../scripts/fake-oidc.test.mjs ../scripts/fake-smtp.test.mjs ../scripts/fake-s3.test.mjs scripts/e2e-registry.test.mjs scripts/run-e2e-gate.test.mjs && vitest run src/testing/browser-errors.test.ts && node scripts/check-e2e-coverage.mjs && node scripts/check-e2e-completeness.mjs && node scripts/check-e2e-lifecycle-contract.mjs", + "check:e2e-completeness": "node scripts/check-e2e-completeness.mjs --strict", "check:public-doc-links": "node scripts/check-public-doc-links.mjs", "check:release-browser-contract": "node scripts/check-release-browser-contract.mjs", "check:workflow-pipefail": "node scripts/check-workflow-pipefail.mjs", "test:unit": "vitest run", "test:unit:coverage": "vitest run --coverage", "test:e2e": "playwright test", - "test:e2e:smoke": "playwright test --grep @smoke", + "test:e2e:pr": "node scripts/run-e2e-gate.mjs pr --exclude-kind setup", + "test:e2e:full": "playwright test", + "test:e2e:nightly": "node scripts/run-e2e-gate.mjs nightly --exclude-kind setup --exclude-id @e2e-system-update-lifecycle-001", + "test:e2e:release": "node scripts/run-e2e-gate.mjs release --exclude-kind setup --exclude-id @e2e-system-update-lifecycle-001", + "test:e2e:smoke": "node scripts/run-e2e-gate.mjs pr --exclude-kind setup", "preview": "vite preview" }, "dependencies": { diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index c932d53..6eca065 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -3,14 +3,43 @@ import { defineConfig, devices } from '@playwright/test' const frontendPort = process.env.ASTER_E2E_FRONTEND_PORT || '15173' const backendPort = process.env.ASTER_E2E_BACKEND_PORT || '18080' const upstreamPort = process.env.ASTER_E2E_UPSTREAM_PORT || '19000' +const smtpPort = process.env.ASTER_E2E_SMTP_PORT || '29001' +const mailAPIPort = process.env.ASTER_E2E_MAIL_API_PORT || '29002' +const s3Port = process.env.ASTER_E2E_S3_PORT || '29003' +const s3APIPort = process.env.ASTER_E2E_S3_API_PORT || '29004' const externalURL = process.env.ASTER_E2E_EXTERNAL_URL -const baseURL = externalURL || `http://127.0.0.1:${frontendPort}` +const oidcProxyPort = process.env.ASTER_E2E_OIDC_PORT || '29005' +const officialPort = process.env.ASTER_E2E_OFFICIAL_PORT || '29006' +const storageDatabaseURL = process.env.ASTERROUTER_SERVER_STORAGE_DATABASE_URL +const storageDatabaseName = (() => { + if (!storageDatabaseURL) return '' + try { + return decodeURIComponent(new URL(storageDatabaseURL).pathname.replace(/^\/+/, '')) + } catch { + return '' + } +})() +const postgresAvailable = Boolean(storageDatabaseURL) || process.env.ASTER_E2E_POSTGRES_AVAILABLE === '1' +const isolatedOIDC = !externalURL && !storageDatabaseURL +const oidcEnabled = isolatedOIDC || process.env.ASTER_E2E_OIDC_ENABLED === '1' +const baseURL = externalURL || (oidcEnabled ? `https://127.0.0.1:${oidcProxyPort}` : `http://127.0.0.1:${frontendPort}`) const artifactDir = process.env.ASTER_E2E_ARTIFACT_DIR const chromiumChannel = process.env.ASTER_E2E_CHROMIUM_CHANNEL const artifactPath = (relative: string) => artifactDir ? `${artifactDir}/${relative}` : `./${relative}` +const artifactStoreDir = artifactDir ? `${artifactDir}/runtime-artifacts` : `${process.cwd()}/test-results/runtime-artifacts` +const runtimeDataDir = artifactDir ? `${artifactDir}/runtime-data` : `${process.cwd()}/test-results/runtime-data` const chromiumUse = chromiumChannel ? { channel: chromiumChannel } : {} const videoMode = process.env.ASTER_E2E_VIDEO === 'off' ? 'off' as const : 'retain-on-failure' as const +process.env.ASTER_E2E_SMTP_PORT = smtpPort +process.env.ASTER_E2E_MAIL_API_URL ||= `http://127.0.0.1:${mailAPIPort}` +process.env.ASTER_E2E_S3_PORT = s3Port +process.env.ASTER_E2E_S3_API_URL ||= `http://127.0.0.1:${s3APIPort}` +process.env.ASTER_E2E_OIDC_AVAILABLE = oidcEnabled ? '1' : '0' +process.env.ASTER_E2E_POSTGRES_AVAILABLE = postgresAvailable ? '1' : '0' +process.env.ASTER_E2E_DATABASE_NAME ||= storageDatabaseName +process.env.ASTER_E2E_OFFICIAL_URL ||= `http://127.0.0.1:${officialPort}` + export default defineConfig({ testDir: './e2e', outputDir: artifactPath('test-results'), @@ -29,6 +58,9 @@ export default defineConfig({ : [['list'], ['html', { outputFolder: artifactPath('playwright-report'), open: 'never' }]], use: { baseURL, + ignoreHTTPSErrors: oidcEnabled, + actionTimeout: 10_000, + navigationTimeout: 20_000, trace: 'retain-on-failure', screenshot: 'only-on-failure', video: videoMode @@ -66,7 +98,23 @@ export default defineConfig({ `ASTER_DEV_FRONTEND_PORT=${frontendPort}`, `VITE_DEV_PROXY_TARGET=http://127.0.0.1:${backendPort}`, `ASTER_E2E_UPSTREAM_PORT=${upstreamPort}`, - 'ASTER_DEV_ISOLATED_MEMORY=1', + `ASTER_E2E_SMTP_PORT=${smtpPort}`, + `ASTER_E2E_MAIL_API_PORT=${mailAPIPort}`, + `ASTER_E2E_S3_PORT=${s3Port}`, + `ASTER_E2E_S3_API_PORT=${s3APIPort}`, + `ASTER_E2E_OIDC_PORT=${oidcProxyPort || '29005'}`, + `ASTER_E2E_OFFICIAL_PORT=${officialPort}`, + `ASTER_E2E_OIDC_ENABLED=${oidcEnabled ? '1' : '0'}`, + 'ASTER_E2E_OIDC_CLIENT_ID=asterrouter-e2e', + 'ASTER_E2E_OIDC_CLIENT_SECRET=asterrouter-e2e-secret', + `ASTER_DEV_ISOLATED_MEMORY=${storageDatabaseURL ? '0' : '1'}`, + 'ASTERROUTER_SERVER_ARTIFACTS_DRIVER=local', + `ASTERROUTER_SERVER_ARTIFACTS_LOCAL_ROOT=${JSON.stringify(artifactStoreDir)}`, + `ASTERROUTER_SERVER_PLUGINS_CACHE_DIR=${JSON.stringify(`${runtimeDataDir}/plugin-cache`)}`, + `ASTERROUTER_SERVER_PLUGINS_ACTIVE_DIR=${JSON.stringify(`${runtimeDataDir}/plugin-active`)}`, + `ASTERROUTER_SERVER_PLUGINS_DATA_DIR=${JSON.stringify(`${runtimeDataDir}/plugin-data`)}`, + `ASTERROUTER_SERVER_MAINTENANCE_BACKUP_DIR=${JSON.stringify(`${runtimeDataDir}/backups`)}`, + `ASTERROUTER_SERVER_MAINTENANCE_DIAGNOSTIC_DIR=${JSON.stringify(`${runtimeDataDir}/diagnostics`)}`, 'ASTERROUTER_SERVER_BOOTSTRAP_DEMO_MODE=true', 'ASTERROUTER_SERVER_SECURITY_SECRET_KEY=asterrouter-e2e-test-secret', 'bash ../scripts/e2e.sh' diff --git a/frontend/scripts/check-e2e-completeness.mjs b/frontend/scripts/check-e2e-completeness.mjs new file mode 100644 index 0000000..534886e --- /dev/null +++ b/frontend/scripts/check-e2e-completeness.mjs @@ -0,0 +1,173 @@ +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { + extractProductAPIOperations, + frontendRoot, + indexOwnerEvidence, + loadCapabilityRegistry, + loadOwnerEvidence, + loadRegistry, + requiredCapabilityProofs, + repositoryRoot +} from './e2e-registry.mjs' + +const strict = process.argv.includes('--strict') +const failures = [] +const gaps = [] +const scenarioRegistry = loadRegistry() +const capabilityRegistry = loadCapabilityRegistry() +const ownerEvidenceRegistry = loadOwnerEvidence() +const scenarios = new Map(scenarioRegistry.scenarios.map((scenario) => [scenario.id, scenario])) +const sourceOperations = new Map(extractProductAPIOperations().map((operation) => [operation.id, operation])) +const capabilities = new Map() +const proofKinds = ['success', 'negative', 'boundary', 'browser'] +const ownerProofKinds = ['success', 'negative', 'boundary'] +const expectedOwnerEvidence = indexOwnerEvidence(ownerEvidenceRegistry) +const ownerClaims = new Set() + +if (capabilityRegistry.schemaVersion !== 1) failures.push('capability-registry.json: schemaVersion must equal 1') +if (!Array.isArray(capabilityRegistry.capabilities)) failures.push('capability-registry.json: capabilities must be an array') +if (ownerEvidenceRegistry.schemaVersion !== 1) failures.push('owner-evidence.json: schemaVersion must equal 1') +if (!Array.isArray(ownerEvidenceRegistry.evidence)) failures.push('owner-evidence.json: evidence must be an array') + +for (const [index, entry] of (ownerEvidenceRegistry.evidence || []).entries()) { + const location = `owner-evidence.json evidence[${index}]` + validateReference(entry.reference, location, 'owner') + if (!Array.isArray(entry.proofs) || entry.proofs.length === 0 || entry.proofs.some((proof) => !ownerProofKinds.includes(proof))) { + failures.push(`${location}: proofs must contain only success/negative/boundary`) + } + if (new Set(entry.proofs || []).size !== (entry.proofs || []).length) failures.push(`${location}: proofs must be unique`) + if (!Array.isArray(entry.operations) || entry.operations.length === 0) failures.push(`${location}: operations are required`) + if (new Set(entry.operations || []).size !== (entry.operations || []).length) failures.push(`${location}: operations must be unique`) + for (const operation of entry.operations || []) { + if (!sourceOperations.has(operation)) failures.push(`${location}: unknown product operation ${operation}`) + for (const proof of entry.proofs || []) { + const claim = `${entry.reference}\u0000${proof}\u0000${operation}` + if (ownerClaims.has(claim)) failures.push(`${location}: duplicate ${proof} claim for ${operation} and ${entry.reference}`) + ownerClaims.add(claim) + } + } +} + +function sameStrings(left, right) { + return Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((value, index) => value === right[index]) +} + +function validateReference(reference, location, proofType) { + if (typeof reference !== 'string' || !reference.trim()) { + failures.push(`${location}: evidence reference must be a non-empty string`) + return + } + if (reference.startsWith('@e2e-')) { + if (proofType === 'owner') { + failures.push(`${location}: owner evidence must reference an owner test, not a browser scenario`) + return + } + const scenario = scenarios.get(reference) + if (!scenario) failures.push(`${location}: unknown scenario ${reference}`) + else if (proofType === 'browser' && !['journey', 'setup'].includes(scenario.kind)) failures.push(`${location}: browser evidence must reference a journey/setup, not ${scenario.kind}`) + return + } + if (proofType === 'browser') { + failures.push(`${location}: browser evidence must reference a registered @e2e-* journey/setup scenario`) + return + } + const [relativePath, testName] = reference.split('#', 2) + const path = resolve(repositoryRoot, relativePath) + if (!existsSync(path)) { + failures.push(`${location}: owner test file does not exist: ${relativePath}`) + return + } + if (!testName) { + failures.push(`${location}: owner test reference must include #TestName: ${reference}`) + return + } + const escapedTestName = testName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + if (!new RegExp(`^func\\s+${escapedTestName}\\s*\\(`, 'm').test(readFileSync(path, 'utf8'))) { + failures.push(`${location}: exact owner test function is absent: ${reference}`) + } +} + +for (const capability of capabilityRegistry.capabilities || []) { + const location = `capability ${JSON.stringify(capability.id)}` + if (capabilities.has(capability.id)) failures.push(`${location}: duplicate id`) + capabilities.set(capability.id, capability) + const source = sourceOperations.get(capability.id) + if (!source) { + failures.push(`${location}: stale operation is not imported by a Vue product surface`) + continue + } + if (!['P0', 'P1', 'P2'].includes(capability.risk)) failures.push(`${location}: risk must be P0, P1, or P2`) + if (typeof capability.owner !== 'string' || !capability.owner.trim()) failures.push(`${location}: owner is required`) + if (capability.interaction !== source.interaction) failures.push(`${location}: interaction drift; expected ${source.interaction}`) + if (!sameStrings(capability.methods, source.methods)) failures.push(`${location}: HTTP methods drift; regenerate the capability registry`) + if (!sameStrings(capability.views, source.views)) failures.push(`${location}: view imports drift; regenerate the capability registry`) + if (!capability.evidence || typeof capability.evidence !== 'object') { + failures.push(`${location}: evidence object is required`) + continue + } + for (const proof of proofKinds) { + const references = capability.evidence[proof] + if (!Array.isArray(references)) { + failures.push(`${location}: evidence.${proof} must be an array`) + continue + } + if (new Set(references).size !== references.length) failures.push(`${location}: evidence.${proof} must be unique`) + for (const reference of references) { + validateReference(reference, `${location} evidence.${proof}`, proof === 'browser' ? 'browser' : 'owner') + if (proof === 'browser' && reference.startsWith('@e2e-')) { + const scenario = scenarios.get(reference) + if (scenario && !scenario.operations?.includes(capability.id)) { + failures.push(`${location} evidence.browser: ${reference} does not declare operation ${capability.id}`) + } + } + } + if (references.length === 0 && requiredCapabilityProofs(capability).includes(proof)) { + gaps.push({ id: capability.id, proof, risk: capability.risk, interaction: capability.interaction }) + } + if (ownerProofKinds.includes(proof)) { + const expected = expectedOwnerEvidence.get(capability.id)?.[proof] || [] + if (!sameStrings(references.filter((reference) => !reference.startsWith('@e2e-')), expected)) { + failures.push(`${location} evidence.${proof}: owner evidence drift; regenerate the capability registry`) + } + } + } +} + +for (const id of sourceOperations.keys()) { + if (!capabilities.has(id)) failures.push(`source operation ${id}: missing capability registry entry`) +} + +for (const scenario of scenarioRegistry.scenarios || []) { + if (!['journey', 'setup'].includes(scenario.kind)) continue + for (const operation of scenario.operations || []) { + const capability = capabilities.get(operation) + if (!capability) continue + if (!capability.evidence.browser.includes(scenario.id)) { + failures.push(`scenario ${scenario.id}: operation ${operation} is missing reciprocal capability browser evidence`) + } + } +} + +if (failures.length > 0) { + process.stderr.write(`Capability coverage contract failed:\n${failures.map((failure) => `- ${failure}`).join('\n')}\n`) + process.exit(1) +} + +const totals = Object.fromEntries(proofKinds.map((proof) => [proof, gaps.filter((gap) => gap.proof === proof).length])) +const commands = [...capabilities.values()].filter((capability) => capability.interaction === 'command').length +const requiredTotals = Object.fromEntries(proofKinds.map((proof) => [ + proof, + [...capabilities.values()].filter((capability) => requiredCapabilityProofs(capability).includes(proof)).length +])) +const summary = `${capabilities.size} operations (${commands} commands), required evidence: success=${requiredTotals.success}, negative=${requiredTotals.negative}, boundary=${requiredTotals.boundary}, browser=${requiredTotals.browser}; gaps: success=${totals.success}, negative=${totals.negative}, boundary=${totals.boundary}, browser=${totals.browser}` +if (gaps.length > 0 && strict) { + process.stderr.write(`E2E completeness gate failed: ${summary}.\n`) + for (const gap of gaps) process.stderr.write(`- ${gap.risk} ${gap.interaction} ${gap.id}: missing ${gap.proof}\n`) + process.exit(1) +} +if (gaps.length > 0) { + process.stdout.write(`Capability registry valid but INCOMPLETE: ${summary}. Run npm run check:e2e-completeness for the fail-closed gap list.\n`) +} else { + process.stdout.write(`E2E completeness gate passed: ${summary}.\n`) +} diff --git a/frontend/scripts/check-e2e-coverage.mjs b/frontend/scripts/check-e2e-coverage.mjs new file mode 100644 index 0000000..92b863e --- /dev/null +++ b/frontend/scripts/check-e2e-coverage.mjs @@ -0,0 +1,142 @@ +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { + extractProductAPIOperations, + extractPlaywrightTests, + extractRouterPaths, + frontendRoot, + grepPattern, + listE2ESpecPaths, + loadRegistry +} from './e2e-registry.mjs' + +const registry = loadRegistry() +const failures = [] +const validKinds = new Set(['surface', 'journey', 'setup']) +const validProofLevels = new Set(['gate-a', 'gate-b']) +const validGates = new Set(['pr', 'nightly', 'release']) +const scenarios = new Map() +const sourceOperations = new Set(extractProductAPIOperations().map((operation) => operation.id)) +const nonEmptyString = (value) => typeof value === 'string' && value.trim().length > 0 +const hasDuplicates = (values) => Array.isArray(values) && new Set(values).size !== values.length + +const playwrightConfig = readFileSync(resolve(frontendRoot, 'playwright.config.ts'), 'utf8') +if (!playwrightConfig.includes('const storageDatabaseURL = process.env.ASTERROUTER_SERVER_STORAGE_DATABASE_URL')) { + failures.push('playwright.config.ts: storage backend selection must read ASTERROUTER_SERVER_STORAGE_DATABASE_URL') +} +if (!playwrightConfig.includes("`ASTER_DEV_ISOLATED_MEMORY=${storageDatabaseURL ? '0' : '1'}`")) { + failures.push('playwright.config.ts: an explicit database URL must disable isolated-memory mode') +} + +if (registry.schemaVersion !== 1) failures.push('scenario-registry.json: schemaVersion must equal 1') +if (!Array.isArray(registry.scenarios) || !Array.isArray(registry.routes)) { + failures.push('scenario-registry.json: scenarios and routes must be arrays') +} + +for (const scenario of registry.scenarios || []) { + const location = `scenario ${JSON.stringify(scenario.id)}` + if (!/^@e2e-[a-z0-9-]+$/.test(scenario.id || '')) failures.push(`${location}: invalid stable id`) + if (scenarios.has(scenario.id)) failures.push(`${location}: duplicate id`) + scenarios.set(scenario.id, scenario) + if (!validKinds.has(scenario.kind)) failures.push(`${location}: invalid kind ${JSON.stringify(scenario.kind)}`) + if (!nonEmptyString(scenario.title)) failures.push(`${location}: title is required`) + if (!nonEmptyString(scenario.owner)) failures.push(`${location}: owner is required`) + if (!nonEmptyString(scenario.fixture)) failures.push(`${location}: fixture is required`) + if (!nonEmptyString(scenario.claim)) failures.push(`${location}: claim is required`) + if (!Array.isArray(scenario.routes) || scenario.routes.length === 0) failures.push(`${location}: routes are required`) + if (hasDuplicates(scenario.routes)) failures.push(`${location}: routes must be unique`) + if (scenario.kind === 'surface') { + if (scenario.operations !== undefined) failures.push(`${location}: surface scenarios must not claim product operations`) + } else { + if (!Array.isArray(scenario.operations)) failures.push(`${location}: operations must be an array`) + else { + if (hasDuplicates(scenario.operations)) failures.push(`${location}: operations must be unique`) + for (const operation of scenario.operations) { + if (!sourceOperations.has(operation)) failures.push(`${location}: unknown product operation ${JSON.stringify(operation)}`) + } + } + } + if (!Array.isArray(scenario.proofLevels) || scenario.proofLevels.length === 0 || scenario.proofLevels.some((level) => !validProofLevels.has(level))) { + failures.push(`${location}: proofLevels must contain only gate-a/gate-b`) + } + if (hasDuplicates(scenario.proofLevels)) failures.push(`${location}: proofLevels must be unique`) + if (!Array.isArray(scenario.gates) || scenario.gates.length === 0 || scenario.gates.some((gate) => !validGates.has(gate))) { + failures.push(`${location}: gates must contain only pr/nightly/release`) + } + if (hasDuplicates(scenario.gates)) failures.push(`${location}: gates must be unique`) + if (scenario.gates?.includes('release') && !scenario.proofLevels?.includes('gate-b')) failures.push(`${location}: release scenarios must prove gate-b`) + if (!nonEmptyString(scenario.spec) || !existsSync(resolve(frontendRoot, scenario.spec))) failures.push(`${location}: spec does not exist: ${scenario.spec}`) +} + +const sourceRoutes = extractRouterPaths() +const registryRoutes = registry.routes || [] +const registryRoutePaths = registryRoutes.map((route) => route.path).sort() +const routesByPath = new Map(registryRoutes.map((route) => [route.path, route])) +for (const path of sourceRoutes.filter((path) => !registryRoutePaths.includes(path))) failures.push(`router: missing route contract for ${path}`) +for (const path of registryRoutePaths.filter((path) => !sourceRoutes.includes(path))) failures.push(`registry: stale route contract for ${path}`) +if (new Set(registryRoutePaths).size !== registryRoutePaths.length) failures.push('registry: route paths must be unique') + +for (const route of registryRoutes) { + const location = `route ${route.path}` + const surface = scenarios.get(route.surface) + if (!surface) failures.push(`${location}: unknown surface scenario ${JSON.stringify(route.surface)}`) + else if (!['surface', 'setup'].includes(surface.kind)) failures.push(`${location}: surface must reference a surface/setup scenario`) + else if (!Array.isArray(surface.routes) || !surface.routes.includes(route.path)) failures.push(`${location}: surface scenario does not declare this route`) + if (!Array.isArray(route.journeys) || route.journeys.length === 0) failures.push(`${location}: at least one vertical journey is required`) + if (hasDuplicates(route.journeys)) failures.push(`${location}: journeys must be unique`) + for (const id of route.journeys || []) { + const journey = scenarios.get(id) + if (!journey) failures.push(`${location}: unknown journey ${JSON.stringify(id)}`) + else if (!['journey', 'setup'].includes(journey.kind)) failures.push(`${location}: ${id} is not a journey/setup scenario`) + else if (!Array.isArray(journey.routes) || !journey.routes.includes(route.path)) failures.push(`${location}: ${id} does not declare this route`) + } +} + +for (const [id, scenario] of scenarios) { + for (const path of Array.isArray(scenario.routes) ? scenario.routes : []) { + const route = routesByPath.get(path) + if (!route) { + failures.push(`scenario ${id}: declares unknown route ${path}`) + continue + } + if (['surface', 'setup'].includes(scenario.kind) && route.surface !== id) failures.push(`scenario ${id}: route ${path} does not reference it as surface`) + if (['journey', 'setup'].includes(scenario.kind) && !route.journeys?.includes(id)) failures.push(`scenario ${id}: route ${path} does not reference it as journey`) + } +} + +const actualTags = new Map() +for (const spec of listE2ESpecPaths()) { + const tests = extractPlaywrightTests(spec) + if (tests.length === 0) failures.push(`${spec}: spec contains no Playwright tests`) + for (const test of tests) { + const location = `${spec}: ${JSON.stringify(test.title)}` + if (!test.title) failures.push(`${location}: test title must be a string literal`) + if (test.modifier !== 'test') failures.push(`${location}: permanent test.${test.modifier} is not allowed`) + if (test.tags.length !== 1) failures.push(`${location}: exactly one @e2e-* scenario id is required`) + for (const tag of test.tags) { + const locations = actualTags.get(tag) || [] + locations.push(spec) + actualTags.set(tag, locations) + } + } +} +for (const [id, scenario] of scenarios) { + const locations = actualTags.get(id) || [] + if (locations.length === 0) failures.push(`scenario ${id}: test title is missing from ${scenario.spec}`) + if (locations.length > 1) failures.push(`scenario ${id}: tag occurs ${locations.length} times (${locations.join(', ')})`) + if (locations.length === 1 && locations[0] !== scenario.spec) failures.push(`scenario ${id}: tag occurs in ${locations[0]}, expected ${scenario.spec}`) +} +for (const [tag, locations] of actualTags) { + if (!scenarios.has(tag)) failures.push(`test tag ${tag}: not registered (${locations.join(', ')})`) +} +for (const gate of validGates) { + const selected = [...scenarios.values()].filter((scenario) => scenario.gates.includes(gate)) + if (!grepPattern(selected)) failures.push(`gate ${gate}: no scenarios selected`) +} + +if (failures.length > 0) { + process.stderr.write(`E2E coverage contract failed:\n${failures.map((failure) => `- ${failure}`).join('\n')}\n`) + process.exit(1) +} + +process.stdout.write(`E2E coverage contract passed: ${sourceRoutes.length} product routes, ${scenarios.size} scenarios, 3 delivery gates.\n`) diff --git a/frontend/scripts/check-e2e-lifecycle-contract.mjs b/frontend/scripts/check-e2e-lifecycle-contract.mjs new file mode 100644 index 0000000..ca948a3 --- /dev/null +++ b/frontend/scripts/check-e2e-lifecycle-contract.mjs @@ -0,0 +1,95 @@ +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)) +const repositoryRoot = resolve(scriptDirectory, '../..') +const lifecycleID = '@e2e-system-update-lifecycle-001' +const failures = [] + +function read(path) { + return readFileSync(resolve(repositoryRoot, path), 'utf8') +} + +function requireText(content, expected, location) { + if (!content.includes(expected)) failures.push(`${location}: missing ${JSON.stringify(expected)}`) +} + +const packagePath = 'frontend/package.json' +const packageJSON = JSON.parse(read(packagePath)) +for (const name of ['test:e2e:nightly', 'test:e2e:release']) { + requireText(packageJSON.scripts[name] || '', `--exclude-id ${lifecycleID}`, `${packagePath}#scripts.${name}`) +} + +const playwrightConfigPath = 'frontend/playwright.config.ts' +const playwrightConfig = read(playwrightConfigPath) +for (const expected of [ + "const oidcEnabled = isolatedOIDC || process.env.ASTER_E2E_OIDC_ENABLED === '1'", + 'oidcEnabled ? `https://127.0.0.1:${oidcProxyPort}`', + "process.env.ASTER_E2E_OIDC_AVAILABLE = oidcEnabled ? '1' : '0'", + 'ignoreHTTPSErrors: oidcEnabled', + '`ASTER_E2E_OIDC_ENABLED=${oidcEnabled ? \'1\' : \'0\'}`' +]) { + requireText(playwrightConfig, expected, playwrightConfigPath) +} + +const runtimePath = 'backend/internal/appcmd/server/runtime.go' +const runtime = read(runtimePath) +for (const expected of [ + 'ASTER_E2E_POSTGRES_AVAILABLE', + 'parsed.Scheme != "postgres" && parsed.Scheme != "postgresql"', + 'token == "e2e" || token == "test"' +]) { + requireText(runtime, expected, runtimePath) +} + +const releaseJourneyPath = 'scripts/test-release-browser-journeys.sh' +const releaseJourney = read(releaseJourneyPath) +for (const output of ['--print-pattern', '--print-ids']) { + requireText(releaseJourney, `--exclude-id ${lifecycleID} ${output}`, releaseJourneyPath) +} + +const lifecyclePath = 'scripts/test-system-update-lifecycle.sh' +const lifecycle = read(lifecyclePath) +for (const expected of [ + 'ASTER_E2E_OFFICIAL_CORE_RELEASE_VERSION="${NEW_VERSION}"', + `--grep ${lifecycleID}`, + 'if [ "${START_COUNT}" != "3" ]', + 'if [ "${START_SEQUENCE}" != "${OLD_VERSION},${NEW_VERSION},${OLD_VERSION}" ]', + 'if [ "${EXIT_FAILURES}" != "0" ]', + "echo 'system_update_lifecycle=passed'", + "echo 'database_class=dedicated_postgresql'", + "echo 'execution=dedicated_release_binary_supervisor'" +]) { + requireText(lifecycle, expected, lifecyclePath) +} + +for (const workflowPath of ['.github/workflows/nightly.yml', '.github/workflows/build.yml', '.github/workflows/release.yml']) { + const workflow = read(workflowPath) + for (const expected of [ + 'ASTER_SYSTEM_UPDATE_E2E_DIR: ${{ runner.temp }}/asterrouter-system-update-lifecycle', + 'bash scripts/test-system-update-lifecycle.sh', + "grep -Fxq 'system_update_lifecycle=passed'", + 'asterrouter-system-update-lifecycle/report.txt', + 'asterrouter-system-update-lifecycle/generations.log', + 'asterrouter-system-update-lifecycle/supervisor.log', + 'asterrouter-system-update-lifecycle/runtime.log', + 'asterrouter-system-update-lifecycle/postgres.log', + 'asterrouter-system-update-lifecycle/official.log', + 'asterrouter-system-update-lifecycle/playwright' + ]) { + requireText(workflow, expected, workflowPath) + } +} + +const nightlyWorkflowPath = '.github/workflows/nightly.yml' +const nightlyWorkflow = read(nightlyWorkflowPath) +const nightlyBrowserStep = nightlyWorkflow.match(/- name: Nightly browser gate[\s\S]*?run: npm run test:e2e:nightly/)?.[0] || '' +requireText(nightlyBrowserStep, "ASTER_E2E_OIDC_ENABLED: '1'", `${nightlyWorkflowPath}#Nightly browser gate`) + +if (failures.length > 0) { + process.stderr.write(`E2E lifecycle contract check failed:\n${failures.join('\n')}\n`) + process.exit(1) +} + +process.stdout.write('Dedicated system update lifecycle gate and evidence contract check passed.\n') diff --git a/frontend/scripts/check-release-browser-contract.mjs b/frontend/scripts/check-release-browser-contract.mjs index d888fe2..838d19a 100644 --- a/frontend/scripts/check-release-browser-contract.mjs +++ b/frontend/scripts/check-release-browser-contract.mjs @@ -41,11 +41,24 @@ for (const expected of [ 'start_runtime "${port}" "${ENTERPRISE_DATABASE_URL}" "${journey_dir}"', 'ASTER_SETUP_JOURNEY_ADMIN_PASSWORD="${ADMIN_PASSWORD}"', 'ASTER_E2E_PASSWORD="${ADMIN_PASSWORD}"', - '"setup_completed":true' + 'ASTER_E2E_SMTP_PORT="${SMTP_PORT}"', + 'ASTER_E2E_MAIL_API_URL="http://127.0.0.1:${MAIL_API_PORT}"', + 'node "scripts/fake-smtp.mjs"', + '"SSL_CERT_FILE=${SMTP_CERT}"', + 'check-e2e-coverage.mjs', + 'run-e2e-gate.mjs" release --exclude-kind setup --exclude-id @e2e-system-update-lifecycle-001 --print-pattern', + 'run-e2e-gate.mjs" release --exclude-kind setup --exclude-id @e2e-system-update-lifecycle-001 --print-ids', + 'run_enterprise_journey "${RELEASE_GREP_PATTERN}"', + '"setup_completed":true', + 'echo "commit=${COMMIT}"', + 'echo "candidate=${ARCHIVE}"', + 'echo "tested_url=http://127.0.0.1:$((BACKEND_PORT + 1))"', + "echo 'database_class=dedicated_postgresql'" ]) { requireText(releaseJourney, expected, releaseJourneyPath) } forbidText(releaseJourney, 'enterprise_setup', releaseJourneyPath) +forbidText(releaseJourney, '@j01', releaseJourneyPath) for (const workflowPath of ['.github/workflows/build.yml', '.github/workflows/release.yml']) { const workflow = read(workflowPath) diff --git a/frontend/scripts/e2e-registry.mjs b/frontend/scripts/e2e-registry.mjs new file mode 100644 index 0000000..0360565 --- /dev/null +++ b/frontend/scripts/e2e-registry.mjs @@ -0,0 +1,270 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { dirname, relative, resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' +import ts from 'typescript' + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)) +export const frontendRoot = resolve(scriptDirectory, '..') +export const repositoryRoot = resolve(frontendRoot, '..') +export const registryPath = resolve(repositoryRoot, 'docs/test/v1/scenario-registry.json') +export const capabilityRegistryPath = resolve(repositoryRoot, 'docs/test/v1/capability-registry.json') +export const ownerEvidencePath = resolve(repositoryRoot, 'docs/test/v1/owner-evidence.json') + +export function loadRegistry() { + return JSON.parse(readFileSync(registryPath, 'utf8')) +} + +export function loadCapabilityRegistry() { + return JSON.parse(readFileSync(capabilityRegistryPath, 'utf8')) +} + +export function loadOwnerEvidence() { + return JSON.parse(readFileSync(ownerEvidencePath, 'utf8')) +} + +function property(object, name) { + return object.properties.find((candidate) => { + if (!ts.isPropertyAssignment(candidate)) return false + return (ts.isIdentifier(candidate.name) || ts.isStringLiteral(candidate.name)) && candidate.name.text === name + }) +} + +function stringValue(node) { + return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) ? node.text : null +} + +function joinRoute(parent, child) { + if (child.startsWith('/')) return child + if (!parent || parent === '/') return `/${child}` + return `${parent.replace(/\/$/, '')}/${child}` +} + +function collectRouteObjects(array, parent, paths) { + for (const element of array.elements) { + if (!ts.isObjectLiteralExpression(element)) continue + const pathProperty = property(element, 'path') + if (!pathProperty) continue + const segment = stringValue(pathProperty.initializer) + if (segment === null || segment.includes(':pathMatch')) continue + const path = joinRoute(parent, segment) + const childrenProperty = property(element, 'children') + if (childrenProperty && ts.isArrayLiteralExpression(childrenProperty.initializer)) { + collectRouteObjects(childrenProperty.initializer, path, paths) + continue + } + if (property(element, 'component') && !property(element, 'redirect')) paths.add(path) + } +} + +export function extractRouterPaths() { + const routerFile = resolve(frontendRoot, 'src/router/index.ts') + const source = ts.createSourceFile( + routerFile, + readFileSync(routerFile, 'utf8'), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ) + let routesArray = null + function visit(node) { + if (routesArray) return + if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'createRouter') { + const options = node.arguments[0] + if (options && ts.isObjectLiteralExpression(options)) { + const routes = property(options, 'routes') + if (routes && ts.isArrayLiteralExpression(routes.initializer)) routesArray = routes.initializer + } + } + ts.forEachChild(node, visit) + } + visit(source) + if (!routesArray) throw new Error(`Unable to find createRouter routes in ${routerFile}`) + const paths = new Set() + collectRouteObjects(routesArray, '', paths) + return [...paths].sort() +} + +function collectSpecPaths(directory, paths) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = resolve(directory, entry.name) + if (entry.isDirectory()) collectSpecPaths(path, paths) + else if (entry.isFile() && entry.name.endsWith('.spec.ts')) paths.push(relative(frontendRoot, path).split(sep).join('/')) + } +} + +export function listE2ESpecPaths() { + const paths = [] + collectSpecPaths(resolve(frontendRoot, 'e2e'), paths) + return paths.sort() +} + +function collectVuePaths(directory, paths) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = resolve(directory, entry.name) + if (entry.isDirectory()) collectVuePaths(path, paths) + else if (entry.isFile() && entry.name.endsWith('.vue')) paths.push(path) + } +} + +const httpMethods = new Set(['get', 'post', 'put', 'patch', 'delete']) + +function exportedAPIFunctions(moduleName) { + const apiPath = resolve(frontendRoot, `src/api/${moduleName}.ts`) + if (!existsSync(apiPath)) return new Map() + const source = ts.createSourceFile(apiPath, readFileSync(apiPath, 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TS) + const functions = new Map() + const exported = new Set() + + for (const statement of source.statements) { + if (!ts.isFunctionDeclaration(statement) || !statement.name || !statement.body) continue + functions.set(statement.name.text, statement) + if (statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) exported.add(statement.name.text) + } + + function methodsFor(name, visiting = new Set()) { + if (visiting.has(name)) return new Set() + const declaration = functions.get(name) + if (!declaration) return new Set() + visiting.add(name) + const methods = new Set() + function visit(node) { + if (ts.isCallExpression(node)) { + const expression = node.expression + if ( + ts.isPropertyAccessExpression(expression) && + ts.isIdentifier(expression.expression) && + expression.expression.text === 'apiClient' && + httpMethods.has(expression.name.text) + ) { + methods.add(expression.name.text) + } else if (ts.isIdentifier(expression) && functions.has(expression.text)) { + for (const method of methodsFor(expression.text, new Set(visiting))) methods.add(method) + } + } + ts.forEachChild(node, visit) + } + visit(declaration.body) + return methods + } + + return new Map([...exported] + .map((name) => [name, [...methodsFor(name)].sort()]) + .filter(([, methods]) => methods.length > 0)) +} + +export function extractProductAPIOperations() { + const vuePaths = [] + collectVuePaths(resolve(frontendRoot, 'src'), vuePaths) + const operations = new Map() + + for (const vuePath of vuePaths) { + const body = readFileSync(vuePath, 'utf8') + const script = body.match(/]*>([\s\S]*?)<\/script>/)?.[1] + if (!script) continue + const source = ts.createSourceFile(`${vuePath}.ts`, script, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS) + for (const statement of source.statements) { + if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue + const specifier = statement.moduleSpecifier.text + if (!specifier.startsWith('@/api/')) continue + const moduleName = specifier.slice('@/api/'.length) + const apiFunctions = exportedAPIFunctions(moduleName) + const bindings = statement.importClause?.namedBindings + if (!bindings || !ts.isNamedImports(bindings)) continue + for (const element of bindings.elements) { + const symbol = (element.propertyName || element.name).text + const methods = apiFunctions.get(symbol) + if (!methods) continue + const id = `${moduleName}:${symbol}` + const interaction = /^(get|list)/.test(symbol) || symbol === 'checkSystemUpdates' ? 'query' : 'command' + const current = operations.get(id) || { + id, + module: moduleName, + symbol, + methods, + interaction, + views: [] + } + current.views.push(relative(repositoryRoot, vuePath).split(sep).join('/')) + operations.set(id, current) + } + } + } + + return [...operations.values()] + .map((operation) => ({ ...operation, views: [...new Set(operation.views)].sort() })) + .sort((left, right) => left.id.localeCompare(right.id)) +} + +export function requiredCapabilityProofs(capability) { + const proofs = new Set(['success']) + const isCommand = capability.interaction === 'command' + if (isCommand || capability.risk === 'P0') { + proofs.add('negative') + proofs.add('boundary') + } + if (isCommand || capability.risk === 'P0' || capability.risk === 'P1') proofs.add('browser') + return [...proofs] +} + +export function indexScenarioOperations(registry) { + const evidence = new Map() + for (const scenario of registry.scenarios || []) { + if (!['journey', 'setup'].includes(scenario.kind)) continue + for (const operation of scenario.operations || []) { + const scenarios = evidence.get(operation) || [] + scenarios.push(scenario.id) + evidence.set(operation, scenarios) + } + } + return new Map([...evidence].map(([operation, scenarios]) => [operation, [...new Set(scenarios)].sort()])) +} + +export function indexOwnerEvidence(registry) { + const evidence = new Map() + for (const entry of registry.evidence || []) { + for (const operation of entry.operations || []) { + const proofs = evidence.get(operation) || { success: [], negative: [], boundary: [] } + for (const proof of entry.proofs || []) proofs[proof]?.push(entry.reference) + evidence.set(operation, proofs) + } + } + return new Map([...evidence].map(([operation, proofs]) => [operation, Object.fromEntries( + Object.entries(proofs).map(([proof, references]) => [proof, [...new Set(references)].sort()]) + )])) +} + +function testCallModifier(expression) { + if (ts.isIdentifier(expression) && expression.text === 'test') return 'test' + if (!ts.isPropertyAccessExpression(expression) || !ts.isIdentifier(expression.expression) || expression.expression.text !== 'test') return null + return ['only', 'skip', 'fixme', 'fail'].includes(expression.name.text) ? expression.name.text : null +} + +export function extractPlaywrightTests(specRelativePath) { + const specPath = resolve(frontendRoot, specRelativePath) + const source = ts.createSourceFile( + specPath, + readFileSync(specPath, 'utf8'), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ) + const tests = [] + function visit(node) { + if (ts.isCallExpression(node)) { + const modifier = testCallModifier(node.expression) + const title = node.arguments[0] && stringValue(node.arguments[0]) + if (modifier && (modifier === 'test' || title)) { + tests.push({ modifier, title, tags: title?.match(/@e2e-[a-z0-9-]+/g) || [] }) + } + } + ts.forEachChild(node, visit) + } + visit(source) + return tests +} + +export function grepPattern(scenarios) { + return scenarios + .map((scenario) => `(?:^|\\s)${scenario.id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?=\\s|$)`) + .join('|') +} diff --git a/frontend/scripts/e2e-registry.test.mjs b/frontend/scripts/e2e-registry.test.mjs new file mode 100644 index 0000000..65cd4f5 --- /dev/null +++ b/frontend/scripts/e2e-registry.test.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { extractProductAPIOperations, indexOwnerEvidence, indexScenarioOperations, requiredCapabilityProofs } from './e2e-registry.mjs' + +test('extractProductAPIOperations returns only exported HTTP operations with transitive methods', () => { + const operations = new Map(extractProductAPIOperations().map((operation) => [operation.id, operation])) + + assert.equal(operations.has('client:ApiClientError'), false) + assert.equal(operations.has('client:isNotFoundError'), false) + assert.deepEqual(operations.get('account:beginAccountIdentityBinding')?.methods, ['post']) + assert.equal(operations.get('account:beginAccountIdentityBinding')?.interaction, 'command') + assert.deepEqual(operations.get('control:getUsageReport')?.methods, ['get']) + assert.equal(operations.get('control:getUsageReport')?.interaction, 'query') + assert.deepEqual(operations.get('system:downloadSystemBackup')?.methods, ['get']) + assert.equal(operations.get('system:downloadSystemBackup')?.interaction, 'command') +}) + +test('requiredCapabilityProofs keeps browser and owner evidence proportional to risk', () => { + assert.deepEqual(requiredCapabilityProofs({ interaction: 'query', risk: 'P2' }), ['success']) + assert.deepEqual(requiredCapabilityProofs({ interaction: 'query', risk: 'P1' }), ['success', 'browser']) + assert.deepEqual(requiredCapabilityProofs({ interaction: 'query', risk: 'P0' }), ['success', 'negative', 'boundary', 'browser']) + assert.deepEqual(requiredCapabilityProofs({ interaction: 'command', risk: 'P2' }), ['success', 'negative', 'boundary', 'browser']) +}) + +test('indexScenarioOperations derives browser evidence only from vertical journeys', () => { + const evidence = indexScenarioOperations({ + scenarios: [ + { id: '@e2e-surface-001', kind: 'surface', operations: ['control:getDashboard'] }, + { id: '@e2e-journey-b', kind: 'journey', operations: ['control:getDashboard', 'control:createAPIKey'] }, + { id: '@e2e-journey-a', kind: 'journey', operations: ['control:getDashboard'] }, + { id: '@e2e-setup-001', kind: 'setup', operations: ['settings:completeEnterpriseSetup'] } + ] + }) + + assert.deepEqual(evidence.get('control:getDashboard'), ['@e2e-journey-a', '@e2e-journey-b']) + assert.deepEqual(evidence.get('control:createAPIKey'), ['@e2e-journey-b']) + assert.deepEqual(evidence.get('settings:completeEnterpriseSetup'), ['@e2e-setup-001']) +}) + +test('indexOwnerEvidence expands grouped owner proofs without duplicates', () => { + const evidence = indexOwnerEvidence({ + evidence: [ + { reference: 'backend/example_test.go#TestLifecycle', proofs: ['success', 'boundary'], operations: ['control:createAPIKey'] }, + { reference: 'backend/example_test.go#TestLifecycle', proofs: ['success'], operations: ['control:createAPIKey'] }, + { reference: 'backend/example_test.go#TestDenied', proofs: ['negative'], operations: ['control:createAPIKey', 'control:getAPIKeys'] } + ] + }) + + assert.deepEqual(evidence.get('control:createAPIKey'), { + success: ['backend/example_test.go#TestLifecycle'], + negative: ['backend/example_test.go#TestDenied'], + boundary: ['backend/example_test.go#TestLifecycle'] + }) + assert.deepEqual(evidence.get('control:getAPIKeys')?.negative, ['backend/example_test.go#TestDenied']) +}) diff --git a/frontend/scripts/generate-e2e-capability-registry.mjs b/frontend/scripts/generate-e2e-capability-registry.mjs new file mode 100644 index 0000000..43acce0 --- /dev/null +++ b/frontend/scripts/generate-e2e-capability-registry.mjs @@ -0,0 +1,81 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { + capabilityRegistryPath, + extractProductAPIOperations, + indexOwnerEvidence, + indexScenarioOperations, + loadOwnerEvidence, + loadRegistry +} from './e2e-registry.mjs' + +const existing = existsSync(capabilityRegistryPath) + ? JSON.parse(readFileSync(capabilityRegistryPath, 'utf8')) + : { capabilities: [] } +const byID = new Map((existing.capabilities || []).map((capability) => [capability.id, capability])) +const browserEvidence = indexScenarioOperations(loadRegistry()) +const ownerEvidence = indexOwnerEvidence(loadOwnerEvidence()) + +function ownerFor(operation) { + if (operation.module !== 'control') return operation.module + const symbol = operation.symbol.toLowerCase() + if (symbol.includes('provider') || symbol.includes('gatewaymodel') || symbol.includes('modelroute')) return 'gateway/supply' + if (symbol.includes('pricing') || symbol.includes('procurement') || symbol.includes('billing')) return 'billing/pricing' + if (symbol.includes('apikey') || symbol.includes('application')) return 'applications/credentials' + if (symbol.includes('department') || symbol.includes('workspaceuser') || symbol.includes('rolebinding') || symbol.includes('organizationgroup')) return 'identity/rbac' + if (symbol.includes('routing')) return 'gateway/routing' + if (symbol.includes('artifact')) return 'artifacts' + if (symbol.includes('aijob')) return 'jobs' + if (symbol.includes('alert')) return 'alerts' + if (symbol.includes('export')) return 'exports' + if (symbol.includes('usage') || symbol.includes('trace') || symbol.includes('audit') || symbol.includes('supply') || symbol.includes('capacity')) return 'operations/observability' + return 'controlplane' +} + +function riskFor(operation) { + const id = operation.id.toLowerCase() + if (/auth|account|apikey|rolebinding|system|backup|restore|restart|update|rollback|license|plugin|pricing|billing|provider|modelroute|gatewaymodel/.test(id)) return 'P0' + if (/delete|disable|revoke|cancel|artifact|job|alert|export|department|organization|routing/.test(id)) return 'P1' + return 'P2' +} + +const capabilities = extractProductAPIOperations().map((operation) => { + const previous = byID.get(operation.id) || {} + const browser = browserEvidence.get(operation.id) || [] + const owner = ownerEvidence.get(operation.id) || { success: [], negative: [], boundary: [] } + return { + id: operation.id, + owner: previous.owner || ownerFor(operation), + risk: previous.risk || riskFor(operation), + interaction: operation.interaction, + methods: operation.methods, + views: operation.views, + evidence: { + success: owner.success, + negative: owner.negative, + boundary: owner.boundary, + browser + }, + notes: previous.notes || '' + } +}) + +const registry = { + schemaVersion: 1, + generatedFrom: 'frontend Vue @/api imports and exported apiClient call graph', + coveragePolicy: { + success: 'Required for every product HTTP operation.', + negative: 'Required for every command and every P0 query.', + boundary: 'Required for every command and every P0 query.', + browser: 'Required for every command and every P0 or P1 query.' + }, + proofContract: { + success: 'The owner or product chain returns the intended result and persists applicable state.', + negative: 'Invalid identity, authorization, dependency, or state is rejected with the public error contract.', + boundary: 'At least one material edge such as empty, limit, idempotency, concurrency, pagination, or restart is verified.', + browser: 'A visible product interaction or projection reaches the public HTTP operation in a registered Playwright journey.' + }, + capabilities +} + +writeFileSync(capabilityRegistryPath, `${JSON.stringify(registry, null, 2)}\n`) +process.stdout.write(`Capability registry synchronized: ${capabilities.length} product HTTP operations.\n`) diff --git a/frontend/scripts/run-e2e-gate.mjs b/frontend/scripts/run-e2e-gate.mjs new file mode 100644 index 0000000..d7166fd --- /dev/null +++ b/frontend/scripts/run-e2e-gate.mjs @@ -0,0 +1,77 @@ +import { spawnSync } from 'node:child_process' +import { grepPattern, loadRegistry } from './e2e-registry.mjs' + +const usage = 'Usage: node scripts/run-e2e-gate.mjs [--print-pattern|--print-ids] [--exclude-kind ] [--exclude-id ] [-- ]\n' + +function fail(message) { + process.stderr.write(`${message}\n${usage}`) + process.exit(2) +} + +const gate = process.argv[2] +const validGates = new Set(['pr', 'nightly', 'release']) +if (!validGates.has(gate)) { + fail(`Unknown E2E gate: ${gate || '(missing)'}`) +} + +const args = process.argv.slice(3) +const separator = args.indexOf('--') +const gateArgs = separator >= 0 ? args.slice(0, separator) : args +const playwrightArgs = separator >= 0 ? args.slice(separator + 1) : [] +const excludedKinds = new Set() +const excludedIDs = new Set() +let output = '' + +for (let index = 0; index < gateArgs.length; index += 1) { + const argument = gateArgs[index] + if (argument === '--print-pattern' || argument === '--print-ids') { + if (output && output !== argument) fail('Only one print mode may be selected.') + output = argument + continue + } + if (argument === '--exclude-kind' || argument === '--exclude-id') { + const value = gateArgs[index + 1] + if (!value || value.startsWith('--')) fail(`${argument} requires a value.`) + if (argument === '--exclude-kind') excludedKinds.add(value) + else excludedIDs.add(value) + index += 1 + continue + } + fail(`Unknown E2E gate option: ${argument}`) +} + +const registry = loadRegistry() +const gateScenarios = registry.scenarios.filter((scenario) => scenario.gates.includes(gate)) +const gateKinds = new Set(gateScenarios.map((scenario) => scenario.kind)) +const gateIDs = new Set(gateScenarios.map((scenario) => scenario.id)) +for (const kind of excludedKinds) { + if (!gateKinds.has(kind)) fail(`Cannot exclude unknown ${gate} gate scenario kind: ${kind}`) +} +for (const id of excludedIDs) { + if (!gateIDs.has(id)) fail(`Cannot exclude scenario not registered for the ${gate} gate: ${id}`) +} + +const scenarios = gateScenarios.filter((scenario) => !excludedKinds.has(scenario.kind) && !excludedIDs.has(scenario.id)) +const pattern = grepPattern(scenarios) +if (!pattern) { + process.stderr.write(`No E2E scenarios are registered for the ${gate} gate.\n`) + process.exit(1) +} + +if (output === '--print-pattern') { + process.stdout.write(`${pattern}\n`) + process.exit(0) +} +if (output === '--print-ids') { + process.stdout.write(`${scenarios.map((scenario) => scenario.id).join(',')}\n`) + process.exit(0) +} + +const executable = process.platform === 'win32' ? 'npx.cmd' : 'npx' +const result = spawnSync(executable, ['playwright', 'test', '--grep', pattern, ...playwrightArgs], { + cwd: process.cwd(), + env: process.env, + stdio: 'inherit' +}) +if (result.error) throw result.error +process.exit(result.status ?? 1) diff --git a/frontend/scripts/run-e2e-gate.test.mjs b/frontend/scripts/run-e2e-gate.test.mjs new file mode 100644 index 0000000..05bfea8 --- /dev/null +++ b/frontend/scripts/run-e2e-gate.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import test from 'node:test' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)) +const gateScript = resolve(scriptDirectory, 'run-e2e-gate.mjs') + +function runGate(...args) { + return spawnSync(process.execPath, [gateScript, ...args], { encoding: 'utf8' }) +} + +test('exclude-id removes a dedicated scenario from both release outputs', () => { + const options = ['release', '--exclude-kind', 'setup', '--exclude-id', '@e2e-system-update-lifecycle-001'] + const ids = runGate(...options, '--print-ids') + const pattern = runGate(...options, '--print-pattern') + + assert.equal(ids.status, 0, ids.stderr) + assert.equal(pattern.status, 0, pattern.stderr) + assert.match(ids.stdout, /@e2e-system-update-001/) + assert.doesNotMatch(ids.stdout, /@e2e-system-update-lifecycle-001/) + assert.match(pattern.stdout, /@e2e-system-update-001/) + assert.doesNotMatch(pattern.stdout, /@e2e-system-update-lifecycle-001/) +}) + +test('exclude-id fails closed for a scenario outside the selected gate', () => { + const result = runGate('pr', '--exclude-id', '@e2e-system-update-lifecycle-001', '--print-ids') + + assert.equal(result.status, 2) + assert.match(result.stderr, /not registered for the pr gate/) +}) + +test('exclude-id requires a value', () => { + const result = runGate('release', '--exclude-id') + + assert.equal(result.status, 2) + assert.match(result.stderr, /--exclude-id requires a value/) +}) diff --git a/frontend/src/components/marketing/MarketingHeroVisual.vue b/frontend/src/components/marketing/MarketingHeroVisual.vue new file mode 100644 index 0000000..d2e28e8 --- /dev/null +++ b/frontend/src/components/marketing/MarketingHeroVisual.vue @@ -0,0 +1,288 @@ + + + + + diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index a394fb6..56f7b6d 100644 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -486,7 +486,8 @@ export default { gatewaySimulator: { subtitle: 'Resolve and inspect the current scheduling plan without calling an upstream provider or consuming limits.', model: 'Requested model', clientProtocol: 'Client protocol', requiredFeatures: 'Required features', estimatedTokens: 'Estimated tokens', run: 'Run simulation', status: 'status', resolvedModel: 'resolved model', routeGroup: 'route group', candidates: 'candidates', - route: 'Route', account: 'Provider / account', adapter: 'Adapter', headroom: 'Headroom', limits: 'Limits / circuit', decision: 'Decision', eligible: 'eligible', eligibleCount: '{count} eligible', concurrent: '{count} concurrent' + route: 'Route', account: 'Provider / account', adapter: 'Adapter', headroom: 'Headroom', limits: 'Limits / circuit', decision: 'Decision', eligible: 'eligible', eligibleCount: '{count} eligible', concurrent: '{count} concurrent', + reasons: { routing_policy_native_protocol_required: 'Native protocol required', routing_policy_price_fact_missing: 'Comparable price fact missing', routing_policy_input_price_exceeded: 'Input price cap exceeded', routing_policy_output_price_exceeded: 'Output price cap exceeded', routing_policy_relative_price_exceeded: 'Cheapest-price multiple exceeded', routing_policy_low_price_pool_excluded: 'Outside the low-price pool', routing_policy_failover_disabled: 'Excluded because failover is disabled', routing_policy_model_blocked: 'Model blocked by policy', routing_policy_protocol_blocked: 'Protocol blocked by policy', client_protocol_unsupported: 'Unsupported client protocol', route_disabled: 'Route disabled', account_disabled: 'Account disabled', account_not_schedulable: 'Account not schedulable', account_expired: 'Account expired', account_cooling_down: 'Account cooling down', secret_missing: 'Account secret missing', upstream_model_not_exposed: 'Upstream model not exposed', provider_not_found: 'Provider not found', provider_disabled: 'Provider disabled', provider_url_invalid: 'Provider URL invalid', circuit_open: 'Circuit open', at_capacity: 'Concurrency exhausted', rpm_exhausted: 'RPM exhausted', tpm_exhausted: 'TPM exhausted', not_schedulable: 'Not schedulable', protocol_incompatible_openai_embeddings: 'Embedding protocol incompatible', protocol_incompatible_native_media: 'Native media protocol incompatible', protocol_incompatible_response_format: 'Response format unsupported', protocol_incompatible_top_k: 'top_k unsupported' } }, routingGroups: { subtitle: 'Advanced routing boundaries for fallback, ordering, capacity, and route governance.', @@ -1266,7 +1267,7 @@ export default { offlinePackageFile: 'Offline plugin package file JSON', installPackage: 'Install', uninstallPackage: 'Uninstall', - noPackages: '', + noPackages: 'No packages are available for this plugin.', status: 'Status', actions: 'Actions', enable: 'Enable', @@ -1437,7 +1438,7 @@ export default { allDeliveryStatuses: 'All delivery statuses', target: 'Target', error: 'Error', - noDeliveries: '', + noDeliveries: 'No delivery attempts match the current filter.', deliveryStatuses: { succeeded: 'Succeeded', failed: 'Failed', @@ -1458,7 +1459,7 @@ export default { searchPlaceholder: 'Search plugins, vendors, or categories', allCategories: 'All categories', allTiers: 'All tiers', - empty: '' + empty: 'No plugins match the current filters.' }, console: { title: 'Management Console', diff --git a/frontend/src/i18n/locales/marketing.ts b/frontend/src/i18n/locales/marketing.ts index a60ddc2..9ebd201 100644 --- a/frontend/src/i18n/locales/marketing.ts +++ b/frontend/src/i18n/locales/marketing.ts @@ -9,7 +9,9 @@ export const marketingZh = { description: '在企业应用与已授权 AI 供应商之间建立统一控制面。一次完成身份校验、访问准入、资源选择、成本约束和请求级证据记录。', primaryAction: '进入企业控制台', secondaryAction: '查看请求决策', factsLabel: '产品事实', factProtocols: '统一接入 OpenAI、Anthropic 与 Gemini 兼容协议', factEvidence: '策略、路由、用量、成本与 Trace 使用同一证据链', - imageAlt: 'AsterRouter 路由策略工作台实际界面', actualProduct: '实际产品界面', actualProductDetail: '路由策略工作台 · v0.23.1' + imageAlt: 'AsterRouter 路由策略工作台实际界面', + liveDecisionLabel: 'AsterRouter 实时路由决策预览', liveDecision: '实时决策', requestApproved: '请求已进入首选线路', requestApprovedDetail: '策略版本、候选顺序与执行结果同步写入 Trace', + policyProof: '访问与路由策略已命中', policyProofDetail: '先准入,再选择供应资源', costProof: '成本边界已检查', costProofDetail: '有效价格未超过策略上限', capacityProof: '容量租约已获取', capacityProofDetail: '并发、RPM 与 TPM 均可用' }, decision: { overline: 'ONE REQUEST, ONE EXPLAINABLE DECISION', title: '每一次模型请求,都经过同一条企业决策链', @@ -59,7 +61,9 @@ export const marketingEn = { description: 'Place one control plane between enterprise applications and authorized AI providers. Enforce identity, access, supply selection, cost boundaries, and request-level evidence on every call.', primaryAction: 'Open enterprise console', secondaryAction: 'Inspect the decision flow', factsLabel: 'Product facts', factProtocols: 'One gateway for OpenAI, Anthropic, and Gemini-compatible protocols', factEvidence: 'Policy, routing, usage, cost, and traces share one evidence chain', - imageAlt: 'Actual AsterRouter routing policy workbench', actualProduct: 'Actual product interface', actualProductDetail: 'Routing policy workbench · v0.23.1' + imageAlt: 'Actual AsterRouter routing policy workbench', + liveDecisionLabel: 'AsterRouter live routing decision preview', liveDecision: 'Live decision', requestApproved: 'Request entered the preferred route', requestApprovedDetail: 'Policy version, candidate order, and outcome are written to the trace', + policyProof: 'Access and routing policy matched', policyProofDetail: 'Admit first, then select supply', costProof: 'Cost boundary checked', costProofDetail: 'Effective price remains inside policy', capacityProof: 'Capacity lease acquired', capacityProofDetail: 'Concurrency, RPM, and TPM are available' }, decision: { overline: 'ONE REQUEST, ONE EXPLAINABLE DECISION', title: 'Every model request follows the same enterprise decision chain', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 26599d2..e48b6e9 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -486,7 +486,8 @@ export default { gatewaySimulator: { subtitle: '不调用上游、不消耗限流额度,解析并检查当前真实调度计划。', model: '请求模型', clientProtocol: '客户端协议', requiredFeatures: '必需能力', estimatedTokens: '预估 Token', run: '运行模拟', status: '状态', resolvedModel: '解析模型', routeGroup: '路由组', candidates: '候选', - route: '路由', account: 'Provider / 账号', adapter: 'Adapter', headroom: '余量', limits: '限流 / 熔断', decision: '调度结论', eligible: '可调度', eligibleCount: '{count} 个可调度', concurrent: '{count} 并发' + route: '路由', account: 'Provider / 账号', adapter: 'Adapter', headroom: '余量', limits: '限流 / 熔断', decision: '调度结论', eligible: '可调度', eligibleCount: '{count} 个可调度', concurrent: '{count} 并发', + reasons: { routing_policy_native_protocol_required: '要求原生协议', routing_policy_price_fact_missing: '缺少可比采购价', routing_policy_input_price_exceeded: '超过输入价格上限', routing_policy_output_price_exceeded: '超过输出价格上限', routing_policy_relative_price_exceeded: '超过相对最低价上限', routing_policy_low_price_pool_excluded: '不在低价候选池内', routing_policy_failover_disabled: '故障切换关闭,未进入候选', routing_policy_model_blocked: '模型被策略阻断', routing_policy_protocol_blocked: '协议被策略阻断', client_protocol_unsupported: '不支持的客户端协议', route_disabled: '路由已停用', account_disabled: '账号已停用', account_not_schedulable: '账号不可调度', account_expired: '账号已过期', account_cooling_down: '账号冷却中', secret_missing: '账号密钥缺失', upstream_model_not_exposed: '账号未开放该上游模型', provider_not_found: 'Provider 不存在', provider_disabled: 'Provider 已停用', provider_url_invalid: 'Provider 地址无效', circuit_open: '熔断器已打开', at_capacity: '并发容量已满', rpm_exhausted: 'RPM 已耗尽', tpm_exhausted: 'TPM 已耗尽', not_schedulable: '当前不可调度', protocol_incompatible_openai_embeddings: 'Embedding 协议不兼容', protocol_incompatible_native_media: '原生媒体协议不兼容', protocol_incompatible_response_format: '不支持响应格式约束', protocol_incompatible_top_k: '不支持 top_k' } }, routingGroups: { subtitle: '维护 fallback、排序、容量和路由治理所需的高级路由边界。', @@ -1266,7 +1267,7 @@ export default { offlinePackageFile: '离线插件包文件 JSON', installPackage: '安装', uninstallPackage: '卸载', - noPackages: '', + noPackages: '该插件暂无可用安装包。', status: '状态', actions: '操作', enable: '启用', @@ -1437,7 +1438,7 @@ export default { allDeliveryStatuses: '全部投递状态', target: '目标', error: '错误', - noDeliveries: '', + noDeliveries: '当前筛选条件下暂无投递记录。', deliveryStatuses: { succeeded: '成功', failed: '失败', @@ -1458,7 +1459,7 @@ export default { searchPlaceholder: '搜索插件、厂商或分类', allCategories: '全部分类', allTiers: '全部层级', - empty: '' + empty: '没有符合当前筛选条件的插件。' }, console: { title: '管理控制台', diff --git a/frontend/src/router/index.test.ts b/frontend/src/router/index.test.ts index 5abbf7a..d533910 100644 --- a/frontend/src/router/index.test.ts +++ b/frontend/src/router/index.test.ts @@ -52,13 +52,22 @@ describe('enterprise router guards', () => { expect(router.currentRoute.value.fullPath).toBe('/portal/overview') }) + it('redirects the legacy plugin center path to its canonical system path', async () => { + localStorage.setItem('asterrouter_admin_token', 'token') + localStorage.setItem('asterrouter_admin_user', JSON.stringify(makeAuthUser({ role: 'super_admin' }))) + await router.push('/console/plugins') + expect(router.currentRoute.value.fullPath).toBe('/console/system/plugins') + await router.push('/console/plugins/com.asterrouter.example/workbench') + expect(router.currentRoute.value.fullPath).toBe('/console/system/plugins/com.asterrouter.example/workbench') + }) + it('registers only the two product entry trees', () => { const paths = router.getRoutes().map((route) => route.path) for (const legacy of ['/admin', '/operator', '/customer', '/platform']) expect(paths).not.toContain(legacy) for (const current of [ '/console/workbench', '/console/applications', '/console/model-services', '/console/policies/access', '/console/policies/routing', '/console/usage', - '/console/organization', '/console/system', '/portal/overview', + '/console/organization', '/console/system', '/console/system/plugins', '/portal/overview', '/portal/applications', '/portal/access', '/portal/usage', '/portal/account' ]) expect(paths).toContain(current) }) diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 9911295..fa470fb 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -117,6 +117,8 @@ const router = createRouter({ { path: 'organization', component: AdminUsersView, meta: { titleKey: 'console.organization', descriptionKey: 'console.organizationSubtitle' } }, { path: 'organization/departments', component: AdminDepartmentsView, meta: { titleKey: 'admin.departments', descriptionKey: 'departments.subtitle' } }, { path: 'organization/groups', component: AdminOrganizationGroupsView, meta: { titleKey: 'organizationGroups.title', descriptionKey: 'organizationGroups.subtitle' } }, + { path: 'plugins', redirect: '/console/system/plugins' }, + { path: 'plugins/:pluginId/workbench', redirect: (to) => `/console/system/plugins/${encodeURIComponent(String(to.params.pluginId || ''))}/workbench` }, { path: 'system', component: AdminSettingsView, meta: { titleKey: 'console.system', descriptionKey: 'console.systemSubtitle' } }, { path: 'system/plugins', component: AdminPluginsView, meta: { titleKey: 'admin.plugins', descriptionKey: 'plugins.subtitle' } }, { path: 'system/plugins/:pluginId/workbench', component: PluginFrontendView, meta: { titleKey: 'admin.plugins', descriptionKey: 'plugins.subtitle' } }, diff --git a/frontend/src/testing/browser-errors.test.ts b/frontend/src/testing/browser-errors.test.ts new file mode 100644 index 0000000..742792e --- /dev/null +++ b/frontend/src/testing/browser-errors.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { isNavigationCancellationError } from './browser-errors' + +describe('isNavigationCancellationError', () => { + it.each([ + 'net::ERR_ABORTED', + 'NS_BINDING_ABORTED', + 'cancelled' + ])('recognizes browser navigation cancellation error %s', (errorText) => { + expect(isNavigationCancellationError(errorText)).toBe(true) + }) + + it.each([ + undefined, + null, + '', + 'net::ERR_FAILED', + 'NS_ERROR_NET_RESET', + 'cancelled by server' + ])('keeps non-navigation failure %s observable', (errorText) => { + expect(isNavigationCancellationError(errorText)).toBe(false) + }) +}) diff --git a/frontend/src/testing/browser-errors.ts b/frontend/src/testing/browser-errors.ts new file mode 100644 index 0000000..fc09990 --- /dev/null +++ b/frontend/src/testing/browser-errors.ts @@ -0,0 +1,9 @@ +const navigationCancellationErrors = new Set([ + 'net::ERR_ABORTED', + 'NS_BINDING_ABORTED', + 'cancelled' +]) + +export function isNavigationCancellationError(errorText: string | null | undefined): boolean { + return typeof errorText === 'string' && navigationCancellationErrors.has(errorText) +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 0983764..5657908 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -735,6 +735,10 @@ export interface GatewaySimulation { route_group: string status: string summary: string + rejection_reason?: string + routing_policy_id?: string + routing_policy_version?: number + routing_policy_preset?: RoutingPolicyPreset candidates: GatewaySimulationCandidate[] } diff --git a/frontend/src/views/LegalDocumentView.vue b/frontend/src/views/LegalDocumentView.vue index 754badc..b37f5fd 100644 --- a/frontend/src/views/LegalDocumentView.vue +++ b/frontend/src/views/LegalDocumentView.vue @@ -1,5 +1,5 @@ diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index 52e0b39..3a3d9d6 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -255,7 +255,7 @@ function changeLocale(event: Event) {
{{ t('auth.accountSignIn') }}
{{ actionMessage }}
-
{{ auth.error }}
+
diff --git a/frontend/src/views/MarketingHomeView.test.ts b/frontend/src/views/MarketingHomeView.test.ts index dea5f35..a23c1cd 100644 --- a/frontend/src/views/MarketingHomeView.test.ts +++ b/frontend/src/views/MarketingHomeView.test.ts @@ -27,6 +27,9 @@ describe('AsterRouter official website', () => { expect(wrapper.text()).toContain('Enterprise AI access and routing infrastructure') expect(wrapper.text()).toContain('Every model request follows the same enterprise decision chain') expect(wrapper.text()).toContain('A policy is not one weight. It is a complete routing contract.') + expect(wrapper.text()).toContain('Live decision') + expect(wrapper.text()).toContain('Cost boundary checked') + expect(wrapper.text()).toContain('Request entered the preferred route') expect(wrapper.get('.hero-product-image').attributes('src')).toBe('/images/asterrouter-routing-workbench.webp') expect(wrapper.get('.primary-action').attributes('href')).toBe('/login') expect(document.title).toContain('AsterRouter') @@ -35,6 +38,8 @@ describe('AsterRouter official website', () => { await nextTick() expect(wrapper.text()).toContain('企业 AI 访问与路由基础设施') expect(wrapper.text()).toContain('策略不是一个权重,而是一份完整路由合同') + expect(wrapper.text()).toContain('实时决策') + expect(wrapper.text()).toContain('成本边界已检查') wrapper.unmount() }) }) diff --git a/frontend/src/views/MarketingHomeView.vue b/frontend/src/views/MarketingHomeView.vue index bb4ab10..4e91f7e 100644 --- a/frontend/src/views/MarketingHomeView.vue +++ b/frontend/src/views/MarketingHomeView.vue @@ -21,6 +21,7 @@ import { } from '@lucide/vue' import { useI18n } from 'vue-i18n' import { RouterLink } from 'vue-router' +import MarketingHeroVisual from '@/components/marketing/MarketingHeroVisual.vue' import { availableLocales, getLocale, setLocale, type LocaleCode } from '@/i18n' import { entryForUser } from '@/router/access' import type { AuthUser } from '@/types' @@ -145,8 +146,7 @@ onBeforeUnmount(() => window.removeEventListener('scroll', handleScroll))
- - +
{{ t('marketing.hero.overline') }}

AsterRouter

@@ -161,7 +161,6 @@ onBeforeUnmount(() => window.removeEventListener('scroll', handleScroll)) {{ t('marketing.hero.factEvidence') }}
-
{{ t('marketing.hero.actualProduct') }}{{ t('marketing.hero.actualProductDetail') }}
@@ -299,15 +298,14 @@ onBeforeUnmount(() => window.removeEventListener('scroll', handleScroll)) .marketing-page button, .marketing-page select, .marketing-page a { letter-spacing: 0; } .marketing-page :where(a, button, select):focus-visible { outline: 3px solid #2e90fa; outline-offset: 3px; } .marketing-shell { width: min(1180px, calc(100% - 48px)); margin-inline: auto; } -.marketing-header { position: fixed; z-index: 60; inset: 0 0 auto; border-bottom: 1px solid rgba(255, 255, 255, .18); background: #08131f; color: #fff; transition: background-color 180ms ease, box-shadow 180ms ease; } -.marketing-header.scrolled, .marketing-header.open { border-color: var(--m-line); background: rgba(255, 255, 255, .97); color: var(--m-ink); box-shadow: 0 8px 28px rgba(18, 25, 38, .07); } +.marketing-header { position: fixed; z-index: 60; inset: 0 0 auto; border-bottom: 1px solid var(--m-line); background: rgba(255, 255, 255, .94); color: var(--m-ink); backdrop-filter: blur(18px); transition: background-color 180ms ease, box-shadow 180ms ease; } +.marketing-header.scrolled, .marketing-header.open { background: rgba(255, 255, 255, .98); box-shadow: 0 8px 28px rgba(18, 25, 38, .07); } .header-inner { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 26px; height: 72px; } .marketing-brand { display: inline-flex; width: max-content; align-items: center; gap: 10px; } .marketing-brand-mark { display: grid; width: 34px; height: 34px; place-items: center; border-radius: 7px; background: #0f766e; color: #fff; font-size: 11px; font-weight: 850; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .18); } .marketing-brand > span:last-child { display: flex; flex-direction: column; line-height: 1.05; } .marketing-brand strong { font-size: 15px; font-weight: 800; } -.marketing-brand small { margin-top: 5px; color: rgba(255, 255, 255, .64); font-size: 9px; font-weight: 650; } -.marketing-header.scrolled .marketing-brand small, .marketing-header.open .marketing-brand small { color: var(--m-muted); } +.marketing-brand small { margin-top: 5px; color: var(--m-muted); font-size: 9px; font-weight: 650; } .desktop-nav { display: flex; align-items: center; gap: 23px; white-space: nowrap; } .desktop-nav a { color: currentColor; font-size: 12px; font-weight: 650; opacity: .76; } .desktop-nav a:hover { opacity: 1; } @@ -315,35 +313,29 @@ onBeforeUnmount(() => window.removeEventListener('scroll', handleScroll)) .marketing-locale { display: inline-flex; align-items: center; gap: 5px; } .marketing-locale select { max-width: 112px; border: 0; background: transparent; color: inherit; font-size: 11px; font-weight: 650; cursor: pointer; } .marketing-locale option { color: var(--m-ink); } -.header-entry { min-height: 40px; display: inline-flex; align-items: center; gap: 7px; padding: 0 15px; border-radius: 999px; background: #fff; color: var(--m-ink) !important; font-size: 12px; font-weight: 750; } -.marketing-header.scrolled .header-entry, .marketing-header.open .header-entry { background: var(--m-ink); color: #fff !important; } +.header-entry { min-height: 40px; display: inline-flex; align-items: center; gap: 7px; padding: 0 15px; border-radius: 999px; background: var(--m-ink); color: #fff !important; font-size: 12px; font-weight: 750; } .mobile-menu-button { display: none; width: 40px; height: 40px; place-items: center; border: 1px solid currentColor; border-radius: 50%; background: transparent; color: inherit; } .mobile-nav { display: none; } -.marketing-hero { position: relative; height: min(760px, calc(100svh - 28px)); min-height: 650px; overflow: hidden; background: #08131f; color: #fff; isolation: isolate; } -.hero-product-image { position: absolute; z-index: -3; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: center top; opacity: .28; } -.hero-scrim { display: none; } -.hero-content { display: flex; height: 100%; max-width: 1180px; flex-direction: column; align-items: flex-start; justify-content: center; padding-top: 64px; } -.hero-overline { display: inline-flex; align-items: center; gap: 8px; color: #78e2d8; font-size: 10px; font-weight: 800; } -.marketing-hero h1 { margin: 20px 0 0; font-size: 64px; line-height: 1; font-weight: 820; letter-spacing: 0; } -.hero-category { display: block; max-width: 660px; margin-top: 18px; font-size: 38px; line-height: 1.2; font-weight: 720; } -.hero-content > p { max-width: 650px; margin: 21px 0 0; color: rgba(255, 255, 255, .78); font-size: 17px; line-height: 1.8; } +.marketing-hero { position: relative; height: min(760px, calc(100svh - 28px)); min-height: 720px; overflow: hidden; border-bottom: 1px solid var(--m-line); background: #f8fafc; color: var(--m-ink); isolation: isolate; } +.hero-content { position: relative; z-index: 2; display: flex; height: 100%; max-width: 1180px; flex-direction: column; align-items: flex-start; justify-content: center; padding: 70px 630px 0 0; } +.hero-overline { display: inline-flex; align-items: center; gap: 8px; color: var(--m-teal); font-size: 10px; font-weight: 800; } +.marketing-hero h1 { margin: 20px 0 0; font-size: 62px; line-height: 1; font-weight: 820; letter-spacing: 0; } +.hero-category { display: block; max-width: 560px; margin-top: 18px; font-size: 37px; line-height: 1.2; font-weight: 720; } +.hero-content > p { max-width: 560px; margin: 21px 0 0; color: var(--m-muted); font-size: 16px; line-height: 1.8; } .hero-actions, .marketing-cta > div > div { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 29px; } .primary-action, .secondary-action { min-height: 50px; display: inline-flex; align-items: center; justify-content: center; gap: 9px; padding: 0 21px; border: 1px solid; border-radius: 999px; font-size: 13px; font-weight: 750; } -.primary-action { border-color: #13b8aa; background: #13b8aa; color: #06241f !important; box-shadow: 0 16px 36px rgba(0, 0, 0, .22); } -.secondary-action { border-color: rgba(255, 255, 255, .36); background: rgba(5, 15, 26, .34); color: #fff !important; backdrop-filter: blur(12px); } -.hero-facts { display: flex; flex-direction: column; gap: 7px; margin-top: 24px; color: rgba(255, 255, 255, .7); font-size: 11px; } +.primary-action { border-color: var(--m-ink); background: var(--m-ink); color: #fff !important; box-shadow: 0 16px 36px rgba(18, 25, 38, .16); } +.secondary-action { border-color: var(--m-line); background: #fff; color: var(--m-ink) !important; } +.hero-facts { display: flex; flex-direction: column; gap: 7px; margin-top: 24px; color: var(--m-muted); font-size: 11px; } .hero-facts span { display: flex; align-items: center; gap: 8px; } -.hero-facts svg { color: #78e2d8; } -.hero-proof { position: absolute; right: max(24px, calc((100% - 1180px) / 2)); bottom: 24px; display: grid; padding-left: 14px; border-left: 2px solid #78e2d8; text-align: right; } -.hero-proof span { color: #78e2d8; font-size: 9px; font-weight: 800; } -.hero-proof strong { font-size: 11px; } +.hero-facts svg { color: var(--m-teal); } .marketing-section { padding: 104px 0; } .section-heading { max-width: 720px; margin-bottom: 46px; } .section-heading.compact { max-width: 760px; } .section-heading > span, .marketing-cta > div > span { color: var(--m-blue); font-size: 10px; font-weight: 850; } .section-heading h2, .marketing-cta h2 { margin: 12px 0 0; font-size: 45px; line-height: 1.2; font-weight: 770; letter-spacing: 0; } .section-heading p, .marketing-cta p { margin: 16px 0 0; color: var(--m-muted); font-size: 15px; line-height: 1.8; } -.decision-section { background: #fff; } +.decision-section { padding-top: 26px; background: #fff; } .decision-workbench { display: grid; grid-template-columns: 280px minmax(0, 1fr); overflow: hidden; border: 1px solid var(--m-line); border-radius: 8px; background: #fff; box-shadow: 0 24px 70px rgba(18, 25, 38, .1); } .decision-stage-list { padding: 18px; border-right: 1px solid var(--m-line); background: #f4f7f9; } .decision-stage-list > div { min-height: 74px; display: grid; grid-template-columns: 26px 25px minmax(0, 1fr); align-items: center; gap: 8px; padding: 10px; border-bottom: 1px solid var(--m-line); color: var(--m-muted); } @@ -429,7 +421,7 @@ onBeforeUnmount(() => window.removeEventListener('scroll', handleScroll)) .marketing-cta h2 { max-width: 780px; } .marketing-cta p { max-width: 690px; } .marketing-cta p { color: #5d6677; } -.marketing-cta .primary-action { color: #06241f !important; } +.marketing-cta .primary-action { color: #fff !important; } .marketing-cta .secondary-action { border-color: var(--m-line); background: #fff; color: var(--m-ink) !important; } .marketing-footer { padding: 62px 0 22px; background: #0a101a; color: rgba(255, 255, 255, .55); } .footer-main { display: grid; grid-template-columns: 1.5fr .7fr .7fr; gap: 64px; } @@ -450,16 +442,18 @@ onBeforeUnmount(() => window.removeEventListener('scroll', handleScroll)) .mobile-locale select { flex: 1; border: 0; background: transparent; color: var(--m-ink); font-size: 12px; font-weight: 700; } .routing-layout { grid-template-columns: 1fr; gap: 20px; } .routing-layout .section-heading { max-width: 760px; } + .marketing-hero { height: 860px; min-height: 860px; } + .hero-content { height: auto; justify-content: flex-start; padding: 116px 24px 0; } + .hero-category, .hero-content > p { max-width: 700px; } } @media (max-width: 820px) { .marketing-shell { width: min(100% - 32px, 1180px); } - .marketing-hero { height: 720px; min-height: 720px; } - .hero-product-image { object-position: 36% top; } + .marketing-hero { height: 820px; min-height: 820px; } .marketing-hero h1 { font-size: 48px; } .hero-category { max-width: 560px; font-size: 31px; } .hero-content > p { max-width: 560px; font-size: 15px; } - .hero-proof { right: 16px; bottom: 16px; } .marketing-section { padding: 80px 0; } + .decision-section { padding-top: 26px; } .section-heading h2, .marketing-cta h2 { font-size: 36px; } .decision-workbench { grid-template-columns: 1fr; } .decision-stage-list { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); padding: 10px; border-right: 0; border-bottom: 1px solid var(--m-line); } @@ -479,15 +473,15 @@ onBeforeUnmount(() => window.removeEventListener('scroll', handleScroll)) @media (max-width: 560px) { .header-inner { height: 66px; } .marketing-brand small { display: none; } - .marketing-hero { height: 700px; min-height: 700px; } - .hero-content { justify-content: flex-start; padding-top: 150px; } - .marketing-hero h1 { margin-top: 18px; font-size: 42px; } - .hero-category { margin-top: 15px; font-size: 27px; } - .hero-content > p { margin-top: 16px; font-size: 14px; } - .hero-actions { width: 100%; max-width: 360px; flex-direction: column; margin-top: 23px; } - .primary-action, .secondary-action { width: 100%; } - .hero-facts { font-size: 10px; } - .hero-proof { display: none; } + .marketing-hero { height: 760px; min-height: 760px; } + .hero-content { padding: 98px 16px 0; } + .hero-overline { font-size: 8px; } + .marketing-hero h1 { margin-top: 15px; font-size: 40px; } + .hero-category { margin-top: 13px; font-size: 25px; } + .hero-content > p { margin-top: 13px; font-size: 13px; line-height: 1.65; } + .hero-actions { width: 100%; max-width: 358px; margin-top: 19px; } + .primary-action, .secondary-action { min-height: 44px; padding-inline: 15px; font-size: 11px; } + .hero-facts { gap: 4px; margin-top: 15px; font-size: 9px; } .section-heading h2, .marketing-cta h2 { font-size: 32px; } .section-heading p, .marketing-cta p { font-size: 14px; } .decision-stage-list { grid-template-columns: 1fr; padding: 8px 16px; } diff --git a/frontend/src/views/admin/AdminArtifactsView.test.ts b/frontend/src/views/admin/AdminArtifactsView.test.ts index 1a9e4ac..e245592 100644 --- a/frontend/src/views/admin/AdminArtifactsView.test.ts +++ b/frontend/src/views/admin/AdminArtifactsView.test.ts @@ -58,6 +58,18 @@ const readyDetail: ArtifactAdminDetail = { events: detail.events } +const deliveredCustomerArtifact: ArtifactAdminRecord = { + ...artifact, + status: 'delivered', + error_type: undefined, + delivered_at: '2026-07-15T10:02:00Z' +} + +const deliveredCustomerDetail: ArtifactAdminDetail = { + artifact: deliveredCustomerArtifact, + events: detail.events +} + describe('AdminArtifactsView', () => { beforeEach(() => { vi.clearAllMocks() @@ -134,6 +146,20 @@ describe('AdminArtifactsView', () => { wrapper.unmount() }) + it('does not request content that was delivered only to a customer-owned sink', async () => { + vi.mocked(control.getArtifacts).mockResolvedValue([deliveredCustomerArtifact]) + vi.mocked(control.getArtifact).mockResolvedValue(deliveredCustomerDetail) + + const wrapper = mount(AdminArtifactsView, { global: { plugins: [i18n] } }) + await flushPromises() + await wrapper.get('button[aria-label="Details"]').trigger('click') + await flushPromises() + + expect(control.getArtifactContent).not.toHaveBeenCalled() + expect(wrapper.get('.artifact-preview').text()).toContain('not currently available for preview') + wrapper.unmount() + }) + it('shows a stable error state when artifact data cannot be loaded', async () => { vi.mocked(control.getArtifacts).mockRejectedValueOnce(new Error('artifact service unavailable')) const wrapper = mount(AdminArtifactsView, { global: { plugins: [i18n] } }) diff --git a/frontend/src/views/admin/AdminArtifactsView.vue b/frontend/src/views/admin/AdminArtifactsView.vue index 5a00407..4986b8c 100644 --- a/frontend/src/views/admin/AdminArtifactsView.vue +++ b/frontend/src/views/admin/AdminArtifactsView.vue @@ -175,6 +175,10 @@ function resetPreview() { } async function loadArtifactPreview(artifact: ArtifactAdminRecord, requestVersion: number) { + if (artifact.policy === 'customer_sink' && artifact.store_driver === 'none') { + previewError.value = t('artifactOps.previewUnavailable') + return + } if (!['ready', 'delivered'].includes(artifact.status) || artifact.size_bytes <= 0) { previewError.value = artifact.size_bytes <= 0 ? t('artifactOps.previewEmpty') diff --git a/frontend/src/views/admin/AdminCostAllocationView.vue b/frontend/src/views/admin/AdminCostAllocationView.vue index 8637178..cb6dc7d 100644 --- a/frontend/src/views/admin/AdminCostAllocationView.vue +++ b/frontend/src/views/admin/AdminCostAllocationView.vue @@ -184,18 +184,20 @@ onMounted(load)
-
- -
+
+
+ +
+