testServerFunctions

tcmts.cli.test.e2e.server.testServerFunctions · #u4j6y7fh · term

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",
      );
    }
  })

uses