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>() })); 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; 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 | 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 { 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) { 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) => Record Promise>; }; 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); }); });