@openparachute/vault 0.6.5-rc.12 → 0.6.5-rc.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.parachute/module.json +1 -0
- package/package.json +1 -1
- package/src/live-frame-parity.test.ts +226 -0
- package/src/module-manifest.ts +8 -0
- package/src/self-register.test.ts +19 -0
- package/src/self-register.ts +6 -1
- package/src/server.ts +24 -0
- package/src/services-manifest.ts +8 -0
- package/src/subscribe.ts +23 -22
- package/src/subscriptions.ts +125 -37
- package/src/test-support/live-frame-corpus.ts +72 -0
- package/src/ws-server.ts +408 -0
- package/src/ws-subscribe.test.ts +474 -0
- package/src/ws-subscribe.ts +242 -0
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live-query WebSocket binding — RAW Bun WS-client integration test
|
|
3
|
+
* (WS-hibernation migration Phase 3, self-host door). Spins up a real
|
|
4
|
+
* `Bun.serve` wired with the production `createSubscribeWsBinding` handlers +
|
|
5
|
+
* upgrade decision, then drives a REAL `new WebSocket(...)` client through the
|
|
6
|
+
* wire contract: first-message auth handshake, chunked snapshot, single-writer
|
|
7
|
+
* live upsert/remove, ping/pong, the close codes (4400/4401/4403/4408),
|
|
8
|
+
* re-auth narrow-or-equal, and the per-vault cap.
|
|
9
|
+
*
|
|
10
|
+
* This is the self-host proof that the SERVER speaks the contract. The
|
|
11
|
+
* raw-client-THROUGH-the-hub-bridge check (the risky proxy seam) is a separate
|
|
12
|
+
* live verification recorded in the PR body; the full browser E2E is Phase 4.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
15
|
+
import { Database } from "bun:sqlite";
|
|
16
|
+
import { SqliteStore } from "../core/src/store.ts";
|
|
17
|
+
import { HookRegistry } from "../core/src/hooks.ts";
|
|
18
|
+
import type { VaultConfig } from "./config.ts";
|
|
19
|
+
import type { AuthResult } from "./auth.ts";
|
|
20
|
+
import { SubscriptionManager } from "./subscriptions.ts";
|
|
21
|
+
import { createSubscribeWsBinding, isWebSocketUpgrade, type SubscribeWsData } from "./ws-server.ts";
|
|
22
|
+
|
|
23
|
+
const VAULT = "testvault";
|
|
24
|
+
|
|
25
|
+
let db: Database;
|
|
26
|
+
let hooks: HookRegistry;
|
|
27
|
+
let store: SqliteStore;
|
|
28
|
+
let manager: SubscriptionManager;
|
|
29
|
+
let servers: Array<ReturnType<typeof Bun.serve>> = [];
|
|
30
|
+
|
|
31
|
+
const vaultConfig = { name: VAULT, created_at: new Date().toISOString(), api_keys: [] } as unknown as VaultConfig;
|
|
32
|
+
|
|
33
|
+
/** A stub auth seam keyed on the Bearer token, exercising every close-code path
|
|
34
|
+
* without a live hub JWKS. Mirrors the AuthResult the real seam returns. */
|
|
35
|
+
async function fakeAuth(req: Request, _cfg: VaultConfig): Promise<{ error: Response } | AuthResult> {
|
|
36
|
+
const header = req.headers.get("authorization") ?? "";
|
|
37
|
+
const token = header.startsWith("Bearer ") ? header.slice(7) : "";
|
|
38
|
+
const mk = (scopes: string[], scoped_tags: string[] | null = null): AuthResult => ({
|
|
39
|
+
permission: "full",
|
|
40
|
+
scopes,
|
|
41
|
+
legacyDerived: false,
|
|
42
|
+
scoped_tags,
|
|
43
|
+
vault_name: null,
|
|
44
|
+
caller_jti: null,
|
|
45
|
+
actor: "test",
|
|
46
|
+
via: "api",
|
|
47
|
+
});
|
|
48
|
+
switch (token) {
|
|
49
|
+
case "admin":
|
|
50
|
+
return mk(["vault:admin", "vault:write", "vault:read"]);
|
|
51
|
+
case "good":
|
|
52
|
+
case "readonly":
|
|
53
|
+
return mk(["vault:read"]);
|
|
54
|
+
case "noscope":
|
|
55
|
+
return mk([]); // authenticates but grants no read → 4403
|
|
56
|
+
case "scoped-eng":
|
|
57
|
+
case "scoped-eng-2": // same tag-scope as scoped-eng (a normal refresh)
|
|
58
|
+
return mk(["vault:read"], ["work/eng"]);
|
|
59
|
+
case "scoped-sales":
|
|
60
|
+
return mk(["vault:read"], ["work/sales"]); // a DIFFERENT tag-scope
|
|
61
|
+
case "forbidden":
|
|
62
|
+
return { error: Response.json({ error: "Forbidden" }, { status: 403 }) };
|
|
63
|
+
default:
|
|
64
|
+
return { error: Response.json({ error: "Unauthorized" }, { status: 401 }) };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function makeServer(overrides: Parameters<typeof createSubscribeWsBinding>[0] = {}) {
|
|
69
|
+
const binding = createSubscribeWsBinding({
|
|
70
|
+
getStore: () => store,
|
|
71
|
+
getVaultConfig: () => vaultConfig,
|
|
72
|
+
manager,
|
|
73
|
+
authenticate: fakeAuth,
|
|
74
|
+
...overrides,
|
|
75
|
+
});
|
|
76
|
+
const server = Bun.serve({
|
|
77
|
+
port: 0,
|
|
78
|
+
websocket: binding.handlers,
|
|
79
|
+
fetch(req, server) {
|
|
80
|
+
const url = new URL(req.url);
|
|
81
|
+
if (isWebSocketUpgrade(req)) {
|
|
82
|
+
const verdict = binding.tryUpgrade(req, server, url.pathname);
|
|
83
|
+
if (verdict.kind === "upgraded") return undefined;
|
|
84
|
+
if (verdict.kind === "response") return verdict.response;
|
|
85
|
+
}
|
|
86
|
+
return new Response("not found", { status: 404 });
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
servers.push(server);
|
|
90
|
+
return { server, binding };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Raw WS client handle. */
|
|
94
|
+
function connect(port: number, path: string) {
|
|
95
|
+
const ws = new WebSocket(`ws://localhost:${port}${path}`);
|
|
96
|
+
const queue: any[] = [];
|
|
97
|
+
const waiters: Array<(m: any) => void> = [];
|
|
98
|
+
let closeInfo: { code: number; reason: string } | null = null;
|
|
99
|
+
const closeWaiters: Array<(c: { code: number; reason: string }) => void> = [];
|
|
100
|
+
let opened = false;
|
|
101
|
+
const openWaiters: Array<() => void> = [];
|
|
102
|
+
|
|
103
|
+
ws.addEventListener("open", () => {
|
|
104
|
+
opened = true;
|
|
105
|
+
openWaiters.splice(0).forEach((r) => r());
|
|
106
|
+
});
|
|
107
|
+
ws.addEventListener("message", (e: MessageEvent) => {
|
|
108
|
+
let m: any;
|
|
109
|
+
try {
|
|
110
|
+
m = JSON.parse(e.data as string);
|
|
111
|
+
} catch {
|
|
112
|
+
m = e.data;
|
|
113
|
+
}
|
|
114
|
+
const w = waiters.shift();
|
|
115
|
+
if (w) w(m);
|
|
116
|
+
else queue.push(m);
|
|
117
|
+
});
|
|
118
|
+
ws.addEventListener("close", (e: CloseEvent) => {
|
|
119
|
+
closeInfo = { code: e.code, reason: e.reason };
|
|
120
|
+
closeWaiters.splice(0).forEach((w) => w(closeInfo!));
|
|
121
|
+
});
|
|
122
|
+
ws.addEventListener("error", () => {
|
|
123
|
+
/* a failed handshake surfaces as error → close; swallow */
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const nextMessage = (ms = 3000): Promise<any> => {
|
|
127
|
+
const q = queue.shift();
|
|
128
|
+
if (q !== undefined) return Promise.resolve(q);
|
|
129
|
+
return new Promise((resolve, reject) => {
|
|
130
|
+
const t = setTimeout(() => reject(new Error("ws message timeout")), ms);
|
|
131
|
+
waiters.push((m) => {
|
|
132
|
+
clearTimeout(t);
|
|
133
|
+
resolve(m);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
ws,
|
|
140
|
+
ready: (): Promise<void> => (opened ? Promise.resolve() : new Promise((r) => openWaiters.push(r))),
|
|
141
|
+
send: (obj: unknown) => ws.send(typeof obj === "string" ? obj : JSON.stringify(obj)),
|
|
142
|
+
nextMessage,
|
|
143
|
+
/** Read snapshot frame(s) until done:true. Returns concatenated notes + frame count. */
|
|
144
|
+
readSnapshot: async (ms = 3000): Promise<{ notes: any[]; frames: number }> => {
|
|
145
|
+
const notes: any[] = [];
|
|
146
|
+
let frames = 0;
|
|
147
|
+
for (;;) {
|
|
148
|
+
const m = await nextMessage(ms);
|
|
149
|
+
expect(m.type).toBe("snapshot");
|
|
150
|
+
notes.push(...m.notes);
|
|
151
|
+
frames++;
|
|
152
|
+
if (m.done) return { notes, frames };
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
waitClose: (ms = 3000): Promise<{ code: number; reason: string }> => {
|
|
156
|
+
if (closeInfo) return Promise.resolve(closeInfo);
|
|
157
|
+
return new Promise((resolve, reject) => {
|
|
158
|
+
const t = setTimeout(() => reject(new Error("ws close timeout")), ms);
|
|
159
|
+
closeWaiters.push((c) => {
|
|
160
|
+
clearTimeout(t);
|
|
161
|
+
resolve(c);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
},
|
|
165
|
+
close: () => ws.close(),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Let deferred hook dispatch + handlers run. */
|
|
170
|
+
function settle(): Promise<void> {
|
|
171
|
+
return new Promise((r) => setTimeout(r, 15));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
beforeEach(() => {
|
|
175
|
+
db = new Database(":memory:");
|
|
176
|
+
hooks = new HookRegistry({ concurrency: 4, logger: { error() {} } });
|
|
177
|
+
store = new SqliteStore(db, { hooks });
|
|
178
|
+
manager = new SubscriptionManager(hooks, { resolveVault: () => VAULT });
|
|
179
|
+
servers = [];
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
afterEach(async () => {
|
|
183
|
+
for (const s of servers) s.stop(true);
|
|
184
|
+
await settle();
|
|
185
|
+
manager.shutdown();
|
|
186
|
+
db.close();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("WS live-query — handshake + snapshot", () => {
|
|
190
|
+
it("authenticates via first message and receives the scoped snapshot", async () => {
|
|
191
|
+
await store.createNote("hello", { tags: ["chat"] });
|
|
192
|
+
await store.createNote("nope", { tags: ["other"] });
|
|
193
|
+
const { server } = makeServer();
|
|
194
|
+
|
|
195
|
+
const h = connect(server.port, `/vault/${VAULT}/api/subscribe?tag=chat`);
|
|
196
|
+
await h.ready();
|
|
197
|
+
h.send({ type: "auth", token: "good" });
|
|
198
|
+
|
|
199
|
+
const snap = await h.readSnapshot();
|
|
200
|
+
expect(snap.frames).toBe(1);
|
|
201
|
+
expect(snap.notes.length).toBe(1);
|
|
202
|
+
expect(snap.notes[0].content).toBe("hello");
|
|
203
|
+
h.close();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("delivers a chunked snapshot the client reassembles (> chunk-max notes)", async () => {
|
|
207
|
+
for (let i = 0; i < 205; i++) await store.createNote(`n${i}`, { tags: ["chat"] });
|
|
208
|
+
const { server } = makeServer();
|
|
209
|
+
|
|
210
|
+
const h = connect(server.port, `/vault/${VAULT}/api/subscribe?tag=chat`);
|
|
211
|
+
await h.ready();
|
|
212
|
+
h.send({ type: "auth", token: "good" });
|
|
213
|
+
|
|
214
|
+
const snap = await h.readSnapshot();
|
|
215
|
+
expect(snap.frames).toBeGreaterThan(1); // chunked across the 200-note bound
|
|
216
|
+
expect(snap.notes.length).toBe(205);
|
|
217
|
+
h.close();
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe("WS live-query — live events", () => {
|
|
222
|
+
it("emits upsert on a matching insert after ready", async () => {
|
|
223
|
+
const { server } = makeServer();
|
|
224
|
+
const h = connect(server.port, `/vault/${VAULT}/api/subscribe?tag=chat`);
|
|
225
|
+
await h.ready();
|
|
226
|
+
h.send({ type: "auth", token: "good" });
|
|
227
|
+
await h.readSnapshot();
|
|
228
|
+
|
|
229
|
+
await store.createNote("live one", { tags: ["chat"] });
|
|
230
|
+
const m = await h.nextMessage();
|
|
231
|
+
expect(m.type).toBe("upsert");
|
|
232
|
+
expect(m.note.content).toBe("live one");
|
|
233
|
+
h.close();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("emits remove on delete", async () => {
|
|
237
|
+
const note = await store.createNote("doomed", { tags: ["chat"] });
|
|
238
|
+
const { server } = makeServer();
|
|
239
|
+
const h = connect(server.port, `/vault/${VAULT}/api/subscribe?tag=chat`);
|
|
240
|
+
await h.ready();
|
|
241
|
+
h.send({ type: "auth", token: "good" });
|
|
242
|
+
await h.readSnapshot();
|
|
243
|
+
|
|
244
|
+
await store.deleteNote(note.id);
|
|
245
|
+
const m = await h.nextMessage();
|
|
246
|
+
expect(m.type).toBe("remove");
|
|
247
|
+
expect(m.id).toBe(note.id);
|
|
248
|
+
h.close();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("does NOT push an insert that doesn't match the query", async () => {
|
|
252
|
+
const { server } = makeServer();
|
|
253
|
+
const h = connect(server.port, `/vault/${VAULT}/api/subscribe?tag=chat`);
|
|
254
|
+
await h.ready();
|
|
255
|
+
h.send({ type: "auth", token: "good" });
|
|
256
|
+
await h.readSnapshot();
|
|
257
|
+
|
|
258
|
+
await store.createNote("irrelevant", { tags: ["other"] });
|
|
259
|
+
await settle();
|
|
260
|
+
// No frame should have arrived — a nextMessage would time out. Prove it via
|
|
261
|
+
// a matching insert landing as the NEXT (and only) message.
|
|
262
|
+
await store.createNote("matches", { tags: ["chat"] });
|
|
263
|
+
const m = await h.nextMessage();
|
|
264
|
+
expect(m.type).toBe("upsert");
|
|
265
|
+
expect(m.note.content).toBe("matches");
|
|
266
|
+
h.close();
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe("WS live-query — ping/pong liveness", () => {
|
|
271
|
+
it("answers the literal ping with pong, subscription intact", async () => {
|
|
272
|
+
const { server } = makeServer();
|
|
273
|
+
const h = connect(server.port, `/vault/${VAULT}/api/subscribe?tag=chat`);
|
|
274
|
+
await h.ready();
|
|
275
|
+
h.send({ type: "auth", token: "good" });
|
|
276
|
+
await h.readSnapshot();
|
|
277
|
+
|
|
278
|
+
h.send("ping");
|
|
279
|
+
const pong = await h.nextMessage();
|
|
280
|
+
expect(pong).toBe("pong");
|
|
281
|
+
|
|
282
|
+
// Subscription still live after the ping.
|
|
283
|
+
await store.createNote("after ping", { tags: ["chat"] });
|
|
284
|
+
const m = await h.nextMessage();
|
|
285
|
+
expect(m.type).toBe("upsert");
|
|
286
|
+
h.close();
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
describe("WS live-query — close codes", () => {
|
|
291
|
+
it("bad token → 4401", async () => {
|
|
292
|
+
const { server } = makeServer();
|
|
293
|
+
const h = connect(server.port, `/vault/${VAULT}/api/subscribe?tag=chat`);
|
|
294
|
+
await h.ready();
|
|
295
|
+
h.send({ type: "auth", token: "wrong" });
|
|
296
|
+
expect((await h.waitClose()).code).toBe(4401);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("auth error 403 → 4403", async () => {
|
|
300
|
+
const { server } = makeServer();
|
|
301
|
+
const h = connect(server.port, `/vault/${VAULT}/api/subscribe?tag=chat`);
|
|
302
|
+
await h.ready();
|
|
303
|
+
h.send({ type: "auth", token: "forbidden" });
|
|
304
|
+
expect((await h.waitClose()).code).toBe(4403);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("authenticates but lacks read scope → 4403", async () => {
|
|
308
|
+
const { server } = makeServer();
|
|
309
|
+
const h = connect(server.port, `/vault/${VAULT}/api/subscribe?tag=chat`);
|
|
310
|
+
await h.ready();
|
|
311
|
+
h.send({ type: "auth", token: "noscope" });
|
|
312
|
+
expect((await h.waitClose()).code).toBe(4403);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("non-auth first message → 4400", async () => {
|
|
316
|
+
const { server } = makeServer();
|
|
317
|
+
const h = connect(server.port, `/vault/${VAULT}/api/subscribe?tag=chat`);
|
|
318
|
+
await h.ready();
|
|
319
|
+
h.send({ type: "upsert", note: {} });
|
|
320
|
+
expect((await h.waitClose()).code).toBe(4400);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("malformed first message → 4400", async () => {
|
|
324
|
+
const { server } = makeServer();
|
|
325
|
+
const h = connect(server.port, `/vault/${VAULT}/api/subscribe?tag=chat`);
|
|
326
|
+
await h.ready();
|
|
327
|
+
h.send("not-json-garbage");
|
|
328
|
+
expect((await h.waitClose()).code).toBe(4400);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("pending socket that never auths → 4408", async () => {
|
|
332
|
+
const { server } = makeServer({ authDeadlineMs: 150 });
|
|
333
|
+
const h = connect(server.port, `/vault/${VAULT}/api/subscribe?tag=chat`);
|
|
334
|
+
await h.ready();
|
|
335
|
+
// Send nothing — the deadline timer must close us.
|
|
336
|
+
expect((await h.waitClose(2000)).code).toBe(4408);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
describe("WS live-query — tag-scope isolation (per-agent boundary)", () => {
|
|
341
|
+
it("a tag-scoped token receives ONLY in-scope notes (snapshot + live)", async () => {
|
|
342
|
+
await store.createNote("eng note", { tags: ["shared", "work/eng"] });
|
|
343
|
+
await store.createNote("sales note", { tags: ["shared", "work/sales"] });
|
|
344
|
+
const { server } = makeServer();
|
|
345
|
+
const h = connect(server.port, `/vault/${VAULT}/api/subscribe?tag=shared`);
|
|
346
|
+
await h.ready();
|
|
347
|
+
h.send({ type: "auth", token: "scoped-eng" }); // scoped to work/eng
|
|
348
|
+
|
|
349
|
+
const snap = await h.readSnapshot();
|
|
350
|
+
expect(snap.notes.map((n: any) => n.content)).toEqual(["eng note"]); // sales filtered out
|
|
351
|
+
|
|
352
|
+
// Live: an out-of-scope insert is SILENT; the next in-scope insert arrives.
|
|
353
|
+
await store.createNote("sales live", { tags: ["shared", "work/sales"] });
|
|
354
|
+
await settle();
|
|
355
|
+
await store.createNote("eng live", { tags: ["shared", "work/eng"] });
|
|
356
|
+
const m = await h.nextMessage();
|
|
357
|
+
expect(m.type).toBe("upsert");
|
|
358
|
+
expect(m.note.content).toBe("eng live");
|
|
359
|
+
h.close();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("re-auth with a CHANGED tag-scope → 4403 (forces a clean reconnect)", async () => {
|
|
363
|
+
const { server } = makeServer();
|
|
364
|
+
const h = connect(server.port, `/vault/${VAULT}/api/subscribe?tag=shared`);
|
|
365
|
+
await h.ready();
|
|
366
|
+
h.send({ type: "auth", token: "scoped-eng" });
|
|
367
|
+
await h.readSnapshot();
|
|
368
|
+
h.send({ type: "auth", token: "scoped-sales" }); // ["work/sales"] ≠ ["work/eng"]
|
|
369
|
+
expect((await h.waitClose()).code).toBe(4403);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("re-auth that DROPS tag-scoping (scoped → unscoped, same verb) → 4403", async () => {
|
|
373
|
+
// The load-bearing case: verb-rank is unchanged (read→read), so ONLY the
|
|
374
|
+
// tag-scope delta check catches this widen-the-scope-without-changing-verb.
|
|
375
|
+
const { server } = makeServer();
|
|
376
|
+
const h = connect(server.port, `/vault/${VAULT}/api/subscribe?tag=shared`);
|
|
377
|
+
await h.ready();
|
|
378
|
+
h.send({ type: "auth", token: "scoped-eng" });
|
|
379
|
+
await h.readSnapshot();
|
|
380
|
+
h.send({ type: "auth", token: "readonly" }); // scoped_tags null, still vault:read
|
|
381
|
+
expect((await h.waitClose()).code).toBe(4403);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("re-auth with the SAME tag-scope stays ready (normal refresh)", async () => {
|
|
385
|
+
const { server } = makeServer();
|
|
386
|
+
const h = connect(server.port, `/vault/${VAULT}/api/subscribe?tag=shared`);
|
|
387
|
+
await h.ready();
|
|
388
|
+
h.send({ type: "auth", token: "scoped-eng" });
|
|
389
|
+
await h.readSnapshot();
|
|
390
|
+
h.send({ type: "auth", token: "scoped-eng-2" }); // identical ["work/eng"]
|
|
391
|
+
await settle();
|
|
392
|
+
// Still live + still correctly scoped: an in-scope write arrives, out-of-scope stays silent.
|
|
393
|
+
await store.createNote("sales after refresh", { tags: ["shared", "work/sales"] });
|
|
394
|
+
await settle();
|
|
395
|
+
await store.createNote("eng after refresh", { tags: ["shared", "work/eng"] });
|
|
396
|
+
const m = await h.nextMessage();
|
|
397
|
+
expect(m.type).toBe("upsert");
|
|
398
|
+
expect(m.note.content).toBe("eng after refresh");
|
|
399
|
+
h.close();
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
describe("WS live-query — re-auth (narrow-or-equal)", () => {
|
|
404
|
+
it("a narrowing re-auth keeps the socket open", async () => {
|
|
405
|
+
const { server } = makeServer();
|
|
406
|
+
const h = connect(server.port, `/vault/${VAULT}/api/subscribe?tag=chat`);
|
|
407
|
+
await h.ready();
|
|
408
|
+
h.send({ type: "auth", token: "admin" }); // rank 2
|
|
409
|
+
await h.readSnapshot();
|
|
410
|
+
|
|
411
|
+
h.send({ type: "auth", token: "readonly" }); // rank 0 — narrow, allowed
|
|
412
|
+
await settle();
|
|
413
|
+
// Still live: a matching write arrives as an upsert (not a close).
|
|
414
|
+
await store.createNote("still live", { tags: ["chat"] });
|
|
415
|
+
const m = await h.nextMessage();
|
|
416
|
+
expect(m.type).toBe("upsert");
|
|
417
|
+
h.close();
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("a widening re-auth → 4403", async () => {
|
|
421
|
+
const { server } = makeServer();
|
|
422
|
+
const h = connect(server.port, `/vault/${VAULT}/api/subscribe?tag=chat`);
|
|
423
|
+
await h.ready();
|
|
424
|
+
h.send({ type: "auth", token: "readonly" }); // rank 0
|
|
425
|
+
await h.readSnapshot();
|
|
426
|
+
|
|
427
|
+
h.send({ type: "auth", token: "admin" }); // rank 2 — widen, refused
|
|
428
|
+
expect((await h.waitClose()).code).toBe(4403);
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
describe("WS live-query — bad query + cap", () => {
|
|
433
|
+
it("rejects an unsupported query at upgrade with a 400 (never opens a socket)", async () => {
|
|
434
|
+
const { server } = makeServer();
|
|
435
|
+
// A raw fetch upgrade with a rejected query gets the byte-identical 400.
|
|
436
|
+
const res = await fetch(`http://localhost:${server.port}/vault/${VAULT}/api/subscribe?search=hi`, {
|
|
437
|
+
headers: { Upgrade: "websocket", Connection: "Upgrade", "Sec-WebSocket-Key": "x", "Sec-WebSocket-Version": "13" },
|
|
438
|
+
});
|
|
439
|
+
expect(res.status).toBe(400);
|
|
440
|
+
expect((await res.json()).code).toBe("UNSUPPORTED_SUBSCRIPTION_QUERY");
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it("refuses over the per-vault cap (503) and self-heals when a socket closes", async () => {
|
|
444
|
+
const { binding } = makeServer({ maxSubscriptions: 2 });
|
|
445
|
+
const fakeWs = (): { data: SubscribeWsData; close(): void; send(): void } => ({
|
|
446
|
+
data: {
|
|
447
|
+
vaultName: VAULT,
|
|
448
|
+
rawQuery: "",
|
|
449
|
+
state: "pending",
|
|
450
|
+
grantedScopes: [],
|
|
451
|
+
grantedTagScope: null,
|
|
452
|
+
handle: null,
|
|
453
|
+
authTimer: null,
|
|
454
|
+
},
|
|
455
|
+
close() {},
|
|
456
|
+
send() {},
|
|
457
|
+
});
|
|
458
|
+
const ws1 = fakeWs();
|
|
459
|
+
const ws2 = fakeWs();
|
|
460
|
+
binding.handlers.open(ws1 as any);
|
|
461
|
+
binding.handlers.open(ws2 as any);
|
|
462
|
+
|
|
463
|
+
const req = new Request(`http://x/vault/${VAULT}/api/subscribe?tag=chat`, { headers: { upgrade: "websocket" } });
|
|
464
|
+
const fakeServer = { upgrade: () => true } as any;
|
|
465
|
+
const full = binding.tryUpgrade(req, fakeServer, `/vault/${VAULT}/api/subscribe`);
|
|
466
|
+
expect(full.kind).toBe("response");
|
|
467
|
+
expect((full as { response: Response }).response.status).toBe(503);
|
|
468
|
+
|
|
469
|
+
// Free a slot → the next upgrade is admitted.
|
|
470
|
+
binding.handlers.close!(ws1 as any, 1000, "", true);
|
|
471
|
+
const ok = binding.tryUpgrade(req, fakeServer, `/vault/${VAULT}/api/subscribe`);
|
|
472
|
+
expect(ok.kind).toBe("upgraded");
|
|
473
|
+
});
|
|
474
|
+
});
|