import { readFile } from "node:fs/promises"; import { afterEach, describe, expect, it } from "vitest"; import { SESSION_COOKIE_NAME, shouldUseSecureAuthCookie } from "@/lib/auth/config"; import { chunkCookieValue, chunkedCookieNames, createSignedJsonValue, parseSessionCookieValue, } from "@/lib/auth/session"; import { clearSessionCookieValues, setSessionCookieValue, } from "@/lib/server/auth/session-cookie"; type CookieContract = { name: string; chunkSize: number; maxChunks: number; maxValueLength: number; chunkNames: string[]; writeExample: { valueCharacter: string; valueLength: number; chunkLengths: number[]; expiresAtUnix: number; }; attributes: { httpOnly: boolean; sameSite: "lax"; path: string; productionSecure: boolean; }; clear: { value: string; maxAgeSeconds: number; }; secureResolutionCases: Array<{ name: string; explicit: string; publicBaseUrl: string; requestUrl: string; expected: boolean; }>; }; type SessionCookieFixture = { version: 1; cookie: CookieContract; secret: string; rawJson: string; payload: string; signature: string; cookieValue: string; }; const fixtureUrl = new URL("../contracts/auth/session-cookie-v1.json", import.meta.url); const cookieEnvironmentKeys = [ "ZHINIAN_AUTH_COOKIE_SECURE", "NEXT_PUBLIC_APP_URL", "ZHINIAN_PUBLIC_BASE_URL", ] as const; const originalCookieEnvironment = new Map( cookieEnvironmentKeys.map((key) => [key, process.env[key]]) ); type RecordedCookie = { name: string; value: string; options: Record; }; afterEach(() => { for (const key of cookieEnvironmentKeys) { const value = originalCookieEnvironment.get(key); if (value === undefined) delete process.env[key]; else process.env[key] = value; } }); async function loadFixture(): Promise { return JSON.parse(await readFile(fixtureUrl, "utf8")) as SessionCookieFixture; } function cookieRecorder() { const writes: RecordedCookie[] = []; const response = { cookies: { set(name: string, value: string, options: Record) { writes.push({ name, value, options }); }, }, } as unknown as Parameters[0]; return { response, writes }; } function configureCookieEnvironment(explicit: string, publicBaseUrl: string) { delete process.env.ZHINIAN_AUTH_COOKIE_SECURE; delete process.env.NEXT_PUBLIC_APP_URL; delete process.env.ZHINIAN_PUBLIC_BASE_URL; if (explicit) process.env.ZHINIAN_AUTH_COOKIE_SECURE = explicit; if (publicBaseUrl) process.env.NEXT_PUBLIC_APP_URL = publicBaseUrl; } describe("session Cookie v1 cross-language contract", () => { it("matches the language-neutral HMAC and payload golden vector", async () => { const fixture = await loadFixture(); const rawSession = JSON.parse(fixture.rawJson) as unknown; expect(fixture.version).toBe(1); expect(await createSignedJsonValue(rawSession, fixture.secret)).toBe(fixture.cookieValue); expect(fixture.cookieValue).toBe(`${fixture.payload}.${fixture.signature}`); await expect(parseSessionCookieValue(fixture.cookieValue, fixture.secret, 150)).resolves.toMatchObject({ version: 1, authMode: "admin", expiresAt: 200, user: { id: "auth:customPC:1", clientId: "customPC", displayName: "张三", }, }); }); it("freezes cookie names, chunk boundaries, attributes, and stale-chunk clearing", async () => { const { cookie } = await loadFixture(); const value = cookie.writeExample.valueCharacter.repeat(cookie.writeExample.valueLength); const expires = new Date(cookie.writeExample.expiresAtUnix * 1000); const { response, writes } = cookieRecorder(); configureCookieEnvironment("true", ""); expect(SESSION_COOKIE_NAME).toBe(cookie.name); expect(chunkedCookieNames(cookie.name, cookie.maxChunks)).toEqual(cookie.chunkNames); expect(chunkCookieValue(value, cookie.chunkSize).map((chunk) => chunk.length)).toEqual( cookie.writeExample.chunkLengths ); setSessionCookieValue(response, "http://127.0.0.1:3000", value, expires); expect(writes.map(({ name }) => name)).toEqual(cookie.chunkNames); expect(writes.slice(0, cookie.writeExample.chunkLengths.length).map(({ value: part }) => part.length)).toEqual( cookie.writeExample.chunkLengths ); for (const write of writes.slice(0, cookie.writeExample.chunkLengths.length)) { expect(write.options).toMatchObject({ httpOnly: cookie.attributes.httpOnly, sameSite: cookie.attributes.sameSite, secure: cookie.attributes.productionSecure, path: cookie.attributes.path, expires, }); expect(write.options).not.toHaveProperty("maxAge"); } for (const write of writes.slice(cookie.writeExample.chunkLengths.length)) { expect(write.value).toBe(cookie.clear.value); expect(write.options).toMatchObject({ httpOnly: cookie.attributes.httpOnly, sameSite: cookie.attributes.sameSite, secure: cookie.attributes.productionSecure, path: cookie.attributes.path, maxAge: cookie.clear.maxAgeSeconds, }); expect(write.options).not.toHaveProperty("expires"); } }); it("rejects values beyond the 20-chunk read ceiling before writing", async () => { const { cookie } = await loadFixture(); const { response, writes } = cookieRecorder(); expect(() => setSessionCookieValue( response, "https://app.example.test", "x".repeat(cookie.maxValueLength + 1), new Date(cookie.writeExample.expiresAtUnix * 1000) ) ).toThrow(/maximum supported size/i); expect(writes).toEqual([]); }); it("clears every possible chunk on logout with the legacy attributes", async () => { const { cookie } = await loadFixture(); const { response, writes } = cookieRecorder(); configureCookieEnvironment("true", ""); clearSessionCookieValues(response, "http://127.0.0.1:3000"); expect(writes.map(({ name }) => name)).toEqual(cookie.chunkNames); expect(writes).toHaveLength(cookie.maxChunks); for (const write of writes) { expect(write.value).toBe(cookie.clear.value); expect(write.options).toEqual({ httpOnly: cookie.attributes.httpOnly, sameSite: cookie.attributes.sameSite, secure: cookie.attributes.productionSecure, path: cookie.attributes.path, maxAge: cookie.clear.maxAgeSeconds, }); } }); it("uses the shared Secure resolution precedence", async () => { const { cookie } = await loadFixture(); for (const testCase of cookie.secureResolutionCases) { configureCookieEnvironment(testCase.explicit, testCase.publicBaseUrl); expect(shouldUseSecureAuthCookie(testCase.requestUrl), testCase.name).toBe(testCase.expected); } }); });