-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathcapabilities.test.ts
More file actions
261 lines (230 loc) · 10.7 KB
/
Copy pathcapabilities.test.ts
File metadata and controls
261 lines (230 loc) · 10.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
// The demo's confinement claims, as executable tests, over the real wire:
// a Cap'n Web WebSocket through the Worker's /ws route into the Durable
// Object — exactly like the browser.
//
// The attenuation tests use the same connection and the same granted stubs,
// but closures the UI would never author — confirming that what a client can
// reach is exactly what its grants scoped, not a matter of the UI's
// good behavior.
import { describe, test, expect, expectTypeOf, vi } from "vitest";
import { SELF } from "cloudflare:test";
import { newWebSocketRpcSession, byRef, doRpc, type ShimStub } from "typegres/capnweb";
import type { Chat, Users, Rooms, Memberships } from "../worker/api";
type Principal = InstanceType<ReturnType<typeof Users.forPrincipal>>;
type Grant = InstanceType<ReturnType<typeof Memberships.forPrincipal>>;
type MemberRoom = InstanceType<ReturnType<typeof Rooms.forMember>>;
const connect = async (): Promise<ShimStub<Chat>> => {
const resp = await SELF.fetch("https://chat.local/ws", {
headers: { Upgrade: "websocket" },
});
expect(resp.status).toBe(101);
const ws = resp.webSocket!;
ws.accept();
return newWebSocketRpcSession(ws as unknown as WebSocket) as unknown as ShimStub<Chat>;
};
const login = async (username: string, password = "hunter2"): Promise<Principal> => {
const api = await connect();
return await api.login({ username, password });
};
const openRoom = async (user: Principal, grant: Grant): Promise<MemberRoom> =>
doRpc(user, () => grant.room().one());
describe("grants (the happy path)", () => {
test("login → createRoom → post → client-authored feed query", async () => {
const alice = await login("alice");
const grant = await alice.createRoom("plans");
const room = await openRoom(alice, grant);
await doRpc(alice, () => room.post("first!").execute());
const feed = await doRpc(alice, () =>
room
.messages()
.select(({ messages, users }) => ({ body: messages.body, author: users.name }))
.orderBy(({ messages }) => messages.id)
.execute(),
);
// DX: the FE gets exactly-typed rows from a client-authored select.
// (toExtend, not toEqualTypeOf, because doRpc's Stubbed<> wrapper adds a
// cosmetic `& Disposable` to results — assignable to the clean shape, so
// field access and assignment work, but not exact-equal.)
expectTypeOf(feed).toExtend<{ body: string; author: string }[]>();
expect(feed).toEqual([{ body: "first!", author: "alice" }]);
});
test("directory → join → post from a second user; join is idempotent", async () => {
const alice = await login("alice");
const aliceGrant = await alice.createRoom("public-square");
const aliceRoom = await openRoom(alice, aliceGrant);
await doRpc(alice, () => aliceRoom.post("hi from alice").execute());
const bob = await login("bob");
const dir = await doRpc(bob, (u) =>
u
.directory()
.select(({ rooms }) => ({ id: rooms.id, name: rooms.name }))
.execute(),
);
const target = dir.find((r) => r.name === "public-square")!;
await bob.joinRoom(target.id);
const bobGrant = await bob.joinRoom(target.id); // idempotent
const bobRoom = await openRoom(bob, bobGrant);
await doRpc(bob, () => bobRoom.post("hi from bob").execute());
const authors = await doRpc(bob, () =>
bobRoom
.members()
.select(({ users }) => ({ name: users.name }))
.orderBy(({ users }) => users.name)
.execute(),
);
expect(authors.map((m) => m.name)).toEqual(["alice", "bob"]);
});
test("client-authored aggregation over a granted builder", async () => {
const alice = await login("alice");
const aliceRoom = await openRoom(alice, await alice.createRoom("stats"));
await doRpc(alice, () => aliceRoom.post("one").execute());
await doRpc(alice, () => aliceRoom.post("two").execute());
const bob = await login("bob");
const dir = await doRpc(bob, (u) =>
u.directory().where(({ rooms }) => rooms.name.eq("stats")).select(({ rooms }) => ({ id: rooms.id })).execute(),
);
const bobGrant = await bob.joinRoom(dir[0]!.id);
const bobRoom = await openRoom(bob, bobGrant);
await doRpc(bob, () => bobRoom.post("hi").execute());
// The server has no "top posters" endpoint; the client composes it —
// group-by-count ordered by count desc, exactly like the demo's UI.
const topPosters = await doRpc(alice, () =>
aliceRoom
.messages()
.groupBy(({ users }) => [users.name])
.select(({ users, messages }) => ({ author: users.name, posts: messages.id.count() }))
.orderBy(({ messages }) => [messages.id.count(), "desc"])
.execute(),
);
// The aggregation's shape flows through to the FE: string author, number count.
expectTypeOf(topPosters).toExtend<{ author: string; posts: number }[]>();
expect(topPosters).toEqual([
{ author: "alice", posts: 2 },
{ author: "bob", posts: 1 },
]);
});
test("client-authored group_concat splices the room into one string", async () => {
const alice = await login("alice");
const room = await openRoom(alice, await alice.createRoom("transcript"));
await doRpc(alice, () => room.post("one").execute());
await doRpc(alice, () => room.post("two").execute());
// A distinctive SQLite aggregate, authored client-side against the
// room's granted builder — reduces the whole feed to a single string.
const rows = await doRpc(alice, () =>
room
.messages()
.select(({ messages }) => ({ transcript: messages.body.groupConcat("\n") }))
.execute(),
);
expectTypeOf(rows).toExtend<{ transcript: string | null }[]>();
expect(rows).toEqual([{ transcript: "one\ntwo" }]);
});
test("live: client-authored subscription pushes on post, stops on unsubscribe", async () => {
const alice = await login("alice");
const room = await openRoom(alice, await alice.createRoom("live-room"));
// byRef: the callback crosses BY REFERENCE; observer-first live()
// resolves the DO's default connection.
const got: { body: string }[][] = [];
const onNext = byRef((rows: { body: string }[]) => {
got.push(rows);
});
const sub = await doRpc(alice, () =>
room
.messages()
.select(({ messages }) => ({ body: messages.body }))
.live()
.observe({ onNext }),
);
await vi.waitFor(() => expect(got.length).toBeGreaterThanOrEqual(1));
expect(got[0]).toEqual([]);
await doRpc(alice, () => room.post("ping").execute());
await vi.waitFor(() => expect(got.at(-1)).toEqual([{ body: "ping" }]));
await sub.unsubscribe();
onNext[Symbol.dispose](); // release the by-reference callback stub
const pushesAfterStop = got.length;
await doRpc(alice, () => room.post("after-stop").execute());
await new Promise((resolve) => setTimeout(resolve, 100));
expect(got.length).toBe(pushesAfterStop);
});
test("wrong password on a claimed username is rejected", async () => {
await login("claimed", "correct-horse");
const api = await connect();
await expect(api.login({ username: "claimed", password: "wrong" })).rejects.toThrow(
/claimed/,
);
});
});
describe("attenuation (confinement)", () => {
test("the pre-login root is powerless: login() and nothing else", async () => {
const api = await connect();
const anyApi = api as any;
expect(await anyApi.directory).toBeUndefined();
await expect(doRpc(anyApi, (a: any) => a.directory())).rejects.toThrow();
});
test("directory rooms are the attenuation floor: no post, no messages", async () => {
const alice = await login("alice");
const room = await openRoom(alice, await alice.createRoom("private"));
await doRpc(alice, () => room.post("secret plans").execute());
const mallory = await login("mallory");
// Mallory sees the room exists (directory data)...
const dir = await doRpc(mallory, (u) =>
u.directory().select(({ rooms }) => ({ id: rooms.id, name: rooms.name })).execute(),
);
expect(dir.map((r) => r.name)).toContain("private");
// ...and can hold directory ROWS — but they're base Rooms: nothing
// amplifying on them. (Hydration mints exactly the grant-site class.)
const rows = await doRpc(mallory, (u) => u.directory().hydrate());
const held = rows.find(Boolean)! as unknown as { post?: unknown; messages?: unknown };
expect(await (held.post as Promise<unknown>)).toBeUndefined();
await expect(
doRpc(mallory, () =>
(held as { messages: () => { execute: () => unknown } }).messages().execute(),
),
).rejects.toThrow();
});
test("password_hash is unreachable through any client-authored select", async () => {
const alice = await login("alice");
const room = await openRoom(alice, await alice.createRoom("leaky"));
// Explicitly selecting the column: it TYPECHECKS (the column exists on
// the class; @expose gating is invisible to the compiler), but it isn't
// @expose'd, so it replays to undefined and the select is rejected
// server-side. The guard is runtime, at the capability boundary.
await expect(
doRpc(alice, () =>
room
.members()
.select(({ users }) => ({ h: users.password_hash }))
.execute(),
),
).rejects.toThrow();
// And the bare row shape simply omits it.
const rows = await doRpc(alice, () => room.members().execute());
for (const row of rows) {
expect(row).not.toHaveProperty("password_hash");
}
});
test("a member room's builders cannot see other rooms", async () => {
const alice = await login("alice");
const mine = await openRoom(alice, await alice.createRoom("mine"));
const other = await openRoom(alice, await alice.createRoom("other"));
await doRpc(alice, () => other.post("elsewhere").execute());
// messages() is pre-scoped by room id; a deliberately "wide" query over
// it still only sees this room.
const rows = await doRpc(alice, () =>
mine
.messages()
.select(({ messages }) => ({ room_id: messages.room_id, body: messages.body }))
.execute(),
);
expect(rows).toEqual([]);
});
test("membership rows from members() cannot amplify — grant facets are self-scoped", async () => {
const alice = await login("alice");
const room = await openRoom(alice, await alice.createRoom("grants"));
// members() hydrates base Users rows; nothing on them reaches rooms.
const memberRows = await doRpc(alice, () => room.members().hydrate());
const other = memberRows[0]! as unknown as { room?: Promise<unknown>; memberships?: Promise<unknown> };
expect(await other.room).toBeUndefined();
expect(await other.memberships).toBeUndefined();
});
});