From 9d8c73561f1d9d612cb91e5ac89028cd0c01c354 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Thu, 6 Aug 2026 19:50:54 +0800 Subject: [PATCH] test(deploy): integrate billing admin rollout checks --- deploy/.env.staging.identity.example | 2 + deploy/Caddyfile.staging | 11 + deploy/README.md | 8 +- deploy/postgres/001-bootstrap-roles.sh | 15 +- ...02-ensure-business-compatibility-roles.sql | 10 +- deploy/validate-staging-database-env.sh | 3 +- deploy/validate-staging-env.sh | 8 + docs/BUG_HISTORY.md | 16 + docs/operations/self-hosted-identity.md | 31 +- frontend/tests/account-api.test.ts | 7 +- frontend/tests/admin-auth.test.ts | 47 +- frontend/tests/admin-contracts.test.ts | 109 +++- frontend/tests/admin-database.test.ts | 20 +- frontend/tests/admin-mfa.test.ts | 84 +++ .../tests/admin-payments-contract.test.ts | 46 +- frontend/tests/admin-reauth.test.ts | 110 ++++ .../admin-ui-permission-contract.test.ts | 143 +++++ frontend/tests/admin-users-contract.test.ts | 14 +- .../application-billing-contract.test.ts | 74 +++ .../tests/database-admin-identity.test.ts | 118 ++++ frontend/tests/database-backup.test.ts | 2 + .../database-billing-adjustments.test.ts | 486 +++++++++++++++ frontend/tests/database-billing-admin.test.ts | 571 ++++++++++++++++++ frontend/tests/database-env-validator.test.ts | 1 + .../tests/database-local-business.test.ts | 75 ++- .../database-self-hosted-identity.test.ts | 48 +- frontend/tests/epay-settings.test.ts | 98 ++- frontend/tests/health-deployment.test.ts | 12 +- frontend/tests/helpers/postgres-fixture.ts | 1 + .../high-risk-billing-routes-contract.test.ts | 108 ++++ frontend/tests/identity-auth-factory.test.ts | 18 +- .../tests/identity-auth-integration.test.ts | 192 +++++- frontend/tests/identity-config.test.ts | 16 +- frontend/tests/identity-host-routing.test.ts | 33 +- .../tests/identity-login-provider.test.ts | 65 +- frontend/tests/identity-session.test.ts | 8 +- frontend/tests/model-catalog.test.ts | 21 + .../model-configuration-security.test.ts | 380 ++++++++++++ .../tests/rectification-agentic-entry.test.ts | 37 +- .../tests/staging-backend-workflows.test.ts | 13 + 40 files changed, 2911 insertions(+), 150 deletions(-) create mode 100644 frontend/tests/admin-mfa.test.ts create mode 100644 frontend/tests/admin-reauth.test.ts create mode 100644 frontend/tests/admin-ui-permission-contract.test.ts create mode 100644 frontend/tests/application-billing-contract.test.ts create mode 100644 frontend/tests/database-admin-identity.test.ts create mode 100644 frontend/tests/database-billing-adjustments.test.ts create mode 100644 frontend/tests/database-billing-admin.test.ts create mode 100644 frontend/tests/high-risk-billing-routes-contract.test.ts create mode 100644 frontend/tests/model-configuration-security.test.ts diff --git a/deploy/.env.staging.identity.example b/deploy/.env.staging.identity.example index b0836d3a..ff80c204 100644 --- a/deploy/.env.staging.identity.example +++ b/deploy/.env.staging.identity.example @@ -9,8 +9,10 @@ SITE_ADDRESS=https://staging.jyotisha.chat AUTH_PROVIDER=self-hosted SELF_HOSTED_IDENTITY_ENABLED=true AUTH_USER_ORIGIN=https://staging.jyotisha.chat +ADMIN_USER_ORIGIN=https://admin.staging.jyotisha.chat IDENTITY_DATABASE_URL=postgresql://identity_runtime:@postgres:5432/jyotisha APP_DATABASE_URL=postgresql://app_runtime:@postgres:5432/jyotisha +SERVICE_DATABASE_URL=postgresql://service_runtime:@postgres:5432/jyotisha ADMIN_DATABASE_URL=postgresql://admin_runtime:@postgres:5432/jyotisha BETTER_AUTH_USER_SECRET= RESEND_API_KEY= diff --git a/deploy/Caddyfile.staging b/deploy/Caddyfile.staging index 66bbb59c..6a79a92c 100644 --- a/deploy/Caddyfile.staging +++ b/deploy/Caddyfile.staging @@ -1,4 +1,15 @@ {$SITE_ADDRESS:https://staging.jyotisha.chat} { encode zstd gzip + + @adminPaths path /admin /admin/* /api/admin/* + respond @adminPaths "Not found" 404 + + reverse_proxy web:3000 +} + +https://admin.staging.jyotisha.chat { + encode zstd gzip + @root path / + redir @root /admin 308 reverse_proxy web:3000 } diff --git a/deploy/README.md b/deploy/README.md index 7521674f..0c24971b 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -166,6 +166,7 @@ Staging is isolated from production: | Item | Value | | --- | --- | | URL | `https://staging.jyotisha.chat` | +| Admin URL | `https://admin.staging.jyotisha.chat` | | Host | `118.26.111.127` | | Path | `/opt/jyotisha-staging` | | Runtime app env | `/opt/jyotisha-staging/.env.staging` (`0600`) | @@ -187,7 +188,7 @@ CADDYFILE_PATH=./Caddyfile.staging SITE_ADDRESS=https://staging.jyotisha.chat ``` -Staging is fully self-hosted: set `AUTH_PROVIDER=self-hosted` and `SELF_HOSTED_IDENTITY_ENABLED=true`. Add the three role-specific server-only database URLs, the single `AUTH_USER_ORIGIN` and `BETTER_AUTH_USER_SECRET`, and staging-only Resend settings listed in `deploy/.env.staging.identity.example`. The main-site Better Auth user session is also used by `/admin`; persisted `identity.users.role=admin` is the only self-hosted backend role, while `viewer` and ordinary users are denied. Browser code uses same-origin APIs; it receives neither database credentials nor Supabase keys. Production remains on Supabase and is not changed by the staging workflow. See `docs/operations/self-hosted-identity.md` for validation and rollback commands. +Staging is fully self-hosted: set `AUTH_PROVIDER=self-hosted` and `SELF_HOSTED_IDENTITY_ENABLED=true`. Add the four role-specific server-only database URLs, the exact `AUTH_USER_ORIGIN=https://staging.jyotisha.chat` and `ADMIN_USER_ORIGIN=https://admin.staging.jyotisha.chat`, the single `BETTER_AUTH_USER_SECRET`, and staging-only Resend settings listed in `deploy/.env.staging.identity.example`. Both hosts run the same application and Better Auth service, but cookies remain host-only; the admin host `/` redirects to `/admin`, and unauthenticated admin requests continue to `/login` on that host. Better Auth trusts only those two origins, while unknown identity hosts fail closed. Persisted `identity.users.role=admin` is the only self-hosted backend role, while `viewer` and ordinary users are denied. Browser code uses same-origin APIs; it receives neither database credentials nor Supabase keys. Production remains on Supabase and is not changed by the staging workflow. See `docs/operations/self-hosted-identity.md` for validation and rollback commands. After source sync and before `up`, the workflow validates `.env.staging` mode/selectors, explicitly pins the three staging selectors against ambient shell overrides, and runs `docker compose --env-file .env.staging -f deploy/docker-compose.server.yml config --quiet`. For later manual inspections, run the same checks only after the tracked deployment files exist on the server. Do not use a manual gate run from `main` as the first publishing path: publishing requires a successful push to `staging`, while manual `Deploy staging` requires a successful gate run for the exact SHA. @@ -234,7 +235,7 @@ touch .env.staging.database chmod 600 .env.staging.database ``` -`.env.staging` contains application selectors and server-only application credentials. `SCHEMA_DATABASE_URL` must not appear in `.env.staging`; neither may any database bootstrap password, `STAGING_BACKUP_ENCRYPTION_KEY`, or migration-runner credential. In particular, there is no `SCHEMA_DATABASE_URL` in `.env.staging`; the schema URL exists only in `.env.staging.database`, which is read by PostgreSQL and the opt-in migrator. +`.env.staging` contains application selectors and server-only application credentials. `SCHEMA_DATABASE_URL` must not appear in `.env.staging`; neither may any database bootstrap password, `STAGING_BACKUP_ENCRYPTION_KEY`, or migration-runner credential. In particular, there is no `SCHEMA_DATABASE_URL` or `SERVICE_RUNTIME_PASSWORD` in `.env.staging`; the schema URL and raw role password exist only in `.env.staging.database`, which is read by PostgreSQL and the opt-in migrator. The web/API runtime receives only the percent-encoded `SERVICE_DATABASE_URL` from `.env.staging`. Generate every `` value from independently generated 32 random bytes (for example, run `openssl rand -base64 32` separately for each value and place it directly into the mode-`0600` file or an approved secret store). Do not reuse a password between roles, paste values into chat, commit either file, or print them in workflow logs. The schema-owner password in `SCHEMA_DATABASE_URL` is the same secret as `SCHEMA_OWNER_PASSWORD`; use a percent-encoded URL password component only, and do not encode the scheme, host, port, or database name. @@ -247,6 +248,7 @@ POSTGRES_PASSWORD= SCHEMA_OWNER_PASSWORD= IDENTITY_RUNTIME_PASSWORD= APP_RUNTIME_PASSWORD= +SERVICE_RUNTIME_PASSWORD= ADMIN_RUNTIME_PASSWORD= MIGRATION_RUNNER_PASSWORD= BACKUP_READER_PASSWORD= @@ -254,6 +256,8 @@ STAGING_BACKUP_ENCRYPTION_KEY= SCHEMA_DATABASE_URL=postgresql://schema_owner:@postgres:5432/jyotisha ``` +Use the same independently generated service-role secret in exactly two host-managed locations: the raw `SERVICE_RUNTIME_PASSWORD` in `.env.staging.database`, and its percent-encoded password component in `SERVICE_DATABASE_URL=postgresql://service_runtime:@postgres:5432/jyotisha` inside `.env.staging`. Keep both files owned by the staging deploy user with mode `0600`. The deploy controller runs both validators with shell tracing disabled before Compose changes; Gitea stores neither value and the workflow must never echo, interpolate, or pass either password as a workflow environment variable. + PostgreSQL is private: `deploy/docker-compose.postgres.yml` has no `ports` mapping, so the staging database is reachable only on the Docker `app` network. The CI overlay is the only host binding and is loopback-only (`127.0.0.1:${POSTGRES_HOST_PORT:-55432}:5432`); do not add a public database port, firewall exception, or browser-facing SQL tool. Normal web/API containers never receive `SCHEMA_DATABASE_URL`. ### Exact deployment and migration order diff --git a/deploy/postgres/001-bootstrap-roles.sh b/deploy/postgres/001-bootstrap-roles.sh index 84de2513..e55cb784 100755 --- a/deploy/postgres/001-bootstrap-roles.sh +++ b/deploy/postgres/001-bootstrap-roles.sh @@ -5,7 +5,8 @@ set +x required=( POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD SCHEMA_OWNER_PASSWORD IDENTITY_RUNTIME_PASSWORD APP_RUNTIME_PASSWORD - ADMIN_RUNTIME_PASSWORD MIGRATION_RUNNER_PASSWORD BACKUP_READER_PASSWORD + SERVICE_RUNTIME_PASSWORD ADMIN_RUNTIME_PASSWORD MIGRATION_RUNNER_PASSWORD + BACKUP_READER_PASSWORD ) for key in "${required[@]}"; do if [ -z "${!key:-}" ]; then @@ -21,6 +22,7 @@ psql --set ON_ERROR_STOP=1 \ --set schema_owner_password="$SCHEMA_OWNER_PASSWORD" \ --set identity_runtime_password="$IDENTITY_RUNTIME_PASSWORD" \ --set app_runtime_password="$APP_RUNTIME_PASSWORD" \ + --set service_runtime_password="$SERVICE_RUNTIME_PASSWORD" \ --set admin_runtime_password="$ADMIN_RUNTIME_PASSWORD" \ --set migration_runner_password="$MIGRATION_RUNNER_PASSWORD" \ --set backup_reader_password="$BACKUP_READER_PASSWORD" <<'SQL' @@ -42,6 +44,12 @@ SELECT format( ) WHERE NOT EXISTS ( SELECT 1 FROM pg_roles WHERE rolname = 'app_runtime' ) \gexec +SELECT format( + 'CREATE ROLE service_runtime WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT PASSWORD %L', + :'service_runtime_password' +) WHERE NOT EXISTS ( + SELECT 1 FROM pg_roles WHERE rolname = 'service_runtime' +) \gexec SELECT format( 'CREATE ROLE admin_runtime WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT PASSWORD %L', :'admin_runtime_password' @@ -58,7 +66,8 @@ WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') \gexec ALTER ROLE service_role BYPASSRLS; GRANT authenticated TO app_runtime; -GRANT service_role TO admin_runtime; +GRANT service_role TO service_runtime; +REVOKE service_role FROM admin_runtime; SELECT format( 'CREATE ROLE migration_runner WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT PASSWORD %L', :'migration_runner_password' @@ -77,7 +86,7 @@ SELECT format( :'database_name' ) \gexec SELECT format( - 'GRANT CONNECT ON DATABASE %I TO identity_runtime, app_runtime, admin_runtime, migration_runner, backup_reader', + 'GRANT CONNECT ON DATABASE %I TO identity_runtime, app_runtime, service_runtime, admin_runtime, migration_runner, backup_reader', :'database_name' ) \gexec diff --git a/deploy/postgres/002-ensure-business-compatibility-roles.sql b/deploy/postgres/002-ensure-business-compatibility-roles.sql index 01fb3cac..20338941 100644 --- a/deploy/postgres/002-ensure-business-compatibility-roles.sql +++ b/deploy/postgres/002-ensure-business-compatibility-roles.sql @@ -7,5 +7,13 @@ where not exists (select 1 from pg_roles where rolname = 'service_role') \gexec alter role service_role bypassrls; +\getenv service_runtime_password SERVICE_RUNTIME_PASSWORD +select format( + 'create role service_runtime with login nosuperuser nocreatedb nocreaterole noinherit password %L', + :'service_runtime_password' +) where not exists (select 1 from pg_roles where rolname = 'service_runtime') \gexec +select format('grant connect on database %I to service_runtime', current_database()) \gexec + grant authenticated to app_runtime; -grant service_role to admin_runtime; +grant service_role to service_runtime; +revoke service_role from admin_runtime; diff --git a/deploy/validate-staging-database-env.sh b/deploy/validate-staging-database-env.sh index d3f47eed..38e7f474 100755 --- a/deploy/validate-staging-database-env.sh +++ b/deploy/validate-staging-database-env.sh @@ -97,7 +97,8 @@ require_once_non_empty() { required=( POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD SCHEMA_OWNER_PASSWORD IDENTITY_RUNTIME_PASSWORD APP_RUNTIME_PASSWORD - ADMIN_RUNTIME_PASSWORD MIGRATION_RUNNER_PASSWORD BACKUP_READER_PASSWORD + SERVICE_RUNTIME_PASSWORD ADMIN_RUNTIME_PASSWORD MIGRATION_RUNNER_PASSWORD + BACKUP_READER_PASSWORD STAGING_BACKUP_ENCRYPTION_KEY SCHEMA_DATABASE_URL ) for key in "${required[@]}"; do diff --git a/deploy/validate-staging-env.sh b/deploy/validate-staging-env.sh index 00d26e46..b0e96aaf 100755 --- a/deploy/validate-staging-env.sh +++ b/deploy/validate-staging-env.sh @@ -55,6 +55,7 @@ require_selector SITE_ADDRESS https://staging.jyotisha.chat require_selector AUTH_PROVIDER self-hosted require_selector SELF_HOSTED_IDENTITY_ENABLED true require_selector AUTH_USER_ORIGIN https://staging.jyotisha.chat +require_selector ADMIN_USER_ORIGIN https://admin.staging.jyotisha.chat require_literal() { local key="$1" @@ -89,6 +90,13 @@ if ! [[ "$app_database_url" =~ ^postgresql://app_runtime:([A-Za-z0-9._~-]|%[0-9A exit 1 fi +require_literal SERVICE_DATABASE_URL 49 +service_database_url="$LITERAL_VALUE" +if ! [[ "$service_database_url" =~ ^postgresql://service_runtime:([A-Za-z0-9._~-]|%[0-9A-Fa-f]{2})+@postgres:5432/jyotisha$ ]]; then + echo "invalid staging database setting: SERVICE_DATABASE_URL" >&2 + exit 1 +fi + require_literal ADMIN_DATABASE_URL 45 admin_database_url="$LITERAL_VALUE" if ! [[ "$admin_database_url" =~ ^postgresql://admin_runtime:([A-Za-z0-9._~-]|%[0-9A-Fa-f]{2})+@postgres:5432/jyotisha$ ]]; then diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index a8890c33..d705dc07 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -2240,3 +2240,19 @@ - 相关记录:BUG-128、ERR-094、ERR-095 - 复发自:BUG-129 第一轮修复未覆盖对象范围 - 修复版本:`e59f15d352787f3d05425ba8c459d092e9801a20`;gate-attested controller bundle 已完成 staging exact-SHA 验收 + +## BUG-130 | self-hosted 计费查询与订单领域调整缺少并发和审计边界 + +- 状态:resolved(local) +- 首次发现:2026-08-06 +- 最近更新:2026-08-06 +- 影响面:self-hosted `LocalPostgresDataClient`、支付商品与订单创建、账户会员查询、生时校正 reservation 查询、订单后台、兑换码后台及 `20260806030000_settle_order_usage_authorization.sql`。 +- 用户现象:self-hosted runtime 无法执行 nested PostgREST select,部分时间和前缀筛选也缺少参数化 builder;一次性商品并发支付可能重复发放权益;订单后台只有读取能力,失败权益、人工补偿和账务退款缺少受控领域动作;兑换码写操作无法强制记录操作原因。 +- 触发条件:通过本地 PostgreSQL adapter 查询商品及权益、读取有效会员或 reservation 前缀;同一用户并发结算任意 `oneTimePerUser` 商品;管理员重试失败发放、人工补偿、登记线下退款,或创建、修改、撤销兑换码。 +- 根因:支付调用方依赖 self-hosted adapter 不支持的关联 select,adapter 又缺少 `lte`/`like` 参数化能力;一次性限制没有由不可变订单快照和数据库唯一约束共同承担;订单状态和余额缺少统一的管理员领域函数、幂等请求、乐观版本及审计合同;旧兑换码 RPC 不接收 reason。 +- 修复:商品及权益改为两次简单查询后在服务端组装,adapter 增加参数化 `lte`/`like`;订单创建写入不可变 `oneTimePerUser` 商品快照,结算与人工补偿统一通过 `(user_id, product_code)` 唯一兑换记录原子阻止重复发放;新增 `admin_adjust_order`,只允许 `retry_grant`、`compensate`、`record_refund`,强制 `billing.adjustments.write`、二次认证、reason、request ID 幂等、expected version 和审计,账务退款明确不调用外部支付网关;兑换码 create/update/revoke 新 RPC 均强制 reason,旧无 reason 签名撤销 runtime 执行权限。未直接修改余额或绕过领域函数改订单状态。 +- 验证:真实 PostgreSQL `tests/database-billing-adjustments.test.ts` 通过,覆盖一次性商品并发仅一次成功、快照不可变、重试/补偿/账务退款、权限、reason、幂等、版本冲突、审计及旧 RPC 权限撤销;综合 `tests/database-billing-admin.test.ts` 与 `tests/database-local-business.test.ts` 通过;计费 route/reauth/contract 测试 38/38 通过;目标 ESLint 与补丁检查通过。全仓 TypeScript 当前被共享工作树中非本任务的 `tests/identity-auth-integration.test.ts:412` 类型错误阻断。 +- 防复发:self-hosted 支付查询不得重新引入 nested PostgREST select;LIKE/范围条件必须参数化;一次性权益必须同时依赖不可变快照和数据库唯一约束;订单与兑换码后台不得直接更新余额或订单状态,所有写入必须经过带 reason、权限、幂等和审计的领域 RPC。 +- 相关记录:BUG-124 +- 复发自:BUG-124 +- 修复版本:待提交(本地可测) diff --git a/docs/operations/self-hosted-identity.md b/docs/operations/self-hosted-identity.md index 0b1e634d..b2e4fddb 100644 --- a/docs/operations/self-hosted-identity.md +++ b/docs/operations/self-hosted-identity.md @@ -10,11 +10,14 @@ Keep these values exactly as shown: AUTH_PROVIDER=self-hosted SELF_HOSTED_IDENTITY_ENABLED=true AUTH_USER_ORIGIN=https://staging.jyotisha.chat +ADMIN_USER_ORIGIN=https://admin.staging.jyotisha.chat ``` -Staging has one browser identity surface on the main site. The same Better Auth user session serves ordinary pages and `/admin`; there is no independent admin origin, secret, cookie, or login host. Server routes translate that session into PostgreSQL request claims. Admin authorization then reads the persisted `identity.users.role` value and permits only `admin`; `viewer` and ordinary users receive `403`. The main auth route continues to return `404` for Better Auth `/api/auth/admin` plugin endpoints, and unknown hosts fail closed with `421`. +The two exact hosts run the same Next.js application and Better Auth service. They share one `BETTER_AUTH_USER_SECRET` and cookie prefix, but Better Auth cookies do not set a `Domain` attribute, so the browser keeps main-site and admin-site sessions host-only. The admin host root redirects to `/admin`, and its login page returns to `/admin` after OTP, password, or MFA completion. The main host login continues to return to `/`. The main auth surface still returns `404` for Better Auth `/api/auth/admin` plugin endpoints; the exact admin host may dispatch those endpoints to the same service. Unknown, suffix-spoofed, malformed, or unconfigured hosts fail closed with `421`. -Use [the tracked staging identity example](../../deploy/.env.staging.identity.example) as a list of names only. Replace bracketed values directly on the server and keep `/opt/jyotisha-staging/.env.staging` owned by `deploy` with mode `0600`. +Server routes translate the authenticated user session into PostgreSQL request claims. Admin authorization reads the persisted `identity.users.role` value and permits only `admin`; `viewer` and ordinary users receive `403` regardless of which host receives the request. + +Use [the tracked staging identity example](../../deploy/.env.staging.identity.example) as a list of names only. Replace bracketed values directly on the server. Keep both `/opt/jyotisha-staging/.env.staging` and `/opt/jyotisha-staging/.env.staging.database` on the staging host, owned by the deployment user, and at mode `0600`. Generate `BETTER_AUTH_USER_SECRET` locally on the server: @@ -22,7 +25,14 @@ Generate `BETTER_AUTH_USER_SECRET` locally on the server: openssl rand -base64 32 ``` -Do not reuse it as a PostgreSQL password. `IDENTITY_DATABASE_URL`, `APP_DATABASE_URL`, and `ADMIN_DATABASE_URL` use their matching passwords from `.env.staging.database`, percent-encoded only in each URL password component. All three must point to the private Compose hostname `postgres:5432/jyotisha`; never publish PostgreSQL on a host port. +Do not reuse it as a PostgreSQL password. `IDENTITY_DATABASE_URL`, `APP_DATABASE_URL`, `SERVICE_DATABASE_URL`, and `ADMIN_DATABASE_URL` use their matching passwords from `.env.staging.database`, percent-encoded only in each URL password component. All four must point to the private Compose hostname `postgres:5432/jyotisha`; never publish PostgreSQL on a host port. + +For the service identity, generate one independent secret and store it in exactly these two host-managed forms: + +- `.env.staging.database`: raw `SERVICE_RUNTIME_PASSWORD=` for PostgreSQL role bootstrap and compatibility validation. +- `.env.staging`: `SERVICE_DATABASE_URL=postgresql://service_runtime:@postgres:5432/jyotisha` for server runtime access. + +The staging deploy controller disables shell tracing and runs both environment validators before any Compose config, pull, or up operation. Neither staging workflow stores, interpolates, passes, or prints `SERVICE_RUNTIME_PASSWORD` or `SERVICE_DATABASE_URL`; they remain in the mode-`0600` host files. The Resend key must be staging-only. `RESEND_FROM_EMAIL` must use a sender/domain verified in Resend. CI never receives this key and uses an in-memory sender. `ADMIN_EMAILS` remains relevant only to the legacy Supabase production path; it is not self-hosted admin authorization. @@ -30,23 +40,28 @@ Validate without printing values: ```bash cd /opt/jyotisha-staging -chmod 600 .env.staging -bash deploy/validate-staging-env.sh .env.staging +chmod 600 .env.staging .env.staging.database +bash deploy/validate-staging-env.sh \ + .env.staging staging.jyotisha.chat deploy/Caddyfile.staging +bash deploy/validate-staging-database-env.sh .env.staging.database ``` ## Migration and smoke checks Apply the reviewed PostgreSQL migrations through the existing `Migrate Staging Database` workflow before deploying the web image. Better Auth users are transactionally projected into `auth.users`, which creates their business profile through the existing trigger. -After deployment, verify the single-domain contract: +After deployment, verify both exact hosts without sending credentials: ```bash curl -fsS https://staging.jyotisha.chat/login >/dev/null -test "$(curl -sS -o /dev/null -w '%{http_code}' https://staging.jyotisha.chat/api/admin/session)" = 401 test "$(curl -sS -o /dev/null -w '%{http_code}' https://staging.jyotisha.chat/api/account)" = 401 +test "$(curl -sS -o /dev/null -w '%{http_code}' https://admin.staging.jyotisha.chat/)" = 308 +test "$(curl -sSI https://admin.staging.jyotisha.chat/ | tr -d '\r' | awk 'tolower($1) == "location:" { print $2 }')" = /admin +curl -fsS https://admin.staging.jyotisha.chat/login >/dev/null +test "$(curl -sS -o /dev/null -w '%{http_code}' https://admin.staging.jyotisha.chat/api/admin/session)" = 401 ``` -An anonymous `/admin` request redirects to `/login`. An authenticated non-admin, including a persisted `viewer`, must not render the admin layout and every `/api/admin/*` route must independently return `403`. Promote a staging user only through a reviewed database operation; the persisted role must include `admin` before the main-site session can enter the backend. +An anonymous admin-host `/admin` request redirects to the admin-host `/login`. An authenticated non-admin, including a persisted `viewer`, must not render the admin layout and every `/api/admin/*` route must independently return `403`. Promote a staging user only through a reviewed database operation; the persisted role must include `admin` before the admin-host session can enter the backend. Complete MFA enrollment, MFA login, and privileged reauthentication on `admin.staging.jyotisha.chat` so every resulting challenge, session, and reauth cookie remains on the admin host. ## Optional import rehearsal diff --git a/frontend/tests/account-api.test.ts b/frontend/tests/account-api.test.ts index 02c7d4a3..eddc8308 100644 --- a/frontend/tests/account-api.test.ts +++ b/frontend/tests/account-api.test.ts @@ -32,10 +32,11 @@ test("account API separates engine confirmation from an accepted usable chart ti assert.match(source, /hasUsableBirthTime:\s*\(profile\.birth_time_status === "accepted" \|\| profile\.birth_time_status === "confirmed"\)/); }); -test("account API returns the same-origin admin entry URL", () => { - assert.match(source, /const adminUrl = isAdmin \? "\/admin\/codes" : null/); +test("account API routes staging admins to the configured exact admin host", () => { + assert.match(source, /readSelfHostedIdentityConfig\(process\.env\)\.adminOrigin/); + assert.match(source, /const adminUrl = isAdmin \? adminEntryUrl\(\) : null/); assert.match(source, /adminUrl,/); - assert.doesNotMatch(source, /NEXT_PUBLIC_ADMIN|AUTH_ADMIN_ORIGIN|adminOrigin/); + assert.doesNotMatch(source, /NEXT_PUBLIC_ADMIN|AUTH_ADMIN_ORIGIN/); }); test("account API projects only the minimum case state needed by the homepage", () => { diff --git a/frontend/tests/admin-auth.test.ts b/frontend/tests/admin-auth.test.ts index 75fa5b94..d4b5d0e6 100644 --- a/frontend/tests/admin-auth.test.ts +++ b/frontend/tests/admin-auth.test.ts @@ -1,49 +1,66 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import test from "node:test"; import { authorizeAdminAccess } from "../src/lib/admin/auth-policy.ts"; import type { IdentityUser } from "../src/modules/identity/contracts.ts"; -function user(role: string[]): IdentityUser { +const adminAuthSource = readFileSync( + new URL("../src/lib/admin/auth.ts", import.meta.url), + "utf8", +); + +function user(): IdentityUser { return { id: "11111111-1111-4111-8111-111111111111", email: "admin@example.com", emailVerified: true, + twoFactorEnabled: false, name: "Admin", image: null, - role, + role: ["user"], }; } test("anonymous admin access is 401", () => { - assert.deepEqual(authorizeAdminAccess(null, "read"), { + assert.deepEqual(authorizeAdminAccess(null, [], "admin.access"), { allowed: false, status: 401, }); }); -test("viewer may neither read nor write", () => { - assert.deepEqual(authorizeAdminAccess(user(["user", "viewer"]), "read"), { +test("missing permission is 403", () => { + assert.deepEqual(authorizeAdminAccess(user(), [], "admin.access"), { allowed: false, status: 403, }); - assert.deepEqual(authorizeAdminAccess(user(["viewer"]), "write"), { + assert.deepEqual(authorizeAdminAccess(user(), ["admin.access"], "models.publish"), { allowed: false, status: 403, }); }); -test("admin may read and write while unprivileged users are 403", () => { - assert.deepEqual(authorizeAdminAccess(user(["admin"]), "read"), { +test("database permission keys authorize only the requested operation", () => { + assert.deepEqual(authorizeAdminAccess(user(), ["admin.access"], "admin.access"), { allowed: true, - role: "admin", }); - assert.deepEqual(authorizeAdminAccess(user(["admin"]), "write"), { + assert.deepEqual(authorizeAdminAccess(user(), ["models.read", "models.publish"], "models.publish"), { allowed: true, - role: "admin", - }); - assert.deepEqual(authorizeAdminAccess(user(["user"]), "read"), { - allowed: false, - status: 403, }); }); + + +test("admin session guard requires the configured admin Host before session lookup", () => { + const guard = adminAuthSource.slice( + adminAuthSource.indexOf("export async function requirePermission"), + adminAuthSource.indexOf("export function requireAdminSession"), + ); + + assert.match(guard, /readSelfHostedIdentityConfig\(process\.env\)/); + assert.match(guard, /resolveIdentitySurface\(adminHeaders\.get\("host"\), identityConfig\) !== "admin"/); + assert.ok( + guard.indexOf("resolveIdentitySurface") < guard.indexOf("requireIdentityServerSession"), + "Host must be rejected before Better Auth session lookup", + ); + assert.doesNotMatch(guard, /x-forwarded-host|forwarded/i); +}); diff --git a/frontend/tests/admin-contracts.test.ts b/frontend/tests/admin-contracts.test.ts index fcc36f34..061781db 100644 --- a/frontend/tests/admin-contracts.test.ts +++ b/frontend/tests/admin-contracts.test.ts @@ -8,6 +8,17 @@ const migration = readFileSync( ); const auth = readFileSync(new URL("../src/lib/admin/auth.ts", import.meta.url), "utf8"); const authPolicy = readFileSync(new URL("../src/lib/admin/auth-policy.ts", import.meta.url), "utf8"); +const authFactory = readFileSync(new URL("../src/modules/identity/auth-factory.ts", import.meta.url), "utf8"); +const adminHttp = readFileSync(new URL("../src/lib/admin/http.ts", import.meta.url), "utf8"); +const rbacMigration = readFileSync(new URL("../supabase/migrations/20260806010000_admin_rbac.sql", import.meta.url), "utf8"); +const bootstrapRoles = readFileSync(new URL("../../deploy/postgres/001-bootstrap-roles.sh", import.meta.url), "utf8"); +const compatibilityRoles = readFileSync(new URL("../../deploy/postgres/002-ensure-business-compatibility-roles.sql", import.meta.url), "utf8"); +const administratorsRoute = readFileSync(new URL("../src/app/api/admin/administrators/route.ts", import.meta.url), "utf8"); +const reauthRoute = readFileSync(new URL("../src/app/api/admin/reauth/route.ts", import.meta.url), "utf8"); +const mfaRoute = readFileSync(new URL("../src/app/api/admin/mfa/route.ts", import.meta.url), "utf8"); +const mfaSecurity = readFileSync(new URL("../src/components/admin/mfa-security.tsx", import.meta.url), "utf8"); +const reasonActionModal = readFileSync(new URL("../src/components/admin/reason-action-modal.tsx", import.meta.url), "utf8"); +const customersRoute = readFileSync(new URL("../src/app/api/admin/customers/route.ts", import.meta.url), "utf8"); const adminUser = readFileSync(new URL("../src/lib/supabase/admin.ts", import.meta.url), "utf8"); const codesRoute = readFileSync(new URL("../src/app/api/admin/codes/route.ts", import.meta.url), "utf8"); const codeRoute = readFileSync(new URL("../src/app/api/admin/codes/[id]/route.ts", import.meta.url), "utf8"); @@ -15,38 +26,41 @@ const providers = readFileSync(new URL("../src/lib/admin/providers.ts", import.m const adminLayout = readFileSync(new URL("../src/app/admin/layout.tsx", import.meta.url), "utf8"); const adminApp = readFileSync(new URL("../src/components/admin/admin-app.tsx", import.meta.url), "utf8"); const adminRootRoute = readFileSync(new URL("../src/app/admin/route.ts", import.meta.url), "utf8"); -const readonlyRoutes = ["users", "credit-transactions", "consultations", "audit-logs"].map((resource) => +const readonlyRoutes = ["customers", "credit-transactions", "consultations", "audit-logs"].map((resource) => readFileSync(new URL(`../src/app/api/admin/${resource}/route.ts`, import.meta.url), "utf8"), ); const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); test("admin APIs use persisted Better Auth roles with admin-only boundaries", () => { - assert.match(auth, /requireIdentityUser/); + assert.match(auth, /requireIdentityServerSession/); assert.match(auth, /getIdentityAuthServices\(\)\.user\.api/); - assert.match(authPolicy, /user\.role\.includes\("admin"\)/); - assert.doesNotMatch(authPolicy, /viewer/); + assert.match(authPolicy, /permissions\.includes\(required\)/); + assert.match(auth, /admin_permission_keys\(\$1\)/); assert.doesNotMatch(auth, /ADMIN_EMAILS|isAdminEmail/); - assert.match(auth, /APP_ENV\?\.trim\(\) === "production"/); - assert.match(codesRoute, /requireAdminSession\("write"\)/); - assert.match(codeRoute, /requireAdminSession\("write"\)/g); + assert.match(auth, /AUTH_PROVIDER\?\.trim\(\) !== "self-hosted"/); + assert.match(codesRoute, /requireHighRiskAdminMutation\(\s*request,\s*"billing\.adjustments\.write",?\s*\)/); + assert.match(codeRoute, /requireHighRiskAdminMutation\(\s*request,\s*"billing\.adjustments\.write",?\s*\)/g); }); -test("self-hosted account entry checks only the persisted admin role", () => { +test("self-hosted account entry uses the database permission graph", () => { const selfHostedBranch = adminUser.slice( adminUser.indexOf('process.env.AUTH_PROVIDER?.trim() === "self-hosted"'), adminUser.indexOf("if (isAdminEmail"), ); assert.match(selfHostedBranch, /queryAdminRows/); - assert.match(selfHostedBranch, /select role from identity\.users where id = \$1 limit 1/); - assert.match(selfHostedBranch, /role === "admin"/); - assert.doesNotMatch(selfHostedBranch, /viewer|isAdminEmail|ADMIN_EMAILS/); - assert.match(auth, /authorizeAdminAccess\(user, access\)/); + assert.match(selfHostedBranch, /admin_has_permission\(\$1, 'admin\.access'\)/); + assert.doesNotMatch(selfHostedBranch, /role === "admin"|viewer|isAdminEmail|ADMIN_EMAILS/); + assert.match(auth, /authorizeAdminAccess\([\s\S]*user,[\s\S]*session\.permissions,[\s\S]*permission/); }); -test("admin navigation exposes payment and package resources", () => { - assert.match(adminApp, /name: "payments", list: "\/admin\/payments", meta: \{ label: "支付管理"/); - assert.match(adminApp, /name: "packages", list: "\/admin\/packages", meta: \{ label: "套餐管理"/); +test("admin navigation exposes separated RBAC and billing resources", () => { + assert.match(adminApp, /name: "administrators", list: "\/admin\/administrators"/); + assert.match(adminApp, /name: "customers", list: "\/admin\/customers"/); + assert.match(adminApp, /name: "products", list: "\/admin\/products"/); + assert.match(adminApp, /name: "subscriptions", list: "\/admin\/subscriptions"/); + assert.match(adminApp, /name: "orders", list: "\/admin\/orders"/); + assert.match(adminApp, /name: "security", list: "\/admin\/security"/); assert.match(adminApp, /CreditCardOutlined/); assert.match(adminApp, /ShoppingOutlined/); }); @@ -71,11 +85,11 @@ test("admin pages and root route are server-gated before rendering or redirectin }); test("readonly resources cannot be mutated through Refine access control", () => { - for (const resource of ["users", "credit-transactions", "consultations", "audit-logs"]) { - assert.match(providers, new RegExp(`"${resource}"`)); + for (const resource of ["customers", "credit-transactions", "consultations", "audit-logs"]) { + assert.match(providers, new RegExp(resource.includes("-") ? `"${resource}"` : `${resource}:`)); } - assert.match(providers, /readOnlyResources\.has/); - assert.match(providers, /此资源只读/); + assert.match(providers, /const resourcePermissions/); + assert.match(providers, /permission && identity\.permissions\.includes\(permission\)/); for (const route of readonlyRoutes) { assert.match(route, /export const POST = readonlyAdminMutation/); assert.match(route, /export const PATCH = readonlyAdminMutation/); @@ -117,3 +131,60 @@ test("Refine dependencies and same-origin admin data provider are present", () = assert.match(providers, /const apiBase = "\/api\/admin"/); assert.doesNotMatch(providers, /https?:\/\//); }); + +test("administrator writes require current-session MFA before scoped email OTP reauthentication", () => { + assert.match(adminHttp, /isSameOriginAdminMutation/); + assert.match(administratorsRoute, /requireHighRiskAdminMutation\(request, "admin\.users\.manage_roles"\)/); + assert.match(adminHttp, /requireAdminMfaIfRequired\(request, session\)[\s\S]*verifyHighRiskAdminProof/); + assert.match(adminHttp, /verifyHighRiskAdminProof/); + assert.match(reauthRoute, /requireAdminMfaIfRequired\(request, session\)[\s\S]*sendVerificationOTP/); + assert.match(reauthRoute, /sendVerificationOTP/); + assert.match(reauthRoute, /verifyEmailOTP/); + assert.match(reauthRoute, /httpOnly: true/); + assert.match(authFactory, /twoFactor\(/); + assert.match(authFactory, /schema: identityModelMapping\.twoFactor/); + assert.doesNotMatch(authFactory, /skipVerificationOnEnable\s*:\s*true/); + assert.match(mfaRoute, /enableTwoFactor/); + assert.match(mfaRoute, /verifyTOTP/); + assert.match(mfaRoute, /verifyBackupCode/); + assert.match(mfaRoute, /generateBackupCodes/); + assert.match(mfaRoute, /disableTwoFactor/); + assert.match(mfaSecurity, /\/api\/admin\/mfa/); + assert.match(reasonActionModal, /action: mfaFactor === "totp" \? "verify" : "recover"/); + assert.match(reasonActionModal, /action: "request"/); + assert.doesNotMatch(administratorsRoute, /ADMIN_MFA_CAPABLE|process\.env/); + assert.doesNotMatch(reauthRoute, /ADMIN_MFA_CAPABLE|process\.env/); +}); + +test("customer birth data uses a narrow permission, is masked by default, and audits sensitive reads", () => { + assert.match(authPolicy, /"admin\.customers\.read"/); + assert.match(authPolicy, /"admin\.customers\.birth_data\.read"/); + assert.match(customersRoute, /requirePermission\("admin\.customers\.read"\)/); + assert.doesNotMatch(customersRoute, /requirePermission\("billing\.orders\.read"\)/); + assert.match(customersRoute, /admin_read_customer_birth_data/); + assert.match(customersRoute, /revealCustomerBirthData/); + assert.match(customersRoute, /birthDataMasked: true/); + assert.match(rbacMigration, /'owner', 'admin\.customers\.birth_data\.read'/); + for (const role of ["support", "operations", "auditor"]) { + assert.doesNotMatch(rbacMigration, new RegExp(`'${role}', 'admin\\.customers\\.birth_data\\.read'`)); + } + assert.match(rbacMigration, /admin\.customer\.birth_data\.read/); + assert.match(rbacMigration, /permission_used[\s\S]*admin\.customers\.birth_data\.read/); + assert.match(rbacMigration, /revoke select \(birth_date, birth_time_status, birth_place_label\)/); +}); + +test("admin runtime cannot assume service_role and keeps explicit RBAC grants", () => { + assert.match(bootstrapRoles, /GRANT service_role TO service_runtime/); + assert.match(bootstrapRoles, /REVOKE service_role FROM admin_runtime/); + assert.doesNotMatch(bootstrapRoles, /GRANT service_role TO admin_runtime/); + assert.match(compatibilityRoles, /grant service_role to service_runtime/); + assert.match(compatibilityRoles, /revoke service_role from admin_runtime/); + assert.doesNotMatch(compatibilityRoles, /grant service_role to admin_runtime/); + assert.match(rbacMigration, /admin_runtime_service_role_membership_must_be_revoked_by_bootstrap/); + assert.match(rbacMigration, /grant execute on function public\.admin_has_permission/); + assert.match(rbacMigration, /public\.admin_read_customer_birth_data\(uuid, uuid\[\], text\)[\s\S]*to admin_runtime/); +}); + +test("last Owner revocations are serialized by one transaction advisory lock", () => { + assert.match(rbacMigration, /pg_advisory_xact_lock\(1096040772, 1\)[\s\S]*v_owner_count/); +}); diff --git a/frontend/tests/admin-database.test.ts b/frontend/tests/admin-database.test.ts index 90518bc7..97ccaeec 100644 --- a/frontend/tests/admin-database.test.ts +++ b/frontend/tests/admin-database.test.ts @@ -3,7 +3,10 @@ import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import test from "node:test"; -import { createLocalPostgresDataClient } from "../src/lib/db/local-postgres-client-core.ts"; +import { + closeLocalPostgresDataPools, + createLocalPostgresDataClient, +} from "../src/lib/db/local-postgres-client-core.ts"; import { startPostgresFixture } from "./helpers/postgres-fixture.ts"; const runnerPath = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url)); @@ -36,11 +39,19 @@ test("admin code functions reject immutable codes, revoked redemption, and roll insert into identity.users (id, name, email, email_verified, email_verified_at, role) values ('${actorId}', 'Admin', 'admin@example.com', true, now(), 'admin') `); + fixture.psql(` + insert into public.admin_users (user_id, created_by) + values ('${actorId}', '${actorId}'); + insert into public.admin_user_roles (admin_user_id, role_id, assigned_by) + select '${actorId}', id, '${actorId}' + from public.admin_roles + where code = 'billing_admin'; + `); const userId = fixture.psql(`select id from identity.users where email = 'admin@example.com'`); const admin = createLocalPostgresDataClient( fixture.connectionUrl("admin_runtime", "admin-runtime-test-password"), null, - "service_role", + null, ); const created = await admin.rpc("admin_create_redemption_codes", { @@ -52,6 +63,7 @@ test("admin code functions reject immutable codes, revoked redemption, and roll expiresAt: null, note: "initial", }], + p_reason: "create audit test code", }); assert.equal(created.error, null); assert.equal((created.data as Array<{ code_mask: string }>)[0]?.code_mask, "JYOTISH-****-AUD1"); @@ -62,6 +74,7 @@ test("admin code functions reject immutable codes, revoked redemption, and roll const revoked = await admin.rpc("admin_revoke_redemption_code", { ...rpcArgs("revoke-1"), p_code_id: createdId, + p_reason: "revoke audit test code", }); assert.equal(revoked.error, null); @@ -83,6 +96,7 @@ test("admin code functions reject immutable codes, revoked redemption, and roll p_note: "changed", p_set_expires_at: false, p_expires_at: null, + p_reason: "verify redeemed codes are immutable", }); assert.ok(immutable.error); assert.equal(fixture.psql(`select note is null from public.redemption_codes where id = '${codeId}'`), "t"); @@ -107,6 +121,7 @@ test("admin code functions reject immutable codes, revoked redemption, and roll expiresAt: null, note: "must rollback", }], + p_reason: "verify audit failure rollback", }); assert.ok(auditFailure.error); assert.equal( @@ -116,6 +131,7 @@ test("admin code functions reject immutable codes, revoked redemption, and roll ); fixture.psql("drop trigger test_fail_admin_audit on audit.admin_audit_logs"); } finally { + await closeLocalPostgresDataPools(); fixture.stop(); } }); diff --git a/frontend/tests/admin-mfa.test.ts b/frontend/tests/admin-mfa.test.ts new file mode 100644 index 00000000..1bd5689e --- /dev/null +++ b/frontend/tests/admin-mfa.test.ts @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + ADMIN_MFA_PROOF_TTL_MS, + issueAdminMfaProof, + resolveAdminMfaStatus, + verifyAdminMfaProof, +} from "../src/lib/admin/auth-policy.ts"; + +const now = 1_786_000_000_000; +const context = { + userId: "11111111-1111-4111-8111-111111111111", + sessionId: "22222222-2222-4222-8222-222222222222", + origin: "https://admin.staging.jyotisha.chat", +}; +const sessionToken = "better-auth-session-token-held-server-side"; +const proofSecret = "admin-proof-secret-held-only-by-the-server"; + +test("MFA proof is server-signed, short-lived, and bound to the current user, session, origin, and session token", () => { + const proof = issueAdminMfaProof(context, proofSecret, sessionToken, now); + assert.equal(verifyAdminMfaProof(proof, context, proofSecret, sessionToken, now + 1_000), true); + assert.equal(verifyAdminMfaProof(proof, { ...context, userId: "other-user" }, proofSecret, sessionToken, now + 1_000), false); + assert.equal(verifyAdminMfaProof(proof, { ...context, sessionId: "other-session" }, proofSecret, sessionToken, now + 1_000), false); + assert.equal(verifyAdminMfaProof(proof, { ...context, origin: "https://evil.example" }, proofSecret, sessionToken, now + 1_000), false); + assert.equal(verifyAdminMfaProof(proof, context, "wrong-server-secret-that-is-long-enough", sessionToken, now + 1_000), false); + assert.equal(verifyAdminMfaProof(proof, context, proofSecret, "rotated-session-token", now + 1_000), false); + assert.equal(verifyAdminMfaProof(proof, context, proofSecret, sessionToken, now + ADMIN_MFA_PROOF_TTL_MS), false); +}); + +test("highRiskWritesEnabled means the current session has the MFA prerequisite, not email proof", () => { + assert.deepEqual(resolveAdminMfaStatus(false, false, false), { + required: false, + enrolled: false, + verified: false, + highRiskWritesEnabled: true, + }); + assert.equal(resolveAdminMfaStatus(true, false, false).highRiskWritesEnabled, false); + assert.equal(resolveAdminMfaStatus(true, true, false).highRiskWritesEnabled, false); + assert.deepEqual(resolveAdminMfaStatus(true, true, true), { + required: true, + enrolled: true, + verified: true, + highRiskWritesEnabled: true, + }); +}); + +test("MFA API keeps native Better Auth enrollment, recovery, rotation, and proof boundaries", () => { + const route = readFileSync(new URL("../src/app/api/admin/mfa/route.ts", import.meta.url), "utf8"); + const authFactory = readFileSync(new URL("../src/modules/identity/auth-factory.ts", import.meta.url), "utf8"); + const migration = readFileSync(new URL("../db/migrations/20260806070000_admin_mfa.sql", import.meta.url), "utf8"); + + assert.match(authFactory, /twoFactor\(/); + assert.doesNotMatch(authFactory, /skipVerificationOnEnable\s*:\s*true/); + assert.match(route, /enableTwoFactor/); + assert.match(route, /verifyTOTP/); + assert.match(route, /verifyBackupCode/); + assert.match(route, /generateBackupCodes/); + assert.match(route, /disableTwoFactor/); + assert.match(route, /headersAfterNativeResponse/); + assert.match(route, /copyNativeCookies\(nativeResponse, response\)/); + assert.match(route, /requireVerifiedMfa\(request, session\)/); + assert.match(route, /ADMIN_MFA_PROOF_COOKIE/); + assert.match(route, /HIGH_RISK_ADMIN_PROOF_COOKIE/); + assert.match(route, /adminProofSigningSecret\(\)/); + assert.doesNotMatch(route, /console\.|logger|log.*(?:seed|secret|backup|totp)|(?:seed|secret|backup|totp).*log/i); + assert.match(migration, /revoke all on table identity\.two_factors[\s\S]*admin_runtime/); + assert.match(migration, /grant select, insert, update, delete on table identity\.two_factors[\s\S]*identity_runtime/); +}); + +test("high-risk UI performs MFA challenge before requesting permission-scoped email OTP", () => { + const modal = readFileSync(new URL("../src/components/admin/reason-action-modal.tsx", import.meta.url), "utf8"); + const reauth = readFileSync(new URL("../src/app/api/admin/reauth/route.ts", import.meta.url), "utf8"); + const http = readFileSync(new URL("../src/lib/admin/http.ts", import.meta.url), "utf8"); + + assert.match(modal, /adminSecurityRequest\("\/api\/admin\/mfa"\)/); + assert.match(modal, /action: mfaFactor === "totp" \? "verify" : "recover"/); + assert.match(modal, /if \(!reauthPermission \|\| !mfaReady\) return/); + assert.match(modal, /action: "request"/); + assert.match(modal, /\/admin\/security/); + assert.match(reauth, /requireAdminMfaIfRequired\(request, session\)[\s\S]*getIdentityEmailOtpApi/); + assert.match(http, /requireAdminMfaIfRequired\(request, session\)[\s\S]*verifyHighRiskAdminProof/); +}); diff --git a/frontend/tests/admin-payments-contract.test.ts b/frontend/tests/admin-payments-contract.test.ts index 5ff9c475..a4da2913 100644 --- a/frontend/tests/admin-payments-contract.test.ts +++ b/frontend/tests/admin-payments-contract.test.ts @@ -8,12 +8,11 @@ const packagesRoute = readFileSync(new URL("src/app/api/admin/packages/route.ts" const paymentPage = readFileSync(new URL("src/app/admin/payments/page.tsx", root), "utf8"); const packagesPage = readFileSync(new URL("src/app/admin/packages/page.tsx", root), "utf8"); const paymentManagement = readFileSync(new URL("src/components/admin/payment-management.tsx", root), "utf8"); -const packageManagement = readFileSync(new URL("src/components/admin/package-management.tsx", root), "utf8"); const adminApp = readFileSync(new URL("src/components/admin/admin-app.tsx", root), "utf8"); const globalsCss = readFileSync(new URL("src/app/globals.css", root), "utf8"); test("支付后台接口使用 self-hosted PostgreSQL 联表且不依赖 Supabase builder", () => { - assert.match(route, /requireAdminSession\("read"\)/); + assert.match(route, /requirePermission\("billing\.orders\.read"\)/); assert.match(route, /queryAdminRows/); assert.match(route, /from public\.payment_orders o/); assert.match(route, /left join public\.payment_packages p on p\.id = o\.package_id/); @@ -47,43 +46,20 @@ test("支付管理页只保留概览、默认折叠的 Z-Pay 配置与支付记 assert.doesNotMatch(paymentManagement, /套餐列表|套餐设置|添加套餐|\/api\/admin\/packages|PackageManagement/); }); -test("套餐管理是支付管理之后的独立资源和页面", () => { - assert.match(adminApp, /name: "payments", list: "\/admin\/payments"[\s\S]*name: "packages", list: "\/admin\/packages", meta: \{ label: "套餐管理"[\s\S]*name: "users"/); +test("统一商品资源进入导航且旧套餐 API 明确跳转", () => { + assert.match(adminApp, /name: "products", list: "\/admin\/products"[\s\S]*name: "subscriptions"/); assert.match(adminApp, /ShoppingOutlined/); assert.match(packagesPage, /PackageManagement/); - assert.doesNotMatch(packagesPage, /redirect/); - assert.match(packageManagement, //); - assert.match(packageManagement, / { - for (const field of ["名称", "描述", "价格(元)", "点数", "排序", "启用"]) assert.match(packageManagement, new RegExp(field)); - assert.match(packageManagement, /套餐列表读取失败/); - assert.match(packageManagement, /loadPackages\(\)/); - assert.match(packageManagement, />重试<\/Button>/); - assert.match(packageManagement, / { - assert.match(packagesRoute, /queryAdminRows/); - assert.match(packagesRoute, /from public\.payment_packages[\s\S]*order by sort_order, created_at/); - assert.match(packagesRoute, /insert into public\.payment_packages[\s\S]*values \(\$1, \$2, \$3, \$4, \$5, \$6, \$7\)[\s\S]*returning/); - assert.match(packagesRoute, /created_by[\s\S]*auth\.user\.id/); - assert.match(packagesRoute, /updateSchema = schema\.extend\(\{ id: z\.string\(\)\.uuid\(\) \}\)/); - assert.match(packagesRoute, /update public\.payment_packages[\s\S]*where id = \$1[\s\S]*returning/); - assert.match(packagesRoute, /set enabled = false, updated_at = clock_timestamp\(\)[\s\S]*returning id/); - assert.match(packagesRoute, /套餐不存在" \}, \{ status: 404 \}/g); - assert.match(packagesRoute, /created_at\.toISOString\(\)/); - assert.match(packagesRoute, /updated_at\.toISOString\(\)/); - assert.doesNotMatch(packagesRoute, /createAdminSupabaseClient|\.from\(|\.insert\(|\.update\(|\.eq\(|\.select\(/); +test("旧套餐写 API 已删除,不再假成功写脱节表", () => { + assert.doesNotMatch(packagesRoute, /payment_packages|queryAdminRows|insert into|update public/); + for (const method of ["POST", "PATCH", "DELETE"]) { + assert.match(packagesRoute, new RegExp(`export function ${method}\\(\\)`)); + } + assert.match(packagesRoute, /status: 410/); + assert.match(packagesRoute, /replacement/); }); test("后台使用独立的全视口纵向滚动容器而不修改全局聊天溢出边界", () => { diff --git a/frontend/tests/admin-reauth.test.ts b/frontend/tests/admin-reauth.test.ts new file mode 100644 index 00000000..b9e00ab0 --- /dev/null +++ b/frontend/tests/admin-reauth.test.ts @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + authorizeAdminAccess, + HIGH_RISK_ADMIN_CHALLENGE_TTL_MS, + issueHighRiskAdminChallenge, + issueHighRiskAdminProof, + isSameOriginAdminMutation, + verifyHighRiskAdminChallenge, + verifyHighRiskAdminProof, +} from "../src/lib/admin/auth-policy.ts"; +import type { IdentityUser } from "../src/modules/identity/contracts.ts"; + +const now = 1_786_000_000_000; +const context = { + userId: "11111111-1111-4111-8111-111111111111", + sessionId: "22222222-2222-4222-8222-222222222222", + permission: "admin.users.manage_roles" as const, + origin: "https://admin.staging.jyotisha.chat", +}; +const sessionToken = "better-auth-session-token-held-server-side"; +const proofSecret = "admin-proof-secret-held-only-by-the-server"; + +function user(): IdentityUser { + return { + id: context.userId, + email: "admin@example.com", + emailVerified: true, + name: "Admin", + image: null, + role: ["user"], + twoFactorEnabled: false, + }; +} + +test("email OTP challenge is short-lived and bound to the requested permission and session", () => { + const challenge = issueHighRiskAdminChallenge(context, proofSecret, sessionToken, now); + assert.equal( + verifyHighRiskAdminChallenge(challenge, context, proofSecret, sessionToken, now + 1_000), + true, + ); + assert.equal(verifyHighRiskAdminChallenge( + challenge, + { ...context, permission: "billing.products.publish" }, + proofSecret, + sessionToken, + now + 1_000, + ), false); + assert.equal(verifyHighRiskAdminChallenge( + challenge, + context, + proofSecret, + "rotated-session-token", + now + 1_000, + ), false); + assert.equal(verifyHighRiskAdminChallenge( + challenge, + context, + proofSecret, + sessionToken, + now + HIGH_RISK_ADMIN_CHALLENGE_TTL_MS, + ), false); +}); + +test("high-risk proof succeeds only for its live Better Auth session", () => { + const proof = issueHighRiskAdminProof(context, proofSecret, sessionToken, now); + assert.equal(verifyHighRiskAdminProof(proof, context, proofSecret, sessionToken, now + 1_000), true); + assert.equal(verifyHighRiskAdminProof(proof, { ...context, sessionId: "wrong-session" }, proofSecret, sessionToken, now + 1_000), false); + assert.equal(verifyHighRiskAdminProof( + proof, + { ...context, permission: "billing.products.publish" }, + proofSecret, + sessionToken, + now + 1_000, + ), false); + assert.equal(verifyHighRiskAdminProof(proof, context, "wrong-server-secret-that-is-long-enough", sessionToken, now + 1_000), false); + assert.equal(verifyHighRiskAdminProof(proof, context, proofSecret, sessionToken, now + 300_000), false); +}); + +test("high-risk authorization rejects missing permission and cross-origin requests", () => { + assert.deepEqual(authorizeAdminAccess(user(), [], context.permission), { allowed: false, status: 403 }); + assert.equal(isSameOriginAdminMutation(context.origin, `${context.origin}/api/admin/reauth`), true); + assert.equal(isSameOriginAdminMutation("https://evil.example", `${context.origin}/api/admin/reauth`), false); + assert.equal(isSameOriginAdminMutation(null, `${context.origin}/api/admin/reauth`), false); +}); + +test("reauth route consumes Better Auth email OTP and sets a scoped HttpOnly proof", () => { + const route = readFileSync(new URL("../src/app/api/admin/reauth/route.ts", import.meta.url), "utf8"); + const modal = readFileSync(new URL("../src/components/admin/reason-action-modal.tsx", import.meta.url), "utf8"); + + assert.match(route, /sendVerificationOTP/); + assert.match(route, /verifyEmailOTP/); + assert.match(route, /type: "email-verification"/); + assert.match(route, /issueHighRiskAdminChallenge/); + assert.match(route, /verifyHighRiskAdminChallenge/); + assert.match(route, /HIGH_RISK_ADMIN_CHALLENGE_COOKIE/); + assert.match(route, /verifyHighRiskAdminChallenge[\s\S]*verifyEmailOTP/); + assert.match(route, /httpOnly: true/); + assert.match(route, /sameSite: "strict"/); + assert.match(route, /secure: true/); + assert.match(route, /path: "\/api\/admin"/); + assert.match(route, /adminProofSigningSecret\(\)/); + assert.doesNotMatch(route, /console\.|otp.*log|log.*otp/i); + assert.match(modal, /reauthPermission/); + assert.match(modal, /action: "request"/); + assert.match(modal, /action: "verify"/); + assert.match(modal, /邮箱验证码/); +}); diff --git a/frontend/tests/admin-ui-permission-contract.test.ts b/frontend/tests/admin-ui-permission-contract.test.ts new file mode 100644 index 00000000..9a0366bd --- /dev/null +++ b/frontend/tests/admin-ui-permission-contract.test.ts @@ -0,0 +1,143 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const source = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8"); + +const highRiskMappings = [ + { + name: "商品保存", + ui: "src/components/admin/product-management.tsx", + api: "src/app/api/admin/products/route.ts", + permission: "billing.products.write", + }, + { + name: "商品发布", + ui: "src/components/admin/product-management.tsx", + api: "src/app/api/admin/products/route.ts", + permission: "billing.products.publish", + }, + { + name: "订阅调整", + ui: "src/components/admin/billing-operations-resources.tsx", + api: "src/app/api/admin/subscriptions/route.ts", + permission: "billing.adjustments.write", + }, + { + name: "订单调整", + ui: "src/components/admin/billing-operations-resources.tsx", + api: "src/app/api/admin/orders/route.ts", + permission: "billing.adjustments.write", + }, + { + name: "兑换码创建", + ui: "src/components/admin/codes-resource.tsx", + api: "src/app/api/admin/codes/route.ts", + permission: "billing.adjustments.write", + }, + { + name: "兑换码编辑与撤销", + ui: "src/components/admin/codes-resource.tsx", + api: "src/app/api/admin/codes/[id]/route.ts", + permission: "billing.adjustments.write", + }, + { + name: "供应商保存", + ui: "src/components/admin/model-management.tsx", + api: "src/app/api/admin/models/route.ts", + permission: "models.write", + }, + { + name: "模型发布", + ui: "src/components/admin/model-management.tsx", + api: "src/app/api/admin/models/route.ts", + permission: "models.publish", + }, + { + name: "模型回滚", + ui: "src/components/admin/model-management.tsx", + api: "src/app/api/admin/models/route.ts", + permission: "models.rollback", + }, + { + name: "功能开关发布", + ui: "src/components/admin/feature-flags-management.tsx", + api: "src/app/api/admin/feature-flags/route.ts", + permission: "ops.flags.write", + }, + { + name: "易支付设置保存", + ui: "src/components/admin/payment-management.tsx", + api: "src/app/api/admin/epay-settings/route.ts", + permission: "billing.adjustments.write", + }, + { + name: "管理员角色变更", + ui: "src/components/admin/administrators-resource.tsx", + api: "src/app/api/admin/administrators/route.ts", + permission: "admin.users.manage_roles", + }, +] as const; + +test("high-risk UI reauth permissions match their API guards", async (t) => { + for (const mapping of highRiskMappings) { + await t.test(mapping.name, () => { + const ui = source(mapping.ui); + const api = source(mapping.api); + assert.match(ui, /ReasonActionModal/); + assert.match(ui, new RegExp(`reauthPermission=[\\s\\S]{0,80}["']${mapping.permission.replaceAll(".", "\\.")}["']|reauthPermission="${mapping.permission.replaceAll(".", "\\.")}"`)); + assert.match(api, new RegExp(`requireHighRiskAdminMutation\\(\\s*request,\\s*(?:permission|["']${mapping.permission.replaceAll(".", "\\.")}["']),?\\s*\\)`)); + assert.match(ui, new RegExp(`permissions\\.includes\\(["']${mapping.permission.replaceAll(".", "\\.")}["']\\)`)); + }); + } +}); + +test("admin request failures are real Error instances and reason modal keeps failures visible", () => { + const providers = source("src/lib/admin/providers.ts"); + const modal = source("src/components/admin/reason-action-modal.tsx"); + assert.match( + providers, + /Object\.assign\(new Error\(message\),\s*\{\s*statusCode: response\.status,/, + ); + assert.doesNotMatch(providers, /throw \{ message, statusCode/); + assert.match(modal, /catch \(error\)[\s\S]*setReauthError\(error instanceof Error \? error\.message/); + assert.match(modal, /name="reason"[\s\S]*required: true, whitespace: true/); + assert.match(modal, /await onSubmit\(reason\.trim\(\)\)/); +}); + +test("customer list is always masked and a single explicit reveal is audited each time", () => { + const providers = source("src/lib/admin/providers.ts"); + const route = source("src/app/api/admin/customers/route.ts"); + const ui = source("src/components/admin/users-resource.tsx"); + const migration = source("supabase/migrations/20260806010000_admin_rbac.sql"); + + assert.match(providers, /customers: \{ read: "admin\.customers\.read" \}/); + assert.match(route, /revealUserId[\s\S]*requirePermission\("admin\.customers\.birth_data\.read"\)/); + assert.match(route, /\[session\.user\.id, \[parsedUserId\.data\], requestId\(request\)\]/); + const listBranch = route.slice(route.indexOf('await requirePermission("admin.customers.read")')); + assert.doesNotMatch(listBranch, /admin_read_customer_birth_data/); + assert.match(listBranch, /birthDate: null[\s\S]*birthTimeStatus: null[\s\S]*birthPlace: null[\s\S]*birthDataMasked: true/); + assert.match(migration, /cardinality\(p_target_user_ids\) <> 1/); + assert.match(ui, /revealUserId=\$\{encodeURIComponent\(userId\)\}/); + assert.match(ui, /再次读取并审计/); + assert.match(ui, /admin\.customers\.birth_data\.read/); +}); + +test("administrator UI supports all six roles and exposes last-owner protection", () => { + const ui = source("src/components/admin/administrators-resource.tsx"); + const route = source("src/app/api/admin/administrators/route.ts"); + const roles = source("src/components/admin/roles-resource.tsx"); + for (const role of ["owner", "model_admin", "billing_admin", "operations", "support", "auditor"]) { + assert.match(ui, new RegExp(`["']${role}["']`)); + } + assert.match(ui, /method: pendingAction\.action === "assign" \? "POST" : "DELETE"/); + const mutationSection = route.slice(route.indexOf("async function mutate")); + assert.match(mutationSection, /last_owner_protected/); + assert.match(mutationSection, /不能撤销最后一位 Owner,请先分配另一位 Owner/); + assert.doesNotMatch(route.slice(0, route.indexOf("async function mutate")), /last_owner_protected/); + assert.match(roles, /系统预置只读矩阵/); + assert.match(roles, /不提供角色权限矩阵写 API/); + const providers = source("src/lib/admin/providers.ts"); + assert.match(providers, /roles: \{ read: "admin\.users\.read" \}/); + assert.doesNotMatch(providers, /roles: \{[^}]*write:/); +}); diff --git a/frontend/tests/admin-users-contract.test.ts b/frontend/tests/admin-users-contract.test.ts index 735414cb..d42941fc 100644 --- a/frontend/tests/admin-users-contract.test.ts +++ b/frontend/tests/admin-users-contract.test.ts @@ -20,9 +20,9 @@ test("ADMIN_EMAILS remains a case-insensitive comma-separated allowlist", () => test("admin surfaces await database-backed administrator checks", () => { assert.match(adminSource, /export async function isAdminUser/); - assert.match(adminSource, /from\("admin_users"\)/); - assert.match(sessionSource, /await requireAdminSession\(\)/); - assert.match(codesSource, /await requireAdminSession\("write"\)/); + assert.match(adminSource, /admin_has_permission\(\$1, 'admin\.access'\)/); + assert.match(sessionSource, /await requirePermission\("admin\.access"\)/); + assert.match(codesSource, /await requireHighRiskAdminMutation\(\s*request,\s*"billing\.adjustments\.write",?\s*\)/); assert.match(accountSource, /const isAdmin = await isAdminUser\(user\)/); assert.match(accountSource, /isAdmin,/); }); @@ -38,10 +38,6 @@ test("admin_users migration is service-role-only and auditable", () => { assert.match(migration, /grant select, insert, update on table public\.admin_users to service_role/); }); -test("admin users route exposes a guarded list and rejects mutations", () => { - assert.match(usersSource, /export async function GET/); - assert.match(usersSource, /await requireAdminSession\(\)/); - assert.match(usersSource, /export const POST = readonlyAdminMutation/); - assert.match(usersSource, /export const PATCH = readonlyAdminMutation/); - assert.match(usersSource, /export const DELETE = readonlyAdminMutation/); +test("legacy admin users route aliases the guarded customer resource", () => { + assert.match(usersSource, /export \{ DELETE, GET, PATCH, POST, PUT, runtime \} from "\.\.\/customers\/route"/); }); diff --git a/frontend/tests/application-billing-contract.test.ts b/frontend/tests/application-billing-contract.test.ts new file mode 100644 index 00000000..56bbba02 --- /dev/null +++ b/frontend/tests/application-billing-contract.test.ts @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { completeUsage } from "../src/lib/consultation-billing.ts"; + +const root = new URL("../", import.meta.url); +const rectificationRoute = readFileSync(new URL("src/app/api/rectification/agent/route.ts", root), "utf8"); +const consultRoute = readFileSync(new URL("src/app/api/consult/route.ts", root), "utf8"); +const packagesRoute = readFileSync(new URL("src/app/api/admin/packages/route.ts", root), "utf8"); + +test("Agentic rectification reuses one case-level usage authorization and the session-pinned model version", () => { + assert.match(rectificationRoute, /select\("id,messages,session_type,model_id,model_config_version"\)/); + assert.match(rectificationRoute, /resolveSessionLanguageModel\(\s*chatSession\.model_id,\s*chatSession\.model_config_version,?\s*\)/); + assert.match(rectificationRoute, /modelConfigVersion: selectedModel\.configVersion/); + assert.doesNotMatch(rectificationRoute, /loadLanguageModelCatalog|resolveLanguageModelFromCatalog|\bresolveLanguageModel\(|\bdefaultLanguageModel\(/); + assert.match(rectificationRoute, /const billingRequestPrefix = `rectification:\$\{sessionId\}`/); + assert.match(rectificationRoute, /from\("usage_reservations"\)[\s\S]*\.eq\("feature_key", "rectification"\)[\s\S]*\.like\("request_id", `\$\{billingRequestPrefix\}%`\)/); + assert.match(rectificationRoute, /return reservations.length === 0[\s\S]*`\$\{billingRequestPrefix\}:retry:\$\{reservations.length\}`/); + assert.match(rectificationRoute, /authorizeUsage\(accounting, \{[\s\S]*requestId: billingRequestId/); + assert.match(rectificationRoute, /completeUsage\(accounting, userId, billingRequestId,/); + assert.match(rectificationRoute, /releaseUsage\(accounting, userId, billingRequestId,/); +}); + + +test("standard consultation resolves and settles the session-pinned model version", () => { + assert.match(consultRoute, /sessionId: z\.string\(\)\.uuid\(\)/); + assert.match(consultRoute, /select\("id,model_id,model_config_version,session_type"\)/); + assert.match(consultRoute, /resolveSessionLanguageModel\(\s*chatSession\.model_id,\s*chatSession\.model_config_version,?\s*\)/); + assert.match(consultRoute, /actualModelId: selectedModel\.id/); + assert.match(consultRoute, /modelConfigVersion: selectedModel\.configVersion/); +}); + +test("standard consultation awaits real usage before its only permanent settlement", () => { + assert.doesNotMatch(consultRoute, /recordActualUsage|void usage\.then/); + assert.doesNotMatch(consultRoute, /inputTokens: 0,[\s\S]*outputTokens: 0,[\s\S]*costMicrousd: 0/); + assert.match(consultRoute, /async function complete\(usage: Promise<\{ inputTokens\?: number; outputTokens\?: number \}>\)/); + assert.match(consultRoute, /const resolved = await usage;/); + assert.match(consultRoute, /await completeUsage\(accounting, userId, requestId, \{[\s\S]*inputTokens,[\s\S]*outputTokens,[\s\S]*costMicrousd/); + assert.match(consultRoute, /const completeWithUsage = \(\) => complete\(result\.totalUsage\)/g); +}); + +test("standard consultation forwards its stable reservation request as the usage event key", async () => { + const eventKey = "00000000-0000-4000-8000-000000000002"; + const calls: Record[] = []; + const accounting = { + async rpc(_rpcName: string, args: Record) { + calls.push(args); + return { + data: { success: true, reservation_id: "00000000-0000-4000-8000-000000000003", credits: 9, error_code: null }, + error: null, + }; + }, + }; + const usage = { eventKey, actualModelId: "model", inputTokens: 1, outputTokens: 2, costMicrousd: 3, durationMs: 4 }; + + await completeUsage(accounting, "00000000-0000-4000-8000-000000000001", eventKey, usage); + await completeUsage(accounting, "00000000-0000-4000-8000-000000000001", eventKey, usage); + + assert.deepEqual(calls.map((call) => (call.p_actual_usage as { eventKey: string }).eventKey), [eventKey, eventKey]); + assert.match(consultRoute, /completeUsage\(accounting, userId, requestId, \{[\s\S]*eventKey: requestId,/); + assert.doesNotMatch(consultRoute, /eventKey:\s*(?:globalThis\.)?crypto\.randomUUID\(\)/); +}); + +test("legacy admin packages cannot mutate detached payment_packages", () => { + assert.doesNotMatch(packagesRoute, /payment_packages|queryAdminRows|insert into|update public/); + assert.match(packagesRoute, /export function GET\(request: Request\)/); + assert.match(packagesRoute, /const replacement = "\/api\/admin\/products"/); + assert.match(packagesRoute, /NextResponse\.redirect\(new URL\(replacement, request\.url\), 308\)/); + for (const method of ["POST", "PATCH", "DELETE"]) { + assert.match(packagesRoute, new RegExp(`export function ${method}\\(\\)`)); + } + assert.match(packagesRoute, /status: 410/); + assert.match(packagesRoute, /error: "旧套餐写接口已停用,请使用统一商品管理。",[\s\S]*replacement,/); +}); diff --git a/frontend/tests/database-admin-identity.test.ts b/frontend/tests/database-admin-identity.test.ts new file mode 100644 index 00000000..5b256bfa --- /dev/null +++ b/frontend/tests/database-admin-identity.test.ts @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { + closeLocalPostgresDataPools, + createLocalPostgresDataClient, + type LocalPostgresDataClient, +} from "../src/lib/db/local-postgres-client-core.ts"; +import { createAdminSupabaseClient } from "../src/lib/supabase/admin-client-core.ts"; +import { startPostgresFixture } from "./helpers/postgres-fixture.ts"; + +const runnerPath = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url)); +const compatibilitySql = readFileSync( + new URL("../../deploy/postgres/002-ensure-business-compatibility-roles.sql", import.meta.url), + "utf8", +); +const unknownUserId = "00000000-0000-4000-8000-000000000001"; + +test("service and restricted admin database identities stay separated", async () => { + const fixture = startPostgresFixture(); + try { + const migration = spawnSync(process.execPath, [runnerPath], { + encoding: "utf8", + env: { + ...process.env, + SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", "schema-owner-test-password"), + }, + }); + assert.equal(migration.status, 0, `${migration.stdout}${migration.stderr}`); + + fixture.psql( + "revoke connect on database jyotisha from public, service_runtime; " + + "revoke service_role from service_runtime; grant service_role to admin_runtime", + ); + const compatibility = spawnSync( + "docker", + [ + "compose", + "--project-name", + fixture.projectName, + "--env-file", + fixture.databaseEnvFile, + "-f", + "../deploy/docker-compose.postgres.yml", + "-f", + "../deploy/docker-compose.postgres-ci.yml", + "exec", + "-T", + "postgres", + "psql", + "-v", + "ON_ERROR_STOP=1", + "-U", + "postgres", + "-d", + "jyotisha", + "-f", + "/dev/stdin", + ], + { + encoding: "utf8", + input: compatibilitySql, + env: { + ...process.env, + DATABASE_ENV_FILE: fixture.databaseEnvFile, + POSTGRES_HOST_PORT: String(fixture.hostPort), + }, + }, + ); + assert.equal(compatibility.status, 0, `${compatibility.stdout}${compatibility.stderr}`); + assert.equal(fixture.psql("select pg_has_role('service_runtime','service_role','MEMBER')"), "t"); + assert.equal(fixture.psql("select pg_has_role('admin_runtime','service_role','MEMBER')"), "f"); + + const previousAuthProvider = process.env.AUTH_PROVIDER; + const previousServiceDatabaseUrl = process.env.SERVICE_DATABASE_URL; + const previousAdminDatabaseUrl = process.env.ADMIN_DATABASE_URL; + process.env.AUTH_PROVIDER = "self-hosted"; + process.env.SERVICE_DATABASE_URL = fixture.connectionUrl("service_runtime", "service-runtime-test-password"); + process.env.ADMIN_DATABASE_URL = fixture.connectionUrl("admin_runtime", "admin-runtime-test-password"); + try { + const service = createAdminSupabaseClient() as unknown as LocalPostgresDataClient; + const serviceProbe = await service.from("profiles").select("id").limit(1); + assert.equal(serviceProbe.error, null); + } finally { + if (previousAuthProvider === undefined) delete process.env.AUTH_PROVIDER; + else process.env.AUTH_PROVIDER = previousAuthProvider; + if (previousServiceDatabaseUrl === undefined) delete process.env.SERVICE_DATABASE_URL; + else process.env.SERVICE_DATABASE_URL = previousServiceDatabaseUrl; + if (previousAdminDatabaseUrl === undefined) delete process.env.ADMIN_DATABASE_URL; + else process.env.ADMIN_DATABASE_URL = previousAdminDatabaseUrl; + } + + const restrictedAdmin = createLocalPostgresDataClient( + fixture.connectionUrl("admin_runtime", "admin-runtime-test-password"), + null, + null, + ); + const permissionProbe = await restrictedAdmin.rpc("admin_permission_keys", { + p_user_id: unknownUserId, + }); + assert.equal(permissionProbe.error, null); + assert.deepEqual(permissionProbe.data, []); + + const elevationProbe = await createLocalPostgresDataClient( + fixture.connectionUrl("admin_runtime", "admin-runtime-test-password"), + null, + "service_role", + ).rpc("admin_permission_keys", { p_user_id: unknownUserId }); + assert.equal(elevationProbe.error?.code, "42501"); + assert.match(elevationProbe.error?.message ?? "", /permission denied to set role/); + } finally { + await closeLocalPostgresDataPools(); + fixture.stop(); + } +}); diff --git a/frontend/tests/database-backup.test.ts b/frontend/tests/database-backup.test.ts index 3fcd2a01..b1a82ba6 100644 --- a/frontend/tests/database-backup.test.ts +++ b/frontend/tests/database-backup.test.ts @@ -26,6 +26,7 @@ const fixtureSecrets = [ "schema-owner-test-password", "identity-runtime-test-password", "app-runtime-test-password", + "service-runtime-test-password", "admin-runtime-test-password", "migration-runner-test-password", "backup-reader-test-password", @@ -37,6 +38,7 @@ POSTGRES_PASSWORD=postgres-test-password SCHEMA_OWNER_PASSWORD=schema-owner-test-password IDENTITY_RUNTIME_PASSWORD=identity-runtime-test-password APP_RUNTIME_PASSWORD=app-runtime-test-password +SERVICE_RUNTIME_PASSWORD=service-runtime-test-password ADMIN_RUNTIME_PASSWORD=admin-runtime-test-password MIGRATION_RUNNER_PASSWORD=migration-runner-test-password BACKUP_READER_PASSWORD=backup-reader-test-password diff --git a/frontend/tests/database-billing-adjustments.test.ts b/frontend/tests/database-billing-adjustments.test.ts new file mode 100644 index 00000000..dd42fade --- /dev/null +++ b/frontend/tests/database-billing-adjustments.test.ts @@ -0,0 +1,486 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import { Client } from "pg"; + +import { startPostgresFixture } from "./helpers/postgres-fixture.ts"; + +const runnerPath = fileURLToPath( + new URL("../scripts/db-migrate.mjs", import.meta.url), +); + +const ids = { + billing: "71000000-0000-4000-8000-000000000001", + support: "71000000-0000-4000-8000-000000000002", + oneTimeUser: "72000000-0000-4000-8000-000000000001", + retryUser: "72000000-0000-4000-8000-000000000002", + compensateUser: "72000000-0000-4000-8000-000000000003", + oneTimeProduct: "73000000-0000-4000-8000-000000000001", + retryProduct: "73000000-0000-4000-8000-000000000002", + compensateProduct: "73000000-0000-4000-8000-000000000003", +}; + +const orderNo = (suffix: string) => `JYADJUST${suffix.padEnd(18, "0")}`; + +function errorText(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + +test("billing order adjustments and redemption reasons are atomic and audited", async () => { + const fixture = startPostgresFixture(); + const admin = new Client({ + connectionString: fixture.connectionUrl("postgres", "postgres-test-password"), + }); + const adminRuntime = new Client({ + connectionString: fixture.connectionUrl( + "admin_runtime", + "admin-runtime-test-password", + ), + }); + const serviceRuntime = new Client({ + connectionString: fixture.connectionUrl( + "service_runtime", + "service-runtime-test-password", + ), + }); + const sql = (statement: string) => fixture.psql(statement); + + try { + const migration = spawnSync(process.execPath, [runnerPath], { + encoding: "utf8", + env: { + ...process.env, + SCHEMA_DATABASE_URL: fixture.connectionUrl( + "schema_owner", + "schema-owner-test-password", + ), + }, + }); + assert.equal(migration.status, 0, migration.stderr); + await Promise.all([ + admin.connect(), + adminRuntime.connect(), + serviceRuntime.connect(), + ]); + await serviceRuntime.query("set role service_role"); + + fixture.psqlAs( + "identity_runtime", + "identity-runtime-test-password", + `insert into identity.users (id,name,email,email_verified,email_verified_at,role) values + ('${ids.billing}','Billing Admin','billing-adjust@example.com',true,now(),'admin'), + ('${ids.support}','Support Admin','support-adjust@example.com',true,now(),'admin'), + ('${ids.oneTimeUser}','One Time User','one-time@example.com',true,now(),'user'), + ('${ids.retryUser}','Retry User','retry@example.com',true,now(),'user'), + ('${ids.compensateUser}','Compensate User','compensate@example.com',true,now(),'user')`, + ); + sql(` + insert into public.admin_users(user_id,created_by) values + ('${ids.billing}','${ids.billing}'),('${ids.support}','${ids.billing}'); + insert into public.admin_user_roles(admin_user_id,role_id,assigned_by) + select v.user_id,r.id,'${ids.billing}'::uuid + from (values + ('${ids.billing}'::uuid,'billing_admin'), + ('${ids.support}'::uuid,'support') + ) v(user_id,role_code) + join public.admin_roles r on r.code=v.role_code; + insert into public.billing_products( + id,code,version,name,product_type,billing_period,interval_count, + price_cents,enabled,status,effective_from,one_time_per_user + ) values + ('${ids.oneTimeProduct}','once_credits',1,'一次性积分','credit_pack','none',0,700,true,'published',now(),true), + ('${ids.retryProduct}','retry_credits',1,'重试积分','credit_pack','none',0,500,true,'published',now(),false), + ('${ids.compensateProduct}','compensate_credits',1,'补偿积分','credit_pack','none',0,400,true,'published',now(),false); + insert into public.product_entitlements(product_id,feature_key,allowance_type,allowance_count,reset_period) values + ('${ids.oneTimeProduct}','chat.standard','credits',7,'none'), + ('${ids.retryProduct}','chat.standard','credits',5,'none'), + ('${ids.compensateProduct}','chat.standard','credits',4,'none'); + `); + + const insertOrder = async (input: { + suffix: string; + userId: string; + productId: string; + code: string; + price: number; + credits: number; + oneTime: boolean; + entitlements?: unknown[]; + }) => { + const entitlements = input.entitlements ?? [ + { + featureKey: "chat.standard", + allowanceType: "credits", + allowanceCount: input.credits, + resetPeriod: "none", + modelTier: null, + fairUsePolicyId: null, + metadata: {}, + }, + ]; + const result = await admin.query<{ id: string }>( + `insert into public.payment_orders ( + order_no,user_id,package_id,product_id,product_code,product_version, + product_snapshot,entitlement_snapshot,money_cents,currency,credits, + grant_type,grant_status + ) values ( + $1,$2,null,$3,$4,1, + jsonb_build_object( + 'id',$3::uuid,'code',$4::text,'version',1,'name',$5::text,'description','', + 'productType','credit_pack','billingPeriod','none','intervalCount',0, + 'priceCents',$6::integer,'currency','CNY','oneTimePerUser',$7::boolean + ), + $8::jsonb,$6,'CNY',$9,'credits','pending' + ) returning id`, + [ + orderNo(input.suffix), + input.userId, + input.productId, + input.code, + input.code, + input.price, + input.oneTime, + JSON.stringify(entitlements), + input.credits, + ], + ); + return result.rows[0].id; + }; + + const onceOrderA = await insertOrder({ + suffix: "ONCEA", + userId: ids.oneTimeUser, + productId: ids.oneTimeProduct, + code: "once_credits", + price: 700, + credits: 7, + oneTime: true, + }); + await insertOrder({ + suffix: "ONCEB", + userId: ids.oneTimeUser, + productId: ids.oneTimeProduct, + code: "once_credits", + price: 700, + credits: 7, + oneTime: true, + }); + + const raceClients = [0, 1].map( + () => + new Client({ + connectionString: fixture.connectionUrl( + "service_runtime", + "service-runtime-test-password", + ), + }), + ); + await Promise.all(raceClients.map((client) => client.connect())); + await Promise.all( + raceClients.map((client) => client.query("set role service_role")), + ); + try { + const results = await Promise.all([ + raceClients[0].query( + "select * from public.settle_order($1,$2,$3,$4)", + [orderNo("ONCEA"), "trade-once-a", 700, "hash-once-a"], + ), + raceClients[1].query( + "select * from public.settle_order($1,$2,$3,$4)", + [orderNo("ONCEB"), "trade-once-b", 700, "hash-once-b"], + ), + ]); + assert.deepEqual( + results.map((result) => result.rows[0].success).sort(), + [false, true], + ); + assert.deepEqual( + results.map((result) => result.rows[0].status).sort(), + ["one_time_limit", "paid"], + ); + } finally { + await Promise.all(raceClients.map((client) => client.end())); + } + assert.equal( + sql(`select credits from public.profiles where id='${ids.oneTimeUser}'`), + "7", + ); + assert.equal( + sql(`select count(*) from public.user_product_redemptions where user_id='${ids.oneTimeUser}' and product_code='once_credits'`), + "1", + ); + await assert.rejects( + admin.query( + "update public.payment_orders set product_snapshot=jsonb_set(product_snapshot,'{name}','\"tampered\"') where id=$1", + [onceOrderA], + ), + (error) => /payment_order_snapshot_immutable/.test(errorText(error)), + ); + + const retryOrder = await insertOrder({ + suffix: "RETRY", + userId: ids.retryUser, + productId: ids.retryProduct, + code: "retry_credits", + price: 500, + credits: 5, + oneTime: false, + }); + await admin.query(` + create function public.test_fail_retry_grant() returns trigger + language plpgsql as $$ + begin + if new.request_id='${orderNo("RETRY")}' then + raise exception 'forced_retry_grant_failure'; + end if; + return new; + end $$; + create trigger test_fail_retry_grant before insert on public.credit_transactions + for each row execute function public.test_fail_retry_grant() + `); + const failedRetry = await serviceRuntime.query( + "select * from public.settle_order($1,$2,$3,$4)", + [orderNo("RETRY"), "trade-retry", 500, "hash-retry"], + ); + assert.equal(failedRetry.rows[0].success, false); + assert.equal(failedRetry.rows[0].status, "grant_failed"); + assert.equal( + sql(`select credits from public.profiles where id='${ids.retryUser}'`), + "0", + ); + await admin.query( + "drop trigger test_fail_retry_grant on public.credit_transactions; drop function public.test_fail_retry_grant()", + ); + + const retry = await adminRuntime.query( + "select * from public.admin_adjust_order($1,$2,'retry_grant',0,$3,$4)", + [ids.billing, retryOrder, "重试失败权益发放", "retry-adjustment-1"], + ); + assert.equal(retry.rows[0].action_success, true); + assert.equal(retry.rows[0].adjustment_version, 1); + assert.equal( + sql(`select credits from public.profiles where id='${ids.retryUser}'`), + "5", + ); + const retryAgain = await adminRuntime.query( + "select * from public.admin_adjust_order($1,$2,'retry_grant',0,$3,$4)", + [ids.billing, retryOrder, "同一请求幂等重放", "retry-adjustment-1"], + ); + assert.equal(retryAgain.rows[0].adjustment_version, 1); + assert.equal( + sql("select count(*) from audit.admin_audit_logs where request_id='retry-adjustment-1'"), + "1", + ); + await assert.rejects( + adminRuntime.query( + "select * from public.admin_adjust_order($1,$2,'record_refund',0,$3,$4)", + [ids.billing, retryOrder, "过期版本不得退款", "refund-stale"], + ), + (error) => + (error as { code?: string }).code === "40001" && + /admin_order_version_conflict/.test(errorText(error)), + ); + + const compensateOrder = await insertOrder({ + suffix: "COMPENSATE", + userId: ids.compensateUser, + productId: ids.compensateProduct, + code: "compensate_credits", + price: 400, + credits: 4, + oneTime: false, + entitlements: [], + }); + const invalidGrant = await serviceRuntime.query( + "select * from public.settle_order($1,$2,$3,$4)", + [orderNo("COMPENSATE"), "trade-compensate", 400, "hash-compensate"], + ); + assert.equal(invalidGrant.rows[0].status, "invalid_snapshot"); + await assert.rejects( + adminRuntime.query( + "select * from public.admin_adjust_order($1,$2,'compensate',0,'',$3)", + [ids.billing, compensateOrder, "compensate-empty-reason"], + ), + (error) => /admin_reason_required/.test(errorText(error)), + ); + await assert.rejects( + adminRuntime.query( + "select * from public.admin_adjust_order($1,$2,'compensate',0,$3,$4)", + [ids.support, compensateOrder, "无权人工补偿", "compensate-denied"], + ), + (error) => /admin_permission_denied/.test(errorText(error)), + ); + const compensated = await adminRuntime.query( + "select * from public.admin_adjust_order($1,$2,'compensate',0,$3,$4)", + [ids.billing, compensateOrder, "确认支付成功,人工补发积分", "compensate-1"], + ); + assert.equal(compensated.rows[0].grant_status, "granted"); + assert.equal( + sql(`select credits from public.profiles where id='${ids.compensateUser}'`), + "4", + ); + assert.equal( + sql(`select count(*) from public.credit_transactions where user_id='${ids.compensateUser}' and transaction_type='compensation'`), + "1", + ); + + const balanceBeforeRefund = sql( + `select credits from public.profiles where id='${ids.retryUser}'`, + ); + const refunded = await adminRuntime.query( + "select * from public.admin_adjust_order($1,$2,'record_refund',1,$3,$4)", + [ids.billing, retryOrder, "线下已退款,仅登记账务状态", "refund-record-1"], + ); + assert.equal(refunded.rows[0].status, "refunded"); + assert.equal(refunded.rows[0].refund_status, "recorded"); + assert.equal( + sql(`select credits from public.profiles where id='${ids.retryUser}'`), + balanceBeforeRefund, + ); + assert.equal( + sql(`select after_value->>'externalGatewayRefundAttempted' from audit.admin_audit_logs where request_id='refund-record-1'`), + "f", + ); + assert.equal( + sql(`select permission_used||':'||reason from audit.admin_audit_logs where request_id='compensate-1'`), + "billing.adjustments.write:确认支付成功,人工补发积分", + ); + + assert.equal( + sql("select has_function_privilege('service_role','public.admin_create_redemption_codes(uuid,text,text,text,jsonb)','execute')"), + "f", + ); + assert.equal( + sql("select has_function_privilege('service_role','public.admin_update_redemption_code(uuid,text,text,text,uuid,boolean,text,boolean,timestamptz)','execute')"), + "f", + ); + assert.equal( + sql("select has_function_privilege('service_role','public.admin_revoke_redemption_code(uuid,text,text,text,uuid)','execute')"), + "f", + ); + for (const role of ["service_role", "service_runtime"]) { + assert.equal( + sql(`select has_function_privilege('${role}','public.admin_adjust_order(uuid,uuid,text,integer,text,text)','execute')`), + "f", + ); + assert.equal( + sql(`select has_function_privilege('${role}','public.admin_create_redemption_codes(uuid,text,text,text,jsonb,text)','execute')`), + "f", + ); + assert.equal( + sql(`select has_function_privilege('${role}','public.admin_update_redemption_code(uuid,text,text,text,uuid,boolean,text,boolean,timestamptz,text)','execute')`), + "f", + ); + assert.equal( + sql(`select has_function_privilege('${role}','public.admin_revoke_redemption_code(uuid,text,text,text,uuid,text)','execute')`), + "f", + ); + } + assert.equal( + sql("select pg_has_role('service_runtime','service_role','MEMBER')"), + "t", + ); + assert.equal( + sql("select has_function_privilege('service_role','public.settle_order(text,text,integer,text)','execute')"), + "t", + ); + for (const signature of [ + "public.admin_adjust_order(uuid,uuid,text,integer,text,text)", + "public.admin_create_redemption_codes(uuid,text,text,text,jsonb,text)", + "public.admin_update_redemption_code(uuid,text,text,text,uuid,boolean,text,boolean,timestamptz,text)", + "public.admin_revoke_redemption_code(uuid,text,text,text,uuid,text)", + ]) { + assert.equal( + sql(`select has_function_privilege('admin_runtime','${signature}','execute')`), + "t", + ); + } + await assert.rejects( + serviceRuntime.query( + "select * from public.admin_adjust_order($1,$2,'record_refund',1,$3,$4)", + [ids.billing, retryOrder, "机器身份不得人工退款", "service-refund-denied"], + ), + (error) => (error as { code?: string }).code === "42501", + ); + await assert.rejects( + serviceRuntime.query( + "select * from public.admin_create_redemption_codes($1,$2,'admin',$3,$4::jsonb,$5)", + [ids.billing, "billing-adjust@example.com", "service-code-denied", "[]", "机器身份不得创建兑换码"], + ), + (error) => (error as { code?: string }).code === "42501", + ); + await assert.rejects( + adminRuntime.query( + "select * from public.admin_create_redemption_codes($1,$2,'admin',$3,$4::jsonb,'')", + [ + ids.billing, + "billing-adjust@example.com", + "code-create-empty", + JSON.stringify([ + { + codeHash: "a".repeat(64), + codeMask: "JYOTISH-****-AAAA", + credits: 3, + expiresAt: null, + note: "test", + }, + ]), + ], + ), + (error) => /admin_reason_required/.test(errorText(error)), + ); + const createdCode = await adminRuntime.query<{ id: string }>( + "select id from public.admin_create_redemption_codes($1,$2,'admin',$3,$4::jsonb,$5)", + [ + ids.billing, + "billing-adjust@example.com", + "code-create-1", + JSON.stringify([ + { + codeHash: "b".repeat(64), + codeMask: "JYOTISH-****-BBBB", + credits: 3, + expiresAt: null, + note: "created", + }, + ]), + "创建客服补偿码", + ], + ); + const codeId = createdCode.rows[0].id; + await adminRuntime.query( + "select * from public.admin_update_redemption_code($1,$2,'admin',$3,$4,true,$5,false,null,$6)", + [ + ids.billing, + "billing-adjust@example.com", + "code-update-1", + codeId, + "updated", + "修正兑换码备注", + ], + ); + await adminRuntime.query( + "select * from public.admin_revoke_redemption_code($1,$2,'admin',$3,$4,$5)", + [ + ids.billing, + "billing-adjust@example.com", + "code-revoke-1", + codeId, + "撤销未发放兑换码", + ], + ); + assert.equal( + sql(`select string_agg(action||':'||permission_used||':'||reason,'|' order by created_at) + from audit.admin_audit_logs where target_id='${codeId}'`), + "redemption_code.create:billing.adjustments.write:创建客服补偿码|redemption_code.update:billing.adjustments.write:修正兑换码备注|redemption_code.revoke:billing.adjustments.write:撤销未发放兑换码", + ); + } finally { + await Promise.all([ + admin.end().catch(() => {}), + adminRuntime.end().catch(() => {}), + serviceRuntime.end().catch(() => {}), + ]); + fixture.stop(); + } +}); diff --git a/frontend/tests/database-billing-admin.test.ts b/frontend/tests/database-billing-admin.test.ts new file mode 100644 index 00000000..65eff9d5 --- /dev/null +++ b/frontend/tests/database-billing-admin.test.ts @@ -0,0 +1,571 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import { Client } from "pg"; + +import { startPostgresFixture } from "./helpers/postgres-fixture.ts"; + +const runnerPath = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url)); +const ids = { + owner: "10000000-0000-4000-8000-000000000001", + billing: "10000000-0000-4000-8000-000000000002", + model: "10000000-0000-4000-8000-000000000003", + support: "10000000-0000-4000-8000-000000000004", + auditor: "10000000-0000-4000-8000-000000000005", + subscriber: "20000000-0000-4000-8000-000000000001", + trialUser: "20000000-0000-4000-8000-000000000002", + annualUser: "20000000-0000-4000-8000-000000000003", + creditUser: "20000000-0000-4000-8000-000000000004", + snapshotUser: "20000000-0000-4000-8000-000000000005", + failedGrantUser: "20000000-0000-4000-8000-000000000006", + concurrentRenewalUser: "20000000-0000-4000-8000-000000000007", + concurrentOverlapUser: "20000000-0000-4000-8000-000000000008", +}; + +function orderNo(suffix: string) { + return `JYTEST${suffix.padEnd(18, "0")}`; +} + +function sqlJson(value: unknown) { + return JSON.stringify(value).replaceAll("'", "''"); +} + +test("billing, subscriptions, usage authorization, RBAC, and model publication remain transactional", async () => { + const fixture = startPostgresFixture(); + const sql = (statement: string) => fixture.psql(statement); + const sqlAsOwner = (statement: string) => fixture.psqlAs( + "schema_owner", + "schema-owner-test-password", + statement, + ); + const expectSqlError = (statement: string, pattern: RegExp) => { + assert.throws(() => sqlAsOwner(statement), pattern); + }; + const expectAdminRuntimeError = (statement: string, pattern: RegExp) => { + assert.throws( + () => fixture.psqlAs("admin_runtime", "admin-runtime-test-password", statement), + pattern, + ); + }; + const insertOrder = (input: { + suffix: string; + userId: string; + productId: string; + code: string; + version?: number; + money: number; + credits?: number; + }) => sql(` + insert into public.payment_orders ( + order_no,user_id,package_id,product_id,product_code,product_version, + product_snapshot,entitlement_snapshot,money_cents,currency,credits,grant_status + ) + select + '${orderNo(input.suffix)}','${input.userId}',null,p.id,p.code,p.version, + jsonb_build_object( + 'id',p.id,'code',p.code,'version',p.version,'name',p.name,'description',p.description, + 'productType',p.product_type,'billingPeriod',p.billing_period,'intervalCount',p.interval_count, + 'priceCents',p.price_cents,'currency',p.currency,'oneTimePerUser',p.one_time_per_user + ), + coalesce((select jsonb_agg(jsonb_build_object( + 'featureKey',e.feature_key,'allowanceType',e.allowance_type,'allowanceCount',e.allowance_count, + 'resetPeriod',e.reset_period,'modelTier',e.model_tier,'fairUsePolicyId',e.fair_use_policy_id, + 'metadata',e.metadata + ) order by e.feature_key) from public.product_entitlements e where e.product_id=p.id),'[]'::jsonb), + ${input.money},'CNY',${input.credits ?? 0},'pending' + from public.billing_products p + where p.id='${input.productId}' and p.code='${input.code}' and p.version=${input.version ?? 1} + returning id + `); + const settleConcurrently = async (orders: Array<{ suffix: string; trade: string; money: number; hash: string }>) => { + const clients = orders.map(() => new Client({ + connectionString: fixture.connectionUrl("postgres", "postgres-test-password"), + })); + await Promise.all(clients.map((client) => client.connect())); + try { + return await Promise.all(orders.map((order, index) => clients[index].query( + "select * from public.settle_order($1,$2,$3,$4)", + [orderNo(order.suffix), order.trade, order.money, order.hash], + ))); + } finally { + await Promise.all(clients.map((client) => client.end())); + } + }; + const completeUsageConcurrently = async (userId: string, requestId: string, events: Array>) => { + const clients = events.map(() => new Client({ + connectionString: fixture.connectionUrl("postgres", "postgres-test-password"), + })); + await Promise.all(clients.map((client) => client.connect())); + try { + return await Promise.all(events.map((event, index) => clients[index].query( + "select * from public.complete_usage($1,$2,$3::jsonb)", + [userId, requestId, JSON.stringify(event)], + ))); + } finally { + await Promise.all(clients.map((client) => client.end())); + } + }; + + try { + const migration = spawnSync(process.execPath, [runnerPath], { + encoding: "utf8", + env: { + ...process.env, + SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", "schema-owner-test-password"), + }, + }); + assert.equal(migration.status, 0, migration.stderr); + assert.match(migration.stdout, /applied 20260806060000_unified_rectification_usage\.sql/); + + fixture.psqlAs("identity_runtime", "identity-runtime-test-password", ` + insert into identity.users (id,name,email,email_verified,email_verified_at,role) values + ('${ids.owner}','Owner','owner@example.com',true,now(),'admin'), + ('${ids.billing}','Billing','billing@example.com',true,now(),'user'), + ('${ids.model}','Model','model@example.com',true,now(),'user'), + ('${ids.support}','Support','support@example.com',true,now(),'user'), + ('${ids.auditor}','Auditor','auditor@example.com',true,now(),'user'), + ('${ids.subscriber}','Subscriber','subscriber@example.com',true,now(),'user'), + ('${ids.trialUser}','Trial','trial@example.com',true,now(),'user'), + ('${ids.annualUser}','Annual','annual@example.com',true,now(),'user'), + ('${ids.creditUser}','Credit','credit@example.com',true,now(),'user'), + ('${ids.snapshotUser}','Snapshot','snapshot@example.com',true,now(),'user'), + ('${ids.failedGrantUser}','Failed Grant','failed-grant@example.com',true,now(),'user'), + ('${ids.concurrentRenewalUser}','Concurrent Renewal','renewal@example.com',true,now(),'user'), + ('${ids.concurrentOverlapUser}','Concurrent Overlap','overlap@example.com',true,now(),'user') + `); + sql(` + update public.profiles set credits=10 where id in ('${ids.subscriber}','${ids.creditUser}'); + insert into public.admin_users(user_id,created_by) values + ('${ids.owner}','${ids.owner}'),('${ids.billing}','${ids.owner}'), + ('${ids.model}','${ids.owner}'),('${ids.support}','${ids.owner}'),('${ids.auditor}','${ids.owner}'); + insert into public.admin_user_roles(admin_user_id,role_id,assigned_by) + select v.user_id,r.id,'${ids.owner}'::uuid + from (values + ('${ids.owner}'::uuid,'owner'),('${ids.billing}'::uuid,'billing_admin'), + ('${ids.model}'::uuid,'model_admin'),('${ids.support}'::uuid,'support'), + ('${ids.auditor}'::uuid,'auditor') + ) v(user_id,role_code) join public.admin_roles r on r.code=v.role_code + `); + + assert.equal(sql(`select public.admin_has_permission('${ids.billing}','billing.products.publish')`), "t"); + assert.equal(sql(`select public.admin_has_permission('${ids.billing}','models.publish')`), "f"); + assert.equal(sql(`select public.admin_has_permission('${ids.model}','models.rollback')`), "t"); + assert.equal(sql(`select public.admin_has_permission('${ids.model}','billing.adjustments.write')`), "f"); + assert.equal(sql(`select public.admin_has_permission('${ids.support}','billing.orders.read')`), "t"); + assert.equal(sql(`select public.admin_has_permission('${ids.support}','billing.products.write')`), "f"); + assert.equal(sql(`select public.admin_has_permission('${ids.auditor}','audit.read')`), "t"); + assert.equal(sql(`select public.admin_has_permission('${ids.auditor}','ops.flags.write')`), "f"); + assert.equal(sql(`select public.admin_has_permission('${ids.owner}','admin.customers.birth_data.read')`), "t"); + assert.equal(sql(`select public.admin_has_permission('${ids.support}','admin.customers.read')`), "t"); + assert.equal(sql(`select public.admin_has_permission('${ids.support}','admin.customers.birth_data.read')`), "f"); + assert.equal(sql("select pg_has_role('admin_runtime','service_role','MEMBER')"), "f"); + assert.equal(sql("select has_function_privilege('admin_runtime','public.admin_permission_keys(uuid)','execute')"), "t"); + assert.equal(sql("select has_column_privilege('admin_runtime','public.profiles','birth_date','select')"), "f"); + expectAdminRuntimeError("set role service_role", /permission denied to set role/); + expectSqlError( + `select * from public.admin_manage_role('${ids.owner}','${ids.owner}','owner',false,'不得移除最后 Owner','last-owner')`, + /last_owner_protected/, + ); + expectSqlError( + `update public.admin_users set revoked_at=clock_timestamp(),revoked_by='${ids.owner}' where user_id='${ids.owner}'`, + /last_owner_protected/, + ); + sql(`select * from public.admin_manage_role('${ids.owner}','${ids.billing}','owner',true,'增加第二位 Owner','second-owner')`); + sql(`update public.admin_users set revoked_at=clock_timestamp(),revoked_by='${ids.billing}' where user_id='${ids.owner}'`); + assert.equal(sql(`select public.admin_has_permission('${ids.owner}','admin.access')`), "f"); + expectSqlError( + `update public.admin_users set revoked_at=clock_timestamp(),revoked_by='${ids.billing}' where user_id='${ids.billing}'`, + /last_owner_protected/, + ); + expectSqlError( + `delete from public.admin_user_roles where admin_user_id='${ids.billing}' and role_id=(select id from public.admin_roles where code='owner')`, + /last_owner_protected/, + ); + sql(`update public.admin_users set revoked_at=null,revoked_by=null where user_id='${ids.owner}'`); + sql(`delete from public.admin_user_roles where admin_user_id='${ids.billing}' and role_id=(select id from public.admin_roles where code='owner')`); + + sql(` + update public.profiles + set birth_date='1990-01-02', birth_time_status='reported', birth_place_label='Taipei' + where id='${ids.subscriber}' + `); + expectAdminRuntimeError( + `select * from public.admin_read_customer_birth_data('${ids.support}',array['${ids.subscriber}'::uuid],'support-birth-read')`, + /admin_permission_denied/, + ); + assert.equal( + fixture.psqlAs( + "admin_runtime", + "admin-runtime-test-password", + `select birth_date||':'||birth_time_status||':'||birth_place_label + from public.admin_read_customer_birth_data('${ids.owner}',array['${ids.subscriber}'::uuid],'owner-birth-read')`, + ), + "1990-01-02:reported:Taipei", + ); + assert.equal( + sql(`select action||':'||permission_used from audit.admin_audit_logs where request_id='owner-birth-read'`), + "admin.customer.birth_data.read:admin.customers.birth_data.read", + ); + + sql(` + insert into public.admin_role_permissions(role_id,permission_id) + select r.id,p.id from public.admin_roles r cross join public.admin_permissions p + where r.code='support' and p.permission_key='admin.users.manage_roles' + on conflict do nothing; + insert into public.admin_user_roles(admin_user_id,role_id,assigned_by) + select '${ids.billing}',id,'${ids.owner}' from public.admin_roles where code='owner' + on conflict do nothing + `); + const ownerRaceClients = Array.from({ length: 3 }, () => new Client({ + connectionString: fixture.connectionUrl("postgres", "postgres-test-password"), + })); + await Promise.all(ownerRaceClients.map(async (client) => { + await client.connect(); + await client.query("set plpgsql.variable_conflict = use_column"); + })); + const [blocker, firstOwnerRevoke, secondOwnerRevoke] = ownerRaceClients; + let revocations: Promise[] = []; + try { + await blocker.query("begin"); + await blocker.query("select pg_advisory_xact_lock(1096040772, 1)"); + const first = firstOwnerRevoke.query( + "select * from public.admin_manage_role($1,$2,'owner',false,$3,$4)", + [ids.support, ids.owner, "并发移除 Owner A", "owner-race-a"], + ); + const second = secondOwnerRevoke.query( + "select * from public.admin_manage_role($1,$2,'owner',false,$3,$4)", + [ids.support, ids.billing, "并发移除 Owner B", "owner-race-b"], + ); + revocations = [first, second]; + let waiters = 0; + for (let attempt = 0; attempt < 40 && waiters < 2; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 25)); + const result = await blocker.query<{ count: string }>(` + select count(*)::text as count from pg_stat_activity + where pid <> pg_backend_pid() + and query like '%admin_manage_role%' + and wait_event_type='Lock' + and wait_event='advisory' + `); + waiters = Number(result.rows[0]?.count ?? 0); + } + assert.equal(waiters, 2, "both Owner revocations must wait on the fixed advisory lock"); + await blocker.query("commit"); + const outcomes = await Promise.allSettled([first, second]); + assert.equal( + outcomes.filter((outcome) => outcome.status === "fulfilled").length, + 1, + outcomes.map((outcome) => outcome.status === "fulfilled" ? "fulfilled" : String(outcome.reason?.message)).join(" | "), + ); + const rejected = outcomes.find((outcome): outcome is PromiseRejectedResult => outcome.status === "rejected"); + assert.match(String(rejected?.reason?.message), /last_owner_protected/); + assert.equal(sql(`select count(*) from public.admin_user_roles ur join public.admin_roles r on r.id=ur.role_id where r.code='owner'`), "1"); + } finally { + await blocker.query("rollback").catch(() => undefined); + await Promise.allSettled(revocations); + await Promise.all(ownerRaceClients.map((client) => client.end())); + } + sql(` + insert into public.admin_user_roles(admin_user_id,role_id,assigned_by) + select '${ids.owner}',id,'${ids.owner}' from public.admin_roles where code='owner' + on conflict do nothing; + delete from public.admin_user_roles + where admin_user_id='${ids.billing}' and role_id=(select id from public.admin_roles where code='owner'); + delete from public.admin_role_permissions + where role_id=(select id from public.admin_roles where code='support') + and permission_id=(select id from public.admin_permissions where permission_key='admin.users.manage_roles') + `); + + const creditProduct = "30000000-0000-4000-8000-000000000001"; + sql(` + insert into public.billing_products(id,code,version,name,product_type,billing_period,interval_count,price_cents,enabled,status,effective_from) + values('${creditProduct}','credits_10',1,'10 点积分','credit_pack','none',0,1000,true,'published',now()); + insert into public.product_entitlements(product_id,feature_key,allowance_type,allowance_count,reset_period) + values('${creditProduct}','chat.standard','credits',10,'none') + `); + insertOrder({ suffix: "CREDIT", userId: ids.creditUser, productId: creditProduct, code: "credits_10", money: 1000, credits: 10 }); + for (let attempt = 0; attempt < 5; attempt += 1) { + assert.equal(sql(`select success||':'||status from public.settle_order('${orderNo("CREDIT")}','trade-credit',1000,'hash-credit')`), "true:paid"); + } + assert.equal(sql(`select credits from public.profiles where id='${ids.creditUser}'`), "20"); + assert.equal(sql(`select count(*) from public.credit_transactions where user_id='${ids.creditUser}' and transaction_type='payment'`), "1"); + + const snapshotProduct = "30000000-0000-4000-8000-000000000002"; + sql(` + insert into public.billing_products(id,code,version,name,product_type,billing_period,interval_count,price_cents,enabled,status,effective_from) + values('${snapshotProduct}','snapshot_monthly',1,'快照月卡','subscription','month',1,1200,true,'published',now()); + insert into public.product_entitlements(product_id,feature_key,allowance_type,allowance_count,reset_period,model_tier) + values('${snapshotProduct}','chat.standard','quota',2,'billing_period','standard') + `); + insertOrder({ suffix: "SNAPSHOT", userId: ids.snapshotUser, productId: snapshotProduct, code: "snapshot_monthly", money: 1200 }); + sql(` + update public.billing_products set billing_period='year',interval_count=2 where id='${snapshotProduct}'; + update public.product_entitlements set allowance_count=99 where product_id='${snapshotProduct}' + `); + assert.equal(sql(`select success||':'||status from public.settle_order('${orderNo("SNAPSHOT")}','trade-snapshot',1200,'hash-snapshot')`), "true:paid"); + assert.equal(sql(`select (ends_at=starts_at+interval '1 month')||':'||(entitlement_snapshot#>>'{0,allowanceCount}') from public.user_subscriptions where source_order_id=(select id from public.payment_orders where order_no='${orderNo("SNAPSHOT")}')`), "true:2"); + assert.equal(sql(`select (s.product_snapshot=o.product_snapshot)||':'||(s.entitlement_snapshot=o.entitlement_snapshot) from public.user_subscriptions s join public.payment_orders o on o.id=s.source_order_id where o.order_no='${orderNo("SNAPSHOT")}'`), "true:true"); + + sql(` + create function public.test_fail_subscription_grant() returns trigger language plpgsql as $$ + begin + if new.user_id='${ids.failedGrantUser}' then raise exception 'forced_grant_failure'; end if; + return new; + end $$; + create trigger test_fail_subscription_grant before insert on public.user_subscriptions + for each row execute function public.test_fail_subscription_grant() + `); + insertOrder({ suffix: "FAILGRANT", userId: ids.failedGrantUser, productId: "00000000-0000-4000-8000-000000000902", code: "standard_monthly", money: 9900 }); + assert.equal(sql(`select success||':'||status from public.settle_order('${orderNo("FAILGRANT")}','trade-failed',9900,'hash-failed')`), "f:grant_failed"); + const failedPaidAt = sql(`select paid_at from public.payment_orders where order_no='${orderNo("FAILGRANT")}'`); + assert.equal(sql(`select status||':'||grant_status||':'||(grant_error like '%forced_grant_failure%')||':'||epay_trade_no||':'||raw_notify_payload_hash||':'||(paid_at is not null) from public.payment_orders where order_no='${orderNo("FAILGRANT")}'`), "grant_pending:failed:true:trade-failed:hash-failed:true"); + assert.equal(sql(`select success||':'||status from public.settle_order('${orderNo("FAILGRANT")}','trade-failed',9900,'hash-failed-retry')`), "f:grant_failed"); + assert.equal(sql(`select count(*) from public.user_subscriptions where source_order_id=(select id from public.payment_orders where order_no='${orderNo("FAILGRANT")}')`), "0"); + sql(`drop trigger test_fail_subscription_grant on public.user_subscriptions; drop function public.test_fail_subscription_grant()`); + assert.equal(sql(`select success||':'||status from public.settle_order('${orderNo("FAILGRANT")}','trade-failed',9900,'hash-failed-retry')`), "true:paid"); + assert.equal(sql(`select (paid_at='${failedPaidAt}'::timestamptz)||':'||raw_notify_payload_hash||':'||grant_status from public.payment_orders where order_no='${orderNo("FAILGRANT")}'`), "true:hash-failed:granted"); + assert.equal(sql(`select success||':'||status from public.settle_order('${orderNo("FAILGRANT")}','trade-failed',9900,'hash-after-grant')`), "true:paid"); + assert.equal(sql(`select raw_notify_payload_hash from public.payment_orders where order_no='${orderNo("FAILGRANT")}'`), "hash-failed"); + + insertOrder({ suffix: "TRIAL1", userId: ids.trialUser, productId: "00000000-0000-4000-8000-000000000901", code: "trial_7d", money: 990 }); + assert.equal(sql(`select success||':'||status from public.settle_order('${orderNo("TRIAL1")}','trade-trial-1',990,'hash-trial-1')`), "true:paid"); + insertOrder({ suffix: "TRIAL2", userId: ids.trialUser, productId: "00000000-0000-4000-8000-000000000901", code: "trial_7d", money: 990 }); + assert.equal(sql(`select success||':'||status from public.settle_order('${orderNo("TRIAL2")}','trade-trial-2',990,'hash-trial-2')`), "f:one_time_limit"); + assert.equal(sql(`select count(*) from public.user_product_redemptions where user_id='${ids.trialUser}' and product_code='trial_7d'`), "1"); + + insertOrder({ suffix: "MONTH1", userId: ids.subscriber, productId: "00000000-0000-4000-8000-000000000902", code: "standard_monthly", money: 9900 }); + assert.equal(sql(`select success||':'||status from public.settle_order('${orderNo("MONTH1")}','trade-month-1',9900,'hash-month-1')`), "true:paid"); + const monthlySubscription = sql(`select grant_reference_id from public.payment_orders where order_no='${orderNo("MONTH1")}'`); + sql(`update public.user_subscriptions set starts_at='2026-07-31 10:00:00+00',ends_at='2026-08-31 10:00:00+00' where id='${monthlySubscription}'`); + insertOrder({ suffix: "MONTH2", userId: ids.subscriber, productId: "00000000-0000-4000-8000-000000000902", code: "standard_monthly", money: 9900 }); + assert.equal(sql(`select success||':'||status from public.settle_order('${orderNo("MONTH2")}','trade-month-2',9900,'hash-month-2')`), "true:paid"); + assert.equal(sql(`select to_char(starts_at at time zone 'UTC','YYYY-MM-DD HH24:MI')||':'||to_char(ends_at at time zone 'UTC','YYYY-MM-DD HH24:MI') from public.user_subscriptions where source_order_id=(select id from public.payment_orders where order_no='${orderNo("MONTH2")}')`), "2026-08-31 10:00:2026-09-30 10:00"); + + insertOrder({ suffix: "OVERLAP", userId: ids.subscriber, productId: "00000000-0000-4000-8000-000000000903", code: "standard_yearly", money: 59900 }); + assert.equal(sql(`select success||':'||status from public.settle_order('${orderNo("OVERLAP")}','trade-overlap',59900,'hash-overlap')`), "f:overlapping_subscription"); + + insertOrder({ suffix: "YEAR1", userId: ids.annualUser, productId: "00000000-0000-4000-8000-000000000903", code: "standard_yearly", money: 59900 }); + assert.equal(sql(`select success||':'||status from public.settle_order('${orderNo("YEAR1")}','trade-year-1',59900,'hash-year-1')`), "true:paid"); + const annualSubscription = sql(`select grant_reference_id from public.payment_orders where order_no='${orderNo("YEAR1")}'`); + sql(`update public.user_subscriptions set starts_at='2027-02-28 00:00:00+00',ends_at='2028-02-29 00:00:00+00' where id='${annualSubscription}'`); + insertOrder({ suffix: "YEAR2", userId: ids.annualUser, productId: "00000000-0000-4000-8000-000000000903", code: "standard_yearly", money: 59900 }); + assert.equal(sql(`select success||':'||status from public.settle_order('${orderNo("YEAR2")}','trade-year-2',59900,'hash-year-2')`), "true:paid"); + assert.equal(sql(`select to_char(ends_at at time zone 'UTC','YYYY-MM-DD') from public.user_subscriptions where source_order_id=(select id from public.payment_orders where order_no='${orderNo("YEAR2")}')`), "2029-02-28"); + sql(`update public.user_subscriptions set starts_at=clock_timestamp()-interval '1 day',ends_at=clock_timestamp()+interval '1 year' where id='${annualSubscription}'`); + assert.equal(sql(`select source||':'||credits from public.authorize_usage('${ids.annualUser}','rectification',null,'annual-monthly-release-1',1)`), "subscription:0"); + assert.equal(sql(`select success||':'||reason||':'||credits from public.authorize_usage('${ids.annualUser}','rectification',null,'annual-monthly-release-2',1)`), "f:feature_quota_exhausted:0"); + sql(`update public.user_subscriptions set starts_at=clock_timestamp()-interval '1 month 1 day' where id='${annualSubscription}'`); + assert.equal(sql(`select source||':'||credits from public.authorize_usage('${ids.annualUser}','rectification',null,'annual-monthly-release-2',1)`), "subscription:0"); + + sql(` + create function public.test_delay_concurrent_subscription_grant() returns trigger language plpgsql as $$ + begin + if new.user_id in ('${ids.concurrentRenewalUser}','${ids.concurrentOverlapUser}') then perform pg_sleep(0.35); end if; + return new; + end $$; + create trigger test_delay_concurrent_subscription_grant before insert on public.user_subscriptions + for each row execute function public.test_delay_concurrent_subscription_grant() + `); + insertOrder({ suffix: "RENEW1", userId: ids.concurrentRenewalUser, productId: "00000000-0000-4000-8000-000000000902", code: "standard_monthly", money: 9900 }); + insertOrder({ suffix: "RENEW2", userId: ids.concurrentRenewalUser, productId: "00000000-0000-4000-8000-000000000902", code: "standard_monthly", money: 9900 }); + const renewalResults = await settleConcurrently([ + { suffix: "RENEW1", trade: "trade-renew-1", money: 9900, hash: "hash-renew-1" }, + { suffix: "RENEW2", trade: "trade-renew-2", money: 9900, hash: "hash-renew-2" }, + ]); + assert.deepEqual(renewalResults.map((result) => result.rows[0].success), [true, true]); + assert.equal(sql(`select count(*) from public.user_subscriptions where user_id='${ids.concurrentRenewalUser}'`), "2"); + assert.equal(sql(`select count(*) from (select starts_at,lag(ends_at) over(order by starts_at) previous_ends_at from public.user_subscriptions where user_id='${ids.concurrentRenewalUser}') s where starts_at=previous_ends_at`), "1"); + + insertOrder({ suffix: "MIXED1", userId: ids.concurrentOverlapUser, productId: "00000000-0000-4000-8000-000000000902", code: "standard_monthly", money: 9900 }); + insertOrder({ suffix: "MIXED2", userId: ids.concurrentOverlapUser, productId: "00000000-0000-4000-8000-000000000903", code: "standard_yearly", money: 59900 }); + const overlapResults = await settleConcurrently([ + { suffix: "MIXED1", trade: "trade-mixed-1", money: 9900, hash: "hash-mixed-1" }, + { suffix: "MIXED2", trade: "trade-mixed-2", money: 59900, hash: "hash-mixed-2" }, + ]); + assert.equal(overlapResults.filter((result) => result.rows[0].success).length, 1); + assert.equal(overlapResults.filter((result) => result.rows[0].status === "overlapping_subscription").length, 1); + assert.equal(sql(`select count(*) from public.user_subscriptions where user_id='${ids.concurrentOverlapUser}'`), "1"); + assert.equal(sql(`select count(*) from public.payment_orders where user_id='${ids.concurrentOverlapUser}' and grant_status='failed' and grant_error='overlapping_subscription'`), "1"); + sql(`drop trigger test_delay_concurrent_subscription_grant on public.user_subscriptions; drop function public.test_delay_concurrent_subscription_grant()`); + + const subscriptionCaseId = "40000000-0000-4000-8000-000000000001"; + const declaredBirthInput = { + birthDate: "1990-01-01", + reportedTime: "05:20", + source: "approximate", + birthTimeClue: "家人记得天刚亮", + uncertaintyBeforeMinutes: 30, + uncertaintyAfterMinutes: 30, + birthplace: { + countryCode: "TW", provinceCode: "TPE", cityCode: "TPE-CITY", districtCode: "DAAN", + latitude: 25.0268, longitude: 121.5434, timezoneOffset: 8, + }, + }; + const firstTurn = { + caseId: subscriptionCaseId, + journeyProtocol: "conversational-evidence-v3", + status: "active", + turnVersion: 0, + narrative: "我们会用已经发生的人生事件验证当前候选范围。", + candidate: { status: "pending_validation", representativeTime: "05:21", rangeStart: "05:10", rangeEnd: "05:30" }, + technicalReceipt: { + calculationVersion: "rectification-v3.1", + stableLayers: ["D1"], sensitiveLayers: ["D9"], candidateDifferenceRefs: ["difference-1"], + }, + evidenceRequest: { domains: ["career", "relocation"], datePrecision: "month_preferred", freeTextAllowed: true }, + evidenceRecap: [], + actions: ["answer", "pause", "abandon"], + pendingConsultationQuestion: null, + }; + const privateCandidate = { + resultId: "40000000-0000-4000-8000-000000000002", + representativeTime: "05:21", + calculationVersion: "rectification-v3.1", + candidateWeights: [0.6, 0.4], + }; + assert.equal(sql(`select source||':'||credits from public.authorize_usage('${ids.subscriber}','rectification',null,'rectification:${subscriptionCaseId}',2)`), "subscription:10"); + sql(` + insert into public.birth_time_rectification_billing ( + case_id,user_id,price,state,reservation_id,reserve_action_id,balance_after,reserved_at + ) select '${subscriptionCaseId}','${ids.subscriber}',2,'reserved',id,'${subscriptionCaseId}',10,now() + from public.usage_reservations where user_id='${ids.subscriber}' and request_id='rectification:${subscriptionCaseId}'; + select public.create_conversational_rectification_case( + '${ids.subscriber}','${subscriptionCaseId}',0,'${subscriptionCaseId}',null,null, + '${sqlJson(declaredBirthInput)}'::jsonb, + '${sqlJson(firstTurn)}'::jsonb, + '{"modelId":"synthetic-model","schemaValidated":true}'::jsonb, + '${sqlJson(privateCandidate)}'::jsonb + ) + `); + assert.equal(sql(`select success||':'||billing_state from public.complete_conversational_rectification_fee('${ids.subscriber}','${subscriptionCaseId}',0,'${subscriptionCaseId}')`), "true:charged"); + assert.equal(sql(`select source||':'||status from public.usage_reservations where user_id='${ids.subscriber}' and request_id='rectification:${subscriptionCaseId}'`), "subscription:completed"); + assert.equal(sql(`select error_code from public.release_usage('${ids.subscriber}','rectification:${subscriptionCaseId}','must not release completed usage')`), "request_completed"); + const completedTurn = { ...firstTurn, status: "completed", evidenceRequest: null, actions: [] }; + sql(`update public.birth_time_rectification_cases set status='completed',turn_state='${sqlJson(completedTurn)}'::jsonb,journey_snapshot='${sqlJson(completedTurn)}'::jsonb where id='${subscriptionCaseId}'`); + sql(`select public.conversational_rectification_refund_unconfirmed_case('${ids.subscriber}','${subscriptionCaseId}','40000000-0000-4000-8000-000000000003')`); + assert.equal(sql(`select state from public.birth_time_rectification_billing where case_id='${subscriptionCaseId}'`), "released"); + assert.equal(sql(`select status from public.usage_reservations where user_id='${ids.subscriber}' and request_id='rectification:${subscriptionCaseId}'`), "released"); + assert.equal(sql(`select source from public.authorize_usage('${ids.subscriber}','rectification',null,'rectification:40000000-0000-4000-8000-000000000004',2)`), "subscription"); + sql(`select * from public.release_usage('${ids.subscriber}','rectification:40000000-0000-4000-8000-000000000004','cleanup')`); + + const orphanCaseId = "40000000-0000-4000-8000-000000000005"; + const replacementCaseId = "40000000-0000-4000-8000-000000000006"; + assert.equal(sql(`select success||':'||credits||':'||billing_state from public.reserve_conversational_rectification_fee('${ids.creditUser}','${orphanCaseId}',0,'${orphanCaseId}',2)`), "true:18:reserved"); + assert.equal(sql(`select success||':'||credits||':'||billing_state from public.reserve_conversational_rectification_fee('${ids.creditUser}','${orphanCaseId}',0,'${orphanCaseId}',2)`), "true:18:reserved"); + assert.equal(sql(`select success||':'||credits||':'||billing_state from public.reserve_conversational_rectification_fee('${ids.creditUser}','${replacementCaseId}',0,'${replacementCaseId}',2)`), "true:18:reserved"); + assert.equal(sql(`select count(*) from public.credit_transactions where user_id='${ids.creditUser}' and request_id='rectification:${orphanCaseId}' and transaction_type='refund'`), "1"); + assert.equal(sql(`select success||':'||credits||':'||billing_state from public.release_conversational_rectification_fee('${ids.creditUser}','${replacementCaseId}',0,'${replacementCaseId}',2)`), "true:20:released"); + assert.equal(sql(`select success||':'||credits||':'||billing_state from public.release_conversational_rectification_fee('${ids.creditUser}','${replacementCaseId}',0,'${replacementCaseId}',2)`), "true:20:released"); + assert.equal(sql(`select count(*) from public.credit_transactions where user_id='${ids.creditUser}' and request_id='rectification:${replacementCaseId}' and transaction_type='refund'`), "1"); + + const agenticCaseKey = "rectification:40000000-0000-4000-8000-000000000007"; + const agenticReservation = sql(`select reservation_id from public.authorize_usage('${ids.creditUser}','rectification',null,'${agenticCaseKey}',2)`); + assert.equal(sql(`select reservation_id||':'||credits from public.authorize_usage('${ids.creditUser}','rectification',null,'${agenticCaseKey}',2)`), `${agenticReservation}:18`); + assert.equal(sql(`select success||':'||reason from public.authorize_usage('${ids.creditUser}','report.full',null,'${agenticCaseKey}',2)`), "f:request_conflict"); + assert.equal(sql(`select success||':'||reason from public.authorize_usage('${ids.creditUser}','rectification','standard-model','${agenticCaseKey}',2)`), "f:request_conflict"); + assert.equal(sql(`select success||':'||reason from public.authorize_usage('${ids.creditUser}','rectification',null,'${agenticCaseKey}',3)`), "f:request_conflict"); + const firstTurnUsage = { eventKey: "turn-1", actualModelId: "standard-model", inputTokens: 10, outputTokens: 5, costMicrousd: 100, durationMs: 1000 }; + assert.equal(sql(`select success||':'||credits from public.complete_usage('${ids.creditUser}','${agenticCaseKey}','${sqlJson(firstTurnUsage)}'::jsonb)`), "true:18"); + assert.equal(sql(`select success||':'||credits from public.complete_usage('${ids.creditUser}','${agenticCaseKey}','${sqlJson(firstTurnUsage)}'::jsonb)`), "true:18"); + assert.equal(sql(`select success||':'||error_code from public.complete_usage('${ids.creditUser}','${agenticCaseKey}','${sqlJson({ ...firstTurnUsage, outputTokens: 6 })}'::jsonb)`), "f:event_payload_conflict"); + const concurrentUsage = await completeUsageConcurrently(ids.creditUser, agenticCaseKey, [ + { eventKey: "turn-2", actualModelId: "standard-model", inputTokens: 20, outputTokens: 10, costMicrousd: 200, durationMs: 2000 }, + { eventKey: "turn-3", actualModelId: "standard-model", inputTokens: 30, outputTokens: 15, costMicrousd: 300, durationMs: 3000 }, + ]); + assert.deepEqual(concurrentUsage.map((result) => result.rows[0].success), [true, true]); + assert.equal(sql(`select reservation_id||':'||credits from public.authorize_usage('${ids.creditUser}','rectification',null,'${agenticCaseKey}',2)`), `${agenticReservation}:18`); + assert.equal(sql(`select count(*) from public.usage_reservations where user_id='${ids.creditUser}' and request_id='${agenticCaseKey}'`), "1"); + assert.equal(sql(`select count(*) from public.credit_transactions where user_id='${ids.creditUser}' and request_id='${agenticCaseKey}' and transaction_type='reserve'`), "1"); + assert.equal(sql(`select count(*) from public.usage_events where reservation_id='${agenticReservation}'`), "3"); + assert.equal(sql(`select input_tokens||':'||output_tokens||':'||cost_microusd||':'||duration_ms from public.usage_ledger where reservation_id='${agenticReservation}'`), "60:30:600:6000"); + + sql(`update public.user_subscriptions set entitlement_snapshot='[{"featureKey":"chat.standard","allowanceType":"quota","allowanceCount":1,"resetPeriod":"billing_period","modelTier":"standard","metadata":{}}]'::jsonb where id='${monthlySubscription}'`); + assert.equal(sql(`select source||':'||credits from public.authorize_usage('${ids.subscriber}','chat.standard',null,'quota-covered',1)`), "subscription:10"); + assert.equal(sql(`select source||':'||credits from public.authorize_usage('${ids.subscriber}','chat.standard',null,'quota-fallback',1)`), "credits:9"); + sql(`select * from public.release_usage('${ids.subscriber}','quota-covered','quota test cleanup')`); + sql(`select * from public.release_usage('${ids.subscriber}','quota-fallback','quota test cleanup')`); + + sql(`update public.user_subscriptions set entitlement_snapshot='[{"featureKey":"chat.standard","allowanceType":"unlimited","allowanceCount":null,"resetPeriod":"billing_period","modelTier":"standard","metadata":{"minuteLimit":1}}]'::jsonb where id='${monthlySubscription}'`); + assert.equal(sql(`select source||':'||credits from public.authorize_usage('${ids.subscriber}','chat.standard',null,'fair-covered',1)`), "subscription:10"); + assert.equal(sql(`select source||':'||credits from public.authorize_usage('${ids.subscriber}','chat.standard',null,'fair-fallback',1)`), "credits:9"); + sql(`select * from public.release_usage('${ids.subscriber}','fair-covered','fair use test cleanup')`); + sql(`select * from public.release_usage('${ids.subscriber}','fair-fallback','fair use test cleanup')`); + sql(`update public.user_subscriptions s set entitlement_snapshot=o.entitlement_snapshot from public.payment_orders o where s.id='${monthlySubscription}' and o.id=s.source_order_id`); + + assert.equal(sql(`select source||':'||credits from public.authorize_usage('${ids.subscriber}','chat.standard',null,'subscription-chat',1)`), "subscription:10"); + assert.equal(sql(`select success from public.complete_usage('${ids.subscriber}','subscription-chat','{"actualModelId":"standard-model","modelConfigVersion":7,"inputTokens":120,"outputTokens":45,"costMicrousd":321,"durationMs":900}'::jsonb)`), "t"); + assert.equal(sql(`select actual_model_id||':'||model_config_version||':'||input_tokens||':'||output_tokens||':'||cost_microusd from public.usage_ledger where user_id='${ids.subscriber}' and request_id='subscription-chat'`), "standard-model:7:120:45:321"); + assert.equal(sql(`select credits from public.profiles where id='${ids.subscriber}'`), "10"); + assert.equal(sql(`select error_code from public.release_usage('${ids.subscriber}','subscription-chat','late release')`), "request_completed"); + + sql(`update public.feature_flags set enabled=false,rollout_percentage=0 where flag_key='billing.subscriptions' and status='published'`); + assert.equal(sql(`select source||':'||credits from public.authorize_usage('${ids.subscriber}','chat.standard',null,'flag-off-chat',1)`), "credits:9"); + assert.equal(sql(`select success from public.complete_usage('${ids.subscriber}','flag-off-chat','{"eventKey":"flag-off-turn"}'::jsonb)`), "t"); + assert.equal(sql(`select count(*) from public.usage_reservations where request_id='flag-off-chat' and source='subscription'`), "0"); + assert.equal(sql(`select credits from public.profiles where id='${ids.subscriber}'`), "9"); + assert.equal(sql(`select success||':'||status from public.settle_order('${orderNo("SNAPSHOT")}','trade-snapshot',1200,'hash-snapshot-flag-off')`), "true:paid"); + assert.equal(sql(`select success||':'||status from public.settle_epay_order('${orderNo("SNAPSHOT")}','trade-snapshot',1200,'hash-snapshot-notify-flag-off')`), "true:paid"); + assert.equal(sql(`select count(*) from public.user_subscriptions where source_order_id=(select id from public.payment_orders where order_no='${orderNo("SNAPSHOT")}')`), "1"); + sql(`update public.profiles set credits=10 where id='${ids.subscriber}'`); + sql(`update public.feature_flags set enabled=true,rollout_percentage=100 where flag_key='billing.subscriptions' and status='published'`); + assert.equal(sql(`select source||':'||credits from public.authorize_usage('${ids.subscriber}','chat.standard',null,'flag-on-chat',1)`), "subscription:10"); + sql(`select * from public.release_usage('${ids.subscriber}','flag-on-chat','feature flag test cleanup')`); + + sql(`update public.user_subscriptions set starts_at=clock_timestamp()-interval '2 months',ends_at=clock_timestamp()-interval '1 second' where id='${monthlySubscription}'`); + assert.equal(sql(`select source||':'||credits from public.authorize_usage('${ids.subscriber}','chat.standard',null,'credit-chat',1)`), "credits:9"); + assert.equal(sql(`select source||':'||credits from public.authorize_usage('${ids.subscriber}','chat.standard',null,'credit-chat',1)`), "credits:9"); + assert.equal(sql(`select count(*) from public.credit_transactions where user_id='${ids.subscriber}' and request_id='credit-chat' and transaction_type='reserve'`), "1"); + assert.equal(sql(`select success||':'||credits from public.release_usage('${ids.subscriber}','credit-chat','generation failed')`), "true:10"); + assert.equal(sql(`select count(*) from public.credit_transactions where user_id='${ids.subscriber}' and request_id='credit-chat' and transaction_type='refund'`), "1"); + + sql(`update public.user_subscriptions set starts_at=clock_timestamp()-interval '1 day',ends_at=clock_timestamp()+interval '1 month' where id='${monthlySubscription}'`); + const provider = sql(`select public.admin_save_model_provider('${ids.model}',null,'test-provider','Test Provider','openai',null,'env:OPENAI_API_KEY',true,'新增测试供应商','provider-1')`); + const premiumDraft = sql(`select public.admin_save_model_draft('${ids.model}','premium-model',null,'${provider}','Premium','', 'premium-v1','premium',2,128000,100,200,true,true,null,'{}','新增高级模型','premium-draft-1')`); + sql(`select public.admin_record_model_connection_test('${ids.model}','${premiumDraft}',200,'premium-test-1')`); + sql(`select public.admin_publish_model('${ids.model}','${premiumDraft}','发布高级模型','premium-publish-1')`); + assert.equal(sql(`select source||':'||credits from public.authorize_usage('${ids.subscriber}','chat.standard','premium-model','premium-fallback',2)`), "credits:8"); + sql(`select * from public.release_usage('${ids.subscriber}','premium-fallback','premium not covered')`); + + const currentEnds = sql(`select ends_at from public.user_subscriptions where id='${monthlySubscription}'`); + sql(`select public.admin_adjust_subscription('${ids.billing}','${monthlySubscription}','extend',3,'${currentEnds}','人工补偿三天','adjust-1')`); + expectSqlError( + `select public.admin_adjust_subscription('${ids.billing}','${monthlySubscription}','extend',1,'${currentEnds}','使用过期版本调整','adjust-stale')`, + /subscription_version_conflict/, + ); + + sql(`update public.model_config_versions set is_default=false where id='${premiumDraft}'`); + const nonDefaultDraft = sql(`select public.admin_save_model_draft('${ids.model}','standard-model',null,'${provider}','Standard','', 'standard-v1','standard',1,64000,10,20,true,false,null,'{}','新增标准模型','standard-draft-1')`); + sql(`select public.admin_record_model_connection_test('${ids.model}','${nonDefaultDraft}',200,'standard-test-invalid')`); + expectSqlError( + `select public.admin_publish_model('${ids.model}','${nonDefaultDraft}','无默认模型不得发布','standard-publish-invalid')`, + /default_model_required/, + ); + sql(`select public.admin_save_model_draft('${ids.model}','standard-model','${nonDefaultDraft}','${provider}','Standard','', 'standard-v1','standard',1,64000,10,20,true,true,null,'{}','设为默认模型','standard-draft-default')`); + sql(`select public.admin_record_model_connection_test('${ids.model}','${nonDefaultDraft}',200,'standard-test-1')`); + sql(`select public.admin_publish_model('${ids.model}','${nonDefaultDraft}','发布默认模型','standard-publish-1')`); + assert.equal(sql(`select count(*) from public.model_config_versions where status='published' and enabled and is_default`), "1"); + + const standardV2 = sql(`select public.admin_save_model_draft('${ids.model}','standard-model',null,'${provider}','Standard 2','', 'standard-v2','standard',1,64000,11,21,true,true,null,'{}','新建第二版','standard-draft-2')`); + sql(`select public.admin_record_model_connection_test('${ids.model}','${standardV2}',200,'standard-test-2')`); + sql(`select public.admin_publish_model('${ids.model}','${standardV2}','发布第二版','standard-publish-2')`); + const standardConfig = sql("select id from public.model_configs where model_id='standard-model'"); + sql(`select public.admin_record_model_connection_test('${ids.model}','${nonDefaultDraft}',200,'standard-rollback-1')`); + sql(`select public.admin_rollback_model('${ids.model}','${standardConfig}',1,'回滚第一版','standard-rollback-1')`); + assert.equal(sql("select version from public.model_config_versions where config_id=(select id from public.model_configs where model_id='standard-model') and status='published'"), "1"); + + const standardV3 = sql(`select public.admin_save_model_draft('${ids.model}','standard-model',null,'${provider}','Standard 3','', 'standard-v3','standard',1,64000,12,22,true,false,'premium-model','{}','设置回退到高级模型','standard-draft-3')`); + sql(`select public.admin_record_model_connection_test('${ids.model}','${standardV3}',200,'standard-test-3')`); + sql(`select public.admin_publish_model('${ids.model}','${standardV3}','发布第三版','standard-publish-3')`); + const premiumV2 = sql(`select public.admin_save_model_draft('${ids.model}','premium-model',null,'${provider}','Premium 2','', 'premium-v2','premium',2,128000,101,201,true,true,'standard-model','{}','构造循环回退草稿','premium-draft-2')`); + sql(`select public.admin_record_model_connection_test('${ids.model}','${premiumV2}',200,'premium-test-cycle')`); + expectSqlError( + `select public.admin_publish_model('${ids.model}','${premiumV2}','循环回退不得发布','premium-publish-cycle')`, + /model_fallback_cycle/, + ); + assert.equal(sql(`select count(*) from public.model_config_versions where status='published' and enabled and is_default`), "1"); + assert.equal(sql(`select count(*) from public.model_publish_events where action='rollback'`), "1"); + } finally { + fixture.stop(); + } +}); diff --git a/frontend/tests/database-env-validator.test.ts b/frontend/tests/database-env-validator.test.ts index 67b8be4a..7de69231 100644 --- a/frontend/tests/database-env-validator.test.ts +++ b/frontend/tests/database-env-validator.test.ts @@ -21,6 +21,7 @@ const validEnvironment = [ "SCHEMA_OWNER_PASSWORD=schema-owner-test-password", "IDENTITY_RUNTIME_PASSWORD=identity-runtime-test-password", "APP_RUNTIME_PASSWORD=app-runtime-test-password", + "SERVICE_RUNTIME_PASSWORD=service-runtime-test-password", "ADMIN_RUNTIME_PASSWORD=admin-runtime-test-password", "MIGRATION_RUNNER_PASSWORD=migration-runner-test-password", "BACKUP_READER_PASSWORD=backup-reader-test-password", diff --git a/frontend/tests/database-local-business.test.ts b/frontend/tests/database-local-business.test.ts index 81d49ddd..8e2482b8 100644 --- a/frontend/tests/database-local-business.test.ts +++ b/frontend/tests/database-local-business.test.ts @@ -3,7 +3,10 @@ import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import test from "node:test"; -import { createLocalPostgresDataClient } from "../src/lib/db/local-postgres-client-core.ts"; +import { + closeLocalPostgresDataPools, + createLocalPostgresDataClient, +} from "../src/lib/db/local-postgres-client-core.ts"; import { loadLatestAgenticRectificationResult } from "../src/lib/rectification-agentic/session.ts"; import { startPostgresFixture } from "./helpers/postgres-fixture.ts"; @@ -39,6 +42,11 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic assert.match(migration.stdout, /applied 20260805020000_reconcile_payment_admin_schema\.sql/); assert.match(migration.stdout, /applied 20260805030000_reconcile_rectification_v4_conversational_turns\.sql/); assert.match(migration.stdout, /applied 20260806000000_personal_reports\.sql/); + assert.match(migration.stdout, /applied 20260806010000_admin_rbac\.sql/); + assert.match(migration.stdout, /applied 20260806020000_billing_products_subscriptions\.sql/); + assert.match(migration.stdout, /applied 20260806030000_settle_order_usage_authorization\.sql/); + assert.match(migration.stdout, /applied 20260806040000_model_configuration\.sql/); + assert.match(migration.stdout, /applied 20260806050000_operations_feature_flags\.sql/); assert.equal( fixture.psql(` @@ -84,8 +92,14 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic where schemaname = 'public' `), [ + "admin_permissions", + "admin_role_permissions", + "admin_roles", + "admin_session_revocations", + "admin_user_roles", "admin_users", "agentic_rectification_results", + "billing_products", "birth_time_rectification_action_receipts", "birth_time_rectification_agent_runs", "birth_time_rectification_billing", @@ -117,12 +131,26 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic "credit_request_cancellations", "credit_transactions", "epay_settings", + "feature_flags", + "model_config_versions", + "model_configs", + "model_connection_test_evidence", + "model_providers", + "model_publish_events", + "notification_templates", "payment_orders", "payment_packages", "personal_reports", + "pricing_experiment_events", + "product_entitlements", "profiles", "redemption_codes", "synastry_reports", + "usage_events", + "usage_ledger", + "usage_reservations", + "user_product_redemptions", + "user_subscriptions", ].join(","), ); @@ -177,7 +205,7 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic assert.equal(nonAbandonedProfiles.error, null); assert.deepEqual(nonAbandonedProfiles.data, { id: userId }); const admin = createLocalPostgresDataClient( - fixture.connectionUrl("admin_runtime", "admin-runtime-test-password"), + fixture.connectionUrl("service_runtime", "service-runtime-test-password"), null, "service_role", ); @@ -203,6 +231,32 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic assert.equal(inserted.error, null); assert.deepEqual(inserted.data, { id: sessionId }); + const beforeTomorrow = await local + .from("chat_sessions") + .select("id") + .eq("user_id", userId) + .lte("updated_at", new Date(Date.now() + 86_400_000).toISOString()) + .single(); + assert.equal(beforeTomorrow.error, null); + assert.deepEqual(beforeTomorrow.data, { id: sessionId }); + + const matchingTitle = await local + .from("chat_sessions") + .select("id") + .eq("user_id", userId) + .like("title", "Local%") + .single(); + assert.equal(matchingTitle.error, null); + assert.deepEqual(matchingTitle.data, { id: sessionId }); + + const injectedLike = await local + .from("chat_sessions") + .select("id") + .eq("user_id", userId) + .like("title", "Local%' OR true --"); + assert.equal(injectedLike.error, null); + assert.deepEqual(injectedLike.data, []); + const rectificationSessionId = "22222222-2222-4222-8222-222222222222"; fixture.psql(` update public.profiles @@ -245,8 +299,8 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic ]); assert.equal( fixture.psqlAs( - "admin_runtime", - "admin-runtime-test-password", + "service_runtime", + "service-runtime-test-password", `set role service_role; select (result ->> 'saved_time') || ':' || (result ->> 'status') || ':' || (result ->> 'idempotent') from ( @@ -263,8 +317,8 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic ); assert.equal( fixture.psqlAs( - "admin_runtime", - "admin-runtime-test-password", + "service_runtime", + "service-runtime-test-password", `set role service_role; select result ->> 'idempotent' from ( @@ -277,8 +331,8 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic ); assert.equal( fixture.psqlAs( - "admin_runtime", - "admin-runtime-test-password", + "service_runtime", + "service-runtime-test-password", `set role service_role; select (result ->> 'saved_time') || ':' || (result ->> 'status') || ':' || (result ->> 'idempotent') from ( @@ -304,8 +358,8 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic ); assert.throws( () => fixture.psqlAs( - "admin_runtime", - "admin-runtime-test-password", + "service_runtime", + "service-runtime-test-password", `set role service_role; select public.accept_agentic_rectification_candidate( '${userId}', '${rectificationSessionId}', '33333333-3333-4333-8333-333333333333', '04:55' @@ -324,6 +378,7 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic assert.equal(redeemed.error, null); assert.deepEqual(redeemed.data, [{ success: true, credits: 3, error_code: null }]); } finally { + await closeLocalPostgresDataPools(); fixture.stop(); } }); diff --git a/frontend/tests/database-self-hosted-identity.test.ts b/frontend/tests/database-self-hosted-identity.test.ts index 3266315b..0e0138e2 100644 --- a/frontend/tests/database-self-hosted-identity.test.ts +++ b/frontend/tests/database-self-hosted-identity.test.ts @@ -45,6 +45,10 @@ test("self-hosted identity migration creates Better Auth tables with least privi firstRun.stdout, /applied 20260721000100_self_hosted_identity\.sql/, ); + assert.match( + firstRun.stdout, + /applied 20260806070000_admin_mfa\.sql/, + ); const secondRun = migrate(); assert.equal(secondRun.status, 0, secondRun.stderr); @@ -59,7 +63,7 @@ test("self-hosted identity migration creates Better Auth tables with least privi from pg_tables where schemaname = 'identity' `), - "accounts,otp_rate_limits,sessions,users,verifications", + "accounts,otp_rate_limits,sessions,two_factors,users,verifications", ); assert.equal( fixture.psql(` @@ -71,6 +75,7 @@ test("self-hosted identity migration creates Better Auth tables with least privi "accounts:schema_owner", "otp_rate_limits:schema_owner", "sessions:schema_owner", + "two_factors:schema_owner", "users:schema_owner", "verifications:schema_owner", ].join(","), @@ -96,6 +101,16 @@ test("self-hosted identity migration creates Better Auth tables with least privi `), "NO:boolean", ); + assert.equal( + fixture.psql(` + select is_nullable || ':' || data_type || ':' || coalesce(column_default, '') + from information_schema.columns + where table_schema = 'identity' + and table_name = 'users' + and column_name = 'two_factor_enabled' + `), + "NO:boolean:f", + ); for (const table of [ "users", @@ -124,6 +139,37 @@ test("self-hosted identity migration creates Better Auth tables with least privi ); } + assert.equal( + fixture.psql( + "select has_table_privilege('identity_runtime', 'identity.two_factors', 'select,insert,update,delete')", + ), + "t", + ); + for (const role of ["public", "app_runtime", "admin_runtime", "backup_reader", "migration_runner"]) { + assert.equal( + fixture.psql( + `select has_table_privilege('${role}', 'identity.two_factors', 'select')`, + ), + "f", + ); + } + assert.equal( + fixture.psql(` + select string_agg(column_name || ':' || is_nullable, ',' order by ordinal_position) + from information_schema.columns + where table_schema = 'identity' and table_name = 'two_factors' + `), + [ + "id:NO", + "user_id:NO", + "secret:NO", + "backup_codes:NO", + "verified:NO", + "failed_verification_count:NO", + "locked_until:YES", + ].join(","), + ); + fixture.psqlAs( "identity_runtime", "identity-runtime-test-password", diff --git a/frontend/tests/epay-settings.test.ts b/frontend/tests/epay-settings.test.ts index 6bd167b8..00cc0f6a 100644 --- a/frontend/tests/epay-settings.test.ts +++ b/frontend/tests/epay-settings.test.ts @@ -3,8 +3,8 @@ import crypto from "node:crypto"; import { readFileSync } from "node:fs"; import test from "node:test"; import { decryptEpayKey, encryptEpayKey, EpayEncryptionError } from "../src/lib/epay/encryption-core"; -import { resolveEpayConfig } from "../src/lib/epay/config-core"; -import { assertPublicEpayGateway, isPublicEpayAddress } from "../src/lib/epay/gateway-policy"; +import { EpayConfigurationError, resolveEpayConfig } from "../src/lib/epay/config-core"; +import { assertConfiguredEpayUrl, assertPublicEpayGateway, assertPublicGatewayUrl, isPublicEpayAddress } from "../src/lib/epay/gateway-policy"; const root = new URL("../", import.meta.url); const route = readFileSync(new URL("src/app/api/admin/epay-settings/route.ts", root), "utf8"); @@ -16,6 +16,7 @@ const configRoute = readFileSync(new URL("src/lib/epay/config.ts", root), "utf8" const availability = readFileSync(new URL("src/lib/epay/availability.ts", root), "utf8"); const packagesRoute = readFileSync(new URL("src/app/api/payment/packages/route.ts", root), "utf8"); const testRoute = readFileSync(new URL("src/app/api/admin/epay-settings/test/route.ts", root), "utf8"); +const gatewayPolicy = readFileSync(new URL("src/lib/epay/gateway-policy.ts", root), "utf8"); const page = readFileSync(new URL("src/app/page.tsx", root), "utf8"); const migration = readFileSync(new URL("supabase/migrations/20260805020000_reconcile_payment_admin_schema.sql", root), "utf8"); @@ -66,12 +67,68 @@ test("配置解析数据库优先且无行时回退环境变量", async () => { assert.equal(environment.returnUrl, "https://staging.example.com/"); }); +test("生产与默认开发测试配置强制 HTTPS,仅显式开关允许 loopback HTTP", async () => { + const base = { + EPAY_GATEWAY_URL: "https://pay.example.com", + EPAY_PID: "merchant", + EPAY_KEY: "secret", + EPAY_NOTIFY_URL: "https://app.example.com/api/payment/epay/notify", + EPAY_RETURN_URL: "https://app.example.com/", + }; + for (const name of ["EPAY_GATEWAY_URL", "EPAY_NOTIFY_URL", "EPAY_RETURN_URL"] as const) { + await assert.rejects( + resolveEpayConfig(async () => null, { ...base, NODE_ENV: "production", [name]: "http://pay.example.com" }), + EpayConfigurationError, + ); + await assert.rejects( + resolveEpayConfig(async () => null, { ...base, NODE_ENV: "test", [name]: "http://localhost:3000" }), + EpayConfigurationError, + ); + } + + const loopback = await resolveEpayConfig(async () => null, { + NODE_ENV: "test", + EPAY_ALLOW_INSECURE_LOOPBACK_HTTP: "true", + EPAY_GATEWAY_URL: "http://127.0.0.1:8080", + EPAY_PID: "merchant", + EPAY_KEY: "secret", + EPAY_NOTIFY_URL: "http://localhost:3000/api/payment/epay/notify", + EPAY_RETURN_URL: "http://[::1]:3000/", + }); + assert.equal(loopback.gatewayUrl.toString(), "http://127.0.0.1:8080/"); + assert.throws(() => assertConfiguredEpayUrl("http://pay.example.com", { + NODE_ENV: "test", + EPAY_ALLOW_INSECURE_LOOPBACK_HTTP: "true", + })); + assert.throws(() => assertConfiguredEpayUrl("http://localhost:3000", { + NODE_ENV: "production", + EPAY_ALLOW_INSECURE_LOOPBACK_HTTP: "true", + })); +}); + +test("公网网关解析拒绝混入私网地址且每次校验只解析一次", async () => { + let lookups = 0; + await assert.rejects( + assertPublicGatewayUrl("https://pay.example.com", { NODE_ENV: "production" }, async () => { + lookups += 1; + return [ + { address: "93.184.216.34", family: 4 }, + { address: "127.0.0.1", family: 4 }, + ]; + }), + /内网|保留地址/, + ); + assert.equal(lookups, 1); +}); + test("管理员 API 不回显任何密钥并强制首次显式录入", () => { - assert.match(route, /requireAdminSession\("read"\)/); - assert.match(route, /requireAdminSession\("write"\)/); + assert.match(route, /requirePermission\("billing\.orders\.read"\)/); + assert.match(route, /requireHighRiskAdminMutation\(request, "billing\.adjustments\.write"\)/); assert.match(route, /\.strict\(\)/); assert.match(route, /crypto\.randomUUID\(\)/); assert.match(route, /首次保存数据库配置时必须输入新的商户密钥/); + assert.match(route, /assertConfiguredEpayUrl\(value\)/); + assert.match(route, /await assertPublicGatewayUrl\(parsed\.data\.gatewayUrl\)/); assert.match(route, /chatEnabled: z\.boolean\(\)/); assert.match(route, /chatEnabled: row\.chat_enabled/); assert.match(route, /queryAdminRows/); @@ -121,7 +178,7 @@ test("支付开关贯通迁移、公共套餐 API、创建订单和对话页", ( assert.match(createRoute, /EPAY_DISABLED/); assert.match(createRoute, /await readEpayAvailability\(\)/); assert.ok(createRoute.indexOf("availability.enabled") < createRoute.indexOf("await readEpayConfig()")); - assert.ok(createRoute.indexOf("availability.enabled") < createRoute.indexOf("payment_packages")); + assert.ok(createRoute.indexOf("availability.enabled") < createRoute.indexOf("billing_products")); assert.match(management, /在对话页开放支付/); assert.match(page, /paymentEnabled &&
/); assert.match(page, /setPaymentEnabled\(false\)[\s\S]*setPaymentPackages\(\[\]\)[\s\S]*setPaymentOrder\(null\)[\s\S]*setPaymentError\(""\)/); @@ -129,37 +186,40 @@ test("支付开关贯通迁移、公共套餐 API、创建订单和对话页", ( test("创建订单返回已签名收银台 URL 且不服务端请求网关", () => { assert.match(createRoute, /assertPublicGatewayUrl\(submitUrl\)/); - assert.match(createRoute, /const signedParams = \{ \.\.\.params, sign: epaySign\(params, config\.key\), sign_type: "MD5" \}/); + assert.match(createRoute, /const signedParams = \{\s*\.\.\.params,\s*sign: epaySign\(params, config\.key\),\s*sign_type: "MD5",?\s*\}/); assert.match(createRoute, /const payUrl = new URL\(submitUrl\)/); assert.match(createRoute, /payUrl\.searchParams\.set\(name, value\)/); - assert.match(createRoute, /NextResponse\.json\(\{ orderNo, payUrl: payUrl\.toString\(\), qrCode: null \}\)/); + assert.match(createRoute, /NextResponse\.json\(\{\s*orderNo,\s*payUrl: payUrl\.toString\(\),\s*qrCode: null,\s*product: productSnapshot,?\s*\}\)/); for (const field of ["money", "name", "notify_url", "out_trade_no", "pid", "return_url", "sitename", "type", "sign", "sign_type"]) assert.match(createRoute, new RegExp(field)); assert.doesNotMatch(createRoute, /fetch\(submitUrl|document\.createElement\("form"\)|submitUrl:|fields[, }]/); assert.doesNotMatch(createRoute, /NextResponse\.json\([^\n]*config\.key|searchParams\.set\([^\n]*config\.key/); assert.match(page, /window\.open\(payload\.payUrl, "_blank", "noopener,noreferrer"\)/); - assert.match(page, /

套餐充值<\/h3>/); + assert.match(page, /

套餐与会员<\/h3>/); assert.match(page, /立即支付/); assert.match(page, /套餐支付暂时不可用,请稍后重试/); assert.doesNotMatch(page, /document\.createElement\("form"\)|payload\.submitUrl|payload\.fields/); }); -test("网关探测只使用 HEAD/GET、受限响应且共享 SSRF 门禁", () => { - assert.match(testRoute, /requireAdminSession\("write"\)/); - assert.match(testRoute, /method: "HEAD"/); - assert.match(testRoute, /response\.status === 405 \|\| response\.status === 501/); - assert.match(testRoute, /method: "GET"/); - assert.doesNotMatch(testRoute, /method: "POST"|payment_orders|\.text\(\)|\.json\(\)/); - assert.match(testRoute, /AbortSignal\.timeout\(8_000\)/); - assert.match(testRoute, /redirect: "manual"/); - assert.match(testRoute, /available,[\s\S]*message:[\s\S]*latencyMs:[\s\S]*status:/); +test("网关探测固定已验证公网 IP、保留 TLS hostname 且限制重定向与响应", () => { + assert.match(testRoute, /requireAdminMutation\(request, "billing\.adjustments\.write"\)/); + assert.match(testRoute, /probePublicEpayGateway\(submitUrl\)/); + assert.doesNotMatch(testRoute, /fetch\(|method: "POST"|payment_orders|\.text\(\)|\.json\(\)/); + assert.match(testRoute, /available,[\s\S]*message:[\s\S]*latencyMs:[\s\S]*status,/); assert.doesNotMatch(testRoute, /pid:|key:|gatewayUrl:|headers:|body:|payment_orders/); - assert.match(testRoute, /assertPublicGatewayUrl\(submitUrl\)/); + assert.match(gatewayPolicy, /const resolved = await withinTimeout\([\s\S]*resolvePublicUrl\(value, options\.lookup \?\? defaultLookup\)/); + assert.match(gatewayPolicy, /const pinned = resolved\.addresses\[0\]!/); + assert.match(gatewayPolicy, /servername: isIP\(hostname\) \? undefined : hostname/); + assert.match(gatewayPolicy, /lookup: \(_hostname, _options, callback\) => callback\(null, pinned\.address, pinned\.family\)/); + assert.match(gatewayPolicy, /requestPinnedHttps\([\s\S]*resolved,[\s\S]*"HEAD"/); + assert.match(gatewayPolicy, /headStatus === 405 \|\| headStatus === 501[\s\S]*requestPinnedHttps\([\s\S]*resolved,[\s\S]*"GET"/); + assert.match(gatewayPolicy, /status >= 300 && status < 400/); + assert.match(gatewayPolicy, /maxResponseBytes|setTimeout\(timeoutMs/); assert.match(createRoute, /assertPublicGatewayUrl\(submitUrl\)/); }); test("纯地址判断拒绝私网、回环、链路本地并接受公网", () => { - for (const address of ["127.0.0.1", "10.0.0.1", "172.16.0.1", "192.168.1.1", "169.254.1.1", "::1", "fc00::1", "fe80::1", "2001:db8::1"]) { + for (const address of ["127.0.0.1", "10.0.0.1", "172.16.0.1", "192.168.1.1", "169.254.1.1", "192.0.2.1", "198.51.100.1", "203.0.113.1", "::1", "fc00::1", "fe80::1", "2001:db8::1"]) { assert.equal(isPublicEpayAddress(address), false, address); } for (const address of ["8.8.8.8", "1.1.1.1", "2606:4700:4700::1111"]) { diff --git a/frontend/tests/health-deployment.test.ts b/frontend/tests/health-deployment.test.ts index abeb0690..4d1c8ea8 100644 --- a/frontend/tests/health-deployment.test.ts +++ b/frontend/tests/health-deployment.test.ts @@ -153,15 +153,19 @@ test("server compose defaults to local images without removing either build", () } }); -test("staging Caddy serves admin on the main site", () => { +test("staging Caddy serves the same app on two exact hosts", () => { const caddy = readFileSync( new URL("../../deploy/Caddyfile.staging", import.meta.url), "utf8", ); assert.match(caddy, /\{\$SITE_ADDRESS:https:\/\/staging\.jyotisha\.chat\}/); - assert.match(caddy, /reverse_proxy web:3000/); - assert.doesNotMatch(caddy, /ADMIN_SITE_ADDRESS|admin\.staging\.jyotisha\.chat|@adminSurface|@adminPaths/); + assert.match(caddy, /@adminPaths path \/admin \/admin\/\* \/api\/admin\/\*/); + assert.match(caddy, /respond @adminPaths "Not found" 404/); + assert.match(caddy, /^https:\/\/admin\.staging\.jyotisha\.chat \{$/m); + assert.equal((caddy.match(/reverse_proxy web:3000/g) ?? []).length, 2); + assert.match(caddy, /@root path \/\n\s+redir @root \/admin 308/); + assert.doesNotMatch(caddy, /\*\.staging\.jyotisha\.chat|:443 \{/); assert.doesNotMatch(caddy, /www\.jyotisha\.chat/); }); @@ -239,8 +243,10 @@ test("staging env validator rejects selector drift, duplicates, and unsafe permi "AUTH_PROVIDER=self-hosted", "SELF_HOSTED_IDENTITY_ENABLED=true", "AUTH_USER_ORIGIN=https://staging.jyotisha.chat", + "ADMIN_USER_ORIGIN=https://admin.staging.jyotisha.chat", "IDENTITY_DATABASE_URL=postgresql://identity_runtime:identity-runtime-test-password@postgres:5432/jyotisha", "APP_DATABASE_URL=postgresql://app_runtime:app-runtime-test-password@postgres:5432/jyotisha", + "SERVICE_DATABASE_URL=postgresql://service_runtime:service-runtime-test-password@postgres:5432/jyotisha", "ADMIN_DATABASE_URL=postgresql://admin_runtime:admin-runtime-test-password@postgres:5432/jyotisha", "BETTER_AUTH_USER_SECRET=user-secret-that-is-at-least-32-bytes-long", "RESEND_API_KEY=re_test_key_that_must_not_be_printed", diff --git a/frontend/tests/helpers/postgres-fixture.ts b/frontend/tests/helpers/postgres-fixture.ts index ebda7d75..ac7fe505 100644 --- a/frontend/tests/helpers/postgres-fixture.ts +++ b/frontend/tests/helpers/postgres-fixture.ts @@ -25,6 +25,7 @@ POSTGRES_PASSWORD=postgres-test-password SCHEMA_OWNER_PASSWORD=schema-owner-test-password IDENTITY_RUNTIME_PASSWORD=identity-runtime-test-password APP_RUNTIME_PASSWORD=app-runtime-test-password +SERVICE_RUNTIME_PASSWORD=service-runtime-test-password ADMIN_RUNTIME_PASSWORD=admin-runtime-test-password MIGRATION_RUNNER_PASSWORD=migration-runner-test-password BACKUP_READER_PASSWORD=backup-reader-test-password diff --git a/frontend/tests/high-risk-billing-routes-contract.test.ts b/frontend/tests/high-risk-billing-routes-contract.test.ts new file mode 100644 index 00000000..7a499a99 --- /dev/null +++ b/frontend/tests/high-risk-billing-routes-contract.test.ts @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const source = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8"); +const packagesRoute = source("src/app/api/payment/packages/route.ts"); +const createRoute = source("src/app/api/payment/epay/create/route.ts"); +const statusRoute = source("src/app/api/payment/epay/status/route.ts"); +const notifyRoute = source("src/app/api/payment/epay/notify/route.ts"); +const productsRoute = source("src/app/api/admin/products/route.ts"); +const epaySettingsRoute = source("src/app/api/admin/epay-settings/route.ts"); +const subscriptionsRoute = source("src/app/api/admin/subscriptions/route.ts"); +const featureFlagsRoute = source("src/app/api/admin/feature-flags/route.ts"); +const modelsRoute = source("src/app/api/admin/models/route.ts"); +const modelMutationHandler = source("src/lib/admin/model-mutation-handler.ts"); +const ordersRoute = source("src/app/api/admin/orders/route.ts"); +const codesRoute = source("src/app/api/admin/codes/route.ts"); +const codeRoute = source("src/app/api/admin/codes/[id]/route.ts"); +const codesHelper = source("src/lib/admin/codes.ts"); +const billingOperationsUi = source("src/components/admin/billing-operations-resources.tsx"); +const codesUi = source("src/components/admin/codes-resource.tsx"); + +test("billing.subscriptions only gates new trial and subscription purchases", () => { + assert.match(packagesRoute, /loadRuntimeFeatureFlags\(\["billing\.subscriptions"\]\)/); + assert.match(packagesRoute, /subscriptionsEnabled \|\| product\.product_type === "credit_pack"/); + assert.match(createRoute, /product\.product_type !== "credit_pack"[\s\S]*billing\.subscriptions[\s\S]*BILLING_SUBSCRIPTIONS_DISABLED/); + assert.ok( + createRoute.indexOf("BILLING_SUBSCRIPTIONS_DISABLED") < createRoute.indexOf('.from("payment_orders").insert'), + "the flag must reject subscription orders before insertion", + ); +}); + +test("existing payment orders remain queryable and settleable when subscriptions are disabled", () => { + assert.doesNotMatch(statusRoute, /billing\.subscriptions|loadRuntimeFeatureFlags/); + assert.doesNotMatch(notifyRoute, /billing\.subscriptions|loadRuntimeFeatureFlags/); + assert.match(statusRoute, /from\("payment_orders"\)/); + assert.match(notifyRoute, /createEpayNotifyHandler[\s\S]*settle_order/); +}); + +test("billing and operations high-risk writes use the shared mutation guard", () => { + assert.match(productsRoute, /requireHighRiskAdminMutation\(request, permission\)/); + assert.match(productsRoute, /body\.data\.reason[\s\S]*requestId/); + assert.match(epaySettingsRoute, /requireHighRiskAdminMutation\(request, "billing\.adjustments\.write"\)/); + assert.match(epaySettingsRoute, /admin_save_epay_settings/); + assert.match(subscriptionsRoute, /requireHighRiskAdminMutation\(request,"billing\.adjustments\.write"\)/); + assert.match(subscriptionsRoute, /body\.data\.reason,rid/); + assert.match(featureFlagsRoute, /action==="publish"\?await requireHighRiskAdminMutation\(request,"ops\.flags\.write"\):await requireAdminMutation/); + assert.match(featureFlagsRoute, /admin_publish_feature_flag[\s\S]*b\.data\.reason,rid/); +}); + +test("model provider changes and release mutations already use the shared high-risk guard", () => { + assert.match(modelsRoute, /action === "saveProvider" \|\| body\.data\.action === "publish" \|\| body\.data\.action === "rollback"/); + assert.match(modelsRoute, /await requireHighRiskAdminMutation\(request, permission\)/); + assert.match(modelsRoute, /handleAdminModelMutation\(/); + assert.match(modelMutationHandler, /admin_save_model_provider/); + assert.match(modelMutationHandler, /admin_publish_model/); + assert.match(modelMutationHandler, /probeAndRecord[\s\S]*admin_rollback_model/); +}); + + +test("self-hosted payment catalog uses simple queries and immutable product snapshots", () => { + assert.doesNotMatch(packagesRoute, /product_entitlements\s*\(/); + assert.doesNotMatch(createRoute, /product_entitlements\s*\(/); + assert.match(packagesRoute, /\.from\("billing_products"\)[\s\S]*\.from\("product_entitlements"\)/); + assert.match(packagesRoute, /entitlementsByProduct/); + assert.match(createRoute, /\.from\("billing_products"\)[\s\S]*\.from\("product_entitlements"\)/); + assert.match(createRoute, /oneTimePerUser:\s*product\.one_time_per_user/); + assert.match(createRoute, /product_snapshot:\s*productSnapshot/); +}); + +test("order adjustments require reauth, reason, version, idempotency request id, and the domain RPC", () => { + assert.match( + ordersRoute, + /requireHighRiskAdminMutation\(\s*request,\s*"billing\.adjustments\.write",?\s*\)/, + ); + assert.match(ordersRoute, /expectedVersion:\s*z\.number\(\)\.int\(\)\.min\(0\)/); + assert.match(ordersRoute, /reason:\s*z\.string\(\)\.trim\(\)\.min\(1\)\.max\(500\)/); + assert.match(ordersRoute, /queryAdminRows/); + assert.match(ordersRoute, /public\.admin_adjust_order\(/); + assert.match(ordersRoute, /requestId\(request\)/); + assert.doesNotMatch(ordersRoute, /createAdminSupabaseClient|\.rpc\(/); + assert.match(billingOperationsUi, /retry_grant/); + assert.match(billingOperationsUi, /compensate/); + assert.match(billingOperationsUi, /record_refund/); + assert.match(billingOperationsUi, /reauthPermission="billing\.adjustments\.write"/); + assert.match(billingOperationsUi, /不调用支付网关|仅记录账务/); +}); + +test("every redemption-code write requires a reason and forwards it to the audited RPC", () => { + assert.match(codesRoute, /requireHighRiskAdminMutation\(\s*request,\s*"billing\.adjustments\.write",?\s*\)/); + assert.equal((codeRoute.match(/requireHighRiskAdminMutation\(/g) ?? []).length, 2); + assert.match(codesRoute, /reason:\s*z\.string\(\)\.trim\(\)\.min\(1\)\.max\(500\)/); + assert.match(codesRoute, /p_reason:\s*parsed\.data\.reason/); + assert.match(codeRoute, /const revokeCodeSchema[\s\S]*reason:\s*z\.string\(\)\.trim\(\)\.min\(1\)\.max\(500\)/); + assert.match(codeRoute, /const updateCodeSchema[\s\S]*reason:\s*z\.string\(\)\.trim\(\)\.min\(1\)\.max\(500\)/); + assert.match(codeRoute, /p_reason:\s*body\.reason/); + assert.match(codeRoute, /p_reason:\s*parsedBody\.data\.reason/); + assert.match(codesHelper, /queryAdminRows/); + assert.match(codesHelper, /public\.admin_create_redemption_codes\(/); + assert.match(codesHelper, /public\.admin_update_redemption_code\(/); + assert.match(codesHelper, /public\.admin_revoke_redemption_code\(/); + assert.doesNotMatch(codesHelper, /createAdminSupabaseClient|\.rpc\(|\$\{functionName\}/); + assert.match(codesUi, /permissions\.includes\("billing\.adjustments\.write"\)/); + assert.equal((codesUi.match(/reauthPermission="billing\.adjustments\.write"/g) ?? []).length, 3); + assert.match(codesUi, /open=\{Boolean\(pendingCreate\)\}[\s\S]*onSubmit=\{submitCreate\}/); + assert.match(codesUi, /open=\{Boolean\(pendingEdit\)\}[\s\S]*onSubmit=\{submitEdit\}/); + assert.match(codesUi, / revoke\(revokeRecord!, reason\)\}/); +}); diff --git a/frontend/tests/identity-auth-factory.test.ts b/frontend/tests/identity-auth-factory.test.ts index 9851f4d8..ed63eacb 100644 --- a/frontend/tests/identity-auth-factory.test.ts +++ b/frontend/tests/identity-auth-factory.test.ts @@ -6,20 +6,32 @@ import { createDatabaseAdminAuthorizer, createIdentityPool } from "../src/module import { FakeEmailOtpSender } from "../src/modules/identity/email/fake-email-otp-sender.ts"; import type { SelfHostedIdentityConfig } from "../src/modules/identity/config.ts"; -const config: SelfHostedIdentityConfig = { provider: "self-hosted", databaseUrl: "postgresql://identity_runtime:test-password@postgres:5432/jyotisha", userOrigin: "https://staging.jyotisha.chat", userSecret: "user-secret-that-is-at-least-32-bytes-long", resendApiKey: "re_test", resendFrom: "Jyotisha " }; +const config: SelfHostedIdentityConfig = { provider: "self-hosted", databaseUrl: "postgresql://identity_runtime:test-password@postgres:5432/jyotisha", userOrigin: "https://staging.jyotisha.chat", adminOrigin: "https://admin.staging.jyotisha.chat", userSecret: "user-secret-that-is-at-least-32-bytes-long", resendApiKey: "re_test", resendFrom: "Jyotisha " }; const database = { kind: "pool" } as unknown as Pool; -test("Better Auth uses one user origin, secret, and cookie", () => { +test("Better Auth trusts only the two exact origins and keeps host-only cookies", () => { const options = buildAuthOptions({ config, database, emailSender: new FakeEmailOtpSender() }); assert.equal(options.baseURL, config.userOrigin); assert.equal(options.secret, config.userSecret); assert.equal(options.advanced?.cookiePrefix, "jyotisha-user"); - assert.deepEqual(options.trustedOrigins, [config.userOrigin]); + assert.deepEqual(options.trustedOrigins, [config.userOrigin, config.adminOrigin]); + assert.equal(options.advanced?.defaultCookieAttributes?.domain, undefined); assert.ok(options.plugins?.some((plugin) => plugin.id === "email-otp")); + assert.ok(options.plugins?.some((plugin) => plugin.id === "two-factor")); assert.ok(options.plugins?.some((plugin) => plugin.id === "admin")); assert.equal(options.databaseHooks, undefined); }); +test("Better Auth two-factor plugin uses the identity schema without bypassing enrollment verification", () => { + const options = buildAuthOptions({ config, database, emailSender: new FakeEmailOtpSender() }); + const plugin = options.plugins?.find((candidate) => candidate.id === "two-factor"); + assert.ok(plugin); + assert.equal(plugin.schema?.twoFactor?.modelName, "two_factors"); + assert.equal(plugin.schema?.user?.fields?.twoFactorEnabled?.defaultValue, false); + assert.equal(plugin.schema?.twoFactor?.fields?.backupCodes?.returned, false); + assert.equal(plugin.schema?.twoFactor?.fields?.secret?.returned, false); +}); + test("OTP policy remains hashed and bounded", async () => { const sender = new FakeEmailOtpSender(); const options = createEmailOtpOptions(sender, config.userSecret, false); diff --git a/frontend/tests/identity-auth-integration.test.ts b/frontend/tests/identity-auth-integration.test.ts index 1f5af954..5ec4b4e3 100644 --- a/frontend/tests/identity-auth-integration.test.ts +++ b/frontend/tests/identity-auth-integration.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; +import { createHmac } from "node:crypto"; import { fileURLToPath } from "node:url"; import test from "node:test"; import { toNextJsHandler } from "better-auth/next-js"; @@ -24,6 +25,7 @@ const migrationsDirectory = fileURLToPath( new URL("../db/migrations", import.meta.url), ); const userHost = "staging.jyotisha.chat"; +const adminHost = "admin.staging.jyotisha.chat"; function request( host: string, @@ -48,11 +50,58 @@ function sessionCookie(response: Response): string { return (response.headers.get("set-cookie") ?? "").split(";", 1)[0]; } +type SetCookieHeaders = Headers & { getSetCookie?: () => string[] }; + +function responseCookieHeader(response: Response): string { + const headers = response.headers as SetCookieHeaders; + const setCookies = headers.getSetCookie?.() + ?? (headers.get("set-cookie")?.split(/,(?=\s*[^;,=\s]+=[^;,]*)/g) ?? []); + return setCookies + .map((value) => value.split(";", 1)[0]) + .filter((value) => value.slice(value.indexOf("=") + 1).length > 0) + .join("; "); +} + +function decodeBase32(value: string): Buffer { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + let bits = ""; + for (const character of value.replace(/=+$/g, "").toUpperCase()) { + const index = alphabet.indexOf(character); + if (index < 0) throw new Error("invalid base32 TOTP secret"); + bits += index.toString(2).padStart(5, "0"); + } + const bytes: number[] = []; + for (let offset = 0; offset + 8 <= bits.length; offset += 8) { + bytes.push(Number.parseInt(bits.slice(offset, offset + 8), 2)); + } + return Buffer.from(bytes); +} + +function totpCode(totpUri: string, now = Date.now()): string { + const uri = new URL(totpUri); + const secret = uri.searchParams.get("secret"); + assert.ok(secret); + const digits = Number(uri.searchParams.get("digits") ?? "6"); + const period = Number(uri.searchParams.get("period") ?? "30"); + const counter = Buffer.alloc(8); + counter.writeBigUInt64BE(BigInt(Math.floor(now / (period * 1_000)))); + const digest = createHmac("sha1", decodeBase32(secret)).update(counter).digest(); + const offset = digest[digest.length - 1] & 0x0f; + const binary = ( + ((digest[offset] & 0x7f) << 24) + | (digest[offset + 1] << 16) + | (digest[offset + 2] << 8) + | digest[offset + 3] + ) >>> 0; + return String(binary % (10 ** digits)).padStart(digits, "0"); +} + const envKeys = [ "AUTH_PROVIDER", "SELF_HOSTED_IDENTITY_ENABLED", "IDENTITY_DATABASE_URL", "AUTH_USER_ORIGIN", + "ADMIN_USER_ORIGIN", "BETTER_AUTH_USER_SECRET", "RESEND_API_KEY", "RESEND_FROM_EMAIL", @@ -80,6 +129,7 @@ test("Better Auth supports shared user OTP/password sessions for admins", async "identity-runtime-test-password", ), userOrigin: `https://${userHost}`, + adminOrigin: `https://${adminHost}`, userSecret: "user-secret-that-is-at-least-32-bytes-long", resendApiKey: "re_test", resendFrom: "Jyotisha ", @@ -92,6 +142,7 @@ test("Better Auth supports shared user OTP/password sessions for admins", async SELF_HOSTED_IDENTITY_ENABLED: "true", IDENTITY_DATABASE_URL: config.databaseUrl, AUTH_USER_ORIGIN: config.userOrigin, + ADMIN_USER_ORIGIN: config.adminOrigin, BETTER_AUTH_USER_SECRET: config.userSecret, RESEND_API_KEY: config.resendApiKey, RESEND_FROM_EMAIL: config.resendFrom, @@ -298,7 +349,7 @@ test("Better Auth supports shared user OTP/password sessions for admins", async "update identity.users set role = 'user,admin' where email = 'new-user@example.com'", ); const adminPasswordLogin = await handlers.POST( - request(userHost, "/api/auth/sign-in/email", { + request(adminHost, "/api/auth/sign-in/email", { email: newEmail, password: resetPassword, }), @@ -308,6 +359,145 @@ test("Better Auth supports shared user OTP/password sessions for admins", async sessionCookie(adminPasswordLogin), /^(?:__Secure-)?jyotisha-user\.session_token=/, ); + + const enrollmentSession = sessionCookie(adminPasswordLogin); + const enableMfa = await handlers.POST( + request( + adminHost, + "/api/auth/two-factor/enable", + { password: resetPassword }, + enrollmentSession, + ), + ); + assert.equal(enableMfa.status, 200); + const enrollment = await enableMfa.json() as { + totpURI: string; + backupCodes: string[]; + }; + assert.match(enrollment.totpURI, /^otpauth:\/\/totp\//); + assert.ok(enrollment.backupCodes.length >= 1); + assert.equal( + fixture.psql( + `select two_factor_enabled::text from identity.users where id = '${newUserId}'`, + ), + "f", + ); + const storedMfa = fixture.psql( + `select secret, backup_codes, verified::text from identity.two_factors where user_id = '${newUserId}'`, + ); + const encryptedBackupCodesBeforeUse = fixture.psql( + `select backup_codes from identity.two_factors where user_id = '${newUserId}'`, + ); + assert.equal( + storedMfa.includes(new URL(enrollment.totpURI).searchParams.get("secret") ?? "never"), + false, + ); + assert.equal(storedMfa.includes(enrollment.backupCodes[0]), false); + assert.match(storedMfa, /\|false$/); + + const verifyEnrollment = await handlers.POST( + request( + adminHost, + "/api/auth/two-factor/verify-totp", + { code: totpCode(enrollment.totpURI), trustDevice: false }, + enrollmentSession, + ), + ); + assert.equal(verifyEnrollment.status, 200, await verifyEnrollment.text()); + const rotatedSession = responseCookieHeader(verifyEnrollment); + assert.match(rotatedSession, /(?:__Secure-)?jyotisha-user\.session_token=/); + assert.equal( + await services.user.api.getSession({ headers: new Headers({ cookie: enrollmentSession }) }), + null, + ); + const enrolledSession = await services.user.api.getSession({ + headers: new Headers({ cookie: rotatedSession }), + }); + assert.equal( + (enrolledSession?.user as { twoFactorEnabled?: boolean } | undefined)?.twoFactorEnabled, + true, + ); + + const mfaPasswordLogin = await handlers.POST( + request(adminHost, "/api/auth/sign-in/email", { + email: newEmail, + password: resetPassword, + }), + ); + assert.equal(mfaPasswordLogin.status, 200); + assert.deepEqual(await mfaPasswordLogin.json(), { + twoFactorRedirect: true, + twoFactorMethods: ["totp"], + }); + const nativeChallengeCookie = responseCookieHeader(mfaPasswordLogin); + assert.match(nativeChallengeCookie, /(?:__Secure-)?jyotisha-user\.two_factor=/); + assert.doesNotMatch(nativeChallengeCookie, /session_token=[^;]+/); + + const verifyLoginTotp = await handlers.POST( + request( + adminHost, + "/api/auth/two-factor/verify-totp", + { code: totpCode(enrollment.totpURI), trustDevice: false }, + nativeChallengeCookie, + ), + ); + assert.equal(verifyLoginTotp.status, 200, await verifyLoginTotp.text()); + const totpSessionCookie = responseCookieHeader(verifyLoginTotp); + assert.match(totpSessionCookie, /(?:__Secure-)?jyotisha-user\.session_token=/); + + const backupLogin = await handlers.POST( + request(adminHost, "/api/auth/sign-in/email", { + email: newEmail, + password: resetPassword, + }), + ); + const backupChallengeCookie = responseCookieHeader(backupLogin); + const verifyBackup = await handlers.POST( + request( + adminHost, + "/api/auth/two-factor/verify-backup-code", + { code: enrollment.backupCodes[0], disableSession: false }, + backupChallengeCookie, + ), + ); + assert.equal(verifyBackup.status, 200, await verifyBackup.text()); + const backupSessionCookie = responseCookieHeader(verifyBackup); + assert.match(backupSessionCookie, /(?:__Secure-)?jyotisha-user\.session_token=/); + assert.notEqual( + fixture.psql( + `select backup_codes from identity.two_factors where user_id = '${newUserId}'`, + ), + encryptedBackupCodesBeforeUse, + ); + + const regenerate = await handlers.POST( + request( + adminHost, + "/api/auth/two-factor/generate-backup-codes", + { password: resetPassword }, + backupSessionCookie, + ), + ); + assert.equal(regenerate.status, 200, await regenerate.clone().text()); + const replacementCodes = (await regenerate.json() as { backupCodes: string[] }).backupCodes; + assert.ok(replacementCodes.length >= 1); + assert.notDeepEqual(replacementCodes, enrollment.backupCodes); + + const disableMfa = await handlers.POST( + request( + adminHost, + "/api/auth/two-factor/disable", + { password: resetPassword }, + backupSessionCookie, + ), + ); + assert.equal(disableMfa.status, 200, await disableMfa.text()); + assert.equal( + fixture.psql( + `select two_factor_enabled::text || ':' || (select count(*)::text from identity.two_factors where user_id = '${newUserId}') from identity.users where id = '${newUserId}'`, + ), + "f:0", + ); } finally { const globalServices = ( globalThis as typeof globalThis & { diff --git a/frontend/tests/identity-config.test.ts b/frontend/tests/identity-config.test.ts index 55267d31..64f4f47b 100644 --- a/frontend/tests/identity-config.test.ts +++ b/frontend/tests/identity-config.test.ts @@ -8,6 +8,7 @@ const selfHostedEnvironment = { SELF_HOSTED_IDENTITY_ENABLED: "true", IDENTITY_DATABASE_URL: "postgresql://identity_runtime:test-password@postgres:5432/jyotisha", AUTH_USER_ORIGIN: "https://staging.jyotisha.chat", + ADMIN_USER_ORIGIN: "https://admin.staging.jyotisha.chat", BETTER_AUTH_USER_SECRET: "user-secret-that-is-at-least-32-bytes-long", RESEND_API_KEY: "re_test_key_that_must_not_be_printed", RESEND_FROM_EMAIL: "Jyotisha Staging ", @@ -18,13 +19,13 @@ test("identity provider defaults to supabase", () => { assert.equal(isSelfHostedIdentityEnabled({}), false); }); -test("identity config accepts one self-hosted user origin and secret", () => { +test("identity config accepts the two exact self-hosted origins and one secret", () => { const config = readIdentityConfig(selfHostedEnvironment); assert.equal(config.provider, "self-hosted"); if (config.provider !== "self-hosted") assert.fail(); assert.equal(config.userOrigin, "https://staging.jyotisha.chat"); + assert.equal(config.adminOrigin, "https://admin.staging.jyotisha.chat"); assert.equal(config.userSecret, selfHostedEnvironment.BETTER_AUTH_USER_SECRET); - assert.equal("adminOrigin" in config, false); assert.equal("adminSecret" in config, false); }); @@ -35,6 +36,7 @@ test("self-hosted identity ignores retired admin-surface variables", () => { BETTER_AUTH_ADMIN_SECRET: "short", }); assert.equal(config.userOrigin, selfHostedEnvironment.AUTH_USER_ORIGIN); + assert.equal(config.adminOrigin, selfHostedEnvironment.ADMIN_USER_ORIGIN); }); test("self-hosted provider requires its enable flag", () => { @@ -44,11 +46,17 @@ test("self-hosted provider requires its enable flag", () => { test("self-hosted identity validates active database, origin, secret, and sender", () => { assert.throws(() => readIdentityConfig({ ...selfHostedEnvironment, IDENTITY_DATABASE_URL: "https://invalid" }), /PostgreSQL URL/); assert.throws(() => readIdentityConfig({ ...selfHostedEnvironment, AUTH_USER_ORIGIN: "http://staging.jyotisha.chat" }), /must use HTTPS/); + assert.throws(() => readIdentityConfig({ ...selfHostedEnvironment, ADMIN_USER_ORIGIN: "https://admin.staging.jyotisha.chat/path" }), /without a path/); + assert.throws(() => readIdentityConfig({ ...selfHostedEnvironment, ADMIN_USER_ORIGIN: selfHostedEnvironment.AUTH_USER_ORIGIN }), /must differ/); assert.throws(() => readIdentityConfig({ ...selfHostedEnvironment, BETTER_AUTH_USER_SECRET: "short" }), /at least 32/); assert.throws(() => readIdentityConfig({ ...selfHostedEnvironment, RESEND_FROM_EMAIL: "invalid" }), /valid email/); }); -test("localhost may use HTTP", () => { - const config = readIdentityConfig({ ...selfHostedEnvironment, AUTH_USER_ORIGIN: "http://localhost:3000" }); +test("localhost may use two distinct HTTP origins", () => { + const config = readIdentityConfig({ + ...selfHostedEnvironment, + AUTH_USER_ORIGIN: "http://localhost:3000", + ADMIN_USER_ORIGIN: "http://admin.localhost:3000", + }); assert.equal(config.provider, "self-hosted"); }); diff --git a/frontend/tests/identity-host-routing.test.ts b/frontend/tests/identity-host-routing.test.ts index 3b5ae6ca..9634083a 100644 --- a/frontend/tests/identity-host-routing.test.ts +++ b/frontend/tests/identity-host-routing.test.ts @@ -7,15 +7,26 @@ const config: SelfHostedIdentityConfig = { provider: "self-hosted", databaseUrl: "postgresql://identity_runtime:test@postgres:5432/jyotisha", userOrigin: "https://staging.jyotisha.chat", + adminOrigin: "https://admin.staging.jyotisha.chat", userSecret: "user-secret-that-is-at-least-32-bytes-long", resendApiKey: "re_test", resendFrom: "Jyotisha ", }; -test("identity host accepts only the configured user origin", () => { +test("identity host accepts only the two configured origins", () => { assert.equal(resolveIdentitySurface("staging.jyotisha.chat", config), "user"); assert.equal(resolveIdentitySurface("STAGING.JYOTISHA.CHAT:443", config), "user"); - for (const host of [null, "", "admin.staging.jyotisha.chat", "staging.jyotisha.chat.evil.example", "staging.jyotisha.chat,evil.example"]) { + assert.equal(resolveIdentitySurface("admin.staging.jyotisha.chat", config), "admin"); + assert.equal(resolveIdentitySurface("ADMIN.STAGING.JYOTISHA.CHAT:443", config), "admin"); + for (const host of [ + null, + "", + "evil.staging.jyotisha.chat", + "staging.jyotisha.chat.evil.example", + "staging.jyotisha.chat,evil.example", + "admin.staging.jyotisha.chat:444", + "admin.staging.jyotisha.chat.", + ]) { assert.equal(resolveIdentitySurface(host, config), null); } }); @@ -41,7 +52,23 @@ test("main auth surface keeps Better Auth admin endpoints closed", async () => { let calls = 0; const handler = async () => { calls += 1; return new Response("unexpected"); }; const handlers = createHostIsolatedAuthHandlers(config, { user: { GET: handler, POST: handler } }); - const response = await handlers.POST(new Request("https://internal/api/auth/admin/set-role", { method: "POST", headers: { host: "staging.jyotisha.chat" } })); + const response = await handlers.POST(new Request("https://internal/api/auth/admin/set-role", { + method: "POST", + headers: { + host: "staging.jyotisha.chat", + "x-forwarded-host": "admin.staging.jyotisha.chat", + }, + })); assert.equal(response.status, 404); assert.equal(calls, 0); }); + +test("admin auth surface reaches the same Better Auth service", async () => { + let calls = 0; + const handler = async () => { calls += 1; return new Response("admin-host"); }; + const handlers = createHostIsolatedAuthHandlers(config, { user: { GET: handler, POST: handler } }); + const response = await handlers.POST(new Request("https://internal/api/auth/admin/set-role", { method: "POST", headers: { host: "admin.staging.jyotisha.chat" } })); + assert.equal(response.status, 200); + assert.equal(await response.text(), "admin-host"); + assert.equal(calls, 1); +}); diff --git a/frontend/tests/identity-login-provider.test.ts b/frontend/tests/identity-login-provider.test.ts index abbd21e1..73fab5e3 100644 --- a/frontend/tests/identity-login-provider.test.ts +++ b/frontend/tests/identity-login-provider.test.ts @@ -4,7 +4,7 @@ import test from "node:test"; import { createSelfHostedAuthActions } from "../src/modules/identity/client.ts"; -test("login page uses one self-hosted user surface", () => { +test("login page routes the two self-hosted surfaces explicitly", () => { const page = readFileSync( new URL("../src/app/login/page.tsx", import.meta.url), "utf8", @@ -13,8 +13,12 @@ test("login page uses one self-hosted user surface", () => { assert.doesNotMatch(page, /["']use client["']/); assert.match(page, /export const dynamic = "force-dynamic"/); assert.match(page, /readIdentityConfig\(process\.env\)/); + assert.match(page, /resolveIdentitySurface\(\(await headers\(\)\)\.get\("host"\), config\)/); + assert.match(page, /if \(!surface\) notFound\(\)/); + assert.match(page, /surface === "admin" \? "\/admin" : "\/"/); assert.match(page, /passwordEnabled=\{config\.provider === "self-hosted"\}/); - assert.doesNotMatch(page, /resolveIdentitySurface|passwordOnly|surface === "admin"|NEXT_PUBLIC_AUTH_PROVIDER/); + assert.match(page, /successPath=\{successPath\}/); + assert.doesNotMatch(page, /passwordOnly|NEXT_PUBLIC_AUTH_PROVIDER/); }); test("self-hosted auth actions call Better Auth without browser token storage", async () => { @@ -46,6 +50,16 @@ test("self-hosted auth actions call Better Auth without browser token storage", return { data: { user: { id: "user-id" } }, error: null }; }, }, + twoFactor: { + async verifyTotp(input) { + calls.push({ operation: "verify-totp", input: { code: input.code } }); + return { data: { status: true }, error: null }; + }, + async verifyBackupCode(input) { + calls.push({ operation: "verify-backup", input: { code: input.code } }); + return { data: { status: true }, error: null }; + }, + }, }, async (input, init) => { fetchCalls.push({ @@ -62,6 +76,8 @@ test("self-hosted auth actions call Better Auth without browser token storage", await actions.send(" Person@Example.com "); await actions.verify(" Person@Example.com ", "123456"); await actions.signInWithPassword(" Person@Example.com ", "password-1"); + await actions.verifyTwoFactor("234567", "totp"); + await actions.verifyTwoFactor("backup-code", "backup-code"); await actions.requestPasswordReset(" Person@Example.com "); await actions.resetPassword( " Person@Example.com ", @@ -84,6 +100,14 @@ test("self-hosted auth actions call Better Auth without browser token storage", operation: "password", input: { email: "person@example.com", password: "password-1" }, }, + { + operation: "verify-totp", + input: { code: "234567" }, + }, + { + operation: "verify-backup", + input: { code: "backup-code" }, + }, { operation: "request-reset", input: { email: "person@example.com" }, @@ -111,6 +135,32 @@ test("self-hosted auth actions call Better Auth without browser token storage", "utf8", ); assert.doesNotMatch(clientSource, /localStorage|sessionStorage/); + assert.match(clientSource, /twoFactorClient\(\)/); +}); + +test("self-hosted sign-in reports the native Better Auth MFA redirect", async () => { + const actions = createSelfHostedAuthActions({ + emailOtp: { + async sendVerificationOtp() { + return { data: { success: true }, error: null }; + }, + }, + signIn: { + async emailOtp() { + return { data: { twoFactorRedirect: true, twoFactorMethods: ["totp"] }, error: null }; + }, + async email() { + return { data: { twoFactorRedirect: true, twoFactorMethods: ["totp"] }, error: null }; + }, + }, + }); + + assert.deepEqual(await actions.verify("admin@example.com", "123456"), { + twoFactorRequired: true, + }); + assert.deepEqual(await actions.signInWithPassword("admin@example.com", "password"), { + twoFactorRequired: true, + }); }); test("self-hosted auth actions expose generic enumeration-safe errors", async () => { @@ -169,11 +219,11 @@ test("login UI preserves accessible OTP, password, registration, and reset input } assert.match( component, - /canUsePassword && !passwordOnly && \(mode === "otp" \|\| mode === "password"\)/, + /step === "email"[\s\S]*canUsePassword[\s\S]*!passwordOnly[\s\S]*\(mode === "otp" \|\| mode === "password"\)/, ); assert.match( component, - /canUsePassword && !passwordOnly && \(mode === "register" \|\| mode === "forgot"\)/, + /step !== "two-factor"[\s\S]*canUsePassword[\s\S]*!passwordOnly[\s\S]*\(mode === "register" \|\| mode === "forgot"\)/, ); assert.match(component, /useState\(passwordOnly \? "password" : "otp"\)/); assert.match(component, /\{showLoginNavigation && \(/); @@ -188,6 +238,13 @@ test("login UI preserves accessible OTP, password, registration, and reset input } assert.match(component, /role="alert"/); assert.match(component, /role="status"/); + assert.match(component, /step === "two-factor"/); + assert.match(component, /selfHostedAuthActions\.verifyTwoFactor\(token, mfaMethod\)/); + assert.match(component, /successPath\?: "\/" \| "\/admin"/); + assert.equal((component.match(/window\.location\.assign\(successPath\)/g) ?? []).length, 5); + assert.doesNotMatch(component, /hostname\.startsWith|admin\.staging/); + assert.match(component, /动态验证码/); + assert.match(component, /恢复码/); const route = readFileSync( new URL("../src/app/api/account/password/route.ts", import.meta.url), diff --git a/frontend/tests/identity-session.test.ts b/frontend/tests/identity-session.test.ts index 162bf43f..cbbb9dfe 100644 --- a/frontend/tests/identity-session.test.ts +++ b/frontend/tests/identity-session.test.ts @@ -14,11 +14,16 @@ function readerFor(role: string | null): IdentitySessionReader { async getSession() { if (role === null) return null; return { - session: { expiresAt: new Date("2030-01-01T00:00:00.000Z") }, + session: { + id: "session-1", + token: "session-token", + expiresAt: new Date("2030-01-01T00:00:00.000Z"), + }, user: { id: "018f4e6d-7a11-7000-8000-000000000001", email: "Person@Example.com", emailVerified: true, + twoFactorEnabled: true, name: "Person", image: null, role, @@ -37,6 +42,7 @@ test("identity session mapper returns a narrow normalized DTO", async () => { id: "018f4e6d-7a11-7000-8000-000000000001", email: "person@example.com", emailVerified: true, + twoFactorEnabled: true, name: "Person", image: null, role: ["user", "admin"], diff --git a/frontend/tests/model-catalog.test.ts b/frontend/tests/model-catalog.test.ts index 4b75f5ef..f830d080 100644 --- a/frontend/tests/model-catalog.test.ts +++ b/frontend/tests/model-catalog.test.ts @@ -82,6 +82,27 @@ test("excludes an invalid catalog entry without leaking its secret", () => { assert.equal(JSON.stringify(catalog.issues).includes("must-not-appear"), false); }); +test("rejects non-model environment variables as provider credentials", () => { + const catalog = resolveLanguageModelCatalog({ + LLM_DEFAULT_MODEL_ID: "unsafe-model", + LLM_MODELS_JSON: JSON.stringify([{ + id: "unsafe-model", + label: "Unsafe", + description: "", + provider: "openai-compatible", + baseURL: "https://models.example.com", + apiKeyEnv: "DATABASE_URL", + model: "unsafe-v1", + creditCost: 1, + }]), + DATABASE_URL: "postgresql://must-not-leave-the-server", + }); + + assert.deepEqual(catalog.models, []); + assert.equal(catalog.issues.includes("catalog_entry_invalid:0"), true); + assert.equal(JSON.stringify(catalog).includes("must-not-leave-the-server"), false); +}); + test("does not choose an undeclared default model", () => { // Given const environment = { diff --git a/frontend/tests/model-configuration-security.test.ts b/frontend/tests/model-configuration-security.test.ts new file mode 100644 index 00000000..71abf0a0 --- /dev/null +++ b/frontend/tests/model-configuration-security.test.ts @@ -0,0 +1,380 @@ +import assert from "node:assert/strict"; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { handleAdminModelMutation } from "../src/lib/admin/model-mutation-handler.ts"; +import { assertAllowedModelProviderUrl } from "../src/lib/epay/gateway-policy.ts"; +import { runMigrations } from "../scripts/db-migrate.mjs"; +import { startPostgresFixture } from "./helpers/postgres-fixture.ts"; + +const migrationPath = fileURLToPath(new URL("../supabase/migrations/20260806040000_model_configuration.sql", import.meta.url)); +const routePath = fileURLToPath(new URL("../src/app/api/admin/models/route.ts", import.meta.url)); +const catalogPath = fileURLToPath(new URL("../src/lib/model-catalog.ts", import.meta.url)); +const componentPath = fileURLToPath(new URL("../src/components/admin/model-management.tsx", import.meta.url)); +const actorId = "10000000-0000-4000-8000-000000000003"; +const sessionOneId = "20000000-0000-4000-8000-000000000001"; +const sessionTwoId = "20000000-0000-4000-8000-000000000002"; + +const publicLookup = async () => [{ address: "93.184.216.34", family: 4 }] as const; + +async function migrateModelConfigurationFixture(connectionString: string) { + const root = fileURLToPath(new URL("..", import.meta.url)); + const temporaryRoot = mkdtempSync(join(tmpdir(), "jyotisha-model-migrations-")); + const directories = ["db/migrations", "supabase/migrations"].map((relative) => { + const source = join(root, relative); + const target = join(temporaryRoot, relative.replace("/", "-")); + mkdirSync(target, { recursive: true }); + for (const filename of readdirSync(source)) { + if (!filename.endsWith(".sql") || filename > "20260806040000_model_configuration.sql") continue; + if (filename === "20260806030000_settle_order_usage_authorization.sql") continue; + cpSync(join(source, filename), join(target, filename)); + } + return target; + }); + try { + await runMigrations({ + connectionString, + migrationsDirectory: undefined, + migrationsDirectories: directories, + logger: console, + }); + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + } +} + +test("model provider URLs require a server-owned public allowlist", async () => { + await assertAllowedModelProviderUrl( + "https://models.example.com/v1", + { MODEL_PROVIDER_BASE_URL_ALLOWLIST: "https://models.example.com" }, + publicLookup, + ); + await assert.rejects( + assertAllowedModelProviderUrl("https://attacker.example/v1", {}, publicLookup), + /允许列表/, + ); + await assert.rejects( + assertAllowedModelProviderUrl( + "https://models.example.com/v1", + { MODEL_PROVIDER_BASE_URL_ALLOWLIST: "https://models.example.com" }, + async () => [{ address: "169.254.169.254", family: 4 }], + ), + /内网|保留地址/, + ); + await assert.rejects( + assertAllowedModelProviderUrl( + "https://service.internal/v1", + { MODEL_PROVIDER_BASE_URL_ALLOWLIST: "https://service.internal" }, + publicLookup, + ), + /内部域名/, + ); +}); + +test("admin and runtime source keep refs private and test exact model versions", () => { + const route = readFileSync(routePath, "utf8"); + const catalog = readFileSync(catalogPath, "utf8"); + const component = readFileSync(componentPath, "utf8"); + assert.doesNotMatch(route, /secretRef:\s*provider\.secret_ref,\s*enabled:/); + assert.doesNotMatch(component, /name="secretRef"|providerId:\s*item\.id/); + assert.match(component, /action: "test", versionId: item\.id/); + assert.match(route, /sanitizeModelSettings\(row\.settings\)/); + assert.match(route, /requireHighRiskAdminMutation/); + assert.match(route, /handleAdminModelMutation/); + assert.match(catalog, /models\.database_catalog/); + assert.match(catalog, /models\.circuit_breaker/); + assert.match(catalog, /assertAllowedModelProviderUrl/); + assert.match(catalog, /resolveSessionLanguageModel/); +}); + +test("mutation handler rejects arbitrary env refs and persists failed connection evidence", async () => { + let queryCount = 0; + const rejected = await handleAdminModelMutation({ + action: "saveProvider", + code: "openai", + name: "OpenAI", + providerType: "openai", + secretRef: "env:DATABASE_URL", + enabled: true, + reason: "unsafe ref", + }, { actorUserId: actorId, requestId: "provider-dangerous-ref" }, { + queryRows: async () => { queryCount += 1; return []; }, + assertAllowedUrl: async () => undefined, + probeAllowed: async () => 200, + invalidateCatalog: () => undefined, + environment: { OPENAI_API_KEY: "model-key" }, + }); + assert.equal(rejected.status, 400); + assert.equal(queryCount, 0); + + const calls: Array<{ sql: string; values?: readonly unknown[] }> = []; + const failed = await handleAdminModelMutation({ + action: "test", + versionId: "30000000-0000-4000-8000-000000000001", + }, { actorUserId: actorId, requestId: "test-unauthorized" }, { + queryRows: async (sql, values) => { + calls.push({ sql, values }); + if (sql.includes("from public.model_config_versions")) { + return [{ + id: "40000000-0000-4000-8000-000000000001", + code: "openai", + provider_type: "openai", + base_url: null, + secret_ref: "env:OPENAI_API_KEY", + enabled: true, + version_id: "30000000-0000-4000-8000-000000000001", + version_enabled: true, + }]; + } + return [{ id: "50000000-0000-4000-8000-000000000001" }]; + }, + assertAllowedUrl: async () => undefined, + probeAllowed: async () => 401, + invalidateCatalog: () => undefined, + environment: { OPENAI_API_KEY: "model-key" }, + }); + assert.equal(failed.status, 409); + assert.equal(calls.length, 2); + assert.match(calls[1]!.sql, /admin_record_model_connection_test/); + assert.deepEqual(calls[1]!.values, [actorId, "30000000-0000-4000-8000-000000000001", 401, "test-unauthorized"]); +}); + +test("provider mutation returns a clear immutable-version conflict", async () => { + const response = await handleAdminModelMutation({ + action: "saveProvider", + id: "40000000-0000-4000-8000-000000000001", + code: "openai-next", + name: "OpenAI Next", + providerType: "openai", + enabled: true, + reason: "rotate provider", + }, { actorUserId: actorId, requestId: "provider-immutable" }, { + queryRows: async () => { + throw Object.assign(new Error("model_provider_runtime_immutable"), { code: "23514" }); + }, + assertAllowedUrl: async () => undefined, + probeAllowed: async () => 200, + invalidateCatalog: () => undefined, + environment: { OPENAI_API_KEY: "model-key" }, + }); + + assert.equal(response.status, 409); + assert.deepEqual(await response.json(), { + error: "已发布或已退役版本使用的供应商连接配置不可修改,请新建供应商和模型版本后重新测试并发布", + code: "model_provider_runtime_immutable", + }); +}); + +test("rollback handler probes and records same-request evidence before changing publication", async () => { + const events: string[] = []; + const providerRow = { + id: "40000000-0000-4000-8000-000000000001", + code: "openai", + provider_type: "openai" as const, + base_url: null, + secret_ref: "env:OPENAI_API_KEY", + enabled: true, + version_id: "30000000-0000-4000-8000-000000000001", + version_enabled: true, + }; + const dependencies = (status: number) => ({ + queryRows: async (sql: string, values?: readonly unknown[]) => { + if (sql.includes("from public.model_config_versions")) { + events.push("lookup"); + return [providerRow]; + } + if (sql.includes("admin_record_model_connection_test")) { + events.push(`evidence:${String(values?.[3])}`); + return [{ id: "50000000-0000-4000-8000-000000000001" }]; + } + if (sql.includes("admin_rollback_model")) { + events.push("rollback"); + return [{ id: providerRow.version_id }]; + } + return []; + }, + assertAllowedUrl: async () => undefined, + probeAllowed: async () => { events.push(`probe:${status}`); return status; }, + invalidateCatalog: () => events.push("invalidate"), + environment: { OPENAI_API_KEY: "model-key" }, + }); + + const action = { + action: "rollback" as const, + configId: "60000000-0000-4000-8000-000000000001", + targetVersion: 1, + reason: "runtime rollback", + }; + const failed = await handleAdminModelMutation( + action, + { actorUserId: actorId, requestId: "rollback-runtime-check" }, + dependencies(403), + ); + assert.equal(failed.status, 409); + assert.deepEqual(events, ["lookup", "probe:403", "evidence:rollback-runtime-check"]); + + events.length = 0; + const passed = await handleAdminModelMutation( + action, + { actorUserId: actorId, requestId: "rollback-runtime-check" }, + dependencies(200), + ); + assert.equal(passed.status, 200); + assert.deepEqual(events, ["lookup", "probe:200", "evidence:rollback-runtime-check", "rollback", "invalidate"]); +}); + +test("database enforces fixed secrets, fresh evidence, rollback viability, and session version pinning", async () => { + const fixture = startPostgresFixture(); + const sql = (statement: string) => fixture.psql(statement); + const sqlAsOwner = (statement: string) => fixture.psqlAs("schema_owner", "schema-owner-test-password", statement); + const expectSqlError = (statement: string, pattern: RegExp) => assert.throws(() => sqlAsOwner(statement), pattern); + + try { + await migrateModelConfigurationFixture( + fixture.connectionUrl("schema_owner", "schema-owner-test-password"), + ); + + assert.equal(sql("select public.expected_model_provider_secret_ref('openai','openai')"), "env:OPENAI_API_KEY"); + assert.equal(sql("select public.expected_model_provider_secret_ref('deepseek','openai-compatible')"), "env:DEEPSEEK_API_KEY"); + assert.equal(sql("select public.expected_model_provider_secret_ref('trusted-edge','openai-compatible')"), "env:MODEL_PROVIDER_TRUSTED_EDGE_API_KEY"); + assert.equal(sql("select public.model_provider_base_url_is_safe('https://api.example.com/v1')"), "t"); + assert.equal(sql("select public.model_provider_base_url_is_safe('https://127.0.0.1/v1')"), "f"); + assert.equal(sql("select public.model_provider_base_url_is_safe('https://metadata.google.internal/v1')"), "f"); + assert.equal(sql("select public.model_settings_contain_secrets('{\"nested\":{\"authorization\":\"Bearer nope\"}}')"), "t"); + + fixture.psqlAs("identity_runtime", "identity-runtime-test-password", ` + insert into identity.users (id,name,email,email_verified,email_verified_at,role) + values ('${actorId}','Model Admin','model-admin@example.com',true,now(),'user') + `); + sql(` + insert into public.admin_users(user_id,created_by) values ('${actorId}','${actorId}'); + insert into public.admin_user_roles(admin_user_id,role_id,assigned_by) + select '${actorId}',id,'${actorId}' from public.admin_roles where code='model_admin' + `); + + for (const [index, secretRef] of ["env:DATABASE_URL", "env:ADMIN_DATABASE_URL", "env:SUPABASE_SERVICE_ROLE_KEY", "env:RESEND_API_KEY", "env:EPAY_KEY"].entries()) { + expectSqlError( + `select public.admin_save_model_provider('${actorId}',null,'openai','OpenAI','openai',null,'${secretRef}',false,'reject dangerous ref','danger-${index}')`, + /model_provider_secret_ref_forbidden/, + ); + } + expectSqlError( + `select public.admin_save_model_provider('${actorId}',null,'unsafe-provider','Unsafe','openai-compatible','https://127.0.0.1/v1','env:MODEL_PROVIDER_UNSAFE_PROVIDER_API_KEY',false,'reject unsafe','unsafe-provider')`, + /model_provider_url_unsafe/, + ); + + const provider = sql(`select public.admin_save_model_provider('${actorId}',null,'openai','OpenAI','openai',null,'env:OPENAI_API_KEY',true,'create provider','provider-create')`); + expectSqlError( + `select public.admin_save_model_draft('${actorId}','default-model',null,'${provider}','Default','', 'model-v1','standard',1,64000,0,0,false,true,null,'{}','disabled default','disabled-default')`, + /default_model_disabled/, + ); + expectSqlError( + `select public.admin_save_model_draft('${actorId}','secret-model',null,'${provider}','Secret','', 'model-v1','standard',1,64000,0,0,true,false,null,'{\"nested\":{\"apiKey\":\"nope\"}}','secret settings','secret-settings')`, + /model_settings_secret_forbidden/, + ); + + const v1 = sql(`select public.admin_save_model_draft('${actorId}','default-model',null,'${provider}','Default v1','', 'model-v1','standard',1,64000,0,0,true,true,null,'{}','create v1','draft-v1')`); + expectSqlError( + `select public.admin_publish_model('${actorId}','${v1}','untested publish','publish-untested')`, + /model_connection_test_required/, + ); + sql(`select public.admin_record_model_connection_test('${actorId}','${v1}',401,'test-v1-401')`); + expectSqlError( + `select public.admin_publish_model('${actorId}','${v1}','401 publish','publish-401')`, + /model_connection_test_required/, + ); + + sql(`select public.admin_record_model_connection_test('${actorId}','${v1}',200,'test-v1-before-change')`); + sql(`update public.model_config_versions set label='Default v1 changed' where id='${v1}'`); + expectSqlError( + `select public.admin_publish_model('${actorId}','${v1}','changed config publish','publish-changed')`, + /model_connection_test_required/, + ); + + const expiringEvidence = sql(`select public.admin_record_model_connection_test('${actorId}','${v1}',200,'test-v1-expired')`); + sql(`update public.model_connection_test_evidence set tested_at=clock_timestamp()-interval '20 minutes',expires_at=clock_timestamp()-interval '10 minutes' where id='${expiringEvidence}'`); + expectSqlError( + `select public.admin_publish_model('${actorId}','${v1}','expired publish','publish-expired')`, + /model_connection_test_required/, + ); + + sql(`select public.admin_record_model_connection_test('${actorId}','${v1}',200,'test-v1-fresh')`); + sql(`select public.admin_save_model_provider('${actorId}','${provider}','openai','OpenAI renamed','openai',null,'env:OPENAI_API_KEY',true,'rename provider','provider-rename')`); + assert.equal(sql(`select public.model_connection_test_is_fresh('${v1}',null)`), "t"); + sql(`select public.admin_publish_model('${actorId}','${v1}','publish v1','publish-v1')`); + sql(`insert into public.chat_sessions(id,user_id,title,theme,messages,model_id) values('${sessionOneId}','${actorId}','v1 session','general','[]','default-model')`); + assert.equal(sql(`select model_id||':'||model_config_version from public.chat_sessions where id='${sessionOneId}'`), "default-model:1"); + + for (const mutation of [ + "code='openai-mutated'", + "provider_type='openai-compatible'", + "base_url='https://models.example.com/v1'", + "secret_ref='env:MODEL_PROVIDER_OPENAI_MUTATED_API_KEY'", + "enabled=false", + ]) { + expectSqlError( + `update public.model_providers set ${mutation} where id='${provider}'`, + /model_provider_runtime_immutable/, + ); + } + expectSqlError( + `select public.admin_save_model_provider('${actorId}','${provider}','openai','OpenAI','openai',null,'env:OPENAI_API_KEY',false,'disable published provider','provider-disable')`, + /model_provider_runtime_immutable/, + ); + + const providerV2 = sql(`select public.admin_save_model_provider('${actorId}',null,'openai-next','OpenAI Next','openai',null,'env:OPENAI_API_KEY',true,'create replacement provider','provider-v2')`); + const v2 = sql(`select public.admin_save_model_draft('${actorId}','default-model',null,'${providerV2}','Default v2','', 'model-v2','standard',1,64000,0,0,true,true,null,'{}','create v2','draft-v2')`); + sql(`select public.admin_record_model_connection_test('${actorId}','${v2}',403,'test-v2-403')`); + expectSqlError( + `select public.admin_publish_model('${actorId}','${v2}','403 publish','publish-v2-403')`, + /model_connection_test_required/, + ); + sql(`select public.admin_record_model_connection_test('${actorId}','${v2}',204,'test-v2-fresh')`); + sql(`select public.admin_publish_model('${actorId}','${v2}','publish v2','publish-v2')`); + expectSqlError( + `update public.model_providers set enabled=false where id='${provider}'`, + /model_provider_runtime_immutable/, + ); + + sql(`update public.chat_sessions set messages='[{\"role\":\"user\",\"content\":\"hello\"}]',model_id='default-model',model_config_version=999 where id='${sessionOneId}'`); + assert.equal(sql(`select model_config_version from public.chat_sessions where id='${sessionOneId}'`), "1"); + assert.equal(sql(`select p.code||':'||coalesce(p.base_url,'native') from public.chat_sessions s join public.model_configs c on c.model_id=s.model_id join public.model_config_versions v on v.config_id=c.id and v.version=s.model_config_version join public.model_providers p on p.id=v.provider_id where s.id='${sessionOneId}'`), "openai:native"); + sql(`insert into public.chat_sessions(id,user_id,title,theme,messages,model_id) values('${sessionTwoId}','${actorId}','v2 session','general','[]','default-model')`); + assert.equal(sql(`select model_config_version from public.chat_sessions where id='${sessionTwoId}'`), "2"); + assert.equal(sql(`select p.code from public.chat_sessions s join public.model_configs c on c.model_id=s.model_id join public.model_config_versions v on v.config_id=c.id and v.version=s.model_config_version join public.model_providers p on p.id=v.provider_id where s.id='${sessionTwoId}'`), "openai-next"); + assert.equal(sql("select has_column_privilege('authenticated','public.chat_sessions','model_config_version','INSERT')"), "f"); + assert.equal(sql("select has_column_privilege('authenticated','public.chat_sessions','model_config_version','UPDATE')"), "f"); + + const configId = sql("select id from public.model_configs where model_id='default-model'"); + expectSqlError( + `select public.admin_rollback_model('${actorId}','${configId}',1,'rollback without same request evidence','rollback-v1')`, + /model_connection_test_required/, + ); + sql(`select public.admin_record_model_connection_test('${actorId}','${v1}',403,'rollback-v1')`); + expectSqlError( + `select public.admin_rollback_model('${actorId}','${configId}',1,'rollback with failed evidence','rollback-v1')`, + /model_connection_test_required/, + ); + sql(`select public.admin_record_model_connection_test('${actorId}','${v1}',200,'rollback-v1')`); + sql(`select public.admin_rollback_model('${actorId}','${configId}',1,'rollback with fresh evidence','rollback-v1')`); + assert.equal(sql(`select version from public.model_config_versions where config_id='${configId}' and status='published'`), "1"); + assert.equal(sql(`select model_config_version from public.chat_sessions where id='${sessionTwoId}'`), "2"); + } finally { + fixture.stop(); + } +}); + +test("migration contains database-enforced secret, evidence, and version-pin contracts", () => { + const migration = readFileSync(migrationPath, "utf8"); + assert.match(migration, /expected_model_provider_secret_ref/); + assert.match(migration, /model_connection_test_evidence/); + assert.match(migration, /model_version_config_hash/); + assert.match(migration, /model_connection_test_is_fresh/); + assert.match(migration, /pin_chat_session_model_config_version/); + assert.match(migration, /model_connection_test_required/); + assert.match(migration, /model_provider_runtime_immutable/); + assert.match(migration, /where p\.id=v_draft\.provider_id[\s\S]*for update/); +}); diff --git a/frontend/tests/rectification-agentic-entry.test.ts b/frontend/tests/rectification-agentic-entry.test.ts index 3c32fd0d..865cbe96 100644 --- a/frontend/tests/rectification-agentic-entry.test.ts +++ b/frontend/tests/rectification-agentic-entry.test.ts @@ -120,10 +120,41 @@ test("candidate results restore only through the authenticated rectification ses assert.match(getRoute, /loadLatestAgenticRectificationResult\(accounting, user\.id, sessionId\)/); }); -test("candidate acceptance is non-billable and happens before consultation credit reservation", () => { +test("candidate acceptance is non-billable and happens before unified usage authorization", () => { const acceptance = route.indexOf('parsed.data.action === "accept_candidate"'); - const reserve = route.indexOf('"begin_consultation_credit"'); - assert.ok(acceptance >= 0 && reserve > acceptance); + const authorize = route.indexOf("authorizeUsage(accounting"); + assert.ok(acceptance >= 0 && authorize > acceptance); + assert.match(route, /featureKey: "rectification"/); + assert.doesNotMatch(route, /begin_consultation_credit|complete_consultation_credit|cancel_consultation_credit/); +}); + +test("Agentic rectification completes or releases unified usage without hiding settlement failures", () => { + assert.match(route, /settlement = await completeUsage\(accounting, userId, billingRequestId, \{/); + assert.match(route, /eventKey: requestId,/); + assert.match(route, /actualModelId: selectedModel\.id/); + assert.match(route, /modelConfigVersion: selectedModel\.configVersion/); + assert.match(route, /inputTokens,/); + assert.match(route, /outputTokens,/); + assert.match(route, /costMicrousd: Math\.round/); + assert.match(route, /durationMs: Date\.now\(\) - usageStartedAt/); + assert.match(route, /settlement = await releaseUsage\(accounting, userId, billingRequestId, "rectification_cancelled"\)/); + assert.match(route, /if \(!settlement\.success\) throw new Error\(settlement\.error_code \?\? "usage_settlement_failed"\)/); + assert.match(route, /if \(!await settle\(true, result\.totalUsage\)\)[\s\S]*type: "error"[\s\S]*return;[\s\S]*send\(\{ type: "done", emitted: true \}\)/); +}); + +test("opening and message retries reuse the caller-owned turn request as their usage event key", () => { + assert.match(route, /const requestId = conversation\.requestId;/); + assert.match(route, /completeUsage\(accounting, userId, billingRequestId, \{[\s\S]*eventKey: requestId,/); + assert.doesNotMatch(route, /eventKey:\s*(?:globalThis\.)?crypto\.randomUUID\(\)/); +}); + +test("Agentic rectification uses one case-level entitlement and the session-pinned model version", () => { + assert.match(route, /select\("id,messages,session_type,model_id,model_config_version"\)/); + assert.match(route, /resolveSessionLanguageModel\(\s*chatSession\.model_id,\s*chatSession\.model_config_version,?\s*\)/); + assert.match(route, /const billingRequestPrefix = `rectification:\$\{sessionId\}`/); + assert.match(route, /requestId: billingRequestId/); + assert.match(route, /modelConfigVersion: selectedModel\.configVersion/); + assert.doesNotMatch(route, /loadLanguageModelCatalog|resolveLanguageModelFromCatalog|\bresolveLanguageModel\(|\bdefaultLanguageModel\(/); }); test("candidate state streams before done and renders reusable multi-column choices", () => { diff --git a/frontend/tests/staging-backend-workflows.test.ts b/frontend/tests/staging-backend-workflows.test.ts index 50dee9a9..6ded1ab5 100644 --- a/frontend/tests/staging-backend-workflows.test.ts +++ b/frontend/tests/staging-backend-workflows.test.ts @@ -739,7 +739,18 @@ test("first immutable deployment rolls back to validated local image IDs", () => test("normal deployment checks migrations but never applies them", () => { const runner = read(deployScript); + const stagingWorkflows = [read(deployWorkflow), read(giteaDeployWorkflow)]; + assert.match(runner, /^#!\/usr\/bin\/env bash\nset -euo pipefail\nset \+x\n/); assert.match(runner, /-f deploy\/docker-compose\.staging\.yml/); + assertOrder(runner, [ + "validate-staging-env.sh", + "validate-staging-database-env.sh", + "compose=(", + '"${compose[@]}" config --quiet', + ]); + for (const workflow of stagingWorkflows) { + assert.doesNotMatch(workflow, /SERVICE_RUNTIME_PASSWORD|SERVICE_DATABASE_URL/); + } assertOrder(runner, [ "pull api web", "up -d --no-build --pull never --wait postgres", @@ -831,8 +842,10 @@ test("public rectification rollout enables the semantic agent and recreates web "AUTH_PROVIDER=self-hosted", "SELF_HOSTED_IDENTITY_ENABLED=true", "AUTH_USER_ORIGIN=https://staging.jyotisha.chat", + "ADMIN_USER_ORIGIN=https://admin.staging.jyotisha.chat", `IDENTITY_DATABASE_URL=postgresql://identity_runtime:${"i".repeat(40)}@postgres:5432/jyotisha`, `APP_DATABASE_URL=postgresql://app_runtime:${"a".repeat(40)}@postgres:5432/jyotisha`, + `SERVICE_DATABASE_URL=postgresql://service_runtime:${"s".repeat(40)}@postgres:5432/jyotisha`, `ADMIN_DATABASE_URL=postgresql://admin_runtime:${"d".repeat(40)}@postgres:5432/jyotisha`, `BETTER_AUTH_USER_SECRET=${"u".repeat(32)}`, "RESEND_API_KEY=re_test_key",