198 lines
6.4 KiB
TypeScript
198 lines
6.4 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 maps model provider configuration failures without exposing details", async () => {
|
|
for (const [error, status, message] of [
|
|
[new Error("模型供应商地址不在服务器允许列表中"), 400, "模型供应商地址不在服务器允许列表中"],
|
|
[new Error("网关地址不允许解析到本机、内网或保留地址"), 400, "网关地址不允许解析到本机、内网或保留地址"],
|
|
[new Error("model_provider_url_unsafe"), 400, "模型供应商地址不安全或解析到内网"],
|
|
[new Error("model_provider_key_required"), 409, "模型供应商密钥未配置"],
|
|
] as const) {
|
|
const response = adminErrorResponse(error);
|
|
assert.equal(response.status, status);
|
|
assert.deepEqual(await response.json(), { error: message });
|
|
}
|
|
|
|
for (const error of [new Error("model_provider_runtime_immutable"), new Error("model_provider_code_immutable")]) {
|
|
const response = adminErrorResponse(error);
|
|
assert.equal(response.status, 500);
|
|
assert.deepEqual(await response.json(), { error: "后台服务暂时不可用" });
|
|
}
|
|
});
|
|
|
|
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: "后台服务暂时不可用" });
|
|
});
|