Preserve 403/503 responses instead of redirecting them through the Caddy root, and recover Owner only for the sole loginable synced identity admin.
243 lines
14 KiB
TypeScript
243 lines
14 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
|
|
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 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(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 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(auth, /authorizeAdminAccess\([\s\S]*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: "后台服务暂时不可用",
|
|
});
|
|
assert.match(adminLayout, /failure\.kind === "login"[\s\S]*redirect\(failure\.location\)/);
|
|
assert.match(adminLayout, /failure\.kind === "forbidden"[\s\S]*forbidden\(\)/);
|
|
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("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,
|
|
/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, /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/);
|
|
});
|