wren/glyph Public Public: anyone can read this; nobody can change it.

forked from apps/glyph

workbenchthis machine
Files 308f4625e0ac59206bd37f1f78c4ecba8ca65968
188 lines · 5.5 KB · ~1.6k tok Raw History
1
/**
2
 * The trail: an allowed change and a refused one each leave a row, through
3
 * the real server; only an admin reads them; and a trail that cannot be
4
 * written costs a log line, never the request.
5
 */
6
import { afterAll, expect, spyOn, test } from "bun:test";
7
import { mkdtempSync } from "node:fs";
8
import { tmpdir } from "node:os";
9
import { join } from "node:path";
10
import { startDrawing } from "./actions.ts";
11
import type { AuditRow } from "./audit.ts";
12
import { openDb } from "./db.ts";
13
import { serve } from "./server.ts";
14
import { sign } from "./who.ts";
15
16
const KEY = "a-key-only-the-forge-holds";
17
const server = serve({
18
  port: 0,
19
  dataDir: mkdtempSync(join(tmpdir(), "app-audit-")),
20
  identityKey: KEY,
21
});
22
const base = `http://${server.hostname}:${server.port}`;
23
afterAll(() => server.stop(true));
24
25
const as = (
26
  id: string,
27
  name: string,
28
  role: "user" | "admin" = "user",
29
): Record<string, string> => {
30
  const identity = `${id}|${name}|${role}|${Date.now() + 60_000}`;
31
  return {
32
    "x-forge-identity": identity,
33
    "x-forge-signature": sign(identity, KEY),
34
  };
35
};
36
const alice = as("u1", "alice");
37
const bob = as("u2", "bob");
38
const root = as("u0", "root", "admin");
39
const call = (
40
  path: string,
41
  init: RequestInit = {},
42
  who: Record<string, string> = {},
43
) =>
44
  fetch(base + path, {
45
    ...init,
46
    headers: { "content-type": "application/json", ...who },
47
  });
48
const trail = async (): Promise<AuditRow[]> =>
49
  (await (await call("/api/audit", {}, root)).json()) as AuditRow[];
50
51
test("an allowed change and a refused one each leave a row", async () => {
52
  const made = await call(
53
    "/api/drawings",
54
    { method: "POST", body: JSON.stringify({ prompt: "kept" }) },
55
    alice,
56
  );
57
  expect(made.status).toBe(202);
58
  const note = (await made.json()) as { id: number };
59
  // Refused at the door: a stranger may create nothing.
60
  expect(
61
    (
62
      await call("/api/drawings", {
63
        method: "POST",
64
        body: JSON.stringify({ prompt: "x" }),
65
      })
66
    ).status,
67
  ).toBe(401);
68
  // Refused by the action: bob could delete a drawing of his own, not alice's.
69
  expect(
70
    (await call(`/api/drawings/${note.id}`, { method: "DELETE" }, bob)).status,
71
  ).toBe(403);
72
  // Refused as cross-site, before the table is asked.
73
  const forged = await fetch(`${base}/api/drawings/${note.id}`, {
74
    method: "DELETE",
75
    headers: { ...alice, "sec-fetch-site": "cross-site" },
76
  });
77
  expect(forged.status).toBe(403);
78
79
  const rows = (await trail()).slice(0, 4).reverse();
80
  expect(
81
    rows.map(
82
      ({ who_name, role, resource, verb, target, allowed, outcome }) => ({
83
        who_name,
84
        role,
85
        resource,
86
        verb,
87
        target,
88
        allowed,
89
        outcome,
90
      }),
91
    ),
92
  ).toEqual([
93
    {
94
      who_name: "alice",
95
      role: "user",
96
      resource: "drawing",
97
      verb: "create",
98
      target: String(note.id),
99
      allowed: 1,
100
      outcome: "ok",
101
    },
102
    {
103
      who_name: null,
104
      role: "anon",
105
      resource: "drawing",
106
      verb: "create",
107
      target: null,
108
      allowed: 0,
109
      outcome: "signin_required",
110
    },
111
    {
112
      who_name: "bob",
113
      role: "user",
114
      resource: "drawing",
115
      verb: "delete",
116
      target: String(note.id),
117
      allowed: 0,
118
      outcome: "forbidden",
119
    },
120
    {
121
      who_name: "alice",
122
      role: "user",
123
      resource: "drawing",
124
      verb: "delete",
125
      target: null,
126
      allowed: 0,
127
      outcome: "cross_site",
128
    },
129
  ]);
130
  for (const row of rows)
131
    expect(Date.parse(row.at)).toBeGreaterThan(Date.now() - 60_000);
132
});
133
134
test("reading is not an event: a GET leaves no row", async () => {
135
  const before = (await trail()).length;
136
  await call("/api/drawings");
137
  await call("/", {}, alice);
138
  expect((await trail()).length).toBe(before);
139
});
140
141
test("only an admin reads the trail, at every door the table names", async () => {
142
  expect((await call("/api/audit")).status).toBe(401);
143
  expect((await call("/api/audit", {}, alice)).status).toBe(403);
144
  const rows = await trail();
145
  expect(rows.length).toBeGreaterThan(0);
146
  expect((await call(`/api/audit/${rows[0]!.id}`, {}, root)).status).toBe(200);
147
  expect((await call(`/api/audit/${rows[0]!.id}`, {}, alice)).status).toBe(403);
148
  expect((await call("/api/audit/999999", {}, root)).status).toBe(404);
149
  // Nobody writes the trail through a door: not even an admin.
150
  expect(
151
    (await call("/api/audit", { method: "POST", body: "{}" }, root)).status,
152
  ).toBe(403);
153
  expect(
154
    (await call(`/api/audit/${rows[0]!.id}`, { method: "DELETE" }, root))
155
      .status,
156
  ).toBe(403);
157
  // Paging back: `before` an id is everything older.
158
  const older = (await (
159
    await call(`/api/audit?before=${rows[0]!.id}&limit=1`, {}, root)
160
  ).json()) as AuditRow[];
161
  expect(older.map((r) => r.id)).toEqual([rows[1]!.id]);
162
});
163
164
test("a trail that cannot be written is a log line, and the change still happens", () => {
165
  const db = openDb(mkdtempSync(join(tmpdir(), "app-audit-broken-")));
166
  db.exec("DROP TABLE audit");
167
  const said = spyOn(console, "error").mockImplementation(() => {});
168
  try {
169
    const added = startDrawing(
170
      db,
171
      { role: "user", id: "u1", name: "alice" },
172
      { prompt: "still here" },
173
    );
174
    expect(added.ok).toBe(true);
175
    expect(said).toHaveBeenCalledTimes(1);
176
    const line = JSON.parse(String(said.mock.calls[0]![0]));
177
    expect(line.audit).toMatchObject({
178
      who_name: "alice",
179
      resource: "drawing",
180
      verb: "create",
181
      allowed: 1,
182
      outcome: "ok",
183
    });
184
    expect(line.unrecorded).toContain("audit");
185
  } finally {
186
    said.mockRestore();
187
  }
188
});
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts