testServerFunctions
server functions: request context, sessions, deliberate and hidden errors, CSRF
import { CHROME, pageText } from "#codebase/tcmts/cli/test/browser"; import { okIn, tempDir, withChildren } from "#codebase/tcmts/cli/test/e2e/harness"; import { AUTH, call, hashOf, serve } from "#codebase/tcmts/cli/test/e2e/server"; import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; export const testServerFunctions = () => withChildren(async (keep) => { const dir = tempDir("server"); const tcm = (...args: string[]) => okIn(dir, ...args); const scratch = (text: string) => fs.writeFileSync(path.join(dir, "scratch.ts"), text); tcm("init"); tcm("lib", "add", "tcm", "server", "--at", "lib.server"); fs.rmSync(path.join(dir, "scratch.ts")); scratch(AUTH); tcm("add"); assert.match(tcm("view", "auth.me"), /runs on the server: it uses lib\.server\.session/); assert.match( tcm("test", "auth"), /2 passed, 0 failed/, "withRequest runs server functions in tests, no network", ); const { url, output } = await serve(dir, keep, "start", "auth.main", "--http", "auth.api"); const jar = new Map<string, string>(); const anon = await call(dir, url, "auth.me", [], jar); assert.equal(anon.status, 401); assert.match(anon.body, /sign in first/); assert.equal((await call(dir, url, "auth.login", ["ada", "nope"], jar)).status, 401); const login = await call(dir, url, "auth.login", ["ada", "lovelace"], jar); assert.equal(login.status, 200); assert.match( login.setCookie[0]!, /^tcm_sid=[^;]+; Path=\/; SameSite=Lax; Max-Age=\d+; HttpOnly$/, ); assert.equal( (await call(dir, url, "auth.me", [], jar, { "x-client": "test" })).body, `"ada via test"`, ); assert.ok( fs.existsSync(path.join(dir, ".tcm", "data", "sessions.sqlite")), "session data lives server-side, apart from the codebase", ); assert.match( fs.readFileSync(path.join(dir, ".tcm", ".gitignore"), "utf8"), /^data\/$[\s\S]*^secret$/m, "app data and secrets are never committed", ); // the cookie is only an id: a made-up one is no session assert.equal( (await call(dir, url, "auth.me", [], new Map([["tcm_sid", "made-up-session-id"]]))).status, 401, ); // revocation: sign in on a second device, sign out everywhere from it; the first device is signed out too const phone = new Map<string, string>(); await call(dir, url, "auth.login", ["ada", "lovelace"], phone); assert.match((await call(dir, url, "auth.signOutEverywhere", [], phone)).body, /^[1-9]\d*$/); assert.equal( (await call(dir, url, "auth.me", [], jar)).status, 401, "the laptop's session was revoked", ); await call(dir, url, "auth.login", ["ada", "lovelace"], jar); // rate limits: a 429 with Retry-After once the window is full assert.equal((await call(dir, url, "auth.ping", [], jar)).status, 200); assert.equal((await call(dir, url, "auth.ping", [], jar)).status, 200); const limited = await fetch(new URL(`/__tcm/rpc/${hashOf(dir, "auth.ping")}`, url), { method: "POST", headers: { "content-type": "application/json" }, body: "[]", }); assert.equal(limited.status, 429); assert.match(limited.headers.get("retry-after") ?? "", /^\d+$/); // streaming: chunks as they're produced, with the caller's session; stopping early stops the server side const stream = await fetch(new URL(`/__tcm/rpc/${hashOf(dir, "auth.ticks")}`, url), { method: "POST", headers: { "content-type": "application/json", cookie: `tcm_sid=${jar.get("tcm_sid")}` }, body: "[3]", }); assert.equal(stream.headers.get("content-type"), "application/x-ndjson"); assert.equal( await stream.text(), ['{"v":"1:ada"}', '{"v":"2:ada"}', '{"v":"3:ada"}', '{"done":true}', ""].join("\n"), ); const ctl = new AbortController(); const partial = await fetch(new URL(`/__tcm/rpc/${hashOf(dir, "auth.ticks")}`, url), { method: "POST", headers: { "content-type": "application/json" }, body: "[50]", signal: ctl.signal, }); const reader = partial.body!.getReader(); await reader.read(); ctl.abort(); for ( const end = Date.now() + 5000; Date.now() < end && !/auth\.ticks stream \d+ chunk\(s\), \d+ms, stopped by the client/.test(output()); ) await new Promise((r) => setTimeout(r, 50)); assert.match(output(), /auth\.ticks stream \d+ chunk\(s\), \d+ms, stopped by the client/); // plain HTTP endpoints: routes are names assert.deepEqual(await (await fetch(new URL("/api/health", url))).json(), { ok: true, method: "GET", }); const hook = await fetch(new URL("/api/hooks/payment", url), { method: "POST", headers: { "content-type": "application/json" }, body: '{"type":"invoice.paid"}', }); assert.equal(hook.status, 202); assert.equal(hook.headers.get("x-handled-by"), "tcm"); assert.deepEqual(await hook.json(), { received: "invoice.paid" }); assert.equal((await fetch(new URL("/api/nope", url))).status, 404); tcm("mv", "auth.api.health", "auth.api.status"); assert.equal( (await fetch(new URL("/api/status", url))).status, 200, "renaming moves the URL, no restart", ); assert.equal((await fetch(new URL("/api/health", url))).status, 404); // in production, unexpected errors are logged on the server and hidden from the browser const crash = await call(dir, url, "auth.crash", [], jar); assert.equal(crash.status, 500); assert.match(crash.body, /"internal error"/); assert.doesNotMatch(crash.body, /hunter2/); assert.match(output(), /auth\.crash failed: Error: db password is hunter2/); // CSRF: no plain form posts, no cross-site calls const form = await fetch(new URL(`/__tcm/rpc/${hashOf(dir, "auth.me")}`, url), { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: "a=1", }); assert.equal(form.status, 415); assert.equal( (await call(dir, url, "auth.me", [], jar, { "sec-fetch-site": "cross-site" })).status, 403, ); await call(dir, url, "auth.logout", [], jar); assert.equal( (await call(dir, url, "auth.me", [], jar)).status, 401, "logging out clears the session", ); // tcm dev shows unexpected errors in full, to the developer const devServer = await serve(dir, keep, "dev", "auth.main"); for ( const end = Date.now() + 15000; Date.now() < end && !/server\s+\d+ function/.test(devServer.output()); ) await new Promise((r) => setTimeout(r, 100)); const devCrash = await fetch( new URL(`/__tcm/rpc/${hashOf(dir, "auth.crash")}`, devServer.url), { method: "POST", headers: { "content-type": "application/json" }, body: "[]" }, ); assert.match(await devCrash.text(), /hunter2/, "dev shows the real error"); if (CHROME) { const live = await serve(dir, keep, "dev", "auth.streamApp"); const text = await pageText(live.url, "#app", (t) => t.split(",").length === 3); assert.equal( text, "1:ada,2:ada,3:ada", "a real browser streams a server generator, with the session", ); } })