Public: anyone can read this; the crew can change it.

The API's board panel read names its Server-Timing phases: session, board, query, render #3928

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/3920-the-api-s-board-panel-read-names-its-ser → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/3920-the-api-s-board-panel-read-names-its-ser was merged into main as b9d470e.

Review

Sign in to review this change.
2 files changed +99 −13
src/http/api.ts +20 −13
@@ -1558,9 +1558,12 @@ export function apiRoutes(router: Router<Handler>): void {
15581558 // board from 0; `from`, `to` and `step` (300, 5m, 1h) the window;
15591559 // `format=csv` the same rows as a table file.
15601560 router.add("GET", "/api/v1/boards/:slug/panels/:id", async (ctx) => {
1561 notePhase(ctx.request, "session", takeSessionTime(ctx.request));

Noting session before boardsAdmin means a 401 or 403 also carries the session phase. I checked this on the preview, and it matches what the issue asked.

15611562 const denied = boardsAdmin(ctx);
15621563 if (denied) return denied;
1563 const shown = await showBoard(ctx.app, ctx.auth.user, ctx.params.slug!);
1564 const shown = await timePhaseAsync(ctx.request, "board", () =>
1565 showBoard(ctx.app, ctx.auth.user, ctx.params.slug!),
1566 );
15641567 const id = ctx.params.id!;
15651568 const panel = shown.ok && /^\d{1,2}$/.test(id) ? shown.value.panels[Number(id)] : undefined;
15661569 if (!shown.ok || !panel) return notFound();
@@ -1568,20 +1571,24 @@ export function apiRoutes(router: Router<Handler>): void {
15681571 if (format !== "json" && format !== "csv") return apiError("invalid_format", 422, "format must be json or csv");
15691572 const window = parseWindow(ctx.url.searchParams);
15701573 if (!window.ok) return apiError("invalid_window", 422, window.error);
1571 const data = panelData(ctx.app.db, shown.value, panel, window.window);
1574 const data = timePhase(ctx.request, "query", () =>
1575 panelData(ctx.app.db, shown.value, panel, window.window),
1576 );
15721577 if (!data.ok) return apiError("bad_query", 400, data.error);
15731578 const headers = { "cache-control": "private, no-store" };
1574 if (format === "csv") {
1575 return new Response(rowsCsv(data.rows), {
1576 headers: {
1577 ...headers,
1578 "content-type": "text/csv; charset=utf-8",
1579 "content-disposition": `attachment; filename="${ctx.params.slug}-${id}.csv"`,
1580 },
1581 });
1582 }
1583 const { ok: _, ...out } = data;
1584 return json({ board: ctx.params.slug, panel: Number(id), title: panel.title, chart: panel.chart, ...out }, 200, headers);
1579 return timePhase(ctx.request, "render", () => {
1580 if (format === "csv") {
1581 return new Response(rowsCsv(data.rows), {
1582 headers: {
1583 ...headers,
1584 "content-type": "text/csv; charset=utf-8",
1585 "content-disposition": `attachment; filename="${ctx.params.slug}-${id}.csv"`,
1586 },
1587 });
1588 }
1589 const { ok: _, ...out } = data;
1590 return json({ board: ctx.params.slug, panel: Number(id), title: panel.title, chart: panel.chart, ...out }, 200, headers);
1591 });
15851592 });
15861593
15871594 router.add("PUT", "/api/v1/boards/:slug", async (ctx) => {
test/api-panel-timing.test.ts added +79
@@ -0,0 +1,79 @@
1 /** The API's board panel read names its Server-Timing phases (#3920). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { writeBoard } from "../src/actions/boards.ts";
7 import { createApp, type App } from "../src/app.ts";
8 import { createToken } from "../src/core/tokens.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11 import { quietLogs } from "./helpers/globals.ts";
12
13 quietLogs();
14
15 let dataDir: string;
16 let app: App;
17 let servers: Servers;
18 let token = "";
19
20 beforeAll(async () => {
21 dataDir = await mkdtemp(join(tmpdir(), "forge-api-panel-timing-"));
22 app = createApp({ dataDir, port: 0 });
23 servers = await serve(app);
24 const made = await createUser(app.db, {
25 origin: "seed",
26 trusted: true,
27 username: "alice",
28 email: "[email protected]",
29 password: "a-good-passphrase",
30 adminIfFirst: true,
31 });
32 if (!made.ok) throw new Error(String(made.error));
33 token = createToken(app.db, made.value.id, "t", "admin").token;
34 const done = await writeBoard(app, made.value, "usage", {
35 title: "Usage",
36 range: "24h",
37 refresh: "off",
38 panels: [
39 { title: "By target", chart: "bar", query: { source: "machine", aggregate: "count", groupBy: "target" }, w: 1, h: 1 },
40 ],
41 });
42 if (!done.ok) throw new Error(JSON.stringify(done.error));
43 });
44
45 afterAll(async () => {
46 await servers.stop();
47 app.close();
48 await rm(dataDir, { recursive: true, force: true });
49 });
50
51 async function phases(path: string): Promise<{ status: number; names: string[]; res: Response }> {
52 const res = await fetch(`${servers.url}${path}`, { headers: { authorization: `Bearer ${token}` } });
53 await res.text();
54 const names = (res.headers.get("server-timing") ?? "").split(", ").map((p) => p.split(";")[0]!);
55 return { status: res.status, names: names.slice(names.indexOf("app") + 1), res };
56 }
57
58 test("a panel read names session, board, query and render", async () => {
59 const got = await phases("/api/v1/boards/usage/panels/0");
60 expect(got.status).toBe(200);
61 expect(got.names).toEqual(["session", "board", "query", "render"]);
62 expect(got.res.headers.get("cache-control")).toBe("private, no-store");
63 });
64
65 test("the CSV answer names the same phases", async () => {
66 const got = await phases("/api/v1/boards/usage/panels/0?format=csv");
67 expect(got.status).toBe(200);
68 expect(got.names).toEqual(["session", "board", "query", "render"]);
69 expect(got.res.headers.get("cache-control")).toBe("private, no-store");
70 });
71
72 test("refusals keep their status and name the phases they reached", async () => {
73 const missing = await phases("/api/v1/boards/usage/panels/9");
74 expect(missing.status).toBe(404);
75 expect(missing.names).toEqual(["session", "board"]);
76 const bad = await phases("/api/v1/boards/usage/panels/0?format=xml");
77 expect(bad.status).toBe(422);
78 expect(bad.names).toEqual(["session", "board"]);
79 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs