298 lines
17 KiB
TypeScript
298 lines
17 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 adminUsersReadPolicyMigration = readFileSync(
|
|
new URL("../supabase/migrations/20260810010000_admin_users_admin_runtime_read_policy.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 shell uses compact operational controls without the return-to-chat sider override", () => {
|
|
assert.match(adminApp, /componentSize="middle"/);
|
|
assert.match(adminApp, /algorithm: theme\.defaultAlgorithm/);
|
|
assert.doesNotMatch(adminApp, /theme\.darkAlgorithm/);
|
|
assert.match(adminApp, /<ThemedLayout>\{children\}<\/ThemedLayout>/);
|
|
assert.doesNotMatch(adminApp, /AdminSider|ThemedSider|ArrowLeftOutlined|返回对话|next\/link|Menu\.Item/);
|
|
});
|
|
|
|
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 scoped email OTP reauthentication without mandatory MFA", () => {
|
|
assert.match(adminHttp, /isTrustedAdminMutationRequest\(request, process\.env\.ADMIN_USER_ORIGIN\)/);
|
|
assert.match(administratorsRoute, /requireHighRiskAdminMutation\(request, "admin\.users\.manage_roles"\)/);
|
|
assert.match(adminHttp, /requireAdminMutation\(request, permission\)[\s\S]*verifyHighRiskAdminProof/);
|
|
assert.doesNotMatch(adminHttp, /requireAdminMfaIfRequired/);
|
|
assert.match(reauthRoute, /requireAdminMutation\(request, parsed\.data\.permission\)[\s\S]*sendVerificationOTP/);
|
|
assert.doesNotMatch(reauthRoute, /requireAdminMfaIfRequired/);
|
|
assert.match(reauthRoute, /sendVerificationOTP/);
|
|
assert.match(reauthRoute, /verifyEmailOTP/);
|
|
assert.match(reauthRoute, /httpOnly: true/);
|
|
assert.match(reasonActionModal, /action: "request"/);
|
|
assert.match(reasonActionModal, /action: "verify"/);
|
|
assert.match(reasonActionModal, /邮箱验证码/);
|
|
assert.doesNotMatch(reasonActionModal, /\/api\/admin\/mfa|\/admin\/security|MfaStatus|mfaFactor|mfaReady/);
|
|
|
|
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.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("admin runtime can read administrators through RLS", () => {
|
|
assert.match(adminUsersReadPolicyMigration, /admin_users_admin_read/);
|
|
assert.match(adminUsersReadPolicyMigration, /for select to admin_runtime using \(true\)/);
|
|
});
|
|
|
|
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/);
|
|
});
|