From 18e899063858f904f02b5d3ecfb51a6839367fae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 21:36:27 +0100 Subject: [PATCH] fix(demo): keep a visitor's language off the shared account The session read copies the language cookie into the account row when the two differ, so on a demo every visitor's language still landed on the one account all visitors share, although the locale route already left the row alone there. Under DEMO_MODE the session read now answers in the visitor's language and writes nothing. The route's unit test mocked the database and the handler wholesale; both paths are now proved against Postgres and read back from the row. --- .../auth/me/locale/__tests__/route.test.ts | 73 -------------- src/lib/auth/session.ts | 18 ++-- .../demo-mode-locale-write.test.ts | 96 +++++++++++++++++++ 3 files changed, 108 insertions(+), 79 deletions(-) delete mode 100644 src/app/api/auth/me/locale/__tests__/route.test.ts create mode 100644 tests/integration/demo-mode-locale-write.test.ts diff --git a/src/app/api/auth/me/locale/__tests__/route.test.ts b/src/app/api/auth/me/locale/__tests__/route.test.ts deleted file mode 100644 index a58f5669d..000000000 --- a/src/app/api/auth/me/locale/__tests__/route.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * The language write on a demo. - * - * A demo is one published account every visitor signs into, so a visitor's - * language must not become the account's: the route answers as usual and - * sets the visitor's own cookie, but leaves the shared row alone. - */ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@/lib/api-handler", async () => { - const actual = - await vi.importActual( - "@/lib/api-handler", - ); - return { ...actual, requireActorAuth: vi.fn() }; -}); -vi.mock("@/lib/db", () => ({ prisma: { user: { update: vi.fn() } } })); -vi.mock("next/headers", () => ({ cookies: vi.fn(async () => ({})) })); -vi.mock("@/lib/i18n/locale-cookie", () => ({ setLocaleCookie: vi.fn() })); -vi.mock("@/lib/rate-limit", () => ({ - checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }), - rateLimitHeaders: vi.fn(() => ({})), -})); -vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); - -import { PUT } from "../route"; -import { requireActorAuth } from "@/lib/api-handler"; -import { prisma } from "@/lib/db"; -import { setLocaleCookie } from "@/lib/i18n/locale-cookie"; - -const ORIGINAL_DEMO_MODE = process.env.DEMO_MODE; - -function put(locale: string) { - return PUT( - new Request("http://localhost/api/auth/me/locale", { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ locale }), - }) as never, - ); -} - -beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(requireActorAuth).mockResolvedValue({ - user: { id: "demo-account", locale: "en" }, - } as never); -}); - -afterEach(() => { - if (ORIGINAL_DEMO_MODE === undefined) delete process.env.DEMO_MODE; - else process.env.DEMO_MODE = ORIGINAL_DEMO_MODE; -}); - -describe("PUT /api/auth/me/locale", () => { - it("stores the language on the account", async () => { - delete process.env.DEMO_MODE; - const res = await put("de"); - expect(res.status).toBe(200); - expect(prisma.user.update).toHaveBeenCalledWith({ - where: { id: "demo-account" }, - data: { locale: "de" }, - }); - }); - - it("keeps a demo visitor's language in their own cookie only", async () => { - process.env.DEMO_MODE = "true"; - const res = await put("de"); - expect(res.status).toBe(200); - expect(prisma.user.update).not.toHaveBeenCalled(); - expect(setLocaleCookie).toHaveBeenCalledWith(expect.anything(), "de"); - }); -}); diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index 4c8188a44..918b25d5a 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -430,12 +430,18 @@ export async function getSession(): Promise<{ // already had, and it is coherent: that browser is showing the older // language too, because the cookie outranks the column on its first // paint as well. - void prisma.user - .update({ - where: { id: session.user.id }, - data: { locale: cookieLocale }, - }) - .catch(() => {}); + // + // A demo is one account every visitor shares: there the language stays + // in this visitor's cookie and never reaches the row the next visitor + // starts from. + if (process.env.DEMO_MODE !== "true") { + void prisma.user + .update({ + where: { id: session.user.id }, + data: { locale: cookieLocale }, + }) + .catch(() => {}); + } // Keep THIS request coherent — a handler resolving the locale from the // returned row must not answer with the value we just replaced. session.user.locale = cookieLocale; diff --git a/tests/integration/demo-mode-locale-write.test.ts b/tests/integration/demo-mode-locale-write.test.ts new file mode 100644 index 000000000..01d764695 --- /dev/null +++ b/tests/integration/demo-mode-locale-write.test.ts @@ -0,0 +1,96 @@ +/** + * The language a demo visitor picks stays with that visitor. + * + * A demo is one published account every visitor signs into. Two paths write + * the account's language: `PUT /api/auth/me/locale`, and the session read, + * which copies the language cookie into the row when the two differ. On a + * demo both leave the shared row alone, so one visitor's choice is not the + * next visitor's starting language; everywhere else both keep writing it. + * The verdict is read back from the row: the route answers 200 either way. + */ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { PUT } from "@/app/api/auth/me/locale/route"; +import { getSession } from "@/lib/auth/session"; +import { sessionCookieName } from "@/lib/auth/session-cookie"; +import { LOCALE_COOKIE } from "@/lib/i18n/locale-cookie"; + +import { callRoute } from "../kit/call-route"; +import { makeSession, makeUser } from "../kit/factories"; +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const ORIGINAL_DEMO_MODE = process.env.DEMO_MODE; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +afterEach(() => { + if (ORIGINAL_DEMO_MODE === undefined) delete process.env.DEMO_MODE; + else process.env.DEMO_MODE = ORIGINAL_DEMO_MODE; +}); + +async function storedLocale(userId: string): Promise { + const row = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: userId }, + select: { locale: true }, + }); + return row.locale; +} + +async function putLocale(locale: string, demo: boolean) { + if (demo) process.env.DEMO_MODE = "true"; + else delete process.env.DEMO_MODE; + const user = await makeUser({ locale: "en" }); + const auth = await makeSession(user); + const res = await callRoute(PUT, { + method: "PUT", + route: "/api/auth/me/locale", + body: { locale }, + auth, + }); + return { status: res.status, userId: user.id }; +} + +/** A session read with the visitor's language cookie set to `locale`. */ +async function readSessionWithCookie(locale: string, demo: boolean) { + if (demo) process.env.DEMO_MODE = "true"; + else delete process.env.DEMO_MODE; + const user = await makeUser({ locale: "en" }); + const auth = await makeSession(user); + cookieJar.set(sessionCookieName(), auth.cookie); + cookieJar.set(LOCALE_COOKIE, locale); + const signedIn = await getSession(); + // The session read writes fire-and-forget; give it the moment it needs. + await new Promise((resolve) => setTimeout(resolve, 200)); + return { signedIn, userId: user.id }; +} + +describe("the account language under DEMO_MODE (real Postgres)", () => { + it("PUT stores the language on an ordinary instance", async () => { + const { status, userId } = await putLocale("de", false); + expect(status).toBe(200); + expect(await storedLocale(userId)).toBe("de"); + }); + + it("PUT on a demo answers 200 and leaves the shared row alone", async () => { + const { status, userId } = await putLocale("de", true); + expect(status).toBe(200); + expect(await storedLocale(userId)).toBe("en"); + }); + + it("the session read copies the cookie language on an ordinary instance", async () => { + const { signedIn, userId } = await readSessionWithCookie("fr", false); + expect(signedIn?.user.locale).toBe("fr"); + expect(await storedLocale(userId)).toBe("fr"); + }); + + it("the session read on a demo answers in the cookie language without writing it", async () => { + const { signedIn, userId } = await readSessionWithCookie("fr", true); + expect(signedIn?.user.locale).toBe("fr"); + expect(await storedLocale(userId)).toBe("en"); + }); +});