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

forked from apps/glyph

workbenchthis machine
Files acd87b6b6146da11e0c9272c92a02a8e707adf4a
173 lines · 5.5 KB · ~1.6k 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
  expect(mayKnock(anon, "note", "list")).toBe(true);
78
  expect(mayKnock(anon, "note", "create")).toBe(false);
79
  expect(mayKnock(anon, "note", "delete")).toBe(false);
80
  // alice may delete a note of her own, so the door lets her ask; the action decides which.
81
  expect(mayKnock(alice, "note", "delete")).toBe(true);
82
  expect(mayKnock(alice, "audit", "list")).toBe(false);
83
  expect(mayKnock(root, "audit", "list")).toBe(true);
84
  expect(mayKnock(root, "audit", "create")).toBe(false);
85
});
86
87
test("a stranger is refused /api before the body is read, when the contract closes it", async () => {
88
  // A body past the cap: read, it would be 413. The door answers first.
89
  const huge = "x".repeat(128 * 1024);
90
  const refused = await fetch(`${base}/api/notes`, {
91
    method: "POST",
92
    headers: { "content-type": "application/json" },
93
    body: huge,
94
  });
95
  expect(refused.status).toBe(401);
96
  const signedIn = await fetch(`${base}/api/notes`, {
97
    method: "POST",
98
    headers: { "content-type": "application/json", ...as("u1", "user") },
99
    body: huge,
100
  });
101
  expect(signedIn.status).toBe(413);
102
  expect(await refused.json()).toEqual({
103
    error: "signin_required",
104
    message: "Sign in first.",
105
  });
106
  expect((await fetch(`${base}/api/audit`)).status).toBe(401);
107
  expect((await fetch(`${base}/api/audit/1`)).status).toBe(401);
108
  expect(
109
    (await fetch(`${base}/api/audit`, { headers: as("u1", "user") })).status,
110
  ).toBe(403);
111
  // What the table opens stays open.
112
  expect((await fetch(`${base}/api/notes`)).status).toBe(200);
113
  expect((await fetch(`${base}/api/notes/0`)).status).toBe(404);
114
  expect((await fetch(`${base}/openapi.json`)).status).toBe(200);
115
});
116
117
test("the API's description is shut to strangers when the table opens them no door", () => {
118
  const closed = readContract(
119
    JSON.stringify({
120
      roles: {
121
        anon: { above: [], held: { by: "everyone" } },
122
        user: { above: ["anon"], held: { by: "identity", role: "user" } },
123
      },
124
      resources: {
125
        note: {
126
          api: "/api/notes",
127
          can: {
128
            create: "user",
129
            read: "user",
130
            update: "user",
131
            delete: "user",
132
            list: "user",
133
          },
134
        },
135
      },
136
    }),
137
  );
138
  if (!closed.ok) throw new Error(closed.problems.join("; "));
139
  expect(openToStrangers(closed.contract)).toBe(false);
140
  expect(openToStrangers()).toBe(true);
141
});
142
143
test("strangers are rationed at the API, reads too", () => {
144
  const db = openDb(mkdtempSync(join(tmpdir(), "app-door-rate-")));
145
  const knock = () =>
146
    door(
147
      db,
148
      new Request("http://app/api/notes"),
149
      new URL("http://app/api/notes"),
150
      { role: "anon" },
151
      "203.0.113.7",
152
    );
153
  const answers = Array.from({ length: STRANGERS.burst + 5 }, knock);
154
  expect(answers.slice(0, STRANGERS.burst).every((a) => a === undefined)).toBe(
155
    true,
156
  );
157
  expect(answers.at(-1)?.status).toBe(429);
158
  // The page itself is not the API: a stranger reading it is never rationed here.
159
  expect(
160
    door(
161
      db,
162
      new Request("http://app/"),
163
      new URL("http://app/"),
164
      { role: "anon" },
165
      "203.0.113.7",
166
    ),
167
  ).toBeUndefined();
168
  // A bucket refills.
169
  const t = Date.now();
170
  for (let i = 0; i < 25; i++) allow("k", { burst: 20, perSecond: 2 }, t);
171
  expect(allow("k", { burst: 20, perSecond: 2 }, t)).toBe(false);
172
  expect(allow("k", { burst: 20, perSecond: 2 }, t + 1000)).toBe(true);
173
});
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts