66a6c96a5c
The recovery migration crossed the identity and RBAC ledgers without guarding schema prerequisites, while unknown configuration, provider, and database failures escaped the admin authorization boundary as 500s. Keep recovery in the DB ledger with explicit prerequisite no-ops, and sanitize unknown authorization failures to the existing 503 path.
287 lines
16 KiB
TypeScript
287 lines
16 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
|
|
import { GET as getAdminUnavailable } from "../src/app/admin-unavailable/route.ts";
|
|
import { resolveAdminPageAccessFailure } from "../src/lib/admin/page-access.ts";
|
|
|
|
const migration = readFileSync(
|
|
new URL("../supabase/migrations/20260805010000_reconcile_admin_redemption_audit.sql", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const auth = readFileSync(new URL("../src/lib/admin/auth.ts", import.meta.url), "utf8");
|
|
const authBoundary = readFileSync(new URL("../src/lib/admin/auth-boundary.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 ownerRecoveryMigration = readFileSync(new URL("../db/migrations/20260807010000_recover_initial_admin_owner.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");
|
|
const providers = readFileSync(new URL("../src/lib/admin/providers.ts", import.meta.url), "utf8");
|
|
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 forbiddenPage = readFileSync(new URL("../src/app/forbidden.tsx", import.meta.url), "utf8");
|
|
const nextConfig = readFileSync(new URL("../next.config.ts", import.meta.url), "utf8");
|
|
const stagingCaddy = readFileSync(new URL("../../deploy/Caddyfile.staging", import.meta.url), "utf8");
|
|
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, /requireIdentityServerSession/);
|
|
assert.match(auth, /getIdentityAuthServices\(\)\.user\.api/);
|
|
assert.match(authPolicy, /permissions\.includes\(required\)/);
|
|
assert.match(auth, /admin_permission_keys\(\$1\)/);
|
|
assert.doesNotMatch(auth, /ADMIN_EMAILS|isAdminEmail/);
|
|
assert.match(authBoundary, /readAuthProvider\(\)\?\.trim\(\) !== "self-hosted"/);
|
|
assert.match(authBoundary, /后台服务暂时不可用", 503/);
|
|
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 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, /admin_has_permission\(\$1, 'admin\.access'\)/);
|
|
assert.doesNotMatch(selfHostedBranch, /role === "admin"|viewer|isAdminEmail|ADMIN_EMAILS/);
|
|
assert.match(authBoundary, /authorizeAdminAccess\([\s\S]*identitySession\.user,[\s\S]*session\.permissions,[\s\S]*permission/);
|
|
});
|
|
|
|
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/);
|
|
});
|
|
|
|
test("admin sider replaces logout with a collapsed-aware return-to-chat link", () => {
|
|
assert.match(adminApp, /ThemedLayout Sider=\{AdminSider\}/);
|
|
assert.match(adminApp, /ThemedSider/);
|
|
assert.match(adminApp, /render=\{\(\{ items, collapsed \}\)/);
|
|
assert.match(adminApp, /\{items\}/);
|
|
assert.match(adminApp, /ArrowLeftOutlined/);
|
|
assert.match(adminApp, /<Link href="\/" aria-label="返回对话">\{collapsed \? null : "返回对话"\}<\/Link>/);
|
|
assert.doesNotMatch(adminApp, /logout\s*[,(}]|authProvider\.logout/);
|
|
});
|
|
|
|
test("admin pages and root route are server-gated before rendering or redirecting", () => {
|
|
assert.match(adminLayout, /await requireAdminSession\("read"\)/);
|
|
assert.match(adminLayout, /redirect\(/);
|
|
assert.match(adminRootRoute, /await requireAdminSession\("read"\)/);
|
|
assert.match(adminRootRoute, /headers: \{ location: "\/admin\/codes" \}/);
|
|
});
|
|
|
|
test("admin authorization denial cannot enter the staging Caddy root redirect loop", () => {
|
|
assert.match(stagingCaddy, /@root path \/\n\s+redir @root \/admin 308/);
|
|
assert.deepEqual(resolveAdminPageAccessFailure(401), { kind: "login", location: "/login" });
|
|
assert.deepEqual(resolveAdminPageAccessFailure(403), {
|
|
kind: "forbidden",
|
|
status: 403,
|
|
message: "无权访问后台",
|
|
});
|
|
assert.deepEqual(resolveAdminPageAccessFailure(503), {
|
|
kind: "unavailable",
|
|
status: 503,
|
|
message: "后台服务暂时不可用",
|
|
location: "/admin-unavailable",
|
|
});
|
|
assert.match(adminLayout, /failure\.kind === "login"[\s\S]*redirect\(failure\.location\)/);
|
|
assert.match(adminLayout, /failure\.kind === "forbidden"[\s\S]*forbidden\(\)/);
|
|
assert.match(adminLayout, /failure\.kind === "unavailable"[\s\S]*redirect\(failure\.location\)/);
|
|
assert.match(adminLayout, /throw error/);
|
|
assert.match(adminRootRoute, /status: failure\.status/);
|
|
assert.match(adminRootRoute, /"cache-control": "no-store"/);
|
|
assert.doesNotMatch(adminLayout, /redirect\([^)]*"\/"/);
|
|
assert.doesNotMatch(adminRootRoute, /location:[^\n]*"\/"/);
|
|
assert.match(nextConfig, /authInterrupts: true/);
|
|
assert.match(forbiddenPage, /403/);
|
|
assert.match(forbiddenPage, /没有后台访问权限/);
|
|
});
|
|
|
|
test("admin unavailable route terminates layout redirects with a no-store 503", async () => {
|
|
const response = getAdminUnavailable();
|
|
|
|
assert.equal(response.status, 503);
|
|
assert.equal(response.headers.get("cache-control"), "no-store");
|
|
assert.equal(response.headers.get("location"), null);
|
|
assert.equal(response.headers.get("content-type"), "text/plain; charset=utf-8");
|
|
assert.equal(await response.text(), "后台服务暂时不可用");
|
|
});
|
|
|
|
test("initial Owner recovery safely no-ops until every RBAC prerequisite exists", () => {
|
|
for (const prerequisite of [
|
|
"public.admin_users",
|
|
"public.admin_roles",
|
|
"public.admin_user_roles",
|
|
"identity.users",
|
|
"auth.users",
|
|
]) {
|
|
const escaped = prerequisite.replaceAll(".", "\\.");
|
|
assert.match(ownerRecoveryMigration, new RegExp(`to_regclass\\('${escaped}'\\) is null`));
|
|
}
|
|
assert.match(ownerRecoveryMigration, /to_regprocedure\('public\.admin_permission_keys\(uuid\)'\) is null/);
|
|
assert.match(ownerRecoveryMigration, /to_regprocedure\('public\.assert_active_admin_owner_exists\(\)'\) is null/);
|
|
assert.match(ownerRecoveryMigration, /then[\s\S]*return;[\s\S]*end if;[\s\S]*pg_advisory_xact_lock/);
|
|
});
|
|
|
|
test("initial Owner recovery is single-candidate, fail-closed, and independent of ADMIN_EMAILS", () => {
|
|
assert.match(ownerRecoveryMigration, /v_active_owner_count > 0[\s\S]*return/);
|
|
assert.match(
|
|
ownerRecoveryMigration,
|
|
/not u\.banned or \(u\.ban_expires is not null and u\.ban_expires <= clock_timestamp\(\)\)/,
|
|
);
|
|
assert.match(ownerRecoveryMigration, /lock table identity\.users, auth\.users in share mode/);
|
|
assert.match(
|
|
ownerRecoveryMigration,
|
|
/not exists \(select 1 from identity\.users\)[\s\S]*not exists \(select 1 from auth\.users\)/,
|
|
);
|
|
assert.match(ownerRecoveryMigration, /join auth\.users a on a\.id = u\.id/);
|
|
assert.match(ownerRecoveryMigration, /left join public\.admin_users existing_admin/);
|
|
assert.match(
|
|
ownerRecoveryMigration,
|
|
/existing_admin\.user_id is null or existing_admin\.revoked_at is null/,
|
|
);
|
|
assert.match(
|
|
ownerRecoveryMigration,
|
|
/unnest\(string_to_array\(u\.role, ','\)\)[\s\S]*btrim\(role_part\.value\) = 'admin'/,
|
|
);
|
|
assert.match(ownerRecoveryMigration, /v_candidate_count <> 1/);
|
|
assert.match(ownerRecoveryMigration, /admin_owner_recovery_requires_exactly_one_active_identity_admin/);
|
|
assert.match(ownerRecoveryMigration, /insert into public\.admin_users/);
|
|
assert.match(
|
|
ownerRecoveryMigration,
|
|
/on conflict on constraint admin_users_pkey do nothing/,
|
|
);
|
|
assert.doesNotMatch(
|
|
ownerRecoveryMigration,
|
|
/on conflict on constraint admin_users_pkey do update[\s\S]*revoked_at\s*=\s*null/,
|
|
);
|
|
assert.match(ownerRecoveryMigration, /insert into public\.admin_user_roles/);
|
|
assert.doesNotMatch(ownerRecoveryMigration, /ADMIN_EMAILS|email\s*=|ilike|lower\(.*email/);
|
|
});
|
|
|
|
test("readonly resources cannot be mutated through Refine access control", () => {
|
|
for (const resource of ["customers", "credit-transactions", "consultations", "audit-logs"]) {
|
|
assert.match(providers, new RegExp(resource.includes("-") ? `"${resource}"` : `${resource}:`));
|
|
}
|
|
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/);
|
|
assert.match(route, /export const DELETE = readonlyAdminMutation/);
|
|
}
|
|
});
|
|
|
|
test("redemption code writes are atomic with append-only redacted audit", () => {
|
|
assert.match(migration, /create table if not exists audit\.admin_audit_logs/);
|
|
assert.match(migration, /admin_audit_logs_append_only/);
|
|
assert.match(migration, /redemption_code\.create/);
|
|
assert.match(migration, /redemption_code\.update/);
|
|
assert.match(migration, /redemption_code\.revoke/);
|
|
assert.match(migration, /before_value is null or not \(before_value \?\| array\['code', 'code_hash', 'token', 'secret', 'key'\]\)/);
|
|
assert.match(migration, /insert into audit\.admin_audit_logs/);
|
|
assert.match(migration, /redeemed codes are immutable/);
|
|
assert.match(migration, /revoked codes are immutable/);
|
|
assert.match(migration, /v_code\.revoked_at is not null/);
|
|
assert.match(migration, /'revoked_code'/);
|
|
assert.match(migration, /set local role service_role|profiles_admin_read/);
|
|
assert.match(migration, /p_codes is null or jsonb_typeof\(p_codes\) is distinct from 'array'/);
|
|
assert.match(migration, /admin_verified_actor_email/);
|
|
});
|
|
|
|
test("plaintext code is returned only by create and never enters audit snapshots", () => {
|
|
assert.match(codesRoute, /plainCodes\.map/);
|
|
assert.match(codesRoute, /code,/);
|
|
assert.doesNotMatch(codeRoute, /codeHash|code_hash|plainCodes/);
|
|
const snapshot = migration.match(/create or replace function public\.admin_redemption_code_snapshot[\s\S]*?revoke all on function/);
|
|
assert.ok(snapshot);
|
|
assert.doesNotMatch(snapshot[0], /code_hash|'code'/);
|
|
assert.match(snapshot[0], /'mask'/);
|
|
});
|
|
|
|
test("Refine dependencies and same-origin admin data provider are present", () => {
|
|
for (const dependency of ["@refinedev/core", "@refinedev/antd", "@refinedev/nextjs-router", "antd"]) {
|
|
assert.ok(packageJson.dependencies[dependency], `${dependency} missing`);
|
|
}
|
|
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/);
|
|
});
|