import assert from "node:assert/strict"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import type { AddressInfo } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test, type TestContext } from "node:test"; import { createApp } from "./app.ts"; import { staticJwks } from "../auth/jwks.ts"; import { SESSION_COOKIE } from "../auth/login.ts"; import type { Plugin } from "../plugin-host/plugin.ts"; import { createI18n } from "../i18n/runtime.ts"; import type { MenuConfig } from "../ui/menu-config.ts"; import { loadI18n } from "../i18n/load.ts"; import { ecJwk, mintJwt } from "./app-harness.ts"; // ---- language (i18n) ---- // The installed catalogs, as server.ts wires them: the shipped core locales (en-US + sv-SE). async function localeApp(t: TestContext): Promise { const app = createApp({ i18n: createI18n(await loadI18n()), jwks: staticJwks([ecJwk]) }); await new Promise((r) => app.listen(0, r)); t.after(() => app.close()); return `http://localhost:${(app.address() as AddressInfo).port}`; } test("?locale serves that language and carries the choice onto the links the page renders", async (t) => { const url = await localeApp(t); const html = await (await fetch(`${url}/?locale=sv-SE`)).text(); assert.match(html, //); // the document says what language it is in assert.match(html, /Logga in/); // the landing page's own words assert.doesNotMatch(html, /Operational web apps/); // The chosen locale rides along, so clicking through the app stays in Swedish without a cookie. assert.match(html, /href="\/login\?locale=sv-SE"/); // …and the picker offers the other installed locale, pointing at this same page. assert.match(html, /hreflang="en-US"/); assert.match(html, /href="\/\?locale=en-US"/); }); test("Accept-Language decides when the URL doesn't, and a lone language matches its region", async (t) => { const url = await localeApp(t); const swedish = await (await fetch(`${url}/`, { headers: { "accept-language": "sv;q=0.9, en;q=0.4" } })).text(); assert.match(swedish, / { const url = await localeApp(t); for (const bad of ["sv-FI", "klingon", "../../etc"]) { const res = await fetch(`${url}/?locale=${encodeURIComponent(bad)}`); assert.equal(res.status, 200); assert.match(await res.text(), / { const url = await localeApp(t); const res = await fetch(`${url}/dashboard?locale=sv-SE`, { redirect: "manual" }); // anonymous ⇒ sign in first assert.equal(res.status, 303); const location = res.headers.get("location") ?? ""; assert.match(location, /^\/login\?/); assert.match(location, /locale=sv-SE/); }); test("the error pages speak the visitor's language too", async (t) => { const url = await localeApp(t); const html = await (await fetch(`${url}/no-such-page?locale=sv-SE`)).text(); assert.match(html, / { const dir = mkdtempSync(join(tmpdir(), "pp-i18n-plugin-")); mkdirSync(join(dir, "demo", "i18n"), { recursive: true }); t.after(() => rmSync(dir, { force: true, recursive: true })); writeFileSync(join(dir, "demo", "i18n", "en-US.ts"), 'const m = { "demo.hello": "Hello from the plugin" };\nexport default m;\n'); // Every plugin-owned render path: the public landing, the gated dashboard, and a hook short-circuit. const demo: Plugin = { apiVersion: "1.0.0", dashboard: (ctx) => ({ html: ctx.t("demo.hello") }), home: (ctx) => ({ html: ctx.t("demo.hello") }), hooks: { onRequest: (ctx) => (ctx.url.pathname === "/hooked" ? { html: ctx.t("demo.hello") } : undefined) }, id: "demo", }; const i18n = createI18n(await loadI18n({ pluginIds: ["demo"], pluginsDir: dir })); const app = createApp({ i18n, jwks: staticJwks([ecJwk]), plugins: [demo], pluginsDir: dir }); await new Promise((r) => app.listen(0, r)); t.after(() => app.close()); const url = `http://localhost:${(app.address() as AddressInfo).port}`; const nowSec = Math.floor(Date.now() / 1000); const cookie = `${SESSION_COOKIE}=${mintJwt({ email: "a@b", exp: nowSec + 600, permissions: [], sub: "u1" })}`; assert.equal(await (await fetch(`${url}/`)).text(), "Hello from the plugin"); assert.equal(await (await fetch(`${url}/hooked`)).text(), "Hello from the plugin"); assert.equal(await (await fetch(`${url}/dashboard`, { headers: { cookie } })).text(), "Hello from the plugin"); }); test("an error page renders without composing the menu — it exists for when the shell's data is what failed", async (t) => { // The chrome getter is lazy on purpose; a render that reads no chrome must not trigger it, or a // broken menu takes the error pages down with it. let built = 0; const menu: MenuConfig = { branding: { get name() { built++; return "Plainpages"; } }, override: {} }; const app = createApp({ jwks: staticJwks([ecJwk]), menu }); await new Promise((r) => app.listen(0, r)); t.after(() => app.close()); const url = `http://localhost:${(app.address() as AddressInfo).port}`; const res = await fetch(`${url}/no-such-page`); assert.equal(res.status, 404); assert.match(await res.text(), /Page not found/); assert.equal(built, 0); }); test("a POST-rendered page still offers the language picker, pointed at a page that answers GET", async (t) => { const dir = mkdtempSync(join(tmpdir(), "pp-post-lang-")); mkdirSync(join(dir, "demo", "views"), { recursive: true }); t.after(() => rmSync(dir, { force: true, recursive: true })); // The view renders the picker exactly as the shell does. writeFileSync(join(dir, "demo", "views", "page.ejs"), `<%- include("partials/locale-switch") %>`); const demo: Plugin = { apiVersion: "1.0.0", id: "demo", routes: [ { handler: () => ({ view: "page" }), method: "GET", path: "/thing" }, { handler: () => ({ view: "page" }), method: "POST", path: "/thing" }, { handler: () => ({ view: "page" }), method: "POST", path: "/thing/act" }, // POST-only: no GET sibling ], }; const app = createApp({ i18n: createI18n(await loadI18n()), plugins: [demo], pluginsDir: dir }); await new Promise((r) => app.listen(0, r)); t.after(() => app.close()); const url = `http://localhost:${(app.address() as AddressInfo).port}`; const post = (path: string, headers: Record = {}) => fetch(url + path, { headers, method: "POST" }); // A POST whose path also answers GET → the picker points at that page. assert.match(await (await post("/demo/thing?locale=sv-SE")).text(), /href="\/demo\/thing\?locale=en-US"/); // A POST-only path → the page the form was submitted from, so the link can't dead-end on a 405. const fromForm = await post("/demo/thing/act?locale=sv-SE", { referer: `${url}/demo/thing?locale=sv-SE` }); assert.match(await fromForm.text(), /href="\/demo\/thing\?locale=en-US"/); // …and with no referer to fall back on, the front page. assert.match(await (await post("/demo/thing/act?locale=sv-SE")).text(), /href="\/\?locale=en-US"/); });