diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c01bd9f4..09089f3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,3 +49,6 @@ jobs: - name: Build (node adapter, same as the Docker image) run: ADAPTER=node pnpm build + + - name: SSR isolation acceptance (tests/ssr) + run: pnpm test:ssr diff --git a/package.json b/package.json index a3713a14..f46958f7 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,6 @@ "openapi-fetch": "^0.14.1", "svelte": "^5.56.10", "svelte-floating-ui": "^1.6.2", - "sveltekit-i18n": "^2.4.2", "trap-focus-svelte": "^1.1.0" }, "devDependencies": { @@ -56,7 +55,8 @@ "format:specific": "prettier --write", "lint": "eslint . --max-warnings 0", "test:ci": "vitest run", - "test:coverage": "vitest run --coverage" + "test:coverage": "vitest run --coverage", + "test:ssr": "vitest --run --config vitest.ssr.config.ts" }, "type": "module", "pnpm": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1377f39..47924936 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,9 +50,6 @@ importers: svelte-floating-ui: specifier: ^1.6.2 version: 1.6.2 - sveltekit-i18n: - specifier: ^2.4.2 - version: 2.4.2(svelte@5.56.10(@typescript-eslint/types@8.68.0)) trap-focus-svelte: specifier: ^1.1.0 version: 1.1.0 @@ -747,14 +744,6 @@ packages: svelte: ^5.46.4 vite: ^8.0.0-beta.7 || ^8.0.0 - '@sveltekit-i18n/base@1.3.8': - resolution: {integrity: sha512-XIoQBPYMc6ENNIRoxYUwRdG60+UjvYdRNgBPFSm6FIdSf4FBs60i+WIqtorJvTMC2jycTMe7So9RODPq1HCbiA==} - peerDependencies: - svelte: '>=3.49.0' - - '@sveltekit-i18n/parser-default@1.1.1': - resolution: {integrity: sha512-/gtzLlqm/sox7EoPKD56BxGZktK/syGc79EbJAPWY5KVitQD9SM0TP8yJCqDxTVPk7Lk0WJhrBGUE2Nn0f5M1w==} - '@tailwindcss/node@4.3.3': resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} @@ -1929,11 +1918,6 @@ packages: resolution: {integrity: sha512-Lcxbj8I/KAbpY+VjtY4ENQBV0dDCipfGAhqb51XQZ67CIQqXgsv/8dPkbILaj4Fb6/b6JAEM/PIVbILXgDQy2g==} engines: {node: '>=18'} - sveltekit-i18n@2.4.2: - resolution: {integrity: sha512-hjRWn4V4DBL8JQKJoJa3MRvn6d32Zo+rWkoSP5bsQ/XIAguPdQUZJ8LMe6Nc1rST8WEVdu9+vZI3aFdKYGR3+Q==} - peerDependencies: - svelte: '>=3.49.0' - symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -2603,12 +2587,6 @@ snapshots: vite: 8.2.2(@types/node@25.9.5)(esbuild@0.27.2)(jiti@2.7.0) vitefu: 1.1.3(vite@8.2.2(@types/node@25.9.5)(esbuild@0.27.2)(jiti@2.7.0)) - '@sveltekit-i18n/base@1.3.8(svelte@5.56.10(@typescript-eslint/types@8.68.0))': - dependencies: - svelte: 5.56.10(@typescript-eslint/types@8.68.0) - - '@sveltekit-i18n/parser-default@1.1.1': {} - '@tailwindcss/node@4.3.3': dependencies: '@jridgewell/remapping': 2.3.5 @@ -3826,12 +3804,6 @@ snapshots: transitivePeerDependencies: - '@typescript-eslint/types' - sveltekit-i18n@2.4.2(svelte@5.56.10(@typescript-eslint/types@8.68.0)): - dependencies: - '@sveltekit-i18n/base': 1.3.8(svelte@5.56.10(@typescript-eslint/types@8.68.0)) - '@sveltekit-i18n/parser-default': 1.1.1 - svelte: 5.56.10(@typescript-eslint/types@8.68.0) - symbol-tree@3.2.4: optional: true diff --git a/src/app.d.ts b/src/app.d.ts index 807d16da..2fddeb0a 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -26,7 +26,8 @@ declare global { /** * Convenience alias for `account.sealedToken`. * Duplicated at the top level so the proxy layer (`/api/proxy/[...path]`) - * can read the token directly from `locals.auth.authToken` without + * (and the logout endpoint, and `$lib/api/client.svelte`'s server-side + * token fallback) can read it directly from `locals.auth.authToken` without * reaching into the nested account object on every proxied request. */ readonly authToken: SealedToken @@ -82,6 +83,15 @@ declare global { authError?: AuthErrorKind /** Set to true when a 401 from /api/me indicates the session has expired or been revoked */ sessionExpired?: boolean + /** + * Language this request renders in, resolved from the visitor's + * `Accept-Language` header. Read by `$lib/app/state/i18n` through the + * request-event accessor, so that one Node process can render different + * languages concurrently. Stamped by the root `+layout.server.ts` load, + * so it is unset outside a request and on error pages rendered before + * that load runs; i18n then falls back to `en`. + */ + lang?: string } interface PageData { slots?: { diff --git a/src/lib/api/client.svelte.test.ts b/src/lib/api/client.svelte.test.ts new file mode 100644 index 00000000..7e69539a --- /dev/null +++ b/src/lib/api/client.svelte.test.ts @@ -0,0 +1,452 @@ +/** + * `coves()` on the server: where the sealed session token comes from, and + * where it is allowed to go. + * + * On the client every request is routed through `/api/proxy`, which injects + * auth from the session cookie. On the server there is no proxy — the render + * calls the upstream directly — so the token has to come from the request that + * is executing. Two properties matter and are pinned below: an authenticated + * render's calls carry its token, and that token never leaves the upstream + * origin. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { AsyncLocalStorage } from 'node:async_hooks' +import type { RequestEvent } from '@sveltejs/kit' + +const K = vi.hoisted(() => ({ + /** The origin the server render legitimately talks to. */ + UPSTREAM: 'https://upstream.internal.example', +})) + +/** Any other origin. The token must never reach one. */ +const FOREIGN = 'https://third-party.example' +const TOKEN = 'sealed-abc' +const EXPLICIT_TOKEN = 'explicit-token' + +vi.mock('$app/environment', () => ({ + browser: false, + dev: false, + building: false, + version: 'test', +})) + +vi.mock('$env/dynamic/public', () => ({ env: {} })) + +const logged = vi.hoisted(() => ({ + error: [] as unknown[][], + warn: [] as unknown[][], +})) + +vi.mock('$lib/app/util/log', () => ({ + log: { + error: (...args: unknown[]) => { + logged.error.push(args) + }, + warn: (...args: unknown[]) => { + logged.warn.push(args) + }, + }, +})) + +vi.mock('$lib/app/state/instance.svelte', () => ({ + DEFAULT_INSTANCE_URL: K.UPSTREAM, + LINKED_INSTANCE_URL: undefined, + instance: { data: K.UPSTREAM }, +})) + +// Profile is mocked to a guest deliberately: the token must be sourced from +// the request event, not from client-side profile state, so these tests must +// still pass with no authenticated profile in module state. +vi.mock('$lib/app/state/auth.svelte', () => ({ + profile: { + current: { type: 'guest', id: 'guest', instance: K.UPSTREAM }, + isAuthenticated: false, + }, +})) + +interface Recorded { + readonly url: string + readonly init: RequestInit | undefined +} + +const recorded: Recorded[] = [] + +const fakeFetch = async ( + input: RequestInfo | URL, + init?: RequestInit, +): Promise => { + recorded.push({ url: String(input), init }) + return new Response(JSON.stringify({ feed: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) +} + +const account = { + did: 'did:plc:abcdefghijklmnopqrstuvwx', + handle: 'mari.test', + instance: K.UPSTREAM, + sealedToken: TOKEN, +} + +const authedLocals = () => ({ + authenticated: true, + account, + authToken: TOKEN, +}) + +const anonLocals = () => ({ authenticated: false }) + +const eventFor = (auth: unknown): RequestEvent => + ({ locals: { auth } }) as unknown as RequestEvent + +/** The Authorization header of the nth recorded request, or null. */ +function authHeaderOf(index = 0): string | null { + const entry = recorded[index] + if (entry === undefined) return null + return new Headers(entry.init?.headers).get('authorization') +} + +/** Builds locals whose account claims `instance`, so origin rules can be probed. */ +const authedLocalsOn = (instance: string) => ({ + authenticated: true, + account: { ...account, instance }, + authToken: TOKEN, +}) + +async function freshClient() { + vi.resetModules() + const { installRequestEventAccessor } = + await import('$lib/app/util/request-event') + const client = await import('./client.svelte') + return { ...client, installRequestEventAccessor } +} + +/** The `fields` bag of a `log.*` call: the last argument, when it is an object. */ +function fieldsOf(call: unknown[]): Record { + const last = call.at(-1) + return typeof last === 'object' && last !== null + ? (last as Record) + : {} +} + +/** Everything every log call carried, flattened to one searchable string. */ +function allLoggedText(): string { + return [...logged.error, ...logged.warn] + .map((call) => + call + .map((arg) => { + try { + return typeof arg === 'string' ? arg : JSON.stringify(arg) + } catch { + return String(arg) + } + }) + .join(' '), + ) + .join('\n') +} + +beforeEach(() => { + logged.error.length = 0 + logged.warn.length = 0 + recorded.length = 0 + // `__VERSION__` is a Vite `define` from vite.config.ts, which the vitest + // config does not carry; `customFetch` reads it on every request. + vi.stubGlobal('__VERSION__', 'test') +}) + +describe('coves() on the server — auth header injection', () => { + it('carries the in-flight request’s token, with caching disabled', async () => { + const { coves, installRequestEventAccessor } = await freshClient() + installRequestEventAccessor(() => eventFor(authedLocals())) + + await coves({ func: fakeFetch }).getDiscover({ limit: 1 }) + + expect(recorded).toHaveLength(1) + expect(authHeaderOf()).toBe(`Bearer ${TOKEN}`) + // An authenticated response is per-user and must not be cached. + expect(recorded[0].init?.cache).toBe('no-store') + }) + + it('sends no Authorization header for an anonymous request', async () => { + const { coves, installRequestEventAccessor } = await freshClient() + installRequestEventAccessor(() => eventFor(anonLocals())) + + await coves({ func: fakeFetch }).getDiscover({ limit: 1 }) + + expect(recorded).toHaveLength(1) + expect(authHeaderOf()).toBeNull() + }) + + it('sends no Authorization header when there is no request at all', async () => { + const { coves } = await freshClient() + + await coves({ func: fakeFetch }).getDiscover({ limit: 1 }) + + expect(authHeaderOf()).toBeNull() + }) + + it('lets an explicitly passed token win over the request’s', async () => { + const { coves, installRequestEventAccessor } = await freshClient() + installRequestEventAccessor(() => eventFor(authedLocals())) + + await coves({ func: fakeFetch, auth: EXPLICIT_TOKEN }).getDiscover({ + limit: 1, + }) + + expect(authHeaderOf()).toBe(`Bearer ${EXPLICIT_TOKEN}`) + }) + + it('never sends the request’s token to another origin', async () => { + // The token is sealed for our upstream. A call aimed anywhere else — a + // remote instance, an image host, anything a route param could name — + // must go out unauthenticated rather than hand a session credential to a + // third party. + const { coves, installRequestEventAccessor } = await freshClient() + installRequestEventAccessor(() => eventFor(authedLocals())) + + await coves({ func: fakeFetch, instanceURL: FOREIGN }).getDiscover({ + limit: 1, + }) + + // The request really was made, and really was aimed off-origin: without + // this the assertion below could pass because nothing happened. + expect(recorded).toHaveLength(1) + expect(recorded[0].url.startsWith(FOREIGN)).toBe(true) + expect(authHeaderOf()).toBeNull() + }) +}) + +describe('coves() on the server — concurrent requests', () => { + it('gives each render only its own credentials', async () => { + const { coves, installRequestEventAccessor } = await freshClient() + + const als = new AsyncLocalStorage() + installRequestEventAccessor(() => als.getStore()) + + const ROUNDS = 5 + const run = (auth: unknown, tag: string): Promise => + als.run(eventFor(auth), async () => { + const seen: string[] = [] + for (let round = 0; round < ROUNDS; round++) { + const calls: Recorded[] = [] + const capture = async ( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise => { + calls.push({ url: String(input), init }) + return new Response('{"feed":[]}', { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + // Built inside the context, as a load function would. + await coves({ func: capture }).getDiscover({ limit: 1 }) + seen.push( + `${tag}:${new Headers(calls[0].init?.headers).get('authorization') ?? 'none'}`, + ) + await Promise.resolve() + } + return seen + }) + + const [authed, anonymous] = await Promise.all([ + run(authedLocals(), 'authed'), + run(anonLocals(), 'anon'), + ]) + + expect(authed).toEqual(Array(ROUNDS).fill(`authed:Bearer ${TOKEN}`)) + expect(anonymous).toEqual(Array(ROUNDS).fill('anon:none')) + }) +}) + +describe('coves() on the server — origin matching fails closed', () => { + it.each([ + { + name: 'same host, different port', + instance: 'http://127.0.0.1:8081', + target: 'http://127.0.0.1:8080', + }, + { + name: 'same host, different scheme', + instance: 'https://coves.social', + target: 'http://coves.social', + }, + { + name: 'bare host instance (normalised to https) vs an http target', + instance: 'coves.social', + target: 'http://coves.social', + }, + ])('withholds the token for $name', async ({ instance, target }) => { + const { coves, installRequestEventAccessor } = await freshClient() + installRequestEventAccessor(() => eventFor(authedLocalsOn(instance))) + + await coves({ func: fakeFetch, instanceURL: target }).getDiscover({ + limit: 1, + }) + + // Assert the call really was aimed where the test says before concluding + // anything from the absent header. + expect(recorded).toHaveLength(1) + expect(new URL(recorded[0].url).origin).toBe(new URL(target).origin) + expect(authHeaderOf()).toBeNull() + }) + + it('withholds the token when the account instance is not a URL at all', async () => { + const { coves, installRequestEventAccessor } = await freshClient() + installRequestEventAccessor(() => eventFor(authedLocalsOn('not a url'))) + + await coves({ func: fakeFetch, instanceURL: K.UPSTREAM }).getDiscover({ + limit: 1, + }) + + // A malformed instance must not throw mid-render, and must not be treated + // as matching whatever it was compared against. + expect(recorded).toHaveLength(1) + expect(authHeaderOf()).toBeNull() + }) +}) + +describe('coves() on the server — explicitly passed tokens', () => { + it('sends an explicit token even to another origin', async () => { + // Documented policy: injection from the request is origin-scoped, but a + // caller passing `auth` by hand has taken responsibility for where it goes. + const { coves, installRequestEventAccessor } = await freshClient() + installRequestEventAccessor(() => eventFor(authedLocals())) + + await coves({ + func: fakeFetch, + auth: EXPLICIT_TOKEN, + instanceURL: FOREIGN, + }).getDiscover({ limit: 1 }) + + expect(new URL(recorded[0].url).origin).toBe(new URL(FOREIGN).origin) + expect(authHeaderOf()).toBe(`Bearer ${EXPLICIT_TOKEN}`) + }) + + it('disables caching for an explicit token too', async () => { + const { coves } = await freshClient() + + await coves({ func: fakeFetch, auth: EXPLICIT_TOKEN }).getDiscover({ + limit: 1, + }) + + expect(recorded[0].init?.cache).toBe('no-store') + }) +}) + +describe('client() on the server — same rules as coves()', () => { + it('carries the in-flight request’s token, with caching disabled', async () => { + const { client, installRequestEventAccessor } = await freshClient() + installRequestEventAccessor(() => eventFor(authedLocals())) + + await client({ func: fakeFetch }).getSite() + + expect(recorded).toHaveLength(1) + expect(authHeaderOf()).toBe(`Bearer ${TOKEN}`) + expect(recorded[0].init?.cache).toBe('no-store') + }) + + it('sends no Authorization header for an anonymous request', async () => { + const { client, installRequestEventAccessor } = await freshClient() + installRequestEventAccessor(() => eventFor(anonLocals())) + + await client({ func: fakeFetch }).getSite() + + expect(authHeaderOf()).toBeNull() + }) + + it('never sends the request’s token to another origin', async () => { + const { client, installRequestEventAccessor } = await freshClient() + installRequestEventAccessor(() => eventFor(authedLocals())) + + await client({ func: fakeFetch, instanceURL: FOREIGN }).getSite() + + expect(recorded).toHaveLength(1) + expect(new URL(recorded[0].url).origin).toBe(new URL(FOREIGN).origin) + expect(authHeaderOf()).toBeNull() + }) +}) + +describe('coves() on the server — the reason a token was withheld is reported', () => { + it('reports an unparseable account instance as an error', async () => { + const { coves, installRequestEventAccessor } = await freshClient() + installRequestEventAccessor(() => eventFor(authedLocalsOn('not a url'))) + + await coves({ func: fakeFetch, instanceURL: K.UPSTREAM }).getDiscover({ + limit: 1, + }) + + // A config or programming fault, not a runtime condition: an account whose + // instance cannot be parsed can never authenticate anything, and silently + // degrading to anonymous renders is how that goes unnoticed for a release. + expect(logged.error).toHaveLength(1) + expect(logged.warn).toHaveLength(0) + + const [message] = logged.error[0] + expect(String(message)).toMatch(/requestToken/i) + expect(fieldsOf(logged.error[0])).toMatchObject({ instance: 'not a url' }) + }) + + it('reports an origin mismatch as a warning, naming both origins', async () => { + const { coves, installRequestEventAccessor } = await freshClient() + installRequestEventAccessor(() => eventFor(authedLocals())) + + await coves({ func: fakeFetch, instanceURL: FOREIGN }).getDiscover({ + limit: 1, + }) + + // Only a warning: fetching a remote instance, an image host or anything a + // route param can name is legitimate, and the token is correctly withheld. + // It is still worth seeing, because it is also what a mis-set + // PUBLIC_INTERNAL_INSTANCE looks like. + expect(logged.warn).toHaveLength(1) + expect(logged.error).toHaveLength(0) + + const fields = JSON.stringify(fieldsOf(logged.warn[0])) + expect(fields).toContain(new URL(FOREIGN).origin) + expect(fields).toContain(new URL(K.UPSTREAM).origin) + }) + + it('never writes the token into a log line', async () => { + const { coves, installRequestEventAccessor } = await freshClient() + + installRequestEventAccessor(() => eventFor(authedLocalsOn('not a url'))) + await coves({ func: fakeFetch, instanceURL: K.UPSTREAM }).getDiscover({ + limit: 1, + }) + + installRequestEventAccessor(() => eventFor(authedLocals())) + await coves({ func: fakeFetch, instanceURL: FOREIGN }).getDiscover({ + limit: 1, + }) + + // Both diagnostics fire while holding a live sealed token. Neither may + // carry it: these lines go to stderr on the server and to the console in + // the browser, and both get archived. + expect(logged.error.length + logged.warn.length).toBeGreaterThan(0) + expect(allLoggedText()).not.toContain(TOKEN) + }) + + it('says nothing when there is simply no session', async () => { + const { coves, installRequestEventAccessor } = await freshClient() + installRequestEventAccessor(() => eventFor(anonLocals())) + + await coves({ func: fakeFetch }).getDiscover({ limit: 1 }) + + // An anonymous render is the normal case, not a fault. Logging it would + // bury the two lines above under one entry per page view. + expect(logged.error).toHaveLength(0) + expect(logged.warn).toHaveLength(0) + }) + + it('says nothing when there is no request at all', async () => { + const { coves } = await freshClient() + + await coves({ func: fakeFetch }).getDiscover({ limit: 1 }) + + expect(logged.error).toHaveLength(0) + expect(logged.warn).toHaveLength(0) + }) +}) diff --git a/src/lib/api/client.svelte.ts b/src/lib/api/client.svelte.ts index f1a0ca10..8d6ac634 100644 --- a/src/lib/api/client.svelte.ts +++ b/src/lib/api/client.svelte.ts @@ -3,6 +3,7 @@ import { profile } from '$lib/app/state/auth.svelte' import { DEFAULT_INSTANCE_URL } from '$lib/app/state/instance.svelte' import { instanceToURL } from '$lib/app/util/url' import { log } from '$lib/app/util/log' +import { currentRequestEvent } from '$lib/app/util/request-event' import { error } from '@sveltejs/kit' import { BaseClient, DEFAULT_CLIENT_TYPE, type ClientType } from './base' import { CovesClient } from './coves' @@ -56,10 +57,66 @@ function toProxyUrl(input: RequestInfo | URL): RequestInfo | URL { } } +/** + * The session token of the request being rendered, if it may be sent to + * `input`. + * + * On the server there is no proxy to inject auth, so a render's upstream calls + * have to carry the token from the request that is executing. That token is a + * session credential for our own upstream only, so it is released solely when + * the target's origin equals the account's instance origin (fail closed): a call aimed at a remote instance, an image host, + * or anywhere a route param could name goes out unauthenticated rather than + * handing a session credential to a third party. + */ +function requestToken(input: RequestInfo | URL): string | undefined { + const auth = currentRequestEvent()?.locals.auth + // An anonymous render, or no request at all, is the ordinary case and not a + // fault. Reporting it would bury the two diagnostics below under one line + // per page view. + if (!auth?.authenticated) return undefined + + const target = input instanceof Request ? input.url : String(input) + const instance = auth.account.instance + + let targetOrigin: string + let upstreamOrigin: string + try { + targetOrigin = new URL(target).origin + upstreamOrigin = new URL(instanceToURL(instance)).origin + } catch (err) { + // A config or programming fault rather than a runtime condition: an + // account whose instance cannot be parsed can never authenticate + // anything, and degrading to anonymous renders in silence is how that + // survives a release unnoticed. + log.error( + '[client] requestToken: unparseable instance or target, withholding session token', + err, + { instance, target }, + ) + return undefined + } + + if (targetOrigin !== upstreamOrigin) { + // Only a warning: fetching a remote instance or an image host is + // legitimate and the token is correctly withheld. Worth seeing anyway, + // because it is also what a mis-set PUBLIC_INTERNAL_INSTANCE looks like. + log.warn( + '[client] requestToken: origin mismatch, withholding session token', + undefined, + { target: targetOrigin, upstream: upstreamOrigin }, + ) + return undefined + } + + return auth.authToken +} + /** * Custom fetch function that handles: * - Client-side: Routes through /api/proxy for auth injection - * - Server-side: Direct calls with auth header (when func is SvelteKit's fetch) + * - Server-side: direct call; Authorization is set from an explicit `auth`, + * else from the in-flight request's session token when the target origin is + * the account's own instance (see `requestToken`) * - User-Agent header addition * * @throws Calls SvelteKit's `error()` with the status code and response body on non-ok responses. @@ -101,9 +158,12 @@ async function customFetch( } return res } else { - // Server-side: Direct call with auth header (token from locals) - if (auth) { - headers.set('Authorization', `Bearer ${auth}`) + // Server-side: direct call, so the auth header is ours to set. An + // explicitly passed token wins; otherwise it comes from the in-flight + // request. + const token = auth ?? requestToken(input) + if (token) { + headers.set('Authorization', `Bearer ${token}`) } const serverInit: RequestInit = { @@ -111,7 +171,8 @@ async function customFetch( headers, } - if (auth) { + // An authenticated response is per-user and must never be cached. + if (token) { serverInit.cache = 'no-store' } @@ -147,18 +208,18 @@ export function client({ } // Auth handling: - // - Client-side: The proxy at /api/proxy injects auth from the session cookie - // - Server-side: The caller MUST pass `auth` explicitly from locals.auth.authToken + // - Client-side: the proxy at /api/proxy injects auth from the session cookie + // - Server-side: an explicit `auth` wins; otherwise `customFetch` falls back + // to the in-flight request's token, and only for our own upstream origin // - // NOTE: profile.current?.jwt is now just the literal 'authenticated' marker (not a real token). - // Server-side requests that need auth MUST pass the auth parameter explicitly. + // NOTE: profile.current?.jwt is just the literal 'authenticated' marker, not + // a real token, and is never used as one. const authToken = auth // TODO(coves-migration): Use CovesClient (see `coves()`) once Lemmy/PieFed adapters are removed return new (clientType?.name == 'piefed' ? PiefedClient : LemmyClient)( instanceToURL(instanceURL), { - // customFetch handles auth header injection for both client and server fetchFunction: (input, init) => customFetch(func, input, init, authToken), headers: {}, }, @@ -202,8 +263,9 @@ async function covesCustomFetch( return f(proxyInput, proxyInit) } else { - if (auth) { - headers.set('Authorization', `Bearer ${auth}`) + const token = auth ?? requestToken(input) + if (token) { + headers.set('Authorization', `Bearer ${token}`) } const serverInit: RequestInit = { @@ -211,7 +273,8 @@ async function covesCustomFetch( headers, } - if (auth) { + // An authenticated response is per-user and must never be cached. + if (token) { serverInit.cache = 'no-store' } diff --git a/src/lib/app/state/auth.svelte.client.test.ts b/src/lib/app/state/auth.svelte.client.test.ts new file mode 100644 index 00000000..4514f416 --- /dev/null +++ b/src/lib/app/state/auth.svelte.client.test.ts @@ -0,0 +1,96 @@ +/** + * `syncFromServer` in the browser. + * + * Two places turn a server session into a client profile: the SSR render + * (through `profileFromSession`) and this method, which adopts the session the + * server put in page data. If they disagree by even one field the page changes + * under the reader at hydration. These pin that they agree. + * + * Separate file because `auth.svelte.test.ts` mocks `browser: false` + * file-wide, and the browser path is what stores a profile. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ServerSession } from './auth.svelte' + +vi.mock('$app/environment', () => ({ + browser: true, + dev: false, + building: false, + version: 'test', +})) + +vi.mock('./instance/env', () => ({ + DEFAULT_INSTANCE_URL: 'https://coves.social', + LINKED_INSTANCE_URL: undefined, +})) + +vi.mock('$lib/server/session', () => ({})) + +/** The module reads and writes localStorage at import time on the browser path. */ +const store = new Map() +vi.stubGlobal('localStorage', { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, value) + }, + removeItem: (key: string) => { + store.delete(key) + }, +}) + +const session = (avatar?: string): ServerSession => + ({ + authenticated: true, + activeAccountId: 'did:plc:abcdefghijklmnopqrstuvwx', + account: { + id: 'did:plc:abcdefghijklmnopqrstuvwx', + did: 'did:plc:abcdefghijklmnopqrstuvwx', + handle: 'mari.test', + instance: 'https://coves.social', + avatar, + }, + }) as unknown as ServerSession + +async function freshAuth() { + vi.resetModules() + store.clear() + return await import('./auth.svelte') +} + +beforeEach(() => { + vi.resetModules() + store.clear() +}) + +describe('syncFromServer', () => { + it('adopts exactly the profile profileFromSession maps', async () => { + const { profile, profileFromSession } = await freshAuth() + const incoming = session('https://cdn.example/avatar.png') + + profile.syncFromServer(incoming) + + // Deep equality against the shared mapper, so the two paths cannot drift + // apart field by field. + expect(profile.meta.profiles).toEqual([profileFromSession(incoming)]) + expect(profile.meta.profile).toBe(incoming.activeAccountId) + }) + + it('agrees with profileFromSession when the account has no avatar', async () => { + const { profile, profileFromSession } = await freshAuth() + const incoming = session(undefined) + + profile.syncFromServer(incoming) + + expect(profile.meta.profiles).toEqual([profileFromSession(incoming)]) + }) + + it('drops to the guest profileFromSession maps for no session', async () => { + const { profile, profileFromSession } = await freshAuth() + profile.syncFromServer(session()) + + profile.syncFromServer(undefined) + + expect(profile.meta.profiles).toEqual([profileFromSession(undefined)]) + expect(profile.meta.profile).toBe('guest') + }) +}) diff --git a/src/lib/app/state/auth.svelte.test.ts b/src/lib/app/state/auth.svelte.test.ts index 5b8a17a2..1aaeda17 100644 --- a/src/lib/app/state/auth.svelte.test.ts +++ b/src/lib/app/state/auth.svelte.test.ts @@ -1,3 +1,5 @@ +import { AsyncLocalStorage } from 'node:async_hooks' +import type { RequestEvent } from '@sveltejs/kit' import { describe, it, expect, vi } from 'vitest' // Mock browser environment and dependencies before importing the module @@ -17,6 +19,7 @@ vi.mock('$lib/server/session', () => ({ import { isAuthenticated, profile, + type ServerSession, type ProfileInfo, type GuestProfile, type AuthenticatedProfile, @@ -185,3 +188,321 @@ describe('Profile.syncFromServer', () => { expect(profile.meta.profile).toBe('guest') }) }) + +/** + * Profile during a server render. + * + * One Node process renders every request, so `profile` cannot answer from + * module state: `meta` is one visitor's localStorage, and on the server there + * is no such thing. Reads must resolve against the request that is executing, + * and writes must not reach across requests — a logged-in render must never + * leave its account visible to the anonymous render running beside it. + * + * `browser: false` is mocked file-wide at the top, which is the server path. + */ +const SEALED_TOKEN = 'sealed-token-must-never-leak' + +// Deliberately NOT the mocked DEFAULT_INSTANCE_URL ('https://coves.social'): +// a profile carrying this value can only have come from the request, never +// from the guest default. +const ACCOUNT_INSTANCE = 'https://upstream.internal.example' + +const account = (handle: string, did: string) => ({ + did, + handle, + instance: ACCOUNT_INSTANCE, + sealedToken: SEALED_TOKEN, + avatar: 'https://cdn.example/avatar.png', +}) + +const MARI = account('mari.test', 'did:plc:abcdefghijklmnopqrstuvwx') +const ALEX = account('alex.test', 'did:plc:zyxwvutsrqponmlkjihgfedc') + +const authedLocals = (who: typeof MARI) => ({ + authenticated: true, + account: who, + authToken: SEALED_TOKEN, +}) + +const anonLocals = () => ({ authenticated: false }) + +const eventFor = (auth: unknown): RequestEvent => + ({ locals: { auth } }) as unknown as RequestEvent + +/** + * A fresh module registry per test. `request-event` is imported from the SAME + * registry as `auth.svelte`, otherwise the accessor lands on a different copy + * of the module than the one Profile reads through and every test silently + * sees a guest. + */ +async function freshAuth() { + vi.resetModules() + const { installRequestEventAccessor } = + await import('$lib/app/util/request-event') + const authModule = await import('./auth.svelte') + return { ...authModule, installRequestEventAccessor } +} + +describe('Profile — server render', () => { + it('resolves the current profile from the in-flight request', async () => { + const { profile, installRequestEventAccessor } = await freshAuth() + installRequestEventAccessor(() => eventFor(authedLocals(MARI))) + + expect(profile.current.type).toBe('authenticated') + expect(profile.current.handle).toBe('mari.test') + expect(profile.current.did).toBe(MARI.did) + expect(profile.current.avatar).toBe(MARI.avatar) + expect(profile.current.instance).toBe(ACCOUNT_INSTANCE) + // Legacy marker the navbar and sidebar still gate on + // (`{#if profile.current?.jwt}`); it is not a token. + expect(profile.current.jwt).toBe('authenticated') + expect(profile.isAuthenticated).toBe(true) + expect(profile.isDefaultProfile).toBe(false) + }) + + it('resolves a guest for an unauthenticated request', async () => { + const { profile, installRequestEventAccessor } = await freshAuth() + installRequestEventAccessor(() => eventFor(anonLocals())) + + expect(profile.current.type).toBe('guest') + expect(profile.isAuthenticated).toBe(false) + }) + + it('resolves a guest when there is no request at all', async () => { + // Module evaluation, a unit test, a background job: reads must degrade to + // a guest rather than throw or hand back the last request's account. + const { profile } = await freshAuth() + + expect(profile.current.type).toBe('guest') + expect(profile.isAuthenticated).toBe(false) + }) + + it('never exposes the sealed token on the profile', async () => { + const { profile, installRequestEventAccessor } = await freshAuth() + installRequestEventAccessor(() => eventFor(authedLocals(MARI))) + + const serialized = JSON.stringify(profile.current) + expect(serialized).not.toContain(SEALED_TOKEN) + expect(serialized).toContain('mari.test') + }) + + describe('concurrent requests do not share a profile', () => { + it('reports each request its own account from the same profile object', async () => { + const { profile, installRequestEventAccessor } = await freshAuth() + + const als = new AsyncLocalStorage() + installRequestEventAccessor(() => als.getStore()) + + // Captured outside every context: all three renders below read this + // very same object and must still disagree. + const shared = profile + + const ROUNDS = 10 + const render = (auth: unknown): Promise => + als.run(eventFor(auth), async () => { + const seen: string[] = [] + for (let round = 0; round < ROUNDS; round++) { + seen.push( + `${shared.current.type}:${shared.current.handle ?? '-'}:${shared.isAuthenticated}`, + ) + // Yield, so the other contexts run between two reads of ours. + await Promise.resolve() + } + return seen + }) + + const [mari, anon, alex] = await Promise.all([ + render(authedLocals(MARI)), + render(anonLocals()), + render(authedLocals(ALEX)), + ]) + + expect(mari).toEqual( + Array(ROUNDS).fill('authenticated:mari.test:true'), + ) + expect(anon).toEqual(Array(ROUNDS).fill('guest:-:false')) + expect(alex).toEqual( + Array(ROUNDS).fill('authenticated:alex.test:true'), + ) + }) + }) + + describe('writes cannot reach across requests', () => { + it('syncFromServer during one render does not change another', async () => { + const { profile, installRequestEventAccessor } = await freshAuth() + const als = new AsyncLocalStorage() + installRequestEventAccessor(() => als.getStore()) + + als.run(eventFor(authedLocals(MARI)), () => { + // A component calling this during SSR must not publish its account to + // the process. + profile.syncFromServer({ + authenticated: true, + activeAccountId: MARI.did, + account: { + id: MARI.did, + did: MARI.did, + handle: MARI.handle, + instance: ACCOUNT_INSTANCE, + avatar: MARI.avatar, + }, + } as unknown as ServerSession) + }) + + const anonymous = als.run(eventFor(anonLocals()), () => ({ + type: profile.current.type, + authenticated: profile.isAuthenticated, + })) + + expect(anonymous).toEqual({ type: 'guest', authenticated: false }) + }) + + it('assigning profile.current during a render is a no-op', async () => { + const { profile, installRequestEventAccessor } = await freshAuth() + installRequestEventAccessor(() => eventFor(anonLocals())) + + // id 'guest' deliberately COLLIDES with the profile already in `meta`. + // The setter overwrites by id, so an implementation that still writes + // through to module state on the server really does corrupt the render + // here — an id that matched nothing would let the test pass for the + // wrong reason. + const impostor = { + type: 'authenticated', + id: 'guest', + instance: ACCOUNT_INSTANCE, + jwt: 'authenticated', + did: MARI.did, + handle: MARI.handle, + } as unknown as AuthenticatedProfile + + profile.current = impostor + + expect(profile.current.type).toBe('guest') + expect(profile.isAuthenticated).toBe(false) + }) + }) +}) + +describe('profileFromSession', () => { + it('maps an authenticated session onto an authenticated profile', async () => { + const { profileFromSession } = await freshAuth() + + const mapped = profileFromSession({ + authenticated: true, + activeAccountId: MARI.did, + account: { + id: MARI.did, + did: MARI.did, + handle: MARI.handle, + instance: ACCOUNT_INSTANCE, + avatar: MARI.avatar, + }, + } as unknown as ServerSession) + + expect(mapped).toEqual({ + type: 'authenticated', + id: MARI.did, + instance: ACCOUNT_INSTANCE, + jwt: 'authenticated', + did: MARI.did, + handle: MARI.handle, + avatar: MARI.avatar, + }) + }) + + it('maps an unauthenticated session, and no session at all, onto a guest', async () => { + const { profileFromSession } = await freshAuth() + + expect( + profileFromSession({ + authenticated: false, + } as unknown as ServerSession).type, + ).toBe('guest') + expect(profileFromSession(undefined).type).toBe('guest') + }) +}) + +describe('Profile.meta — server render', () => { + it('describes only the account of the request being rendered', async () => { + const { profile, installRequestEventAccessor } = await freshAuth() + installRequestEventAccessor(() => eventFor(authedLocals(MARI))) + + // `meta` is what ProfileSelection renders from — the account switcher + // lists `meta.profiles`. On the server it must describe this request, not + // whatever the process last wrote. + expect(profile.meta.profiles).toHaveLength(1) + expect(profile.meta.profiles[0]).toEqual(profile.current) + expect(profile.meta.profile).toBe(MARI.did) + }) + + it('describes a lone guest for an anonymous request', async () => { + const { profile, installRequestEventAccessor } = await freshAuth() + installRequestEventAccessor(() => eventFor(anonLocals())) + + expect(profile.meta.profiles).toHaveLength(1) + expect(profile.meta.profiles[0].type).toBe('guest') + expect(profile.meta.profile).toBe('guest') + }) + + it('does not leak one request’s account list into another', async () => { + const { profile, installRequestEventAccessor } = await freshAuth() + const als = new AsyncLocalStorage() + installRequestEventAccessor(() => als.getStore()) + + const shared = profile + const ROUNDS = 10 + const render = (auth: unknown): Promise => + als.run(eventFor(auth), async () => { + const seen: string[] = [] + for (let round = 0; round < ROUNDS; round++) { + seen.push( + `${shared.meta.profile}:${shared.meta.profiles.map((p) => p.handle ?? '-').join(',')}`, + ) + await Promise.resolve() + } + return seen + }) + + const [mari, anon, alex] = await Promise.all([ + render(authedLocals(MARI)), + render(anonLocals()), + render(authedLocals(ALEX)), + ]) + + expect(mari).toEqual(Array(ROUNDS).fill(`${MARI.did}:mari.test`)) + expect(anon).toEqual(Array(ROUNDS).fill('guest:-')) + expect(alex).toEqual(Array(ROUNDS).fill(`${ALEX.did}:alex.test`)) + }) +}) + +describe('sessionFromLocals — shape parity with the server’s own mapper', () => { + it('builds the same account fields the server sends to the client', async () => { + const { sessionFromLocals } = await freshAuth() + // The REAL server module, not the file-wide `{}` mock: these two mappers + // describe the same account from different sides, and a field added to + // one and not the other is a silent divergence between what SSR renders + // and what the client hydrates with. + const { toClientAccount } = await vi.importActual< + typeof import('$lib/server/session') + >('$lib/server/session') + + const mapped = sessionFromLocals(authedLocals(MARI) as never) + // Narrowing rather than `!`: `ClientSession` is a discriminated union, and + // only the authenticated arm carries an account. + if (!mapped?.authenticated) { + throw new Error('expected an authenticated session') + } + + expect(Object.keys(mapped.account).sort()).toEqual( + Object.keys(toClientAccount(MARI as never)).sort(), + ) + expect(mapped.account).toEqual(toClientAccount(MARI as never)) + }) + + it('maps an unauthenticated request to no session at all', async () => { + const { sessionFromLocals } = await freshAuth() + + expect(sessionFromLocals(anonLocals() as never)).toBeUndefined() + expect(sessionFromLocals(undefined)).toBeUndefined() + }) +}) diff --git a/src/lib/app/state/auth.svelte.ts b/src/lib/app/state/auth.svelte.ts index e126a034..c8f1482e 100644 --- a/src/lib/app/state/auth.svelte.ts +++ b/src/lib/app/state/auth.svelte.ts @@ -2,6 +2,7 @@ import { browser } from '$app/environment' import { DEFAULT_INSTANCE_URL } from './instance/env' import { moveItem } from '../util/array' import { log } from '$lib/app/util/log' +import { currentRequestEvent } from '$lib/app/util/request-event' import type { ClientSession, DID, @@ -227,13 +228,37 @@ export interface LogoutResult { } class Profile { - meta = $state( + #meta = $state( getFromStorage('profileData', isValidProfileData) ?? { profiles: [createGuestProfile()], profile: 'guest', }, ) + /** + * The account list this read belongs to. + * + * In the browser it is module state, restored from localStorage. During a + * server render it describes the in-flight request instead: the account + * switcher and the sidebar render from `meta`, and one process renders every + * visitor, so module state here would show them the previous request's + * account. + * + * Outside a request — module evaluation, a unit test, a background job — + * there is no visitor to describe and the process's own state is all there + * is. That state is a lone guest unless something on this process wrote to + * it, and only browser-side code paths do. + */ + get meta(): ProfileData { + if (browser) return this.#meta + + const event = currentRequestEvent() + if (event === undefined) return this.#meta + + const current = profileFromSession(sessionFromLocals(event.locals.auth)) + return { profiles: [current], profile: current.id } + } + #current = $derived( this.meta.profiles.find((i) => i.id == this.meta.profile) ?? createGuestProfile(), @@ -243,12 +268,27 @@ class Profile { return createGuestProfile() } - get current() { - return this.#current + /** + * The profile this read belongs to. + * + * In the browser that is module state, backed by localStorage — one user, + * one answer. On the server one process renders every visitor, so the answer + * comes from the request that is currently executing and nothing is + * remembered between requests: a logged-in render must never leave its + * account visible to the anonymous render running beside it. + */ + get current(): ProfileInfo { + if (browser) return this.#current + return profileFromSession( + sessionFromLocals(currentRequestEvent()?.locals.auth), + ) } - set current(value) { + set current(value: ProfileInfo) { if (!value) return + // `meta` is shared by every in-flight request on the server, where the + // request — not an assignment — decides who is signed in. + if (!browser) return const index = this.meta.profiles.findLastIndex((i) => i.id === value.id) if (index != -1) this.meta.profiles[index] = value } @@ -272,19 +312,9 @@ class Profile { return } - // Convert server account to client ProfileInfo format - const serverProfile: AuthenticatedProfile = { - type: 'authenticated', - id: serverSession.account.id, - instance: serverSession.account.instance, - jwt: 'authenticated', - did: serverSession.account.did, - handle: serverSession.account.handle, - avatar: serverSession.account.avatar, - } - - // Update local state - this.meta.profiles = [serverProfile] + // Through the shared mapper, so what the client adopts here and what the + // server render produced cannot drift apart field by field. + this.meta.profiles = [profileFromSession(serverSession)] this.meta.profile = serverSession.activeAccountId } @@ -386,10 +416,11 @@ class Profile { } get isDefaultProfile(): boolean { - // A default/guest profile has type 'guest' + // Reads `current`, not `#current`: on the server the profile belongs to + // the in-flight request, and module state has no say in it. + const current = this.current return ( - this.#current.type === 'guest' && - this.#current.instance == DEFAULT_INSTANCE_URL + current.type === 'guest' && current.instance == DEFAULT_INSTANCE_URL ) } @@ -399,7 +430,7 @@ class Profile { * @returns `true` if the profile is authenticated (type === 'authenticated') */ get isAuthenticated(): boolean { - return this.#current.type === 'authenticated' + return this.current.type === 'authenticated' } // TODO(coves-migration): Implement role checking via Coves API when roles endpoint is available. @@ -466,6 +497,57 @@ class Profile { export const profile = new Profile() +/** + * Maps a server session onto a client profile. + * + * The one place that conversion lives, so the server render (which resolves + * the profile from the in-flight request) and `syncFromServer` (which adopts + * it in the browser) cannot drift apart. + */ +export function profileFromSession( + session: ServerSession | undefined, +): ProfileInfo { + if (!session?.authenticated) return createGuestProfile() + + return { + type: 'authenticated', + id: session.account.id, + instance: session.account.instance, + jwt: 'authenticated', + did: session.account.did, + handle: session.account.handle, + avatar: session.account.avatar, + } +} + +/** + * Narrows the request's auth state to the client-safe session shape. + * + * Mirrors `toClientSession` in `$lib/server/session`, which this layer may + * import types from but not code. The sealed token is deliberately dropped: + * nothing on a profile is allowed to carry it, since profiles are serialized + * into the page and into localStorage. + */ +export function sessionFromLocals( + auth: App.AuthState | undefined, +): ServerSession | undefined { + if (!auth?.authenticated) return undefined + + const { account } = auth + return { + authenticated: true, + // The UI identifies accounts by `id`; the DID is what fills that role. + activeAccountId: account.did, + account: { + id: account.did, + did: account.did, + handle: account.handle, + instance: account.instance, + avatar: account.avatar, + }, + } +} + /** * Creates a default guest profile. */ diff --git a/src/lib/app/state/i18n/dictionary.test.ts b/src/lib/app/state/i18n/dictionary.test.ts new file mode 100644 index 00000000..2c321f1a --- /dev/null +++ b/src/lib/app/state/i18n/dictionary.test.ts @@ -0,0 +1,247 @@ +/** + * Pins dictionary loading, flattening, fallback and caching. + * + * Every expectation reads the REAL json in this directory — those files are + * the data, and a test that mocked them would prove nothing about the + * flattening. Each test gets a fresh module instance so the "not yet loaded" + * cases cannot silently depend on a sibling test having loaded a locale. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const logged = vi.hoisted(() => ({ + error: [] as unknown[][], + warn: [] as unknown[][], +})) + +vi.mock('$lib/app/util/log', () => ({ + log: { + error: (...args: unknown[]) => { + logged.error.push(args) + }, + warn: (...args: unknown[]) => { + logged.warn.push(args) + }, + }, +})) + +/** + * Polish stands in for a dictionary chunk that will not load — the everyday + * case being a client that survived a redeploy and asks for a hashed chunk + * that no longer exists. + * + * The failure lives in a `default` GETTER rather than a throwing factory, so + * it fires once per ACCESS. Vitest caches the module, so a factory that threw + * would be re-served from cache and a retry would be indistinguishable from a + * short-circuit; a getter is re-run every time the loader reads `.default`, + * which makes attempts countable. + */ +const plChunk = vi.hoisted(() => ({ attempts: 0, failUntilAttempt: 0 })) + +vi.mock('./pl.json', () => ({ + get default() { + plChunk.attempts += 1 + if (plChunk.attempts <= plChunk.failUntilAttempt) { + throw new Error('Failed to fetch dynamically imported module: pl.json') + } + return { account: { login: 'Zaloguj się' } } + }, +})) + +type DictionaryModule = typeof import('./dictionary') + +const freshModule = (): Promise => import('./dictionary') + +/** Reads `account.login` out of a loaded locale, for the retry assertion. */ +const lookupOnce = ( + mod: DictionaryModule, + locale: string, +): string | undefined => mod.lookup(locale, 'account.login') + +beforeEach(() => { + vi.resetModules() + logged.error.length = 0 + logged.warn.length = 0 + plChunk.attempts = 0 + plChunk.failUntilAttempt = 0 +}) + +describe('dictionary', () => { + describe('lookup — flattened dotted keys', () => { + it('resolves a nested json path as a dotted key', async () => { + const { ensureLoaded, lookup } = await freshModule() + await ensureLoaded('en') + + expect(lookup('en', 'account.login')).toBe('Log in') + }) + + it('returns undefined for an interior (object) node', async () => { + const { ensureLoaded, lookup } = await freshModule() + await ensureLoaded('en') + + // `account` is an object in en.json. Flattening must not surface it as + // a translatable value — the caller gets undefined and falls through. + // Paired with the leaf below so this cannot pass on an empty cache. + expect(lookup('en', 'account.login')).toBe('Log in') + expect(lookup('en', 'account')).toBeUndefined() + }) + + it('returns undefined for an unknown key rather than the key itself', async () => { + const { ensureLoaded, lookup } = await freshModule() + await ensureLoaded('en') + + // The key-as-value behaviour belongs to `translate`, not `lookup`. + expect(lookup('en', 'account.login')).toBe('Log in') + expect(lookup('en', 'no.such.key.here')).toBeUndefined() + }) + }) + + describe('translate — lookup, fallback, interpolation', () => { + it('returns the locale string once that locale is loaded', async () => { + const { ensureLoaded, translate } = await freshModule() + await ensureLoaded('de') + + expect(translate('de', 'account.login')).toBe('Anmelden') + }) + + it('falls back to en for a key the locale does not define', async () => { + const { ensureLoaded, translate } = await freshModule() + await ensureLoaded('de') + + // `account.block` exists in en.json and not in de.json. + expect(translate('de', 'account.block')).toBe('Block user') + }) + + it('returns the key itself when neither the locale nor en defines it', async () => { + const { ensureLoaded, translate } = await freshModule() + await ensureLoaded('de') + + expect(translate('de', 'no.such.key.here')).toBe('no.such.key.here') + }) + + it('interpolates params into the resolved string', async () => { + const { ensureLoaded, translate } = await freshModule() + await ensureLoaded('de') + + expect(translate('de', 'account.versionGate', { version: '1.0' })).toBe( + 'Diese Version von Kelp unterstützt Instanzen mit 1.0 oder höher.', + ) + }) + + it('returns the en string for a locale that has not been loaded yet', async () => { + const { translate } = await freshModule() + + // No ensureLoaded call at all: en must be available without loading, and + // an unloaded locale must degrade to it rather than throw or return + // undefined. This is what makes a server render safe before any await. + expect(translate('de', 'account.login')).toBe('Log in') + }) + }) + + describe('cache — loaded once, never mutated', () => { + it('does not reload a locale that is already cached', async () => { + const { ensureLoaded, getDictionary } = await freshModule() + + await ensureLoaded('de') + const first = getDictionary('de') + await ensureLoaded('de') + const second = getDictionary('de') + + expect(first).toBeDefined() + // Object identity, not deep equality: a reload would build a new object. + expect(second).toBe(first) + }) + + it('leaves other locales untouched when a locale loads', async () => { + const { ensureLoaded, getDictionary, lookup } = await freshModule() + + await ensureLoaded('en') + const enBefore = getDictionary('en') + expect(lookup('en', 'account.login')).toBe('Log in') + + await ensureLoaded('de') + + expect(getDictionary('en')).toBe(enBefore) + expect(lookup('en', 'account.login')).toBe('Log in') + expect(lookup('de', 'account.login')).toBe('Anmelden') + }) + }) + + describe('locale codes are data, not property names', () => { + it('does not mistake a prototype key for a loadable locale', async () => { + const { ensureLoaded, getDictionary, lookup } = await freshModule() + + // `loaders` is a plain object, so `loaders['__proto__']` yields + // Object.prototype and `loaders['constructor']` yields a function — + // neither is a loader, and both are reachable from an Accept-Language + // header or a `?lang=` param. + await expect(ensureLoaded('__proto__')).resolves.toBeUndefined() + await expect(ensureLoaded('constructor')).resolves.toBeUndefined() + + expect(getDictionary('__proto__')).toBeUndefined() + expect(getDictionary('constructor')).toBeUndefined() + expect(lookup('__proto__', 'account.login')).toBeUndefined() + }) + + it('falls back to en for a prototype key', async () => { + const { translate } = await freshModule() + + expect(translate('__proto__', 'account.login')).toBe('Log in') + expect(translate('constructor', 'account.login')).toBe('Log in') + }) + + it('does not resolve inherited object properties as translations', async () => { + const { ensureLoaded, lookup } = await freshModule() + await ensureLoaded('en') + + // `toString` and friends live on every object's prototype; a lookup for + // one must miss rather than return a function's source. + expect(lookup('en', 'toString')).toBeUndefined() + expect(lookup('en', 'constructor')).toBeUndefined() + }) + }) + + describe('a dictionary that cannot be fetched', () => { + it('resolves instead of throwing, and reports it once', async () => { + const { ensureLoaded } = await freshModule() + plChunk.failUntilAttempt = 1 + + // Today this rejection propagates. From `+layout.server.ts` that is an + // unhandled load failure and the reader gets a 500 — for a translation + // file. Degrading to English is the proportionate response. + await expect(ensureLoaded('pl')).resolves.toBeUndefined() + + expect(logged.warn.length + logged.error.length).toBe(1) + expect(JSON.stringify([...logged.warn, ...logged.error])).toContain('pl') + }) + + it('leaves the locale uncached so lookups fall back to en', async () => { + const { ensureLoaded, getDictionary, translate } = await freshModule() + plChunk.failUntilAttempt = 1 + + await ensureLoaded('pl') + + // Nothing half-built left behind: no empty dictionary that would make + // every key resolve to itself instead of to English. + expect(getDictionary('pl')).toBeUndefined() + expect(translate('pl', 'account.login')).toBe('Log in') + }) + + it('retries on the next call rather than caching the failure', async () => { + const { ensureLoaded, getDictionary } = await freshModule() + plChunk.failUntilAttempt = 1 + + await ensureLoaded('pl') + expect(plChunk.attempts).toBe(1) + expect(getDictionary('pl')).toBeUndefined() + + // A chunk fetch fails for transient reasons — a flaky network, a deploy + // in flight. Remembering the failure would strand that reader in English + // for the life of the tab. + await ensureLoaded('pl') + + expect(plChunk.attempts).toBe(2) + expect(getDictionary('pl')).toBeDefined() + expect(lookupOnce(await freshModule(), 'pl')).toBe('Zaloguj się') + }) + }) +}) diff --git a/src/lib/app/state/i18n/dictionary.ts b/src/lib/app/state/i18n/dictionary.ts new file mode 100644 index 00000000..a786ce0f --- /dev/null +++ b/src/lib/app/state/i18n/dictionary.ts @@ -0,0 +1,140 @@ +/** + * Locale dictionary loading and key lookup. + * + * Dictionaries are flattened to dotted keys on load, cached immutably per + * locale, and fall back to `en`. Pinned by `dictionary.test.ts`. + * + * `en` is imported statically rather than through a loader: a server render is + * synchronous, so the fallback locale has to be readable before any await. + */ +import { log } from '$lib/app/util/log' +import enSource from './en.json' +import { interpolate } from './interpolate' + +/** The locale every lookup falls back to; always available without loading. */ +export const FALLBACK_LOCALE = 'en' + +/** A loaded dictionary: dotted key -> template string. */ +export type Dictionary = Readonly> + +/** One lazily-imported json file per translatable locale, keyed by its code. */ +const loaders: Readonly< + Record Promise<{ readonly default: unknown }>> +> = { + ar: () => import('./ar.json'), + bg: () => import('./bg.json'), + de: () => import('./de.json'), + es: () => import('./es.json'), + et: () => import('./et.json'), + fi: () => import('./fi.json'), + fr: () => import('./fr.json'), + he: () => import('./he.json'), + hu: () => import('./hu.json'), + ja: () => import('./ja.json'), + nl: () => import('./nl.json'), + pl: () => import('./pl.json'), + pt: () => import('./pt.json'), + 'pt-BR': () => import('./pt-BR.json'), + ru: () => import('./ru.json'), + tr: () => import('./tr.json'), + 'zh-Hans': () => import('./zh-Hans.json'), + 'zh-Hant': () => import('./zh-Hant.json'), +} + +/** + * Every locale code the app ships a dictionary for, `en` included. + * Derived from the loader map so the two can never drift apart. + */ +export const AVAILABLE_LOCALES: readonly string[] = Object.freeze([ + FALLBACK_LOCALE, + ...Object.keys(loaders), +]) + +/** + * Collects the string leaves of `node` into `flat` under dotted paths. + * Interior nodes are not themselves translatable, so they get no entry. + */ +function collect( + node: unknown, + prefix: string, + flat: Record, +): void { + if (node === null || typeof node !== 'object') return + for (const [key, value] of Object.entries(node)) { + const path = prefix === '' ? key : `${prefix}.${key}` + if (typeof value === 'string') { + flat[path] = value + } else { + collect(value, path, flat) + } + } +} + +function toDictionary(source: unknown): Dictionary { + const flat: Record = {} + collect(source, '', flat) + return Object.freeze(flat) +} + +const cache = new Map([ + [FALLBACK_LOCALE, toDictionary(enSource)], +]) + +/** Loads `locale`'s dictionary into the cache. A no-op once cached. */ +export async function ensureLoaded(locale: string): Promise { + if (cache.has(locale)) return + // Locale codes arrive from an Accept-Language header or a query param, and + // `loaders` is a plain object: `loaders['__proto__']` yields Object.prototype + // and `loaders['constructor']` a function, neither of which is a loader. + if (!Object.hasOwn(loaders, locale)) return + + let dictionary: Dictionary + try { + const loaded = await loaders[locale]() + dictionary = toDictionary(loaded.default) + } catch (err) { + // A chunk fetch fails for transient reasons — a flaky network, or a client + // that survived a redeploy asking for a hashed chunk that is gone. Letting + // it reject would fail the whole page load over a translation file, so the + // locale is simply left uncached: lookups fall back to en, and the next + // call retries rather than being stranded by a remembered failure. + log.warn( + '[i18n] dictionary failed to load, falling back to en', + err, + { locale }, + ) + return + } + + // Another caller may have finished this locale while the import was in + // flight; keep the dictionary that is already cached so its identity holds. + if (cache.has(locale)) return + cache.set(locale, dictionary) +} + +/** The cached dictionary for `locale`, or undefined if it is not loaded. */ +export function getDictionary(locale: string): Dictionary | undefined { + return cache.get(locale) +} + +/** The raw template for `key` in `locale`, or undefined. No fallback. */ +export function lookup(locale: string, key: string): string | undefined { + const dictionary = cache.get(locale) + // `hasOwn` keeps inherited names (`toString`, `constructor`) from resolving. + if (dictionary === undefined || !Object.hasOwn(dictionary, key)) { + return undefined + } + return dictionary[key] +} + +/** `lookup` + fallback to `en` + interpolation; returns `key` if unknown. */ +export function translate( + locale: string, + key: string, + params?: Record, +): string { + const template = lookup(locale, key) ?? lookup(FALLBACK_LOCALE, key) + if (template === undefined) return key + // The template may come from `en` while numbers still format in `locale`. + return interpolate(template, params, locale) +} diff --git a/src/lib/app/state/i18n/index.browser.test.ts b/src/lib/app/state/i18n/index.browser.test.ts new file mode 100644 index 00000000..7cf867cb --- /dev/null +++ b/src/lib/app/state/i18n/index.browser.test.ts @@ -0,0 +1,321 @@ +/** + * The public i18n module, exercised on the BROWSER path. + * + * In the browser there is no request event and never more than one user, so + * the locale IS module state: `locale.set` and `loadTranslations` steer what + * every component renders. Separate file from `index.test.ts` because + * `$app/environment`'s `browser` flag is mocked per-file and the two paths + * need opposite values. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { get } from 'svelte/store' + +vi.mock('$app/environment', () => ({ + browser: true, + dev: false, + building: false, + version: 'test', +})) + +/** + * Lets a test hold one locale's load open while another finishes, so the + * "which language wins" question can be asked deterministically instead of + * being decided by whichever dynamic import happens to resolve first. + */ +const logged = vi.hoisted(() => ({ + error: [] as unknown[][], + warn: [] as unknown[][], +})) + +vi.mock('$lib/app/util/log', () => ({ + log: { + error: (...args: unknown[]) => { + logged.error.push(args) + }, + warn: (...args: unknown[]) => { + logged.warn.push(args) + }, + }, +})) + +const control = vi.hoisted(() => { + const gates = new Map; open: () => void }>() + /** Locales whose dictionary load should fail, as a missing chunk would. */ + const failing = new Set() + return { + gates, + failing, + hold(locale: string) { + let open!: () => void + const promise = new Promise((resolve) => { + open = resolve + }) + const gate = { promise, open } + gates.set(locale, gate) + return gate + }, + } +}) + +vi.mock('./dictionary', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + ensureLoaded: async (locale: string): Promise => { + if (control.failing.has(locale)) { + throw new Error(`failed to fetch dictionary chunk for ${locale}`) + } + await actual.ensureLoaded(locale) + await control.gates.get(locale)?.promise + }, + } +}) + +const LOGIN_DE = 'Anmelden' +const LOGIN_FR = 'Connexion' +const LOGIN_EN = 'Log in' +// Polish is touched by no other test in this file. `vi.resetModules()` gives +// `./index` a fresh module but NOT the dictionary cache underneath it — the +// mock factory's `importOriginal()` hands back the same instance every time — +// so a locale another test already loaded would make these pass for free. +const LOGIN_PL = 'Zaloguj się' + +async function freshI18n() { + vi.resetModules() + return await import('./index') +} + +beforeEach(() => { + vi.resetModules() + control.gates.clear() + control.failing.clear() + logged.error.length = 0 + logged.warn.length = 0 +}) + +describe('i18n public module — browser path', () => { + it('follows locale.set once the dictionary is loaded', async () => { + const { t, locale, loadTranslations } = await freshI18n() + await loadTranslations('fr') + + locale.set('fr') + + expect(get(locale)).toBe('fr') + expect(get(t)('account.login')).toBe(LOGIN_FR) + expect(t.get('account.login')).toBe(LOGIN_FR) + }) + + it('loadTranslations sets the locale and t follows it', async () => { + const { t, locale, loadTranslations } = await freshI18n() + + await loadTranslations('de') + + expect(get(locale)).toBe('de') + expect(get(t)('account.login')).toBe(LOGIN_DE) + }) + + it('switches language on a later load without a reload', async () => { + const { t, locale, loadTranslations } = await freshI18n() + + await loadTranslations('de') + expect(get(t)('account.login')).toBe(LOGIN_DE) + + await loadTranslations('fr') + + expect(get(locale)).toBe('fr') + expect(get(t)('account.login')).toBe(LOGIN_FR) + }) +}) + +/** + * Everything above reads with `get()`, which subscribes and unsubscribes on + * the spot — so it would keep passing even if the store never told anyone the + * language changed. Components subscribe once and stay subscribed. These pin + * that a live subscriber is pushed a new value. + */ +describe('i18n public module — browser subscribers are notified', () => { + it('pushes a new translation to a t subscriber when locale.set runs', async () => { + const { t, locale, loadTranslations } = await freshI18n() + await loadTranslations('de') + await loadTranslations('fr') + locale.set('de') + + const seen: string[] = [] + const unsubscribe = t.subscribe((translate) => + seen.push(translate('account.login')), + ) + expect(seen).toEqual([LOGIN_DE]) + + locale.set('fr') + + expect(seen).toEqual([LOGIN_DE, LOGIN_FR]) + unsubscribe() + }) + + it('pushes a new translation to a t subscriber when loadTranslations runs', async () => { + const { t, loadTranslations } = await freshI18n() + await loadTranslations('fr') + + const seen: string[] = [] + const unsubscribe = t.subscribe((translate) => + seen.push(translate('account.login')), + ) + expect(seen).toEqual([LOGIN_FR]) + + await loadTranslations('de') + + expect(seen).toEqual([LOGIN_FR, LOGIN_DE]) + unsubscribe() + }) + + it('pushes the new code to a locale subscriber', async () => { + const { locale, loadTranslations } = await freshI18n() + await loadTranslations('de') + await loadTranslations('fr') + locale.set('de') + + const seen: string[] = [] + const unsubscribe = locale.subscribe((code) => seen.push(code)) + expect(seen).toEqual(['de']) + + locale.set('fr') + + expect(seen).toEqual(['de', 'fr']) + unsubscribe() + }) + + it('stops pushing once unsubscribed', async () => { + // The browser store keeps a subscriber registry; a component that is torn + // down must leave it, or every language change walks a growing list of + // dead closures. + const { locale, loadTranslations } = await freshI18n() + await loadTranslations('de') + await loadTranslations('fr') + locale.set('de') + + const seen: string[] = [] + const unsubscribe = locale.subscribe((code) => seen.push(code)) + unsubscribe() + + locale.set('fr') + + expect(seen).toEqual(['de']) + }) +}) + +describe('i18n public module — locale.set loads what it names', () => { + it('loads the dictionary and notifies subscribers', async () => { + const { t, locale } = await freshI18n() + const { getDictionary } = await import('./dictionary') + + // Stated as a precondition rather than assumed: nothing has loaded pl. + // Setting the locale has to be enough on its own — a component calling + // `locale.set` is not going to call `loadTranslations` as well, and until + // the dictionary arrives every key falls back to en. + expect(getDictionary('pl')).toBeUndefined() + + const seen: string[] = [] + const unsubscribe = t.subscribe((translate) => + seen.push(translate('account.login')), + ) + expect(seen).toEqual([LOGIN_EN]) + + locale.set('pl') + + await vi.waitFor(() => { + expect(get(locale)).toBe('pl') + expect(get(t)('account.login')).toBe(LOGIN_PL) + }) + // A live subscriber must be told, not just a fresh `get()`. + expect(seen.at(-1)).toBe(LOGIN_PL) + unsubscribe() + }) + + it('resolves a regional tag through the alias map', async () => { + const { t, locale } = await freshI18n() + + locale.set('de-DE') + + await vi.waitFor(() => { + // `de-DE` has no dictionary of its own; German is what serves it. + expect(get(locale)).toBe('de') + expect(get(t)('account.login')).toBe(LOGIN_DE) + }) + }) +}) + +describe('i18n public module — overlapping loads', () => { + it('ends on the language requested last, not the one that finished last', async () => { + const { t, locale, loadTranslations } = await freshI18n() + + // German is requested first but held open; French is requested second and + // completes immediately. A reader who asked for French must not be flipped + // back to German when the earlier request finally lands. + const german = control.hold('de') + const germanLoad = loadTranslations('de') + await loadTranslations('fr') + + german.open() + await germanLoad + + expect(get(locale)).toBe('fr') + expect(get(t)('account.login')).toBe(LOGIN_FR) + }) +}) + +describe('i18n public module — a dictionary that cannot be fetched', () => { + it('keeps the chosen language, falls back to en, and reports it once', async () => { + const { t, locale } = await freshI18n() + const { getDictionary } = await import('./dictionary') + // What a stale client sees after a redeploy: the chunk it asks for is gone. + // Hungarian is touched by no other test in this file: the dictionary cache + // survives `vi.resetModules()`, so a locale another test loaded would + // already be cached and this path would never run. + expect(getDictionary('hu')).toBeUndefined() + control.failing.add('hu') + + locale.set('hu') + + await vi.waitFor(() => { + expect(logged.warn).toHaveLength(1) + }) + + // A warning, not an error: the UI still works, in English. The locale + // stays committed so the language menu reflects what the reader picked + // rather than silently snapping back. + expect(logged.error).toHaveLength(0) + expect(get(locale)).toBe('hu') + expect(get(t)('account.login')).toBe(LOGIN_EN) + expect(JSON.stringify(logged.warn[0])).toContain('hu') + }) + + it('does not reject out of locale.set', async () => { + const { locale } = await freshI18n() + control.failing.add('hu') + + // `locale.set` returns void; a rejection escaping it becomes an unhandled + // rejection that no caller can catch. + expect(() => locale.set('hu')).not.toThrow() + + await vi.waitFor(() => { + expect(logged.warn).toHaveLength(1) + }) + }) + + it('still switches language on a later, working load', async () => { + const { t, locale } = await freshI18n() + control.failing.add('hu') + locale.set('hu') + await vi.waitFor(() => { + expect(logged.warn).toHaveLength(1) + }) + + // A failed load must not wedge the store. + locale.set('de') + + await vi.waitFor(() => { + expect(get(locale)).toBe('de') + expect(get(t)('account.login')).toBe(LOGIN_DE) + }) + }) +}) diff --git a/src/lib/app/state/i18n/index.test.ts b/src/lib/app/state/i18n/index.test.ts new file mode 100644 index 00000000..40c21dd8 --- /dev/null +++ b/src/lib/app/state/i18n/index.test.ts @@ -0,0 +1,225 @@ +/** + * The public i18n module, exercised on the SERVER path. + * + * One Node process renders every request, so `t` and `locale` cannot hold a + * single module-level value: they must resolve against whichever request is + * executing when they are read. These tests hold two request contexts open at + * once through `AsyncLocalStorage` — the same mechanism SvelteKit's + * `getRequestEvent()` uses — and read the SAME store objects from both. + * + * A svelte `readable()`/`derived()` singleton cannot satisfy the isolation + * test by construction: it computes once and pushes one value to every + * subscriber. Passing it requires stores that recompute on every subscribe. + */ +import { AsyncLocalStorage } from 'node:async_hooks' +import type { RequestEvent } from '@sveltejs/kit' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { get } from 'svelte/store' +import type { TranslateFn } from './index' + +vi.mock('$app/environment', () => ({ + browser: false, + dev: false, + building: false, + version: 'test', +})) + +const LOGIN_EN = 'Log in' +const LOGIN_DE = 'Anmelden' +const LOGIN_FR = 'Connexion' + +/** Every locale the app ships a dictionary for. */ +const EXPECTED_LOCALES = [ + 'ar', + 'bg', + 'de', + 'en', + 'es', + 'et', + 'fi', + 'fr', + 'he', + 'hu', + 'ja', + 'nl', + 'pl', + 'pt', + 'pt-BR', + 'ru', + 'tr', + 'zh-Hans', + 'zh-Hant', +] + +const asEvent = (lang: string): RequestEvent => + ({ locals: { lang } }) as unknown as RequestEvent + +/** + * A fresh module registry per test. + * + * `request-event` is imported from the SAME registry as `index`, otherwise the + * accessor would be installed on a different copy of the module than the one + * `index` reads through, and every server test would silently fall back to en. + * Resetting also clears the dictionary cache, which is what lets the + * "before anything loaded de" case be asserted at all. + */ +async function freshI18n() { + vi.resetModules() + const { installRequestEventAccessor } = + await import('$lib/app/util/request-event') + const i18n = await import('./index') + return { ...i18n, installRequestEventAccessor } +} + +beforeEach(() => { + vi.resetModules() +}) + +describe('i18n public module — server path', () => { + describe('reads the locale from the in-flight request', () => { + it('resolves t and locale from locals.lang', async () => { + const { t, locale, loadTranslations, installRequestEventAccessor } = + await freshI18n() + installRequestEventAccessor(() => asEvent('de')) + await loadTranslations('de') + // Loaded AFTER de, so any implementation holding one process-wide + // "current locale" is now pointing at fr while this request wants de. + // Without this line the test passes on a global-locale design by + // coincidence. + await loadTranslations('fr') + + expect(get(t)('account.login')).toBe(LOGIN_DE) + expect(t.get('account.login')).toBe(LOGIN_DE) + expect(get(locale)).toBe('de') + expect(locale.get()).toBe('de') + }) + + it('ignores locale.set — on the server the request is the only source of truth', async () => { + const { t, locale, loadTranslations, installRequestEventAccessor } = + await freshI18n() + installRequestEventAccessor(() => asEvent('de')) + await loadTranslations('de') + await loadTranslations('fr') + + // A component reaching for `locale.set` during SSR must not be able to + // repoint a render that another request is also using. + locale.set('fr') + + expect(get(locale)).toBe('de') + expect(get(t)('account.login')).toBe(LOGIN_DE) + expect(t.get('account.login')).toBe(LOGIN_DE) + }) + + it('falls back to en when no accessor is installed', async () => { + // Module-evaluation time, a server-side unit test, a background job: + // there is no request, and reads must degrade rather than throw. + const { t } = await freshI18n() + + expect(t.get('account.login')).toBe(LOGIN_EN) + expect(get(t)('account.login')).toBe(LOGIN_EN) + }) + }) + + describe('concurrent requests do not share a locale', () => { + it('yields a different language per context from the same store objects', async () => { + const { t, locale, loadTranslations, installRequestEventAccessor } = + await freshI18n() + + const als = new AsyncLocalStorage() + installRequestEventAccessor(() => als.getStore()) + + await loadTranslations('de') + await loadTranslations('fr') + + // Captured once, outside both contexts: the two renders below read the + // very same store objects, and must still disagree. + const tStore = t + const localeStore = locale + + const ROUNDS = 10 + const render = (lang: string): Promise => + als.run(asEvent(lang), async () => { + const seen: string[] = [] + for (let round = 0; round < ROUNDS; round++) { + seen.push(`${get(localeStore)}:${get(tStore)('account.login')}`) + // Yield, so the other context runs between two reads of ours. + await Promise.resolve() + } + return seen + }) + + const [german, french] = await Promise.all([render('de'), render('fr')]) + + expect(german).toEqual(Array(ROUNDS).fill(`de:${LOGIN_DE}`)) + expect(french).toEqual(Array(ROUNDS).fill(`fr:${LOGIN_FR}`)) + }) + }) + + describe('translations are readable synchronously once loaded', () => { + it('returns the en string before the requested locale has loaded', async () => { + const { t, installRequestEventAccessor } = await freshI18n() + installRequestEventAccessor(() => asEvent('de')) + + // Nothing has loaded de. A server render is synchronous, so this must + // produce the fallback rather than throwing or returning a promise. + expect(t.get('account.login')).toBe(LOGIN_EN) + }) + + it('returns the locale string synchronously after it has loaded', async () => { + const { t, loadTranslations, installRequestEventAccessor } = + await freshI18n() + installRequestEventAccessor(() => asEvent('de')) + + await loadTranslations('de') + // Again loaded last, so a global-locale design cannot pass this by + // happening to point at de. + await loadTranslations('fr') + + // No await between the load and the read: a server render is + // synchronous and cannot wait for a dictionary here. + expect(t.get('account.login')).toBe(LOGIN_DE) + }) + }) + + describe('locales and aliases', () => { + it('lists every shipped locale', async () => { + const { locales } = await freshI18n() + + expect([...get(locales)].sort()).toEqual(EXPECTED_LOCALES) + expect([...locales.get()].sort()).toEqual(EXPECTED_LOCALES) + }) + + it('maps regional codes onto the dictionary that serves them', async () => { + const { aliases } = await freshI18n() + + expect(aliases.get('zh-CN')).toBe('zh-Hans') + expect(aliases.get('zh-TW')).toBe('zh-Hant') + expect(aliases.get('de-AT')).toBe('de') + expect(aliases.get('en-GB')).toBe('en') + }) + }) + + describe('a captured translator keeps its language', () => { + it('renders the request it came from even when called outside it', async () => { + const { t, loadTranslations, installRequestEventAccessor } = + await freshI18n() + const als = new AsyncLocalStorage() + installRequestEventAccessor(() => als.getStore()) + await loadTranslations('de') + + let captured: TranslateFn | undefined + als.run(asEvent('de'), () => { + const unsubscribe = t.subscribe((translate) => { + captured = translate + }) + unsubscribe() + }) + + // A render routinely hands `$t` to something that runs later — a toast + // callback, an error formatter, a promise continuation — by which time + // the request context is gone. The translator carries its language with + // it rather than falling back to en at the moment it is invoked. + expect(captured?.('account.login')).toBe(LOGIN_DE) + }) + }) +}) diff --git a/src/lib/app/state/i18n/index.ts b/src/lib/app/state/i18n/index.ts index 4b80dba8..b3c36e25 100644 --- a/src/lib/app/state/i18n/index.ts +++ b/src/lib/app/state/i18n/index.ts @@ -1,106 +1,58 @@ -import { default as i18n, type Config, type Parser } from 'sveltekit-i18n' - -const config: Config = { - loaders: [ - { - locale: 'en', - key: '', - loader: async () => (await import('./en.json')).default, - }, - { - locale: 'he', - key: '', - loader: async () => (await import('./he.json')).default, - }, - { - locale: 'ar', - key: '', - loader: async () => (await import('./ar.json')).default, - }, - { - locale: 'bg', - key: '', - loader: async () => (await import('./bg.json')).default, - }, - { - locale: 'de', - key: '', - loader: async () => (await import('./de.json')).default, - }, - { - locale: 'es', - key: '', - loader: async () => (await import('./es.json')).default, - }, - { - locale: 'et', - key: '', - loader: async () => (await import('./et.json')).default, - }, - { - locale: 'fi', - key: '', - loader: async () => (await import('./fi.json')).default, - }, - { - locale: 'fr', - key: '', - loader: async () => (await import('./fr.json')).default, - }, - { - locale: 'hu', - key: '', - loader: async () => (await import('./hu.json')).default, - }, - { - locale: 'ja', - key: '', - loader: async () => (await import('./ja.json')).default, - }, - { - locale: 'nl', - key: '', - loader: async () => (await import('./nl.json')).default, - }, - { - locale: 'pl', - key: '', - loader: async () => (await import('./pl.json')).default, - }, - { - locale: 'pt', - key: '', - loader: async () => (await import('./pt.json')).default, - }, - { - locale: 'pt-BR', - key: '', - loader: async () => (await import('./pt-BR.json')).default, - }, - { - locale: 'ru', - key: '', - loader: async () => (await import('./ru.json')).default, - }, - { - locale: 'tr', - key: '', - loader: async () => (await import('./tr.json')).default, - }, - { - locale: 'zh-Hans', - key: '', - loader: async () => (await import('./zh-Hans.json')).default, - }, - { - locale: 'zh-Hant', - key: '', - loader: async () => (await import('./zh-Hant.json')).default, - }, - ], - fallbackLocale: 'en', +/** + * The app's i18n surface: `t`, `locale`, `locales`, + * `loadTranslations` and `aliases`. + * + * The server renders every request in one Node process, so `t` and `locale` + * must not hold a value of their own. They are hand-rolled stores that + * recompute on every subscribe, resolving the language from the request that + * is currently executing (`locals.lang`, reached through the request-event + * accessor, which is backed by AsyncLocalStorage). A svelte `readable()` or + * `derived()` would hold one module-level value and leak one visitor's + * language into another's render. + * + * In the browser there is no request and only ever one user, so the locale is + * ordinary module state that `locale.set` and `loadTranslations` steer, and + * subscribers are notified when it changes. + */ +import { browser } from '$app/environment' +import type { Readable, Subscriber, Unsubscriber } from 'svelte/store' +import { currentRequestEvent } from '$lib/app/util/request-event' +import { log } from '$lib/app/util/log' +import { + AVAILABLE_LOCALES, + ensureLoaded, + FALLBACK_LOCALE, + getDictionary, + translate, +} from './dictionary' + +/** Translates a dotted key, interpolating `params` into the result. */ +export type TranslateFn = ( + key: string, + params?: Record, +) => string + +/** `$t(key, params)` in markup; `t.get(key, params)` outside it. */ +export interface TranslationStore extends Readable { + get(key: string, params?: Record): string +} + +/** `$locale` in markup; `locale.get()` / `locale.set()` outside it. */ +export interface LocaleStore extends Readable { + get(): string + /** Ignored on the server, where the in-flight request decides the language. */ + set(value: string): void +} + +/** The locale codes the app ships dictionaries for. */ +export interface LocalesStore extends Readable { + get(): readonly string[] } +/** + * Regional codes mapped onto the dictionary that serves them, so a visitor + * asking for `de-AT` gets German rather than the `en` fallback. + */ export const aliases = new Map([ ['zh-CN', 'zh-Hans'], ['zh-TW', 'zh-Hant'], @@ -121,8 +73,182 @@ export const aliases = new Map([ ['he-IL', 'he'], ]) -export const { t, locale, locales, loading, loadTranslations } = new i18n< - Parser.Params, - object, - object ->(config) +/** The code whose dictionary actually serves `code`. */ +function resolveLocale(code: string): string { + return aliases.get(code) ?? code +} + +const noop = (): void => {} + +/** Browser-only. On the server this is never read. */ +let clientLocale = FALLBACK_LOCALE + +/** + * The language of the render in progress. + * + * Server: whatever the in-flight request resolved, never a module-level value. + * Falls back to `en` outside a request — module evaluation, a unit test, a + * background job — so reads degrade instead of throwing. + */ +function currentLocale(): string { + if (browser) return clientLocale + return currentRequestEvent()?.locals.lang ?? FALLBACK_LOCALE +} + +interface RecomputingStore { + subscribe(run: Subscriber): Unsubscriber + /** Pushes a freshly computed value to every subscriber. Browser only. */ + notify(): void +} + +/** + * A store whose value is computed at subscribe time rather than held. + * + * On the server nothing is retained: each subscribe computes against the + * request that is executing, and there is no subscriber registry to accumulate + * across requests. In the browser subscribers are tracked so that a language + * change re-renders what is already on screen. + */ +function recomputingStore(compute: () => T): RecomputingStore { + if (!browser) { + return { + subscribe(run: Subscriber): Unsubscriber { + run(compute()) + return noop + }, + notify: noop, + } + } + + const subscribers = new Set>() + return { + subscribe(run: Subscriber): Unsubscriber { + subscribers.add(run) + run(compute()) + return () => { + subscribers.delete(run) + } + }, + notify(): void { + const value = compute() + for (const run of subscribers) run(value) + }, + } +} + +const localeStore = recomputingStore(currentLocale) + +// The locale is captured when the store is subscribed, so a `$t` handed to a +// callback keeps rendering the language of the request it came from even if it +// is invoked after that request's context has gone. +const translationStore = recomputingStore(() => { + const at = currentLocale() + return (key, params) => translate(at, key, params) +}) + +/** Tells subscribers the language or its dictionary changed. */ +function announce(): void { + localeStore.notify() + translationStore.notify() +} + +/** + * Which language request is the current one. + * + * Loading a dictionary is asynchronous, so two requests can be in flight at + * once — a `locale.set` from a language menu while `loadTranslations` is still + * fetching the previous choice. Only the newest may commit, otherwise the load + * that finishes last wins and flips the reader back to a language they have + * already moved on from. + */ +let latestRequest = 0 + +function beginRequest(): number { + latestRequest += 1 + return latestRequest +} + +/** Points the browser at `code` and tells subscribers. Browser only. */ +function commit(code: string): void { + clientLocale = code + announce() +} + +export const t: TranslationStore = { + subscribe: translationStore.subscribe, + get: (key, params) => translate(currentLocale(), key, params), +} + +export const locale: LocaleStore = { + subscribe: localeStore.subscribe, + get: currentLocale, + set(value: string): void { + // A component reaching for `locale.set` during SSR must not be able to + // repoint a render another request is also using. + if (!browser) return + + const resolved = resolveLocale(value) + const request = beginRequest() + + // Committed before the dictionary arrives: the chosen language is current + // straight away, and its keys read as `en` until the load lands. Whoever + // calls this is a language menu, not a loader — nobody else is going to + // fetch the dictionary on its behalf. + commit(resolved) + + // Whether a load is needed at all is `ensureLoaded`'s decision, not one to + // second-guess here; this only records whether anything NEW can arrive, so + // an already-cached language does not push a redundant re-render. + const hadDictionary = getDictionary(resolved) !== undefined + + void ensureLoaded(resolved) + .then(() => { + // A newer request has won in the meantime; announcing now would render + // that language with this one's dictionary. + if (request !== latestRequest) return + if (hadDictionary) return + announce() + }) + .catch((err: unknown) => { + // A warning, not an error: the UI still works, in English. The locale + // stays committed so the language menu keeps showing what the reader + // picked rather than snapping back on its own. Reported whether or not + // this request is still the current one — the load really did fail. + log.warn( + '[i18n] locale.set: dictionary failed to load, falling back to en', + err, + { locale: resolved }, + ) + }) + }, +} + +export const locales: LocalesStore = { + subscribe(run: Subscriber): Unsubscriber { + run(AVAILABLE_LOCALES) + return noop + }, + get: () => AVAILABLE_LOCALES, +} + +/** + * Ensures `code`'s dictionary is cached, and in the browser switches to it. + * + * On the server the language belongs to the request, so this only warms the + * cache — the render then reads it synchronously. + */ +export async function loadTranslations(code: string): Promise { + if (!browser) { + // The server's language belongs to the request, so this only warms the + // cache — the render then reads it synchronously. + await ensureLoaded(code) + return + } + + const request = beginRequest() + await ensureLoaded(code) + // A language requested after this one has already been committed; landing + // now would undo the reader's newer choice. + if (request !== latestRequest) return + commit(code) +} diff --git a/src/lib/app/state/i18n/interpolate.forms.test.ts b/src/lib/app/state/i18n/interpolate.forms.test.ts new file mode 100644 index 00000000..de46ce97 --- /dev/null +++ b/src/lib/app/state/i18n/interpolate.forms.test.ts @@ -0,0 +1,152 @@ +/** + * The placeholder forms that appear in the REAL dictionaries but are not + * covered by `interpolate.test.ts`. + * + * Every template below is copied verbatim from a json file in this directory + * (the source file:key is named above each one), and every expectation was + * captured by running `@sveltekit-i18n/parser-default` 1.1.1 — the library + * being replaced — not derived from reading its source. + * + * This is a behaviour-preserving migration across 446 call sites, so these + * pin what the old library DOES — with one agreed exception. `undefined:` was + * unreachable in parser-default and is now honoured, because the old result + * was a dangling half-sentence in 10 locales. Both affected expectations are + * marked DELIBERATE DIVERGENCE inline. + */ +import { describe, expect, it } from 'vitest' +import { interpolate } from './interpolate' + +// fr.json:routes.frontpage.endFeed — the well-formed representative of the +// `undefined:`-plus-nested-`default:` shape used by 10 dictionaries. +const FR_END_FEED = + 'Vous avez atteint la fin de {{community_name; undefined:the feed.; default:{{community_name}}.}}' + +// fi.json:routes.frontpage.endFeed — same shape, but the nested placeholder +// sits inside longer default text rather than being the whole default. +const FI_END_FEED = + 'Olet saavuttanut {{community_name; undefined:syötteen lopun.; default:yhteisön {{community_name}} lopun.}}' + +// ru.json:routes.frontpage.endFeed — missing its closing `}}`. Three +// dictionaries ship a template like this; see the RED report. +const RU_END_FEED_MALFORMED = + 'Вы достигли конца {{community_name; undefined:ленты.; default:{{community_name}}.' + +describe('interpolate — forms present in the real dictionaries', () => { + describe('`undefined:` as a variant option key', () => { + it('resolves the nested default when the param is present', () => { + expect(interpolate(FR_END_FEED, { community_name: 'cats' }, 'en')).toBe( + 'Vous avez atteint la fin de cats.', + ) + }) + + it('takes the undefined: branch when the param is missing', () => { + // DELIBERATE DIVERGENCE from parser-default 1.1.1, which returned the + // `default:` branch here ("...la fin de ") because it tested for + // undefined before ever reading the option list — making `undefined:` + // unreachable and leaving a dangling prefix in 10 locales. + expect(interpolate(FR_END_FEED, {}, 'en')).toBe( + 'Vous avez atteint la fin de the feed.', + ) + }) + + it('also takes the undefined: branch for the literal string "undefined"', () => { + expect( + interpolate(FR_END_FEED, { community_name: 'undefined' }, 'en'), + ).toBe('Vous avez atteint la fin de the feed.') + }) + + it('treats null as a value, not as absent', () => { + expect(interpolate(FR_END_FEED, { community_name: null }, 'en')).toBe( + 'Vous avez atteint la fin de null.', + ) + }) + }) + + describe('a nested placeholder inside a default: option', () => { + it('re-parses the default text, substituting into it', () => { + // The library re-runs the whole parse over its own output until no + // placeholders remain, which is what makes the nested form work. + expect(interpolate(FI_END_FEED, { community_name: 'kissat' }, 'en')).toBe( + 'Olet saavuttanut yhteisön kissat lopun.', + ) + }) + + it('takes the undefined: branch rather than a half-empty nested default', () => { + // DELIBERATE DIVERGENCE from parser-default 1.1.1, which rendered the + // default with an empty nested placeholder ("yhteisön lopun.", note the + // double space); honouring `undefined:` gives the translated wording. + expect(interpolate(FI_END_FEED, {}, 'en')).toBe( + 'Olet saavuttanut syötteen lopun.', + ) + }) + }) + + describe('`default` as a plain param name', () => { + it('substitutes params.default like any other param', () => { + // en.json:nav.commands.search, and 13 other en keys, use this name. + expect( + interpolate('Search for **{{default}}**', { default: 'cats' }, 'en'), + ).toBe('Search for **cats**') + expect(interpolate('Score: {{default}}', { default: 42 }, 'en')).toBe( + 'Score: 42', + ) + }) + + it('renders empty when params.default is itself missing', () => { + expect(interpolate('Search for **{{default}}**', {}, 'en')).toBe( + 'Search for ****', + ) + }) + + it('does not let a stray default param stand in for another placeholder', () => { + // `default` is an ordinary param name here and nothing more. The old + // library also treated it as the fallback value for every OTHER + // placeholder whose param was absent; no dictionary string and no call + // site relies on that, so it is deliberately not carried over. + expect( + interpolate( + 'Hello {{name}}', + { name: 'Mari', default: 'FALLBACK' }, + 'en', + ), + ).toBe('Hello Mari') + }) + + it('uses a declared default: option, not a stray default param', () => { + expect( + interpolate( + '{{users; 1:user; default:users;}}', + { default: 'FALLBACK' }, + 'en', + ), + ).toBe('users') + }) + }) + + describe('option-list punctuation and malformed templates', () => { + it('accepts a final option with no trailing semicolon', () => { + // ar.json:routes.frontpage.footer omits the `;` after the last default. + const arabicFooter = + '{{users:number}} {{users; 1:مستخدم; default:مستخدمين}} {{users; 1:نشيط; default:نشيطين}}' + expect(interpolate(arabicFooter, { users: 1 }, 'en')).toBe( + '1 مستخدم نشيط', + ) + expect(interpolate(arabicFooter, { users: 3 }, 'en')).toBe( + '3 مستخدمين نشيطين', + ) + }) + + it('does not throw on an unbalanced template, and keeps the literal text', () => { + // Deliberately not pinning the exact output: the tail is garbage either + // way. What must hold is that malformed data degrades instead of + // throwing, and that the translated prose around it still reaches the + // user. + expect(() => + interpolate(RU_END_FEED_MALFORMED, { community_name: 'X' }, 'en'), + ).not.toThrow() + expect( + interpolate(RU_END_FEED_MALFORMED, { community_name: 'X' }, 'en'), + ).toContain('Вы достигли конца') + }) + }) +}) diff --git a/src/lib/app/state/i18n/interpolate.security.test.ts b/src/lib/app/state/i18n/interpolate.security.test.ts new file mode 100644 index 00000000..2d658f1d --- /dev/null +++ b/src/lib/app/state/i18n/interpolate.security.test.ts @@ -0,0 +1,51 @@ +/** + * Param values are data, never templates. + * + * Translation templates are ours and may recurse — a variant's text is itself + * interpolated, which is how `default:{{community_name}}.` works. Param VALUES + * are not ours: they carry community names, handles, post titles and search + * queries. Feeding a value back through the parser would let a string that + * merely contains `{{ … }}` reach into the param bag it was rendered with, or + * expand without bound. + * + * These pass against the current implementation — they characterise a property + * it already has rather than driving new behaviour. Their job is to fail loudly + * if a later change to `resolve()` starts re-parsing values. Both were shown to + * bite by inverting that line and watching them fail (see the RED report). + */ +import { describe, expect, it } from 'vitest' +import { interpolate } from './interpolate' + +describe('interpolate — param values are never re-parsed as templates', () => { + it('leaves placeholder syntax in a value as literal text', () => { + // Self-referential: a re-parsing implementation recurses without bound + // here rather than merely returning the wrong string. + expect(interpolate('Hi {{name}}', { name: '{{name}}' }, 'en')).toBe( + 'Hi {{name}}', + ) + }) + + it('does not let a value reach another param through its own placeholder', () => { + // The value names a param that IS in the bag. Re-parsing would render + // "Hi LEAKED" — a value deciding which data it gets to read. + expect( + interpolate( + 'Hi {{name}}', + { name: '{{secret}}', secret: 'LEAKED' }, + 'en', + ), + ).toBe('Hi {{secret}}') + }) + + it('holds for a value substituted into a variant, where the template does recurse', () => { + // `default:{{community_name}}.` is re-parsed by design; the value that + // lands in it must not be. + expect( + interpolate( + '{{community_name; undefined:the feed.; default:{{community_name}}.}}', + { community_name: '{{secret}}', secret: 'LEAKED' }, + 'en', + ), + ).toBe('{{secret}}.') + }) +}) diff --git a/src/lib/app/state/i18n/interpolate.test.ts b/src/lib/app/state/i18n/interpolate.test.ts new file mode 100644 index 00000000..879bacc1 --- /dev/null +++ b/src/lib/app/state/i18n/interpolate.test.ts @@ -0,0 +1,112 @@ +/** + * Pins the placeholder semantics of `@sveltekit-i18n/parser-default` 1.1.1, + * the library `interpolate` replaces. 446 `$t(` call sites depend on these, + * so every expectation below was captured by running the real parser rather + * than read off its (minified) source. + */ +import { describe, expect, it } from 'vitest' +import { interpolate } from './interpolate' + +describe('interpolate', () => { + describe('{{name}} — plain substitution', () => { + it('substitutes the param, stringified', () => { + expect(interpolate('{{name}}', { name: 'Mari' }, 'en')).toBe('Mari') + // Falsy values still render: `0` must not collapse to the empty string. + expect(interpolate('{{name}}', { name: 0 }, 'en')).toBe('0') + }) + + it('renders an empty string when the param is missing', () => { + expect(interpolate('{{name}}', {}, 'en')).toBe('') + expect(interpolate('{{name}}', undefined, 'en')).toBe('') + // Paired with surrounding text so this cannot pass by returning ''. + expect(interpolate('Hi {{name}}!', {}, 'en')).toBe('Hi !') + }) + }) + + describe('{{x:number}} — locale-formatted number', () => { + it('formats with Intl maximumFractionDigits 2, in the given locale', () => { + expect(interpolate('{{votes:number}}', { votes: 1234.567 }, 'en')).toBe( + '1,234.57', + ) + expect(interpolate('{{votes:number}}', { votes: 1234.567 }, 'de')).toBe( + '1.234,57', + ) + }) + + it('coerces a numeric string', () => { + expect(interpolate('{{votes:number}}', { votes: '1234.567' }, 'en')).toBe( + '1,234.57', + ) + }) + + it('renders a non-numeric value as zero, but a missing param as empty', () => { + // Not symmetric, and deliberately so: the parser returns the default + // ('') before the modifier ever runs when the param is absent, but + // coerces a present-and-unparseable value through `+value || +default`. + expect(interpolate('{{votes:number}}', { votes: 'abc' }, 'en')).toBe('0') + expect(interpolate('{{votes:number}}', {}, 'en')).toBe('') + }) + + it('renders an empty string when the locale is empty', () => { + // Intl cannot be constructed without a locale, so the modifier yields + // nothing — but the literal text around it survives. + expect(interpolate('{{votes:number}}', { votes: 1234.567 }, '')).toBe('') + expect(interpolate('x {{votes:number}} y', { votes: 1234.567 }, '')).toBe( + 'x y', + ) + }) + }) + + describe('{{count; 1:one; default:many;}} — variant by value', () => { + // Every param name here is 3+ characters, matching the dictionaries. The + // parser being replaced mis-handles shorter names (see the note in the + // RED report); no dictionary uses one, so that quirk is not pinned. + it('picks the variant whose key matches the param value', () => { + const template = '{{users; 1:user; default:users;}}' + expect(interpolate(template, { users: 1 }, 'en')).toBe('user') + expect(interpolate(template, { users: 3 }, 'en')).toBe('users') + }) + + it('renders the declared default when the param is missing', () => { + const template = '{{count; 1:one; default:many;}}' + expect(interpolate(template, {}, 'en')).toBe('many') + // Contrast: a matching value must still win over the default. + expect(interpolate(template, { count: 1 }, 'en')).toBe('one') + }) + + it('renders an empty string when nothing matches and no default is declared', () => { + expect(interpolate('{{users; 1:user;}}', { users: 2 }, 'en')).toBe('') + expect(interpolate('a{{users; 1:user;}}b', { users: 2 }, 'en')).toBe('ab') + }) + }) + + describe('surrounding text and multiple placeholders', () => { + it('preserves literal text and leaves placeholder-free templates alone', () => { + expect( + interpolate('Hello {{name}}, welcome', { name: 'Mari' }, 'en'), + ).toBe('Hello Mari, welcome') + expect(interpolate('no placeholders', { a: 1 }, 'en')).toBe( + 'no placeholders', + ) + }) + + it('resolves every placeholder in one template', () => { + expect( + interpolate('{{first}} {{second}}', { first: 'x', second: 'y' }, 'en'), + ).toBe('x y') + }) + + it('resolves the real routes.frontpage.footer template', () => { + // Verbatim from en.json — the one string in the dictionaries that uses a + // number modifier and a variant list on the same param. + const footer = '{{users:number}} active {{users; 1:user; default:users;}}' + expect(interpolate(footer, { users: 1 }, 'en')).toBe('1 active user') + expect(interpolate(footer, { users: 1234.567 }, 'en')).toBe( + '1,234.57 active users', + ) + expect(interpolate(footer, { users: 1234.567 }, 'de')).toBe( + '1.234,57 active users', + ) + }) + }) +}) diff --git a/src/lib/app/state/i18n/interpolate.ts b/src/lib/app/state/i18n/interpolate.ts new file mode 100644 index 00000000..f73fe237 --- /dev/null +++ b/src/lib/app/state/i18n/interpolate.ts @@ -0,0 +1,197 @@ +/** + * Placeholder interpolation for the i18n dictionaries. + * + * Replaces `@sveltekit-i18n/parser-default` 1.1.1, behaviour-preserving except + * that `undefined:` variants are honoured for a missing param (see the + * DELIBERATE DIVERGENCE cases in `interpolate.forms.test.ts`). The spec is the + * test trio: `interpolate.test.ts`, `interpolate.forms.test.ts`, + * `interpolate.security.test.ts` — not this comment. + * + * Three forms appear in the dictionaries: + * `{{name}}` the param, stringified + * `{{votes:number}}` the param, locale-formatted + * `{{users; 1:user; default:us;}}` a variant chosen by the param's value + * + * Variant values may themselves contain placeholders (`default:{{name}}.`), so + * scanning is nesting-aware throughout: never split a body on a bare `;` or `:` + * without first accounting for an inner `{{ … }}`. + */ + +const OPEN = '{{' +const CLOSE = '}}' + +/** Variant key selected when the param's value matches no other. */ +const DEFAULT_VARIANT = 'default' + +/** The only modifier the dictionaries use. */ +const NUMBER_MODIFIER = 'number' + +/** A parsed placeholder body: `name[:modifier][; key:value]…`. */ +interface Placeholder { + readonly name: string + readonly modifier: string | undefined + /** Empty when the placeholder declares no variants. */ + readonly variants: ReadonlyMap +} + +/** + * Index of the `}}` that closes a placeholder whose body starts at `from`, + * or -1 if the template never closes it. + */ +function findClose(template: string, from: number): number { + let depth = 0 + let index = from + while (index < template.length) { + if (template.startsWith(OPEN, index)) { + depth += 1 + index += OPEN.length + } else if (template.startsWith(CLOSE, index)) { + if (depth === 0) return index + depth -= 1 + index += CLOSE.length + } else { + index += 1 + } + } + return -1 +} + +/** Splits on `separator`, ignoring separators inside a nested placeholder. */ +function splitTopLevel(body: string, separator: string): string[] { + const parts: string[] = [] + let depth = 0 + let start = 0 + let index = 0 + while (index < body.length) { + if (body.startsWith(OPEN, index)) { + depth += 1 + index += OPEN.length + } else if (body.startsWith(CLOSE, index)) { + depth = Math.max(0, depth - 1) + index += CLOSE.length + } else { + if (depth === 0 && body[index] === separator) { + parts.push(body.slice(start, index)) + start = index + 1 + } + index += 1 + } + } + parts.push(body.slice(start)) + return parts +} + +/** First index of `character` outside any nested placeholder, or -1. */ +function indexOfTopLevel(text: string, character: string): number { + let depth = 0 + let index = 0 + while (index < text.length) { + if (text.startsWith(OPEN, index)) { + depth += 1 + index += OPEN.length + } else if (text.startsWith(CLOSE, index)) { + depth = Math.max(0, depth - 1) + index += CLOSE.length + } else { + if (depth === 0 && text[index] === character) return index + index += 1 + } + } + return -1 +} + +function parsePlaceholder(body: string): Placeholder { + const [head = '', ...rest] = splitTopLevel(body, ';') + + const modifierAt = indexOfTopLevel(head, ':') + const name = (modifierAt === -1 ? head : head.slice(0, modifierAt)).trim() + const modifier = + modifierAt === -1 ? undefined : head.slice(modifierAt + 1).trim() + + const variants = new Map() + for (const segment of rest) { + const valueAt = indexOfTopLevel(segment, ':') + if (valueAt === -1) continue + const key = segment.slice(0, valueAt).trim() + // First declaration of a key wins; a trailing `;` yields an empty segment. + if (key === '' || variants.has(key)) continue + variants.set(key, segment.slice(valueAt + 1).trim()) + } + + return { name, modifier, variants } +} + +/** + * Formats `value` the way the parser did: coerced to a number, unparseable + * values falling back to zero, at most two fraction digits in `locale`. + * + * An empty locale is pinned to render nothing — `Intl` cannot be constructed + * without one — and a locale tag `Intl` rejects degrades the same way rather + * than throwing mid-render. + */ +function formatNumber(value: unknown, locale: string): string { + if (locale === '') return '' + const numeric = Number(value) + try { + return new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format( + Number.isFinite(numeric) ? numeric : 0, + ) + } catch { + return '' + } +} + +function resolve( + placeholder: Placeholder, + params: Record | undefined, + locale: string, +): string { + const value = params?.[placeholder.name] + + if (placeholder.modifier === NUMBER_MODIFIER && value !== undefined) { + return formatNumber(value, locale) + } + + if (placeholder.variants.size === 0) { + // A missing param renders nothing; a falsy one still renders. + return value === undefined ? '' : String(value) + } + + // `String(undefined)` is `'undefined'` — the variant key ten dictionaries + // use for "the param was not supplied" — so an absent param selects an + // `undefined:` option before falling through to `default:`. + const chosen = + placeholder.variants.get(String(value)) ?? + placeholder.variants.get(DEFAULT_VARIANT) + + // A variant's text may itself hold placeholders (`default:{{name}}.`). + return chosen === undefined ? '' : interpolate(chosen, params, locale) +} + +/** Replaces every `{{…}}` in `template`, preserving the literal text around it. */ +export function interpolate( + template: string, + params: Record | undefined, + locale: string, +): string { + let result = '' + let index = 0 + + while (index < template.length) { + const start = template.indexOf(OPEN, index) + if (start === -1) break + + const end = findClose(template, start + OPEN.length) + if (end === -1) break + + result += template.slice(index, start) + result += resolve( + parsePlaceholder(template.slice(start + OPEN.length, end)), + params, + locale, + ) + index = end + CLOSE.length + } + + return result + template.slice(index) +} diff --git a/src/lib/feature/feeds/feed.svelte.server.test.ts b/src/lib/feature/feeds/feed.svelte.server.test.ts new file mode 100644 index 00000000..0d286869 --- /dev/null +++ b/src/lib/feature/feeds/feed.svelte.server.test.ts @@ -0,0 +1,63 @@ +/** + * The feed cache must not exist on the server. + * + * `feeds` is a module-level map keyed by route id, which is exactly right in + * the browser — a client-side navigation back to `/` should reuse the posts it + * already has. On the server that same map is shared by every visitor, so a + * cached entry is one request's feed handed to the next. Separate file from + * `feed.svelte.test.ts`, which mocks `browser: true` file-wide. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('$app/environment', () => ({ + browser: false, + dev: false, + building: false, + version: 'test', +})) + +// The real module reads localStorage at import time, which node has no notion +// of; the cache factory only needs `profile.meta.profile`. +vi.mock('$lib/app/state/auth.svelte', () => ({ + profile: { meta: { profile: undefined } }, +})) + +import { feed, feeds } from './feed.svelte' + +type HomeInit = Parameters>[1] + +const init: HomeInit = async () => ({ feed: [], params: {} }) + +beforeEach(() => { + feeds.clear() +}) + +describe('feed() during a server render', () => { + it('retains nothing in the shared cache', () => { + feed('/', init) + feed('/', init) + + // Every entry left here outlives the request that created it and is + // visible to the next visitor rendered by this process. + expect(feeds.size).toBe(0) + }) + + it('hands each render its own Feed instance', () => { + const first = feed('/', init) + const second = feed('/', init) + + expect(second).not.toBe(first) + }) + + it('keeps data loaded by one render out of the next', async () => { + const first = feed('/', (async () => ({ + feed: [{ marker: 'first-request' }], + params: {}, + })) as unknown as HomeInit) + await first.load({}) + + const second = feed('/', init) + + expect(second.peek()).toBeUndefined() + }) +}) diff --git a/src/lib/feature/feeds/feed.svelte.ts b/src/lib/feature/feeds/feed.svelte.ts index 5f78ec2c..270b7f43 100644 --- a/src/lib/feature/feeds/feed.svelte.ts +++ b/src/lib/feature/feeds/feed.svelte.ts @@ -200,7 +200,10 @@ export function feed( } const feedData = new Feed(init as unknown as FetchFn) - feeds.set(id, feedData as Feed) + // Browser only: `feeds` is keyed by route id and shared by every visitor a + // server process renders, so an entry cached here would hand one request's + // posts to the next. Each server render gets its own Feed and drops it. + if (browser) feeds.set(id, feedData as Feed) return feedData } diff --git a/src/lib/feature/instance/siteStats.svelte.test.ts b/src/lib/feature/instance/siteStats.svelte.test.ts new file mode 100644 index 00000000..45c54409 --- /dev/null +++ b/src/lib/feature/instance/siteStats.svelte.test.ts @@ -0,0 +1,53 @@ +/** + * Site stats must not be fetched or retained during a server render. + * + * `siteStats` is a module-level singleton with a five-minute cache. A server + * render that populated it would publish one request's numbers to every later + * visitor, and would add an upstream round-trip to a render that does not need + * one. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const covesSpy = vi.hoisted(() => vi.fn()) + +vi.mock('$app/environment', () => ({ + browser: false, + dev: false, + building: false, + version: 'test', +})) + +vi.mock('$lib/api/client.svelte', () => ({ coves: covesSpy })) + +import { siteStats } from './siteStats.svelte' + +beforeEach(() => { + covesSpy.mockReset() +}) + +describe('siteStats during a server render', () => { + it('makes no upstream call', async () => { + await siteStats.fetch() + + expect(covesSpy).not.toHaveBeenCalled() + }) + + it('retains nothing on the shared singleton', async () => { + await siteStats.fetch() + + expect(siteStats.data).toBeUndefined() + expect(siteStats.error).toBeUndefined() + // A stuck `loading` would also be shared state — every later render would + // see a spinner it never started. + expect(siteStats.loading).toBe(false) + }) + + it('stays inert across repeated renders', async () => { + await siteStats.fetch() + await siteStats.fetch() + await siteStats.fetch() + + expect(covesSpy).not.toHaveBeenCalled() + expect(siteStats.data).toBeUndefined() + }) +}) diff --git a/src/lib/feature/legacy/item.svelte.browser.test.ts b/src/lib/feature/legacy/item.svelte.browser.test.ts new file mode 100644 index 00000000..040874ca --- /dev/null +++ b/src/lib/feature/legacy/item.svelte.browser.test.ts @@ -0,0 +1,50 @@ +/** + * The browser half of the resumables contract. + * + * Without this, `item.svelte.test.ts` could be satisfied by making `add` a + * no-op everywhere — which would silently delete the "jump back in" feature + * rather than make it request-safe. + */ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('$app/environment', () => ({ + browser: true, + dev: false, + building: false, + version: 'test', +})) + +vi.mock('$lib/ui/kit', () => ({ toast: vi.fn() })) + +import { resumables, type ResumableItem } from './item.svelte' + +const item = (name: string): ResumableItem => ({ + url: `/c/${name}`, + name, + type: 'community', +}) + +describe('resumables in the browser', () => { + it('records what was visited, most recent first', () => { + resumables.add(item('news.coves.social')) + resumables.add(item('linux.coves.social')) + + expect(resumables.items.map((i) => i.name)).toEqual([ + 'linux.coves.social', + 'news.coves.social', + ]) + }) + + it('does not record the same entry twice', () => { + const before = resumables.items.length + + resumables.add(item('linux.coves.social')) + + expect(resumables.items).toHaveLength(before) + // Asserting the entry is still THERE, once — a list that records nothing + // at all would satisfy the length check alone. + expect( + resumables.items.filter((i) => i.name === 'linux.coves.social'), + ).toHaveLength(1) + }) +}) diff --git a/src/lib/feature/legacy/item.svelte.test.ts b/src/lib/feature/legacy/item.svelte.test.ts new file mode 100644 index 00000000..ad6818cb --- /dev/null +++ b/src/lib/feature/legacy/item.svelte.test.ts @@ -0,0 +1,45 @@ +/** + * The resumables list must not accumulate on the server. + * + * `resumables` is a module-level "jump back in" list. In the browser it is one + * person's recent history. On the server it is shared by every visitor, so + * anything pushed during a render leaks the communities and posts one visitor + * was reading to everyone rendered afterwards. + * + * Both tests run against the module-level singleton components actually read, + * not a fresh import — and the second deliberately runs after the first, + * because that is the relationship two consecutive server renders have. + */ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('$app/environment', () => ({ + browser: false, + dev: false, + building: false, + version: 'test', +})) + +vi.mock('$lib/ui/kit', () => ({ toast: vi.fn() })) + +import { resumables, type ResumableItem } from './item.svelte' + +const item = (name: string): ResumableItem => ({ + url: `/c/${name}`, + name, + type: 'community', +}) + +describe('resumables during a server render', () => { + it('retains nothing a render adds', () => { + resumables.add(item('news.coves.social')) + resumables.add(item('private.coves.social')) + + expect(resumables.items).toHaveLength(0) + }) + + it('leaves nothing behind for the next render to find', () => { + expect(JSON.stringify(resumables.items)).not.toContain( + 'private.coves.social', + ) + }) +}) diff --git a/src/lib/feature/legacy/item.svelte.ts b/src/lib/feature/legacy/item.svelte.ts index 29932cd3..1880ccdf 100644 --- a/src/lib/feature/legacy/item.svelte.ts +++ b/src/lib/feature/legacy/item.svelte.ts @@ -1,3 +1,4 @@ +import { browser } from '$app/environment' import type { CommentView, CommunityView, @@ -132,6 +133,10 @@ class ResumableStore { } add(item: ResumableItem) { + // This list is one person's recent history. On the server it is shared by + // every visitor the process renders, so anything added during a render + // would show the next visitor what the last one was reading. + if (!browser) return if (this.#items.find((i) => JSON.stringify(i) === JSON.stringify(item))) return this.#items.unshift(item) diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 8d982a7e..a726f21e 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -17,6 +17,14 @@ export const load = async ({ request, locals }) => { } } + // The language travels on the request, not on a module-level store: one Node + // process serves every visitor, so universal code reads it back through the + // request-event accessor rather than from shared state. + locals.lang = preferredLanguage + + // Preload only. This warms the dictionary cache so the render — which is + // synchronous — can resolve keys without awaiting; it must not repoint the + // shared locale, and on the server `loadTranslations` deliberately does not. await loadTranslations(preferredLanguage) // Build client-safe session (without sensitive tokens) diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts index 699d0a19..bc747712 100644 --- a/src/routes/+layout.ts +++ b/src/routes/+layout.ts @@ -5,9 +5,13 @@ import { settings } from '$lib/app/state/settings.svelte' export const ssr = env.PUBLIC_SSR_ENABLED?.toLowerCase() == 'true' -export const load = async () => { +export const load = async ({ data }) => { if (browser) { - const initLocale = settings.language ?? navigator?.language ?? 'en' + // `data.lang` is what the server actually rendered in. It has to outrank + // `navigator.language`, or the first paint flips language under the reader + // when the two disagree; an explicit user setting still wins over both. + const initLocale = + settings.language ?? data?.lang ?? navigator?.language ?? 'en' await loadTranslations(aliases.get(initLocale) ?? initLocale) } diff --git a/src/routes/layout.server.test.ts b/src/routes/layout.server.test.ts new file mode 100644 index 00000000..9be61e4b --- /dev/null +++ b/src/routes/layout.server.test.ts @@ -0,0 +1,180 @@ +/** + * The root server load: Accept-Language negotiation and session passthrough. + * + * This load runs once per request in a process shared by every request, so the + * language it resolves has to travel on the request (`locals.lang`) rather than + * by pointing a module-level store at it. The tests below pin both halves: the + * negotiation result, and the absence of any global mutation. + * + * `$lib/app/state/i18n` is mocked only to record calls — `loadTranslations` + * still delegates to the real implementation, and `locales`/`aliases` are the + * real ones, so the negotiation cases exercise the genuine locale list. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const calls = vi.hoisted(() => ({ + loadTranslations: [] as string[], + localeSet: [] as string[], +})) + +vi.mock('$lib/app/state/i18n', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + loadTranslations: async (locale: string): Promise => { + calls.loadTranslations.push(locale) + await actual.loadTranslations(locale) + }, + locale: { + subscribe: actual.locale.subscribe, + set: (value: string): void => { + calls.localeSet.push(value) + }, + }, + } +}) + +const { load } = await import('./+layout.server') + +interface CallOptions { + readonly header?: string + readonly authenticated?: boolean +} + +const ACCOUNT = { + did: 'did:plc:abcdefghijklmnopqrstuvwx', + handle: 'mari.test', + instance: 'http://127.0.0.1:8081', + sealedToken: 'sealed-token', + avatar: undefined, +} + +function makeEvent({ header, authenticated = false }: CallOptions) { + const headers: Record = {} + if (header !== undefined) headers['Accept-Language'] = header + return { + request: new Request('http://localhost/', { headers }), + locals: { + auth: authenticated + ? { authenticated: true, account: ACCOUNT, authToken: 'sealed-token' } + : { authenticated: false }, + }, + } +} + +async function callLoad(options: CallOptions = {}) { + const event = makeEvent(options) + const data = await load(event as unknown as Parameters[0]) + return { data: data as { lang: string; session: unknown }, event } +} + +const langFor = async (header?: string): Promise => + (await callLoad({ header })).data.lang + +beforeEach(() => { + calls.loadTranslations.length = 0 + calls.localeSet.length = 0 +}) + +describe('root server load — language negotiation', () => { + it('resolves a plain tag the app ships a dictionary for', async () => { + expect(await langFor('de')).toBe('de') + }) + + it('resolves a regional tag through the alias map', async () => { + expect(await langFor('de-DE')).toBe('de') + expect(await langFor('zh-CN')).toBe('zh-Hans') + expect(await langFor('zh-TW')).toBe('zh-Hant') + }) + + it('falls back to en for an unknown tag and for no header at all', async () => { + expect(await langFor('xx')).toBe('en') + expect(await langFor(undefined)).toBe('en') + }) + + it('prefers the highest-priority entry the app can serve', async () => { + // The loop walks the header reversed and overwrites on every match, so the + // EARLIEST matching entry is the one left standing — which is the entry + // the client ranked highest. + expect(await langFor('fr,de;q=0.8')).toBe('fr') + expect(await langFor('de,fr;q=0.8')).toBe('de') + // Skips entries it cannot serve rather than giving up at the first one. + expect(await langFor('xx,de')).toBe('de') + }) + + describe('characterizations — current behaviour, not necessarily desired', () => { + it('maps pt-BR to pt even though pt-BR is itself a shipped locale', async () => { + // The alias lookup is consulted before the availability check is used to + // pick a value, and `aliases` maps pt-BR -> pt, so a Brazilian client + // never receives pt-BR.json. + expect(await langFor('pt-BR')).toBe('pt') + }) + + it('ignores any entry that follows a space after the comma', async () => { + // Entries are split on ',' and never trimmed, so " de" matches nothing. + // Clients that pad their Accept-Language lose every entry but the first. + expect(await langFor('xx, de')).toBe('en') + expect(await langFor('xx,de')).toBe('de') + }) + + it('matches tags case-sensitively', async () => { + expect(await langFor('DE')).toBe('en') + expect(await langFor('de')).toBe('de') + }) + }) +}) + +describe('root server load — the language travels on the request', () => { + it('stamps the resolved language onto locals', async () => { + const { event } = await callLoad({ header: 'de' }) + + // The contract that makes per-request rendering possible: universal code + // reads `locals.lang` through the request-event accessor rather than a + // module-level store. + expect((event.locals as { lang?: string }).lang).toBe('de') + }) + + it('stamps the fallback language when nothing matched', async () => { + const { event } = await callLoad({ header: 'xx' }) + + expect((event.locals as { lang?: string }).lang).toBe('en') + }) + + it('resolves each request independently of the one before it', async () => { + // No memo, no "last resolved language" carried between calls: a request + // the app cannot serve must land on en even when the previous one + // resolved to something else. + expect(await langFor('de')).toBe('de') + expect(await langFor('fr')).toBe('fr') + expect(await langFor('xx')).toBe('en') + }) + + it('never calls locale.set', async () => { + // Regression guard. The load may preload a dictionary; it may not point + // the shared locale at this request's language. + await callLoad({ header: 'de' }) + + expect(calls.localeSet).toEqual([]) + // Preloading IS allowed, and is how the render stays synchronous. + expect(calls.loadTranslations).toEqual(['de']) + }) +}) + +describe('root server load — session passthrough', () => { + it('returns a client session for an authenticated request', async () => { + const { data } = await callLoad({ authenticated: true }) + + expect(data.session).toMatchObject({ + authenticated: true, + account: { handle: 'mari.test', did: ACCOUNT.did }, + }) + // The sealed token must never reach the client. + expect(JSON.stringify(data.session)).not.toContain('sealed-token') + }) + + it('returns a null session for an anonymous request', async () => { + const { data } = await callLoad({ authenticated: false }) + + expect(data.session).toBeNull() + }) +}) diff --git a/src/routes/layout.test.ts b/src/routes/layout.test.ts new file mode 100644 index 00000000..9aef9720 --- /dev/null +++ b/src/routes/layout.test.ts @@ -0,0 +1,114 @@ +/** + * The root universal load, on the browser path. + * + * Its job is to pick the locale the client should hydrate with. That choice + * has to agree with what the server already rendered, or the first paint + * flips language: the server's answer arrives as `data.lang` from + * `+layout.server.ts`, so it must outrank the browser's own `navigator` + * preference and yield only to an explicit user setting. + * + * Order pinned here: `settings.language ?? data.lang ?? navigator.language`. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const state = vi.hoisted(() => ({ + language: undefined as string | undefined, + loadCalls: [] as string[], +})) + +vi.mock('$app/environment', () => ({ + browser: true, + dev: false, + building: false, + version: 'test', +})) + +vi.mock('$env/dynamic/public', () => ({ + env: { PUBLIC_SSR_ENABLED: 'true' }, +})) + +vi.mock('$lib/app/state/settings.svelte', () => ({ + settings: { + get language(): string | undefined { + return state.language + }, + }, +})) + +vi.mock('$lib/app/state/i18n', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + // Recorded, not performed: this test is about which locale is chosen. + loadTranslations: async (locale: string): Promise => { + state.loadCalls.push(locale) + }, + } +}) + +const { load, ssr } = await import('./+layout') + +/** + * The current `load` declares no parameters; the contract under test is that + * it receives the parent server load's data. Cast rather than `any` so the + * shape being passed is still checked. + */ +type LayoutLoad = (event: { data: { lang?: string } }) => Promise +const callLoad = (data: { lang?: string } = {}): Promise => + (load as unknown as LayoutLoad)({ data }) + +beforeEach(() => { + state.language = undefined + state.loadCalls.length = 0 +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('root universal load — locale resolution order', () => { + it('prefers the user setting over everything else', async () => { + vi.stubGlobal('navigator', { language: 'ja' }) + state.language = 'fr' + + await callLoad({ lang: 'de' }) + + expect(state.loadCalls).toEqual(['fr']) + }) + + it('prefers the server-rendered language over the browser preference', async () => { + // Without this the client hydrates in `navigator`'s language while the + // server rendered in the one it negotiated, and the page changes language + // under the reader. + vi.stubGlobal('navigator', { language: 'fr' }) + state.language = undefined + + await callLoad({ lang: 'de' }) + + expect(state.loadCalls).toEqual(['de']) + }) + + it('falls back to the browser preference, through the alias map', async () => { + vi.stubGlobal('navigator', { language: 'en-US' }) + state.language = undefined + + await callLoad({}) + + expect(state.loadCalls).toEqual(['en']) + }) + + it('resolves a regional server language through the alias map too', async () => { + vi.stubGlobal('navigator', { language: 'ja' }) + state.language = undefined + + await callLoad({ lang: 'zh-CN' }) + + expect(state.loadCalls).toEqual(['zh-Hans']) + }) +}) + +describe('root universal load — ssr flag', () => { + it('mirrors PUBLIC_SSR_ENABLED', () => { + expect(ssr).toBe(true) + }) +}) diff --git a/tests/ssr/global-setup.ts b/tests/ssr/global-setup.ts new file mode 100644 index 00000000..594f81e3 --- /dev/null +++ b/tests/ssr/global-setup.ts @@ -0,0 +1,171 @@ +/** + * Global setup for the SSR acceptance tier. + * + * Builds the app with adapter-node, boots a mock upstream and `node build` + * against it, and hands the base URL to the tests. Everything runs once for + * the whole tier: these tests are about what ONE Node process does when many + * requests are in flight at the same time, so they must share a server. + */ +import { spawn, type ChildProcess } from 'node:child_process' +import net from 'node:net' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import type { TestProject } from 'vitest/node' +import { startMockUpstream, type MockUpstream } from './mock-upstream' + +declare module 'vitest' { + interface ProvidedContext { + ssrBaseUrl: string + /** The mock upstream's origin, for reading its XRPC request log. */ + mockUpstreamUrl: string + } +} + +const REPO_ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../..', +) + +/** How long the built server gets to accept its first connection. */ +const READY_TIMEOUT_MS = 30_000 +const READY_POLL_MS = 100 + +function run( + command: string, + args: string[], + env: NodeJS.ProcessEnv, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: REPO_ROOT, + env, + stdio: ['ignore', 'pipe', 'pipe'], + }) + let output = '' + child.stdout.on('data', (chunk: Buffer) => (output += chunk.toString())) + child.stderr.on('data', (chunk: Buffer) => (output += chunk.toString())) + child.on('error', reject) + child.on('exit', (code) => + code === 0 + ? resolve() + : reject( + new Error( + `\`${command} ${args.join(' ')}\` exited ${code}:\n${output}`, + ), + ), + ) + }) +} + +async function freePort(): Promise { + const server = net.createServer() + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as net.AddressInfo + await new Promise((resolve) => server.close(() => resolve())) + return port +} + +/** Bounded poll: resolves as soon as the port accepts a TCP connection. */ +async function waitForPort( + port: number, + child: ChildProcess, + log: () => string, +): Promise { + const deadline = Date.now() + READY_TIMEOUT_MS + for (;;) { + if (child.exitCode !== null) { + throw new Error( + `built server exited ${child.exitCode} before accepting connections:\n${log()}`, + ) + } + const connected = await new Promise((resolve) => { + const socket = net.connect({ port, host: '127.0.0.1' }) + socket.once('connect', () => { + socket.destroy() + resolve(true) + }) + socket.once('error', () => { + socket.destroy() + resolve(false) + }) + }) + if (connected) return + if (Date.now() > deadline) { + throw new Error( + `built server did not accept connections within ${READY_TIMEOUT_MS}ms:\n${log()}`, + ) + } + await new Promise((resolve) => setTimeout(resolve, READY_POLL_MS)) + } +} + +export default async function setup( + project: TestProject, +): Promise<() => Promise> { + await run('pnpm', ['build'], { ...process.env, ADAPTER: 'node' }) + + let upstream: MockUpstream | undefined + let server: ChildProcess | undefined + + const teardown = async (): Promise => { + server?.kill('SIGKILL') + await upstream?.close() + } + + try { + upstream = await startMockUpstream() + const port = await freePort() + const baseUrl = `http://127.0.0.1:${port}` + + let serverLog = '' + server = spawn('node', ['build'], { + cwd: REPO_ROOT, + env: { + ...process.env, + // NODE_ENV=production so the built server behaves as it will in deploy + // (adapter-node and Svelte's runtime read it). `$app/environment`'s `dev` + // is fixed at build time and already false here, so this does NOT + // decide the dev-only canonical-host redirect in hooks.server.ts. + NODE_ENV: 'production', + HOST: '127.0.0.1', + PORT: String(port), + ORIGIN: baseUrl, + PUBLIC_SSR_ENABLED: 'true', + PUBLIC_INSTANCE_URL: baseUrl, + PUBLIC_INTERNAL_INSTANCE: upstream.url, + ALLOW_HTTP_INTERNAL_INSTANCE: 'true', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + server.stdout?.on( + 'data', + (chunk: Buffer) => (serverLog += chunk.toString()), + ) + server.stderr?.on( + 'data', + (chunk: Buffer) => (serverLog += chunk.toString()), + ) + + await waitForPort(port, server, () => serverLog) + + // One warm-up render, checked here rather than in the tests. A page that + // grows a new upstream call would otherwise reach the suite as a 500 and + // read as a behavioural failure; this reports the missing route by name. + const warmup = await fetch(`${baseUrl}/`) + await warmup.text() + if (warmup.status !== 200 || upstream.unknownPaths.length > 0) { + throw new Error( + `warm-up GET / returned ${warmup.status}; ` + + `unmocked upstream paths: ${JSON.stringify(upstream.unknownPaths)}\n${serverLog}`, + ) + } + + project.provide('ssrBaseUrl', baseUrl) + project.provide('mockUpstreamUrl', upstream.url) + } catch (error) { + await teardown() + throw error + } + + return teardown +} diff --git a/tests/ssr/mock-upstream.ts b/tests/ssr/mock-upstream.ts new file mode 100644 index 00000000..24ed15bf --- /dev/null +++ b/tests/ssr/mock-upstream.ts @@ -0,0 +1,156 @@ +/** + * A stand-in for the Go backend, for the SSR acceptance tier. + * + * The built SvelteKit server talks to `PUBLIC_INTERNAL_INSTANCE` for two + * things when rendering `/`: + * - `GET /api/me` (hooks.server.ts, only when a session cookie is present) + * - `GET /xrpc/social.coves.feed.getDiscover` (the `/` page load) + * Anything else is a 404 and is recorded so a future page change surfaces as a + * named gap rather than an opaque 500. + * + * Beyond answering, it RECORDS: every `/xrpc/*` call with the `Authorization` + * header it arrived with, so a test can check what credentials a render + * actually put on the wire rather than only what it rendered. Both records are + * readable over the control plane, since the tests run in a different process. + */ +import http from 'node:http' +import type { AddressInfo } from 'node:net' + +/** + * The accounts this upstream knows, keyed by the `coves_session` cookie value + * that authenticates as each. Two of them, so a test can tell "each request + * sees ITS OWN account" apart from "every request sees the only account there + * is". Any other cookie value gets a 401. + */ +export const MOCK_ACCOUNTS = { + a: { did: 'did:plc:abcdefghijklmnopqrstuvwx', handle: 'mari.test' }, + b: { did: 'did:plc:zyxwvutsrqponmlkjihgfedc', handle: 'alex.test' }, +} as const + +export type MockAccountKey = keyof typeof MOCK_ACCOUNTS + +/** Control-plane path the tests read the XRPC request log from. */ +export const XRPC_LOG_PATH = '/__test/xrpc-requests' + +/** Control-plane path listing paths the mock did not recognise. */ +export const UNKNOWN_PATHS_PATH = '/__test/unknown-paths' + +/** One inbound XRPC call, as the upstream saw it. */ +export interface XrpcRequest { + readonly path: string + /** The inbound `Authorization` header verbatim, or null if there was none. */ + readonly authorization: string | null +} + +export interface MockUpstream { + /** Origin the built server should be pointed at, e.g. `http://127.0.0.1:51234`. */ + readonly url: string + /** Distinct `METHOD /path` strings the mock did not recognise. */ + readonly unknownPaths: readonly string[] + close(): Promise +} + +/** + * Kit's universal `fetch` enforces CORS on cross-origin server-side loads + * (`load_data.js`: a missing `Access-Control-Allow-Origin` throws, which turns + * the page into a 500). The test server and the mock are on different ports, + * so every response carries the header. + */ +const BASE_HEADERS = { + 'content-type': 'application/json', + 'access-control-allow-origin': '*', +} as const + +export async function startMockUpstream(): Promise { + const unknownPaths: string[] = [] + + const xrpcRequests: XrpcRequest[] = [] + + const server = http.createServer((req, res) => { + const path = new URL(req.url ?? '/', 'http://upstream.invalid').pathname + + // Control plane. Not part of the backend's surface, so it is checked + // before anything else and never counts as an unknown path. + if (path === XRPC_LOG_PATH) { + if (req.method === 'DELETE') { + xrpcRequests.length = 0 + res.writeHead(200, BASE_HEADERS) + res.end(JSON.stringify({ ok: true })) + return + } + res.writeHead(200, BASE_HEADERS) + res.end(JSON.stringify({ requests: xrpcRequests })) + return + } + + if (path === UNKNOWN_PATHS_PATH) { + if (req.method === 'DELETE') { + unknownPaths.length = 0 + res.writeHead(200, BASE_HEADERS) + res.end(JSON.stringify({ ok: true })) + return + } + res.writeHead(200, BASE_HEADERS) + res.end(JSON.stringify({ paths: [...new Set(unknownPaths)] })) + return + } + + if (path.startsWith('/xrpc/')) { + // Recorded before the route check so an unmocked XRPC path still shows + // up here rather than vanishing into the 404 branch. + xrpcRequests.push({ + path, + authorization: req.headers.authorization ?? null, + }) + } + + if (path === '/api/me') { + const key = /coves_session=([^;]*)/.exec(req.headers.cookie ?? '')?.[1] + const identity = + key !== undefined && key in MOCK_ACCOUNTS + ? MOCK_ACCOUNTS[key as MockAccountKey] + : undefined + if (identity === undefined) { + res.writeHead(401, BASE_HEADERS) + res.end(JSON.stringify({ error: 'Unauthorized' })) + return + } + res.writeHead(200, BASE_HEADERS) + res.end(JSON.stringify(identity)) + return + } + + if ( + path === '/xrpc/social.coves.feed.getDiscover' || + path === '/xrpc/social.coves.feed.getTimeline' + ) { + // An empty feed is enough: the tests assert on the shell — sidebar + // login state, the signed-in handle, feed tabs — not on post rendering. + res.writeHead(200, BASE_HEADERS) + res.end(JSON.stringify({ feed: [] })) + return + } + + unknownPaths.push(`${req.method ?? 'GET'} ${path}`) + res.writeHead(404, BASE_HEADERS) + res.end(JSON.stringify({ error: 'NotFound', message: `no route ${path}` })) + }) + + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + + const { port } = server.address() as AddressInfo + + return { + url: `http://127.0.0.1:${port}`, + get unknownPaths() { + return [...new Set(unknownPaths)] + }, + close: () => + new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())), + ), + } +} diff --git a/tests/ssr/ssr-isolation.test.ts b/tests/ssr/ssr-isolation.test.ts new file mode 100644 index 00000000..d0d28a64 --- /dev/null +++ b/tests/ssr/ssr-isolation.test.ts @@ -0,0 +1,272 @@ +/** + * SSR request isolation, observed from outside the process. + * + * One Node server renders every request, and several pieces of app state that + * look per-request in the browser are module-level singletons on the server: + * the i18n dictionary/locale, and `profile.current`. Each test below holds the + * server under concurrent load and checks that what a response carries matches + * what THAT request asked for: + * + * 1. language — every response renders in its own `Accept-Language` + * 2. identity — every response renders the account its own cookie names + * 3. credentials — every upstream call carries its own request's token + * + * The first two read the rendered HTML; the third reads what the mock upstream + * actually received, which is the only way to catch a render that displays the + * right account while fetching its data as somebody else. + * + * Every test asserts `status === 200` and a home-page marker before any content + * claim: an upstream failure renders Kit's error page, which contains neither a + * login string nor a handle, and would otherwise satisfy every "must not + * contain the other one" assertion by accident. + */ +import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest' +import { + MOCK_ACCOUNTS, + UNKNOWN_PATHS_PATH, + XRPC_LOG_PATH, + type XrpcRequest, +} from './mock-upstream' + +/** `src/lib/feature/filter/FeedTabs.svelte` — rendered only by `/`, never by `+error.svelte`. */ +const PAGE_MARKER = 'aria-label="Feed"' + +/** `account.login` from `src/lib/app/state/i18n/{de,fr,en}.json`, as rendered by the Sidebar. */ +const LOGIN = { de: 'Anmelden', fr: 'Connexion', en: 'Log in' } as const + +const baseUrl = inject('ssrBaseUrl') +const mockUpstreamUrl = inject('mockUpstreamUrl') +const xrpcLogUrl = `${mockUpstreamUrl}${XRPC_LOG_PATH}` +const unknownPathsUrl = `${mockUpstreamUrl}${UNKNOWN_PATHS_PATH}` + +/** + * The three kinds of request every identity test fires: two different signed-in + * readers and one anonymous one. + * + * `hooks.server.ts` treats the session cookie's value as the sealed token and + * puts it on `locals.auth.authToken`, so the cookie value is also the Bearer + * value that request's upstream calls should carry. + */ +const IDENTITIES = ['a', 'b', null] as const +type Identity = (typeof IDENTITIES)[number] + +const headersFor = (identity: Identity): Record => + identity === null ? {} : { Cookie: `coves_session=${identity}` } + +const handleOf = (identity: Identity): string | null => + identity === null ? null : MOCK_ACCOUNTS[identity].handle + +/** Every handle the upstream could possibly return. */ +const ALL_HANDLES = Object.values(MOCK_ACCOUNTS).map((a) => a.handle) + +/** + * The markup a reader sees, with `