import { readFile } from "node:fs/promises"; import { afterEach, describe, expect, it, vi } from "vitest"; import * as logoutRoute from "@/app/api/auth/logout/route"; type LogoutFixture = { version: 1; path: string; methods: string[]; status: number; location: string; requiresAuthentication: boolean; cookieFixture: string; duplicateBaseCookieWrite: boolean; }; type SessionCookieFixture = { cookie: { chunkNames: string[]; attributes: { httpOnly: boolean; sameSite: string; path: string }; clear: { value: string; maxAgeSeconds: number }; }; }; const fixtureUrl = new URL("../contracts/auth/logout-v1.json", import.meta.url); async function loadFixture(): Promise { return JSON.parse(await readFile(fixtureUrl, "utf8")) as LogoutFixture; } async function loadCookieFixture(relativePath: string): Promise { return JSON.parse(await readFile(new URL(`../contracts/auth/${relativePath}`, import.meta.url), "utf8")) as SessionCookieFixture; } function setCookieLines(response: Response): string[] { const headers = response.headers as Headers & { getSetCookie?: () => string[] }; return headers.getSetCookie?.() ?? [response.headers.get("set-cookie") ?? ""]; } afterEach(() => vi.unstubAllEnvs()); describe("logout HTTP v1 cross-language contract", () => { it("allows anonymous GET and POST and returns the same 307 redirect", async () => { const fixture = await loadFixture(); expect({ version: fixture.version, path: fixture.path, methods: fixture.methods }).toEqual({ version: 1, path: "/api/auth/logout", methods: ["GET", "POST"] }); expect(Object.keys(logoutRoute).sort()).toEqual(["GET", "POST", "runtime"]); expect(fixture.requiresAuthentication).toBe(false); for (const method of fixture.methods) { const response = await logoutRoute[method as "GET" | "POST"]( new Request("https://app.example.test/api/auth/logout", { method }) ); expect(response.status, method).toBe(fixture.status); expect(response.headers.get("location"), method).toBe(fixture.location); } }); it("clears all 20 legacy chunk names and preserves the externally visible duplicate base write", async () => { const fixture = await loadFixture(); const cookieFixture = await loadCookieFixture(fixture.cookieFixture); vi.stubEnv("ZHINIAN_AUTH_COOKIE_SECURE", "true"); const response = await logoutRoute.POST(new Request("http://127.0.0.1/api/auth/logout", { method: "POST" })); const lines = setCookieLines(response); const names = lines.map((line) => line.slice(0, line.indexOf("="))); expect(lines).toHaveLength(cookieFixture.cookie.chunkNames.length + (fixture.duplicateBaseCookieWrite ? 1 : 0)); expect(names).toEqual([ ...cookieFixture.cookie.chunkNames, ...(fixture.duplicateBaseCookieWrite ? [cookieFixture.cookie.chunkNames[0]] : []) ]); for (const line of lines) { expect(line).toContain("HttpOnly"); expect(line).toContain("Path=/"); expect(line).toContain("SameSite=lax"); expect(line).toContain("Secure"); expect(line).toContain(`Max-Age=${cookieFixture.cookie.clear.maxAgeSeconds}`); } }); });