benjivers/notes Public Public: anyone can read this; nobody can change it.

Files 48459319b3fcac902928ea7f5ec007a0d1a287bc
181 lines · 6.1 KB · ~1.8k tok Raw History
1
/**
2
 * The door turns away, before a byte of the body is read, exactly who the
3
 * table says could never pass — and nobody it would let through.
4
 */
5
import { afterAll, expect, test } from "bun:test";
6
import { mkdtempSync } from "node:fs";
7
import { tmpdir } from "node:os";
8
import { join } from "node:path";
9
import { readContract } from "./can.ts";
10
import { openDb } from "./db.ts";
11
import {
12
  allow,
13
  door,
14
  doorOf,
15
  mayKnock,
16
  openToStrangers,
17
  STRANGERS,
18
} from "./security.ts";
19
import { serve } from "./server.ts";
20
import { sign } from "./who.ts";
21
22
const KEY = "a-key-only-the-forge-holds";
23
const server = serve({
24
  port: 0,
25
  dataDir: mkdtempSync(join(tmpdir(), "app-door-")),
26
  identityKey: KEY,
27
});
28
const base = `http://${server.hostname}:${server.port}`;
29
afterAll(() => server.stop(true));
30
const as = (id: string, role: "user" | "admin"): Record<string, string> => {
31
  const identity = `${id}|${id}|${role}|${Date.now() + 60_000}`;
32
  return {
33
    "x-forge-identity": identity,
34
    "x-forge-signature": sign(identity, KEY),
35
  };
36
};
37
38
test("a path is a resource's door by the contract's api, and nothing else is", () => {
39
  expect(doorOf("GET", "/api/notes")).toEqual({
40
    resource: "note",
41
    verb: "list",
42
  });
43
  expect(doorOf("POST", "/api/notes")).toEqual({
44
    resource: "note",
45
    verb: "create",
46
  });
47
  expect(doorOf("GET", "/api/notes/7")).toEqual({
48
    resource: "note",
49
    verb: "read",
50
  });
51
  expect(doorOf("PATCH", "/api/notes/7")).toEqual({
52
    resource: "note",
53
    verb: "update",
54
  });
55
  expect(doorOf("DELETE", "/api/notes/7")).toEqual({
56
    resource: "note",
57
    verb: "delete",
58
  });
59
  expect(doorOf("GET", "/api/audit")).toEqual({
60
    resource: "audit",
61
    verb: "list",
62
  });
63
  for (const [method, path] of [
64
    ["PUT", "/api/notes"],
65
    ["GET", "/api/notes/7/x"],
66
    ["GET", "/api/notesy"],
67
    ["GET", "/api/notes/"],
68
    ["POST", "/notes"],
69
  ] as const)
70
    expect(doorOf(method, path)).toBeUndefined();
71
});
72
73
test("the door refuses only what no row would open", () => {
74
  const anon = { role: "anon" } as const;
75
  const alice = { role: "user", id: "u1", name: "alice" } as const;
76
  const root = { role: "admin", id: "u0", name: "root" } as const;
77
  // A vault is its author's: a stranger has no door to any of it.
78
  expect(mayKnock(anon, "note", "list")).toBe(false);
79
  expect(mayKnock(anon, "note", "read")).toBe(false);
80
  expect(mayKnock(anon, "note", "create")).toBe(false);
81
  expect(mayKnock(anon, "note", "delete")).toBe(false);
82
  // alice may delete a note of her own, so the door lets her ask; the action decides which.
83
  expect(mayKnock(alice, "note", "delete")).toBe(true);
84
  expect(mayKnock(alice, "audit", "list")).toBe(false);
85
  expect(mayKnock(root, "audit", "list")).toBe(true);
86
  expect(mayKnock(root, "audit", "create")).toBe(false);
87
});
88
89
test("a stranger is refused /api before the body is read, when the contract closes it", async () => {
90
  expect((await fetch(`${base}/api/audit`)).status).toBe(401);
91
  expect((await fetch(`${base}/api/audit/1`)).status).toBe(401);
92
  expect(
93
    (await fetch(`${base}/api/audit`, { headers: as("u1", "user") })).status,
94
  ).toBe(403);
95
  // A stranger reads no note, and — the table opening them no door — not the API's description either.
96
  expect((await fetch(`${base}/api/notes`)).status).toBe(401);
97
  expect((await fetch(`${base}/api/notes/0`)).status).toBe(401);
98
  expect((await fetch(`${base}/openapi.json`)).status).toBe(401);
99
  // What the table opens to a person stays open to them.
100
  const alice = as("u1", "user");
101
  expect((await fetch(`${base}/api/notes`, { headers: alice })).status).toBe(200);
102
  expect((await fetch(`${base}/api/notes/0`, { headers: alice })).status).toBe(404);
103
  expect((await fetch(`${base}/openapi.json`, { headers: alice })).status).toBe(200);
104
  // A body past the cap: read, it would be 413. The door answers first.
105
  // (Last, because the refusal leaves the body unread on its connection.)
106
  const huge = "x".repeat(2 * 1024 * 1024);
107
  const signedIn = await fetch(`${base}/api/notes`, {
108
    method: "POST",
109
    headers: { "content-type": "application/json", ...alice },
110
    body: huge,
111
  });
112
  expect(signedIn.status).toBe(413);
113
  const refused = await fetch(`${base}/api/notes`, {
114
    method: "POST",
115
    headers: { "content-type": "application/json" },
116
    body: huge,
117
  });
118
  expect(refused.status).toBe(401);
119
  expect(await refused.json()).toEqual({
120
    error: "signin_required",
121
    message: "Sign in first.",
122
  });
123
});
124
125
test("the API's description is shut to strangers when the table opens them no door", () => {
126
  const closed = readContract(
127
    JSON.stringify({
128
      roles: {
129
        anon: { above: [], held: { by: "everyone" } },
130
        user: { above: ["anon"], held: { by: "identity", role: "user" } },
131
      },
132
      resources: {
133
        note: {
134
          api: "/api/notes",
135
          can: {
136
            create: "user",
137
            read: "user",
138
            update: "user",
139
            delete: "user",
140
            list: "user",
141
          },
142
        },
143
      },
144
    }),
145
  );
146
  if (!closed.ok) throw new Error(closed.problems.join("; "));
147
  expect(openToStrangers(closed.contract)).toBe(false);
148
  expect(openToStrangers()).toBe(false);
149
});
150
151
test("strangers are rationed at the API, reads too", () => {
152
  const db = openDb(mkdtempSync(join(tmpdir(), "app-door-rate-")));
153
  const knock = () =>
154
    door(
155
      db,
156
      new Request("http://app/mcp"),
157
      new URL("http://app/mcp"),
158
      { role: "anon" },
159
      "203.0.113.7",
160
    );
161
  const answers = Array.from({ length: STRANGERS.burst + 5 }, knock);
162
  expect(answers.slice(0, STRANGERS.burst).every((a) => a === undefined)).toBe(
163
    true,
164
  );
165
  expect(answers.at(-1)?.status).toBe(429);
166
  // The page itself is not the API: a stranger reading it is never rationed here.
167
  expect(
168
    door(
169
      db,
170
      new Request("http://app/"),
171
      new URL("http://app/"),
172
      { role: "anon" },
173
      "203.0.113.7",
174
    ),
175
  ).toBeUndefined();
176
  // A bucket refills.
177
  const t = Date.now();
178
  for (let i = 0; i < 25; i++) allow("k", { burst: 20, perSecond: 2 }, t);
179
  expect(allow("k", { burst: 20, perSecond: 2 }, t)).toBe(false);
180
  expect(allow("k", { burst: 20, perSecond: 2 }, t + 1000)).toBe(true);
181
});
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts