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.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 21:36:27 +01:00
1 parent 0278b6045e
commit 18e8990638
3 files changed
+108 -79

No files matched your search

@@ -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<typeof import("@/lib/api-handler")>(
"@/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");
});
});
+12 -6
View File
@@ -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;
@@ -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<string | null> {
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");
});
});