From 8f3b037da444a682537f3ddb45630e970875721d Mon Sep 17 00:00:00 2001 From: Cesar Alcazar Date: Mon, 19 Oct 2026 13:28:49 -0600 Subject: [PATCH] yarm6mesq: Refresh expired tokens and revoke on sign-out --- src/app/api/session/route.ts | 25 +++++- src/components/App.tsx | 16 +++- src/lib/auth-client.ts | 13 ++++ src/lib/minnect.ts | 137 ++++++++++++++++++++++++++++----- src/lib/session-refresh.ts | 104 +++++++++++++++++++++++++ src/lib/session-store.ts | 11 +++ tests/session-refresh.test.ts | 139 ++++++++++++++++++++++++++++++++++ tests/session.test.ts | 87 ++++++++++++++++++++- 8 files changed, 507 insertions(+), 25 deletions(-) create mode 100644 src/lib/session-refresh.ts create mode 100644 tests/session-refresh.test.ts diff --git a/src/app/api/session/route.ts b/src/app/api/session/route.ts index 65ddd1e..6f7ee4d 100644 --- a/src/app/api/session/route.ts +++ b/src/app/api/session/route.ts @@ -1,13 +1,15 @@ import { NextResponse, type NextRequest } from "next/server"; import { loginToMinnect, LoginError } from "@/lib/minnect"; import { createSession, getSession } from "@/lib/session-store"; +import { endSession } from "@/lib/session-refresh"; import { SESSION_COOKIE, sessionCookieOptions } from "@/lib/cookie"; // This route holds the session on the server. The browser talks only to this // app; Minnect tokens never leave the server (ADR-001). // -// This task implements POST (sign in) and GET (who am I). DELETE/revoke and -// token refresh are separate, later tasks and are not implemented here. +// POST signs in, GET says who the person is, DELETE signs out (revoke + clear). +// The reusable token-refresh logic lives in session-refresh.ts for the feed +// route (a later task) to call before it uses a token. export const runtime = "nodejs"; @@ -45,6 +47,7 @@ export async function POST(req: NextRequest) { profileAccessTokenExpiresAt: tokens.profileAccessTokenExpiresAt, profileRefreshToken: tokens.profileRefreshToken, feedAccessToken: tokens.feedAccessToken, + feedAccessTokenExpiresAt: tokens.feedAccessTokenExpiresAt, person, createdAt: Date.now(), }); @@ -87,3 +90,21 @@ export async function GET(req: NextRequest) { pictureUrl: session.person.pictureUrl, }); } + +/** + * DELETE /api/session + * Sign out: revoke the refresh token at Minnect (best-effort), delete the + * server session, and clear the cookie. Always returns 200: sign-out succeeds + * even when revoke errors. After this, the session is gone, so every + * token-requiring route (and GET /api/session) returns 401 { error: "sign_in" }. + */ +export async function DELETE(req: NextRequest) { + const sessionId = req.cookies.get(SESSION_COOKIE)?.value; + + await endSession(sessionId); + + const res = NextResponse.json({ ok: true }); + // Expire the cookie with the same attributes it was set with. + res.cookies.set(SESSION_COOKIE, "", { ...sessionCookieOptions(), maxAge: 0 }); + return res; +} diff --git a/src/components/App.tsx b/src/components/App.tsx index 1bf4a2a..d578158 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { loadSession, type Person } from "@/lib/auth-client"; +import { loadSession, signOut, type Person } from "@/lib/auth-client"; import { SignInForm } from "./SignInForm"; import { Logo } from "./Logo"; @@ -9,6 +9,7 @@ type State = { kind: "loading" } | { kind: "out" } | { kind: "in"; person: Perso export function App() { const [state, setState] = useState({ kind: "loading" }); + const [signingOut, setSigningOut] = useState(false); useEffect(() => { let live = true; @@ -20,6 +21,14 @@ export function App() { }; }, []); + async function handleSignOut() { + if (signingOut) return; + setSigningOut(true); + await signOut(); + setSigningOut(false); + setState({ kind: "out" }); + } + if (state.kind === "loading") { return (
@@ -30,7 +39,7 @@ export function App() { if (state.kind === "out") { return setState({ kind: "in", person })} />; } - // Placeholder shell. The home feed and sign-out are later tasks. + // Placeholder shell. The home feed is a later task; sign-out is wired here. return (
@@ -39,6 +48,9 @@ export function App() { {state.person.name} {state.person.email} +
); diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index 99e2d58..5614acc 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -30,6 +30,19 @@ export async function loadSession(f: Fetch = fetch): Promise { } } +/** + * DELETE /api/session: sign out. Resolves once the server has cleared the + * session and cookie. A failed request still returns the UI to signed-out, + * because the local session view is no longer trustworthy. + */ +export async function signOut(f: Fetch = fetch): Promise { + try { + await f("/api/session", { method: "DELETE", credentials: "same-origin", cache: "no-store" }); + } catch { + // Ignore: the caller returns to the sign-in screen regardless. + } +} + export type SignInResult = { ok: true; person: Person } | { ok: false; message: string }; /** POST /api/session. A 401 is always the one wrong-credentials message. */ diff --git a/src/lib/minnect.ts b/src/lib/minnect.ts index 5739344..e038d9d 100644 --- a/src/lib/minnect.ts +++ b/src/lib/minnect.ts @@ -30,6 +30,20 @@ export interface LoginTokens { profileAccessTokenExpiresAt: number | null; profileRefreshToken: string; feedAccessToken: string; + feedAccessTokenExpiresAt: number | null; +} + +/** Profile tokens from a fresh login or a refresh, with the access-token expiry resolved to an epoch. */ +export interface ProfileTokens { + profileAccessToken: string; + profileAccessTokenExpiresAt: number | null; + profileRefreshToken: string; +} + +/** The feed access token and its expiry (null when the body does not carry one). */ +export interface FeedToken { + feedAccessToken: string; + feedAccessTokenExpiresAt: number | null; } export interface LoginResult { @@ -103,6 +117,106 @@ function readFeedToken(body: JsonBody): string | undefined { ); } +// expiresIn (seconds) -> absolute epoch ms, or null when it is not a number. +function expiresAtFrom(expiresIn: unknown, now: number): number | null { + return typeof expiresIn === "number" ? now + expiresIn * 1000 : null; +} + +/** + * Exchange a profile access token for a feed token. + * `POST /api/v4/auth/external-services/feed` with body `{}`. + * Used by sign-in and by refresh. Throws LoginError(FEED_ERROR) on failure. + */ +export async function exchangeFeedToken( + profileAccessToken: string, + options: LoginOptions = {}, +): Promise { + const doFetch = options.fetchImpl ?? fetch; + const res = await doFetch( + `${PROFILES_BASE_URL}/api/v4/auth/external-services/feed`, + { + method: "POST", + headers: jsonHeaders(profileAccessToken), + body: JSON.stringify({}), + }, + ); + if (res.status !== 200) { + throw new LoginError(502, FEED_ERROR); + } + const body = await parseJson(res); + const feedAccessToken = readFeedToken(body); + if (!feedAccessToken) { + throw new LoginError(502, FEED_ERROR); + } + // Feed-token expiry is read opportunistically: the spec does not require the + // body to carry it. When it is absent, the feed-call-returns-401 path (the + // feed route, a later task) is the backstop for a stale feed token. + const root = asRecord(body); + const rawExpires = asRecord(root.data).expiresIn ?? root.expiresIn; + return { + feedAccessToken, + feedAccessTokenExpiresAt: expiresAtFrom(rawExpires, Date.now()), + }; +} + +/** + * Exchange a profile refresh token for a new profile access + refresh token. + * `POST /api/v4/auth/refresh-token` with `{ refreshToken }`. The new tokens are + * read with the same field order as sign-in. No password is involved. Throws a + * plain Error on any failure; the caller treats that as "refresh failed". + */ +export async function refreshProfileTokens( + refreshToken: string, + options: LoginOptions = {}, +): Promise { + const doFetch = options.fetchImpl ?? fetch; + const res = await doFetch( + `${PROFILES_BASE_URL}/api/v4/auth/refresh-token`, + { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify({ refreshToken }), + }, + ); + if (res.status !== 200) { + throw new Error("Profile token refresh failed."); + } + const { + accessToken, + refreshToken: newRefreshToken, + expiresIn, + } = readProfileTokens(await parseJson(res)); + if (!accessToken || !newRefreshToken) { + throw new Error("Profile token refresh returned no tokens."); + } + return { + profileAccessToken: accessToken, + profileAccessTokenExpiresAt: expiresAtFrom(expiresIn, Date.now()), + profileRefreshToken: newRefreshToken, + }; +} + +/** + * Revoke a profile refresh token on sign-out. + * `POST /api/v4/auth/revoke-token` with `{ refreshToken }`. Best-effort: any + * error (network or non-200) is swallowed so that sign-out still succeeds. + */ +export async function revokeToken( + refreshToken: string, + options: LoginOptions = {}, +): Promise { + const doFetch = options.fetchImpl ?? fetch; + try { + await doFetch(`${PROFILES_BASE_URL}/api/v4/auth/revoke-token`, { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify({ refreshToken }), + }); + } catch { + // Sign-out still succeeds if revoke fails (spec, "Sign out"). + } +} + /** * Runs the three-call sign-in: login -> auth/me -> feed-token exchange. * Returns the person to show and the tokens to store server-side. @@ -134,8 +248,7 @@ export async function loginToMinnect( if (!profileAccessToken || !profileRefreshToken) { throw new LoginError(502, GENERIC_SIGN_IN_ERROR); } - const profileAccessTokenExpiresAt = - typeof expiresIn === "number" ? Date.now() + expiresIn * 1000 : null; + const profileAccessTokenExpiresAt = expiresAtFrom(expiresIn, Date.now()); // 2. Who the person is. const meRes = await doFetch(`${PROFILES_BASE_URL}/api/v4/auth/me`, { @@ -160,21 +273,10 @@ export async function loginToMinnect( const pictureUrl = asString(user.profilePictureUrl) ?? null; // 3. Feed token exchange. - const feedRes = await doFetch( - `${PROFILES_BASE_URL}/api/v4/auth/external-services/feed`, - { - method: "POST", - headers: jsonHeaders(profileAccessToken), - body: JSON.stringify({}), - }, + const { feedAccessToken, feedAccessTokenExpiresAt } = await exchangeFeedToken( + profileAccessToken, + options, ); - if (feedRes.status !== 200) { - throw new LoginError(502, FEED_ERROR); - } - const feedToken = readFeedToken(await parseJson(feedRes)); - if (!feedToken) { - throw new LoginError(502, FEED_ERROR); - } return { person: { userId, name, email: personEmail, pictureUrl }, @@ -182,7 +284,8 @@ export async function loginToMinnect( profileAccessToken, profileAccessTokenExpiresAt, profileRefreshToken, - feedAccessToken: feedToken, + feedAccessToken, + feedAccessTokenExpiresAt, }, }; } diff --git a/src/lib/session-refresh.ts b/src/lib/session-refresh.ts new file mode 100644 index 0000000..ba4a5d7 --- /dev/null +++ b/src/lib/session-refresh.ts @@ -0,0 +1,104 @@ +import { + deleteSession, + getSession, + updateSession, + type SessionData, +} from "./session-store"; +import { exchangeFeedToken, refreshProfileTokens, revokeToken } from "./minnect"; + +// Reusable server-side session logic the route handlers share. The feed route +// (a later task) calls `ensureFreshSession` before using a token, and +// `refreshSession` once after a feed call returns 401. Sign-out uses +// `endSession`. Minnect tokens never leave the server; nothing here is logged. + +/** Refresh when a token expires within this window (spec: two minutes). */ +export const REFRESH_WINDOW_MS = 2 * 60 * 1000; + +interface RefreshOptions { + /** Injectable fetch, for testing. Defaults to the global fetch. */ + fetchImpl?: typeof fetch; + /** Injectable clock for the expiry decision, for testing. Defaults to Date.now(). */ + now?: number; +} + +/** True when the expiry is known and falls within the refresh window. */ +function expiresSoon(expiresAt: number | null, now: number): boolean { + return expiresAt !== null && expiresAt - now <= REFRESH_WINDOW_MS; +} + +/** + * Refresh the profile and feed tokens of a session and persist them, keeping the + * same session id (and the same person). Uses the stored profile refresh token, + * never a password. On any failure the session is deleted and `null` is + * returned, so the browser is sent back to the sign-in screen. + */ +export async function refreshSession( + sessionId: string, + options: RefreshOptions = {}, +): Promise { + const session = getSession(sessionId); + if (!session) return null; + + try { + const profile = await refreshProfileTokens( + session.profileRefreshToken, + options, + ); + const feed = await exchangeFeedToken(profile.profileAccessToken, options); + + const updated: SessionData = { + ...session, + profileAccessToken: profile.profileAccessToken, + profileAccessTokenExpiresAt: profile.profileAccessTokenExpiresAt, + profileRefreshToken: profile.profileRefreshToken, + feedAccessToken: feed.feedAccessToken, + feedAccessTokenExpiresAt: feed.feedAccessTokenExpiresAt, + }; + updateSession(sessionId, updated); + return updated; + } catch { + // Refresh failed: drop the session. The caller returns 401 { error: "sign_in" }. + deleteSession(sessionId); + return null; + } +} + +/** + * Load a session and, when its profile or feed access token is within the + * refresh window, refresh it first. Returns the usable session, or `null` when + * there is no session or the refresh failed. Call this before using a token. + */ +export async function ensureFreshSession( + sessionId: string | undefined, + options: RefreshOptions = {}, +): Promise { + if (!sessionId) return null; + const session = getSession(sessionId); + if (!session) return null; + + const now = options.now ?? Date.now(); + if ( + expiresSoon(session.profileAccessTokenExpiresAt, now) || + expiresSoon(session.feedAccessTokenExpiresAt, now) + ) { + return refreshSession(sessionId, options); + } + return session; +} + +/** + * Sign-out. Revoke the refresh token (best-effort) and delete the server + * session. Always resolves: a revoke error does not stop sign-out. The caller + * clears the cookie. + */ +export async function endSession( + sessionId: string | undefined, + options: Pick = {}, +): Promise { + if (!sessionId) return; + const session = getSession(sessionId); + if (session) { + await revokeToken(session.profileRefreshToken, options); + } + deleteSession(sessionId); +} diff --git a/src/lib/session-store.ts b/src/lib/session-store.ts index cd321c7..8ae967b 100644 --- a/src/lib/session-store.ts +++ b/src/lib/session-store.ts @@ -20,6 +20,8 @@ export interface SessionData { profileAccessTokenExpiresAt: number | null; profileRefreshToken: string; feedAccessToken: string; + /** Epoch milliseconds when the feed access token expires, or null if unknown. */ + feedAccessTokenExpiresAt: number | null; person: SessionPerson; createdAt: number; } @@ -43,6 +45,15 @@ export function getSession(id: string): SessionData | null { return store.get(id) ?? null; } +/** + * Replace the data for an existing session. Used after a token refresh to + * persist the new tokens. A no-op when the session is already gone, so a + * refresh cannot resurrect a signed-out session. + */ +export function updateSession(id: string, data: SessionData): void { + if (store.has(id)) store.set(id, data); +} + export function deleteSession(id: string): void { store.delete(id); } diff --git a/tests/session-refresh.test.ts b/tests/session-refresh.test.ts new file mode 100644 index 0000000..f7d538c --- /dev/null +++ b/tests/session-refresh.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it, vi } from "vitest"; +import { + endSession, + ensureFreshSession, + refreshSession, +} from "@/lib/session-refresh"; +import { createSession, getSession, type SessionData } from "@/lib/session-store"; + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + +// Seeds a session whose profile access token expires at `profileExpiresAt` +// (and feed at `feedExpiresAt`), with known "old" tokens. +function seedSession( + profileExpiresAt: number | null, + feedExpiresAt: number | null = null, +): string { + const data: SessionData = { + profileAccessToken: "old-access", + profileAccessTokenExpiresAt: profileExpiresAt, + profileRefreshToken: "old-refresh", + feedAccessToken: "old-feed", + feedAccessTokenExpiresAt: feedExpiresAt, + person: { userId: "u1", name: "Pat Rivera", email: "person@example.com", pictureUrl: null }, + createdAt: Date.now(), + }; + return createSession(data); +} + +// Stub for the refresh path: refresh-token -> new profile tokens, feed -> new feed token. +function refreshFetchStub( + overrides: Partial> = {}, +) { + return vi.fn(async (input: RequestInfo | URL): Promise => { + const url = String(input); + if (url.endsWith("/api/v4/auth/refresh-token")) { + return ( + overrides.refresh ?? + json({ data: { accessToken: "new-access", refreshToken: "new-refresh", expiresIn: 3600 } }) + ); + } + if (url.endsWith("/api/v4/auth/external-services/feed")) { + return overrides.feed ?? json({ accessToken: "new-feed" }); + } + throw new Error(`unexpected fetch to ${url}`); + }); +} + +describe("ensureFreshSession", () => { + it("an access token expiring within 2 min triggers refresh; tokens change, no password", async () => { + const id = seedSession(Date.now() + 60_000); // 1 min out: inside the 2-min window + const fetchImpl = refreshFetchStub(); + + const updated = await ensureFreshSession(id, { fetchImpl: fetchImpl as unknown as typeof fetch }); + + expect(updated).not.toBeNull(); + expect(updated!.profileAccessToken).toBe("new-access"); + expect(updated!.profileRefreshToken).toBe("new-refresh"); + expect(updated!.feedAccessToken).toBe("new-feed"); + // Persisted under the same id. + expect(getSession(id)!.profileAccessToken).toBe("new-access"); + // Refresh used the stored refresh token, never the login/password route. + expect(fetchImpl.mock.calls.some(([u]) => String(u).endsWith("/api/v4/auth/login"))).toBe(false); + }); + + it("a feed token expiring within 2 min also triggers refresh", async () => { + const id = seedSession(Date.now() + 60 * 60_000, Date.now() + 30_000); + const fetchImpl = refreshFetchStub(); + + const updated = await ensureFreshSession(id, { fetchImpl: fetchImpl as unknown as typeof fetch }); + + expect(updated!.feedAccessToken).toBe("new-feed"); + expect(updated!.profileAccessToken).toBe("new-access"); + }); + + it("tokens comfortably in the future are not refreshed", async () => { + const id = seedSession(Date.now() + 60 * 60_000, Date.now() + 60 * 60_000); + const fetchImpl = refreshFetchStub(); + + const same = await ensureFreshSession(id, { fetchImpl: fetchImpl as unknown as typeof fetch }); + + expect(same!.profileAccessToken).toBe("old-access"); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("no cookie / unknown session is null", async () => { + expect(await ensureFreshSession(undefined)).toBeNull(); + expect(await ensureFreshSession("does-not-exist")).toBeNull(); + }); +}); + +describe("refreshSession failure", () => { + it("a failed refresh deletes the session", async () => { + const id = seedSession(Date.now() + 60_000); + const fetchImpl = refreshFetchStub({ refresh: json({ data: null }, 401) }); + + const result = await refreshSession(id, { fetchImpl: fetchImpl as unknown as typeof fetch }); + + expect(result).toBeNull(); + expect(getSession(id)).toBeNull(); + }); + + it("a failed feed exchange during refresh also deletes the session", async () => { + const id = seedSession(Date.now() + 60_000); + const fetchImpl = refreshFetchStub({ feed: json({ data: null }, 500) }); + + const result = await ensureFreshSession(id, { fetchImpl: fetchImpl as unknown as typeof fetch }); + + expect(result).toBeNull(); + expect(getSession(id)).toBeNull(); + }); +}); + +describe("endSession", () => { + it("revokes the refresh token and deletes the session", async () => { + const id = seedSession(Date.now() + 60 * 60_000); + const fetchImpl = vi.fn(async (_input: RequestInfo | URL) => json({ data: { success: true } })); + + await endSession(id, { fetchImpl: fetchImpl as unknown as typeof fetch }); + + expect(fetchImpl.mock.calls.some(([u]) => String(u).endsWith("/api/v4/auth/revoke-token"))).toBe(true); + expect(getSession(id)).toBeNull(); + }); + + it("still deletes the session when revoke throws", async () => { + const id = seedSession(Date.now() + 60 * 60_000); + const fetchImpl = vi.fn(async (_input: RequestInfo | URL): Promise => { + throw new Error("revoke down"); + }); + + await expect( + endSession(id, { fetchImpl: fetchImpl as unknown as typeof fetch }), + ).resolves.toBeUndefined(); + expect(getSession(id)).toBeNull(); + }); +}); diff --git a/tests/session.test.ts b/tests/session.test.ts index 3e1df7c..fc341ea 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -1,15 +1,26 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; -import { GET, POST } from "@/app/api/session/route"; +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 three Minnect calls. `overrides` lets a test -// force a non-200 or a weird body at any step. -function makeFetchStub(overrides: Partial> = {}) { +// 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 ?? @@ -181,3 +192,71 @@ describe("GET /api/session", () => { 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(""); + }); +});