Files
NianAIGC/tests/auth-current-session-contract.test.ts

243 lines
8.2 KiB
TypeScript

import { readFile } from "node:fs/promises";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createRequire } from "node:module";
import { getAuthRuntimeConfig } from "@/lib/auth/config";
import type { AuthSession } from "@/lib/auth/session";
const { getOptionalAuthSession } = vi.hoisted(() => ({
getOptionalAuthSession: vi.fn<() => Promise<AuthSession | null>>()
}));
vi.mock("@/lib/server/auth/current-user", () => ({ getOptionalAuthSession }));
import * as currentSessionRoute from "@/app/api/auth/me/route";
type ExpectedConfig = {
required: boolean;
configured: boolean;
sessionSecret: string | null;
};
type CurrentSessionFixture = {
version: 1;
path: string;
methods: {
GET: MethodContract;
HEAD: MethodContract & { executesGet: true };
OPTIONS: MethodContract;
unsupported: MethodContract & { methods: string[] };
};
authConfigurationCases: Array<{
name: string;
environment: Record<string, string>;
expected: ExpectedConfig;
}>;
responses: {
anonymous: CurrentSessionResponse;
authenticatedUser: CurrentSessionResponse;
unboundSuperAdministrator: CurrentSessionResponse;
};
forbiddenSessionKeys: string[];
infrastructureError: {
directGet: "rejects";
transportStatus: number;
mustNotReturnAnonymous: boolean;
};
};
type MethodContract = {
status: number;
body: "json" | "empty";
contentType: string | null;
allow: string | null;
};
type CurrentSessionResponse = {
authenticated: boolean;
authRequired: boolean;
authConfigured: boolean;
authMode: "user" | "admin" | null;
user: Record<string, unknown> | null;
};
const fixtureUrl = new URL("../contracts/auth/current-session-v1.json", import.meta.url);
const require = createRequire(import.meta.url);
const authEnvironmentKeys = [
"NODE_ENV",
"ZHINIAN_AUTH_REQUIRED",
"ZHINIAN_AUTH_DISABLED",
"ZHINIAN_AUTH_SESSION_SECRET",
"AUTH_SESSION_SECRET",
"NEXTAUTH_SECRET"
] as const;
const originalEnvironment = new Map(authEnvironmentKeys.map((key) => [key, process.env[key]]));
afterEach(() => {
getOptionalAuthSession.mockReset();
restoreAuthEnvironment();
});
async function loadFixture(): Promise<CurrentSessionFixture> {
return JSON.parse(await readFile(fixtureUrl, "utf8")) as CurrentSessionFixture;
}
function restoreAuthEnvironment() {
for (const key of authEnvironmentKeys) {
const original = originalEnvironment.get(key);
if (original === undefined) Reflect.deleteProperty(process.env, key);
else Reflect.set(process.env, key, original);
}
}
function configureAuthEnvironment(environment: Record<string, string>) {
for (const key of authEnvironmentKeys) Reflect.deleteProperty(process.env, key);
for (const [key, value] of Object.entries(environment)) Reflect.set(process.env, key, value);
}
function sessionFor(response: CurrentSessionResponse): AuthSession {
if (!response.user || !response.authMode) throw new Error("authenticated fixture response required");
return {
version: 1,
authMode: response.authMode,
issuedAt: 100,
expiresAt: 200,
sessionVersion: 7,
accessToken: "must-not-leak",
tokenType: "bearer",
user: response.user as AuthSession["user"]
};
}
async function expectJsonResponse(expected: CurrentSessionResponse) {
const fixture = await loadFixture();
const response = await currentSessionRoute.GET();
expect(response.status).toBe(fixture.methods.GET.status);
expect(response.headers.get("content-type")).toBe(fixture.methods.GET.contentType);
expect(response.headers.get("allow")).toBe(fixture.methods.GET.allow);
expect(await response.json()).toEqual(expected);
}
describe("current session HTTP v1 cross-language contract", () => {
it("freezes the path and Next.js automatic method semantics", async () => {
const fixture = await loadFixture();
expect(fixture.version).toBe(1);
expect(fixture.path).toBe("/api/auth/me");
expect(Object.keys(currentSessionRoute).sort()).toEqual(["GET", "runtime"]);
expect(currentSessionRoute.runtime).toBe("nodejs");
expect(fixture.methods).toEqual({
GET: { status: 200, body: "json", contentType: "application/json", allow: null },
HEAD: {
status: 200,
body: "empty",
contentType: "application/json",
allow: null,
executesGet: true
},
OPTIONS: { status: 204, body: "empty", contentType: null, allow: "GET, HEAD, OPTIONS" },
unsupported: {
methods: ["POST", "PUT", "PATCH", "DELETE"],
status: 405,
body: "empty",
contentType: null,
allow: null
}
});
const { autoImplementMethods } = require(
"next/dist/server/route-modules/app-route/helpers/auto-implement-methods.js"
) as {
autoImplementMethods: (handlers: Record<string, unknown>) => Record<string, () => Promise<Response>>;
};
const handlers = autoImplementMethods({ GET: currentSessionRoute.GET });
getOptionalAuthSession.mockResolvedValue(null);
configureAuthEnvironment({ NODE_ENV: "development" });
for (const method of ["HEAD", "OPTIONS", ...fixture.methods.unsupported.methods]) {
const response = await handlers[method]();
const expected = method === "HEAD"
? fixture.methods.HEAD
: method === "OPTIONS"
? fixture.methods.OPTIONS
: fixture.methods.unsupported;
expect(response.status, method).toBe(expected.status);
expect(response.headers.get("content-type"), method).toBe(expected.contentType);
expect(response.headers.get("allow"), method).toBe(expected.allow);
if (method !== "HEAD") expect(await response.text(), method).toBe("");
}
});
it("consumes every auth configuration case through the real runtime resolver", async () => {
const fixture = await loadFixture();
expect(fixture.authConfigurationCases.length).toBeGreaterThanOrEqual(4);
for (const testCase of fixture.authConfigurationCases) {
configureAuthEnvironment(testCase.environment);
const config = getAuthRuntimeConfig();
expect(
{
required: config.required,
configured: config.configured,
sessionSecret: config.sessionSecret ?? null
},
testCase.name
).toEqual(testCase.expected);
}
});
it("returns the exact five-key anonymous response", async () => {
const fixture = await loadFixture();
configureAuthEnvironment({ NODE_ENV: "development" });
getOptionalAuthSession.mockResolvedValue(null);
expect(Object.keys(fixture.responses.anonymous).sort()).toEqual(
["authenticated", "authRequired", "authConfigured", "authMode", "user"].sort()
);
await expectJsonResponse(fixture.responses.anonymous);
});
it.each(["authenticatedUser", "unboundSuperAdministrator"] as const)(
"returns the exact public projection for %s and omits optional fields",
async (caseName) => {
const fixture = await loadFixture();
const expected = fixture.responses[caseName];
configureAuthEnvironment({
NODE_ENV: "production",
ZHINIAN_AUTH_SESSION_SECRET: "route-test-secret"
});
getOptionalAuthSession.mockResolvedValue(sessionFor(expected));
await expectJsonResponse(expected);
const serialized = JSON.stringify(expected);
for (const key of fixture.forbiddenSessionKeys) {
expect(serialized, `${caseName} leaked ${key}`).not.toContain(`"${key}"`);
}
if (caseName === "unboundSuperAdministrator") {
expect(expected.user).not.toHaveProperty("tenantId");
expect(expected.user).not.toHaveProperty("organizationId");
expect(expected.user).not.toHaveProperty("organizationName");
}
}
);
it("propagates infrastructure errors for transport mapping instead of returning anonymous", async () => {
const fixture = await loadFixture();
const failure = new Error("authorization snapshot unavailable");
configureAuthEnvironment({
NODE_ENV: "production",
ZHINIAN_AUTH_SESSION_SECRET: "route-test-secret"
});
getOptionalAuthSession.mockRejectedValue(failure);
expect(fixture.infrastructureError).toEqual({
directGet: "rejects",
transportStatus: 500,
mustNotReturnAnonymous: true
});
await expect(currentSessionRoute.GET()).rejects.toBe(failure);
});
});