import { afterEach, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; import { DELETE, GET, POST } from "@/app/api/session/route"; import { SESSION_COOKIE } from "@/lib/cookie"; import { getSession } from "@/lib/session-store"; // A fetch stub that walks the Minnect calls. `overrides` lets a test force a // non-200 or a weird body at any step. `revoke` covers the sign-out call. function makeFetchStub( overrides: Partial> = {}, ) { return vi.fn(async (input: RequestInfo | URL): Promise => { const url = typeof input === "string" ? input : input.toString(); if (url.endsWith("/api/v4/auth/revoke-token")) { return ( overrides.revoke ?? new Response(JSON.stringify({ data: { success: true } }), { status: 200, headers: { "content-type": "application/json" }, }) ); } if (url.endsWith("/api/v4/auth/login")) { return ( overrides.login ?? new Response( JSON.stringify({ data: { accessToken: "profile-access", refreshToken: "profile-refresh", expiresIn: 3600 }, }), { status: 200, headers: { "content-type": "application/json" } }, ) ); } if (url.endsWith("/api/v4/auth/me")) { return ( overrides.me ?? new Response( JSON.stringify({ data: { userId: "user-123", email: "person@example.com", firstName: "Pat", lastName: "Rivera", profilePictureUrl: "https://cdn.example.com/pat.jpg", role: "user", }, }), { status: 200, headers: { "content-type": "application/json" } }, ) ); } if (url.endsWith("/api/v4/auth/external-services/feed")) { return ( overrides.feed ?? new Response(JSON.stringify({ accessToken: "feed-access" }), { status: 200, headers: { "content-type": "application/json" }, }) ); } throw new Error(`unexpected fetch to ${url}`); }); } function postRequest(body: unknown): NextRequest { return new NextRequest("http://localhost/api/session", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); } afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); describe("POST /api/session", () => { it("wrong password: 401, generic message, no cookie, no session", async () => { const serverError = { data: null, errors: [{ message: "User not found in our records", code: "UnauthorizedException" }], }; vi.stubGlobal( "fetch", makeFetchStub({ login: new Response(JSON.stringify(serverError), { status: 401, headers: { "content-type": "application/json" }, }), }), ); const res = await POST(postRequest({ email: "person@example.com", password: "wrong" })); const json = await res.json(); expect(res.status).toBe(401); expect(json).toEqual({ error: "Email or password is incorrect." }); // The Minnect message must not leak through. expect(JSON.stringify(json)).not.toContain("records"); // No cookie is set. expect(res.cookies.get(SESSION_COOKIE)).toBeUndefined(); }); it("missing fields: 400, no cookie", async () => { vi.stubGlobal("fetch", makeFetchStub()); const res = await POST(postRequest({ email: "person@example.com" })); expect(res.status).toBe(400); expect(res.cookies.get(SESSION_COOKIE)).toBeUndefined(); }); it("correct login: 200, person body, opaque session cookie, server session created", async () => { vi.stubGlobal("fetch", makeFetchStub()); const res = await POST(postRequest({ email: "person@example.com", password: "right" })); const json = await res.json(); expect(res.status).toBe(200); expect(json).toEqual({ name: "Pat Rivera", email: "person@example.com", pictureUrl: "https://cdn.example.com/pat.jpg", }); const cookie = res.cookies.get(SESSION_COOKIE); expect(cookie).toBeDefined(); expect(cookie?.httpOnly).toBe(true); expect(cookie?.sameSite).toBe("lax"); // The cookie is an opaque id, not a Minnect token. const id = cookie!.value; expect(id).not.toContain("profile-access"); expect(id).not.toContain("feed-access"); // The tokens live server-side only. const session = getSession(id); expect(session?.profileAccessToken).toBe("profile-access"); expect(session?.profileRefreshToken).toBe("profile-refresh"); expect(session?.feedAccessToken).toBe("feed-access"); }); it("feed-token exchange fails: 502, feed message, no cookie", async () => { vi.stubGlobal( "fetch", makeFetchStub({ feed: new Response(JSON.stringify({ data: null }), { status: 500, headers: { "content-type": "application/json" }, }), }), ); const res = await POST(postRequest({ email: "person@example.com", password: "right" })); const json = await res.json(); expect(res.status).toBe(502); expect(json).toEqual({ error: "Could not open the feed." }); expect(res.cookies.get(SESSION_COOKIE)).toBeUndefined(); }); }); describe("GET /api/session", () => { it("no cookie: 401 sign_in", async () => { const res = await GET(new NextRequest("http://localhost/api/session")); const json = await res.json(); expect(res.status).toBe(401); expect(json).toEqual({ error: "sign_in" }); }); it("unknown session id: 401 sign_in", async () => { const res = await GET( new NextRequest("http://localhost/api/session", { headers: { cookie: `${SESSION_COOKIE}=does-not-exist` }, }), ); const json = await res.json(); expect(res.status).toBe(401); expect(json).toEqual({ error: "sign_in" }); }); it("valid session: returns the person's name and email", async () => { vi.stubGlobal("fetch", makeFetchStub()); const postRes = await POST(postRequest({ email: "person@example.com", password: "right" })); const id = postRes.cookies.get(SESSION_COOKIE)!.value; const res = await GET( new NextRequest("http://localhost/api/session", { headers: { cookie: `${SESSION_COOKIE}=${id}` }, }), ); const json = await res.json(); expect(res.status).toBe(200); expect(json.name).toBe("Pat Rivera"); expect(json.email).toBe("person@example.com"); }); }); function requestWithCookie(method: string, id: string): NextRequest { return new NextRequest("http://localhost/api/session", { method, headers: { cookie: `${SESSION_COOKIE}=${id}` }, }); } describe("DELETE /api/session", () => { it("revokes, deletes the session, clears the cookie; next GET is 401", async () => { const fetchStub = makeFetchStub(); vi.stubGlobal("fetch", fetchStub); const postRes = await POST(postRequest({ email: "person@example.com", password: "right" })); const id = postRes.cookies.get(SESSION_COOKIE)!.value; expect(getSession(id)).not.toBeNull(); const delRes = await DELETE(requestWithCookie("DELETE", id)); expect(delRes.status).toBe(200); // The revoke-token call was made. expect( fetchStub.mock.calls.some(([u]) => String(u).endsWith("/api/v4/auth/revoke-token")), ).toBe(true); // Cookie is cleared (empty value, immediate expiry). const cookie = delRes.cookies.get(SESSION_COOKIE); expect(cookie?.value).toBe(""); expect(cookie?.maxAge).toBe(0); // Session is gone. expect(getSession(id)).toBeNull(); // The next token-requiring call (GET) returns 401 sign_in. const getRes = await GET(requestWithCookie("GET", id)); expect(getRes.status).toBe(401); expect(await getRes.json()).toEqual({ error: "sign_in" }); }); it("still succeeds when revoke returns an error", async () => { vi.stubGlobal("fetch", makeFetchStub()); const postRes = await POST(postRequest({ email: "person@example.com", password: "right" })); const id = postRes.cookies.get(SESSION_COOKIE)!.value; // Now make revoke fail. vi.stubGlobal( "fetch", makeFetchStub({ revoke: new Response(JSON.stringify({ data: null }), { status: 500, headers: { "content-type": "application/json" }, }), }), ); const delRes = await DELETE(requestWithCookie("DELETE", id)); expect(delRes.status).toBe(200); expect(delRes.cookies.get(SESSION_COOKIE)?.value).toBe(""); expect(getSession(id)).toBeNull(); }); it("no cookie: still 200 and clears the cookie", async () => { vi.stubGlobal("fetch", makeFetchStub()); const delRes = await DELETE(new NextRequest("http://localhost/api/session", { method: "DELETE" })); expect(delRes.status).toBe(200); expect(delRes.cookies.get(SESSION_COOKIE)?.value).toBe(""); }); });