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.
179 lines
5.3 KiB
TypeScript
179 lines
5.3 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import { adminErrorResponse } from "../src/lib/admin/admin-error-response.ts";
|
|
import {
|
|
AdminAuthorizationError,
|
|
authorizeAdminRequest,
|
|
type AdminAuthorizationDependencies,
|
|
type AdminSession,
|
|
} from "../src/lib/admin/auth-boundary.ts";
|
|
import { authorizeAdminAccess } from "../src/lib/admin/auth-policy.ts";
|
|
import type { IdentityUser } from "../src/modules/identity/contracts.ts";
|
|
import {
|
|
IdentityAuthorizationError,
|
|
type IdentityServerSession,
|
|
} from "../src/modules/identity/session.ts";
|
|
|
|
function user(): IdentityUser {
|
|
return {
|
|
id: "11111111-1111-4111-8111-111111111111",
|
|
email: "admin@example.com",
|
|
emailVerified: true,
|
|
twoFactorEnabled: false,
|
|
name: "Admin",
|
|
image: null,
|
|
role: ["user"],
|
|
};
|
|
}
|
|
|
|
function identitySession(): IdentityServerSession {
|
|
return {
|
|
user: user(),
|
|
sessionId: "session-id",
|
|
sessionToken: "session-token",
|
|
expiresAt: new Date("2026-08-08T00:00:00.000Z"),
|
|
};
|
|
}
|
|
|
|
function adminSession(): AdminSession {
|
|
const identity = identitySession();
|
|
return {
|
|
user: identity.user,
|
|
roles: ["owner"],
|
|
permissions: ["admin.access"],
|
|
requiresMfa: false,
|
|
identitySession: {
|
|
id: identity.sessionId,
|
|
token: identity.sessionToken,
|
|
expiresAt: identity.expiresAt,
|
|
},
|
|
};
|
|
}
|
|
|
|
type TestConfig = { adminHost: string };
|
|
|
|
function dependencies(
|
|
overrides: Partial<AdminAuthorizationDependencies<TestConfig>> = {},
|
|
): AdminAuthorizationDependencies<TestConfig> {
|
|
return {
|
|
readAuthProvider: () => "self-hosted",
|
|
readRequestHeaders: () => new Headers({ host: "admin.example.com" }),
|
|
readIdentityConfig: () => ({ adminHost: "admin.example.com" }),
|
|
resolveIdentitySurface: (host, config) =>
|
|
host === config.adminHost ? "admin" : null,
|
|
requireIdentitySession: async () => identitySession(),
|
|
loadAdminSession: async () => adminSession(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
async function rejectedAuthorization(
|
|
overrides: Partial<AdminAuthorizationDependencies<TestConfig>>,
|
|
): Promise<AdminAuthorizationError> {
|
|
try {
|
|
await authorizeAdminRequest("admin.access", undefined, dependencies(overrides));
|
|
} catch (error) {
|
|
assert.ok(error instanceof AdminAuthorizationError);
|
|
return error;
|
|
}
|
|
assert.fail("authorization unexpectedly succeeded");
|
|
}
|
|
|
|
test("anonymous admin access is 401", () => {
|
|
assert.deepEqual(authorizeAdminAccess(null, [], "admin.access"), {
|
|
allowed: false,
|
|
status: 401,
|
|
});
|
|
});
|
|
|
|
test("missing permission is 403", () => {
|
|
assert.deepEqual(authorizeAdminAccess(user(), [], "admin.access"), {
|
|
allowed: false,
|
|
status: 403,
|
|
});
|
|
assert.deepEqual(authorizeAdminAccess(user(), ["admin.access"], "models.publish"), {
|
|
allowed: false,
|
|
status: 403,
|
|
});
|
|
});
|
|
|
|
test("database permission keys authorize only the requested operation", () => {
|
|
assert.deepEqual(authorizeAdminAccess(user(), ["admin.access"], "admin.access"), {
|
|
allowed: true,
|
|
});
|
|
assert.deepEqual(authorizeAdminAccess(user(), ["models.read", "models.publish"], "models.publish"), {
|
|
allowed: true,
|
|
});
|
|
});
|
|
|
|
test("admin authorization checks the configured admin Host before session lookup", async () => {
|
|
let sessionRead = false;
|
|
const error = await rejectedAuthorization({
|
|
readRequestHeaders: () => new Headers({ host: "user.example.com" }),
|
|
requireIdentitySession: async () => {
|
|
sessionRead = true;
|
|
return identitySession();
|
|
},
|
|
});
|
|
|
|
assert.equal(error.status, 403);
|
|
assert.equal(error.message, "无权访问后台");
|
|
assert.equal(sessionRead, false);
|
|
});
|
|
|
|
test("known admin and identity authorization errors preserve 401/403 semantics", async () => {
|
|
const original = new AdminAuthorizationError("请求来源不可信", 403);
|
|
const preserved = await rejectedAuthorization({
|
|
readIdentityConfig: () => {
|
|
throw original;
|
|
},
|
|
});
|
|
assert.equal(preserved, original);
|
|
|
|
for (const [status, message] of [[401, "请先登录"], [403, "无权访问后台"]] as const) {
|
|
const mapped = await rejectedAuthorization({
|
|
requireIdentitySession: async () => {
|
|
throw new IdentityAuthorizationError("provider detail", status);
|
|
},
|
|
});
|
|
assert.equal(mapped.status, status);
|
|
assert.equal(mapped.message, message);
|
|
}
|
|
});
|
|
|
|
test("configuration, identity reader, and RBAC query failures become sanitized 503", async () => {
|
|
const failures: Array<Partial<AdminAuthorizationDependencies<TestConfig>>> = [
|
|
{
|
|
readIdentityConfig: () => {
|
|
throw new Error("BETTER_AUTH_USER_SECRET leaked detail");
|
|
},
|
|
},
|
|
{
|
|
requireIdentitySession: async () => {
|
|
throw new Error("better-auth or identity database leaked detail");
|
|
},
|
|
},
|
|
{
|
|
loadAdminSession: async () => {
|
|
throw new Error("postgres admin_permission_keys leaked detail");
|
|
},
|
|
},
|
|
];
|
|
|
|
for (const failure of failures) {
|
|
const error = await rejectedAuthorization(failure);
|
|
assert.equal(error.status, 503);
|
|
assert.equal(error.message, "后台服务暂时不可用");
|
|
}
|
|
});
|
|
|
|
test("adminErrorResponse preserves the sanitized authorization 503", async () => {
|
|
const response = adminErrorResponse(
|
|
new AdminAuthorizationError("后台服务暂时不可用", 503),
|
|
);
|
|
|
|
assert.equal(response.status, 503);
|
|
assert.deepEqual(await response.json(), { error: "后台服务暂时不可用" });
|
|
});
|