feat: add shared backend authorization seams
This commit is contained in:
1 parent
d0207fcebe
commit
48dd5d07c8
9 files changed
+1086
No files matched your search
@@ -0,0 +1,82 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { hasAdminSessionAccess, hasSuperAdminAccess } from "@/lib/auth/permissions";
|
||||
import type { AuthSession } from "@/lib/auth/session";
|
||||
|
||||
type FixtureCase = {
|
||||
name: string;
|
||||
state: { required: boolean; configured: boolean };
|
||||
requirement: "app" | "admin" | "super_admin";
|
||||
resolverOutcome: "not_called" | "unauthenticated" | "authenticated" | "infrastructure_error";
|
||||
session?: { authMode: "user" | "admin"; role: "user" | "organization_admin" | "super_admin" };
|
||||
expected: { outcome: string; status: number; role?: string; authMode?: string };
|
||||
};
|
||||
|
||||
type Fixture = { version: 1; cookieName: string; cases: FixtureCase[] };
|
||||
|
||||
const fixtureUrl = new URL("../contracts/auth/platform-http-auth-v1.json", import.meta.url);
|
||||
|
||||
describe("platform HTTP authorization v1 contract", () => {
|
||||
it("freezes the current configuration fallback and error categories", async () => {
|
||||
const fixture = JSON.parse(await readFile(fixtureUrl, "utf8")) as Fixture;
|
||||
expect(fixture.version).toBe(1);
|
||||
expect(fixture.cookieName).toBe("zhinian_session");
|
||||
|
||||
for (const contractCase of fixture.cases) {
|
||||
expect(evaluateConfiguration(contractCase), contractCase.name).toEqual(contractCase.expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the current role and auth-mode permission helpers", async () => {
|
||||
const fixture = JSON.parse(await readFile(fixtureUrl, "utf8")) as Fixture;
|
||||
for (const contractCase of fixture.cases.filter((item) => item.session)) {
|
||||
const session = fixtureSession(contractCase.session!);
|
||||
const allowed = contractCase.requirement === "app"
|
||||
? true
|
||||
: contractCase.requirement === "admin"
|
||||
? hasAdminSessionAccess(session)
|
||||
: hasSuperAdminAccess(session.user);
|
||||
expect(allowed, contractCase.name).toBe(contractCase.expected.outcome === "authenticated");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function evaluateConfiguration(contractCase: FixtureCase): FixtureCase["expected"] {
|
||||
if (contractCase.resolverOutcome === "infrastructure_error") {
|
||||
return { outcome: "infrastructure_error", status: 500 };
|
||||
}
|
||||
if (contractCase.resolverOutcome === "authenticated") {
|
||||
const session = fixtureSession(contractCase.session!);
|
||||
const allowed = contractCase.requirement === "app"
|
||||
|| (contractCase.requirement === "admin" && hasAdminSessionAccess(session))
|
||||
|| (contractCase.requirement === "super_admin" && hasSuperAdminAccess(session.user));
|
||||
return allowed
|
||||
? { outcome: "authenticated", status: 200, role: session.user.role, authMode: session.authMode }
|
||||
: { outcome: "forbidden", status: 403 };
|
||||
}
|
||||
if (!contractCase.state.required) {
|
||||
return { outcome: "authenticated", status: 200, role: "super_admin", authMode: "admin" };
|
||||
}
|
||||
if (!contractCase.state.configured) return { outcome: "configuration_error", status: 503 };
|
||||
return { outcome: "unauthenticated", status: 401 };
|
||||
}
|
||||
|
||||
function fixtureSession(input: NonNullable<FixtureCase["session"]>): AuthSession {
|
||||
return {
|
||||
version: 1,
|
||||
authMode: input.authMode,
|
||||
issuedAt: 100,
|
||||
expiresAt: 200,
|
||||
user: {
|
||||
id: "fixture-user",
|
||||
subject: "fixture-user",
|
||||
displayName: "Fixture User",
|
||||
clientId: "platform",
|
||||
role: input.role,
|
||||
authorities: [],
|
||||
scope: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
PublicApiAuthError,
|
||||
assertInternalWorkerToken,
|
||||
authenticatePublicApiRequest,
|
||||
getPublicApiClients,
|
||||
publicApiOwnerId,
|
||||
type PublicApiClient
|
||||
} from "@/lib/server/public-api-auth";
|
||||
|
||||
type AuthError = { status: number; message: string };
|
||||
type HeaderMap = Record<string, string>;
|
||||
type PublicApiAuthFixture = {
|
||||
version: 1;
|
||||
apiKeys: string;
|
||||
clients: PublicApiClient[];
|
||||
authenticationCases: Array<{
|
||||
name: string;
|
||||
headers: HeaderMap;
|
||||
expected?: { client: PublicApiClient; owner: string };
|
||||
error?: AuthError;
|
||||
}>;
|
||||
ownerCases: Array<{ id: string; owner: string; maxPartLength?: number }>;
|
||||
workerCases: Array<{
|
||||
name: string;
|
||||
production: boolean;
|
||||
configuredToken: string;
|
||||
headers: HeaderMap;
|
||||
allowed?: boolean;
|
||||
error?: AuthError;
|
||||
}>;
|
||||
};
|
||||
|
||||
const fixtureUrl = new URL("../contracts/auth/public-api-auth-v1.json", import.meta.url);
|
||||
const environmentKeys = ["ZHINIAN_API_KEYS", "ZHINIAN_INTERNAL_WORKER_TOKEN", "NODE_ENV"] as const;
|
||||
const originalEnvironment = new Map(environmentKeys.map((key) => [key, process.env[key]]));
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of environmentKeys) {
|
||||
const value = originalEnvironment.get(key);
|
||||
if (value === undefined) Reflect.deleteProperty(process.env, key);
|
||||
else Reflect.set(process.env, key, value);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadFixture(): Promise<PublicApiAuthFixture> {
|
||||
return JSON.parse(await readFile(fixtureUrl, "utf8")) as PublicApiAuthFixture;
|
||||
}
|
||||
|
||||
function request(headers: HeaderMap): Request {
|
||||
return new Request("https://api.example.test/api/v1/generations", { headers });
|
||||
}
|
||||
|
||||
function expectTypedError(action: () => unknown, expected: AuthError) {
|
||||
try {
|
||||
action();
|
||||
throw new Error("expected PublicApiAuthError");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(PublicApiAuthError);
|
||||
expect(error).toMatchObject(expected);
|
||||
}
|
||||
}
|
||||
|
||||
describe("public API authentication v1 cross-language contract", () => {
|
||||
it("freezes comma/newline parsing, trimming, default ids, and empty filtering", async () => {
|
||||
const fixture = await loadFixture();
|
||||
process.env.ZHINIAN_API_KEYS = fixture.apiKeys;
|
||||
|
||||
expect(fixture.version).toBe(1);
|
||||
expect(getPublicApiClients()).toEqual(fixture.clients);
|
||||
});
|
||||
|
||||
it("freezes public credential transport, precedence, errors, client, and owner", async () => {
|
||||
const fixture = await loadFixture();
|
||||
process.env.ZHINIAN_API_KEYS = fixture.apiKeys;
|
||||
|
||||
for (const testCase of fixture.authenticationCases) {
|
||||
if (testCase.error) {
|
||||
expectTypedError(() => authenticatePublicApiRequest(request(testCase.headers)), testCase.error);
|
||||
continue;
|
||||
}
|
||||
const client = authenticatePublicApiRequest(request(testCase.headers));
|
||||
expect(client, testCase.name).toEqual(testCase.expected?.client);
|
||||
expect(publicApiOwnerId(client), testCase.name).toBe(testCase.expected?.owner);
|
||||
}
|
||||
});
|
||||
|
||||
it("sanitizes and bounds public owner ids", async () => {
|
||||
const fixture = await loadFixture();
|
||||
for (const testCase of fixture.ownerCases) {
|
||||
const owner = publicApiOwnerId(testCase.id);
|
||||
expect(owner, testCase.id).toBe(testCase.owner);
|
||||
if (testCase.maxPartLength) expect(owner.slice(4)).toHaveLength(testCase.maxPartLength);
|
||||
}
|
||||
});
|
||||
|
||||
it("freezes internal worker development bypass and production failure semantics", async () => {
|
||||
const fixture = await loadFixture();
|
||||
for (const testCase of fixture.workerCases) {
|
||||
Reflect.set(process.env, "NODE_ENV", testCase.production ? "production" : "development");
|
||||
Reflect.set(process.env, "ZHINIAN_INTERNAL_WORKER_TOKEN", testCase.configuredToken);
|
||||
if (testCase.error) {
|
||||
expectTypedError(() => assertInternalWorkerToken(request(testCase.headers)), testCase.error);
|
||||
} else {
|
||||
expect(() => assertInternalWorkerToken(request(testCase.headers)), testCase.name).not.toThrow();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user