@openparachute/vault 0.7.3-rc.13 → 0.7.3-rc.2
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/README.md +3 -5
- package/core/src/conformance.ts +1 -2
- package/core/src/content-range.test.ts +0 -127
- package/core/src/content-range.ts +0 -100
- package/core/src/contract-typed-index.test.ts +3 -4
- package/core/src/core.test.ts +4 -521
- package/core/src/expand.ts +3 -11
- package/core/src/indexed-fields.test.ts +3 -9
- package/core/src/indexed-fields.ts +1 -9
- package/core/src/mcp.ts +10 -601
- package/core/src/notes.ts +4 -201
- package/core/src/schema-defaults.ts +1 -85
- package/core/src/search-fts-v25.test.ts +1 -9
- package/core/src/search-query.test.ts +0 -42
- package/core/src/search-query.ts +0 -27
- package/core/src/seed-packs.test.ts +1 -117
- package/core/src/seed-packs.ts +1 -217
- package/core/src/store.ts +3 -59
- package/core/src/tag-schemas.ts +12 -27
- package/core/src/types.ts +1 -7
- package/core/src/vault-projection.ts +0 -55
- package/package.json +1 -1
- package/src/add-pack.test.ts +0 -32
- package/src/auth-hub-jwt.test.ts +1 -118
- package/src/auth.ts +0 -64
- package/src/cli.ts +1 -5
- package/src/contract-errors.test.ts +1 -2
- package/src/mcp-http.ts +4 -33
- package/src/mcp-tools.ts +14 -61
- package/src/oauth-discovery.ts +0 -31
- package/src/onboarding-seed.test.ts +0 -64
- package/src/routes.ts +95 -92
- package/src/routing.test.ts +4 -229
- package/src/routing.ts +23 -152
- package/src/scopes.ts +0 -22
- package/src/server.ts +0 -7
- package/src/storage.test.ts +1 -200
- package/src/transcription-worker.test.ts +0 -151
- package/src/transcription-worker.ts +52 -113
- package/src/vault.test.ts +11 -48
- package/core/src/attachment/bytes-provider.ts +0 -65
- package/core/src/attachment/policy.test.ts +0 -66
- package/core/src/attachment/policy.ts +0 -131
- package/core/src/attachment/tickets.test.ts +0 -45
- package/core/src/attachment/tickets.ts +0 -117
- package/core/src/attachment-tickets-tool.test.ts +0 -286
- package/core/src/display-title.test.ts +0 -190
- package/core/src/lede.test.ts +0 -96
- package/core/src/search-title-boost.test.ts +0 -125
- package/src/attachment-bytes.ts +0 -68
- package/src/attachment-tickets.test.ts +0 -475
- package/src/attachment-tickets.ts +0 -340
- package/src/read-attachment.test.ts +0 -436
|
@@ -1,475 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Attachment tickets — end-to-end lifecycle (mint via the real MCP tool
|
|
3
|
-
* call path, spend via the real unauthenticated `/vault/<name>/tickets/<id>`
|
|
4
|
-
* route dispatched through `routing.ts`'s `route()`). Covers the Wave 1
|
|
5
|
-
* security posture: single-use, TTL/expiry, size-cap, wrong-vault scoping,
|
|
6
|
-
* uniform 404, upload→row+transcribe, download streaming + path
|
|
7
|
-
* confinement, and the connect-time instructions block.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { describe, test, expect } from "bun:test";
|
|
11
|
-
import { join } from "path";
|
|
12
|
-
import { tmpdir } from "os";
|
|
13
|
-
|
|
14
|
-
const testDir = join(
|
|
15
|
-
tmpdir(),
|
|
16
|
-
`vault-attachment-tickets-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
17
|
-
);
|
|
18
|
-
process.env.PARACHUTE_HOME = testDir;
|
|
19
|
-
// Deliberately NOT setting ASSETS_DIR (unlike storage.test.ts, which wants a
|
|
20
|
-
// single shared assets root for its own reasons): that env var is global to
|
|
21
|
-
// the whole `bun test` process (all files share one Node process), so
|
|
22
|
-
// setting it here would leak into every OTHER test file's assetsDir() calls
|
|
23
|
-
// too. Each vault below gets a unique, unset-ASSETS_DIR-default assets dir
|
|
24
|
-
// (`vaultDir(name)/assets`) scoped under this file's own unique
|
|
25
|
-
// PARACHUTE_HOME — fully isolated without needing the override.
|
|
26
|
-
|
|
27
|
-
const { route } = await import("./routing.ts");
|
|
28
|
-
const { handleScopedMcp } = await import("./mcp-http.ts");
|
|
29
|
-
const { getServerInstruction } = await import("./mcp-tools.ts");
|
|
30
|
-
const { writeVaultConfig } = await import("./config.ts");
|
|
31
|
-
const { getVaultStore } = await import("./vault-store.ts");
|
|
32
|
-
const {
|
|
33
|
-
getSharedAttachmentTicketProvider,
|
|
34
|
-
InProcessAttachmentTicketProvider,
|
|
35
|
-
sweepExpiredAttachmentTickets,
|
|
36
|
-
startAttachmentTicketSweep,
|
|
37
|
-
stopAttachmentTicketSweep,
|
|
38
|
-
} = await import("./attachment-tickets.ts");
|
|
39
|
-
const { generateTicketId } = await import("../core/src/attachment/tickets.ts");
|
|
40
|
-
import type { AttachmentTicket } from "../core/src/attachment/tickets.ts";
|
|
41
|
-
const { attachmentsInstructionBlock } = await import("../core/src/vault-projection.ts");
|
|
42
|
-
|
|
43
|
-
/** `route()` takes the pathname as a separate arg (server.ts derives it from `req.url`) — this wrapper matches that call shape everywhere below. */
|
|
44
|
-
async function routeReq(req: Request): Promise<Response> {
|
|
45
|
-
return route(req, new URL(req.url).pathname);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function freshVault(prefix: string): string {
|
|
49
|
-
const name = `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
50
|
-
writeVaultConfig({ name, api_keys: [], created_at: new Date().toISOString() });
|
|
51
|
-
return name;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** Bypasses `authenticateVaultRequest` the same way vault.test.ts's scope-tier tests do — a fabricated already-resolved auth object. */
|
|
55
|
-
function mcpAuth(scopes: string[]) {
|
|
56
|
-
return {
|
|
57
|
-
permission: scopes.includes("vault:write") || scopes.includes("vault:admin") ? "full" : "read",
|
|
58
|
-
scopes,
|
|
59
|
-
legacyDerived: false,
|
|
60
|
-
scoped_tags: null,
|
|
61
|
-
} as any;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
async function callTool(vaultName: string, name: string, args: Record<string, unknown>): Promise<any> {
|
|
65
|
-
const req = new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
|
|
66
|
-
method: "POST",
|
|
67
|
-
headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
|
|
68
|
-
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
|
|
69
|
-
});
|
|
70
|
-
const res = await handleScopedMcp(req, vaultName, mcpAuth(["vault:read", "vault:write"]));
|
|
71
|
-
const body = (await res.json()) as any;
|
|
72
|
-
if (body.error) {
|
|
73
|
-
const err = new Error(body.error.message);
|
|
74
|
-
Object.assign(err, body.error.data ?? {});
|
|
75
|
-
throw err;
|
|
76
|
-
}
|
|
77
|
-
return JSON.parse(body.result.content[0].text);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
describe("attachment tickets — upload lifecycle", () => {
|
|
81
|
-
test("mint → spend → row registered with size + original_name; second spend of the same ticket 404s", async () => {
|
|
82
|
-
const vaultName = freshVault("tickets-upload");
|
|
83
|
-
const store = getVaultStore(vaultName);
|
|
84
|
-
const note = await store.createNote("# Target\n", { path: "target" });
|
|
85
|
-
|
|
86
|
-
const mint = await callTool(vaultName, "request-attachment-upload", {
|
|
87
|
-
note: note.id,
|
|
88
|
-
filename: "photo.png",
|
|
89
|
-
size_bytes: 5,
|
|
90
|
-
mime_type: "image/png",
|
|
91
|
-
});
|
|
92
|
-
expect(mint.method).toBe("PUT");
|
|
93
|
-
expect(mint.url).toContain(`/vault/${vaultName}/tickets/`);
|
|
94
|
-
expect(mint.max_bytes).toBe(5);
|
|
95
|
-
|
|
96
|
-
const bytes = new Uint8Array([1, 2, 3, 4, 5]);
|
|
97
|
-
const spendRes = await routeReq(
|
|
98
|
-
new Request(mint.url, { method: "PUT", headers: { "content-type": "image/png" }, body: bytes }),
|
|
99
|
-
);
|
|
100
|
-
expect(spendRes.status).toBe(201);
|
|
101
|
-
const attachment = (await spendRes.json()) as any;
|
|
102
|
-
expect(attachment.noteId).toBe(note.id);
|
|
103
|
-
expect(attachment.mimeType).toBe("image/png");
|
|
104
|
-
expect(attachment.metadata.original_name).toBe("photo.png");
|
|
105
|
-
expect(attachment.metadata.size).toBe(5);
|
|
106
|
-
|
|
107
|
-
const rows = await store.getAttachments(note.id);
|
|
108
|
-
expect(rows.length).toBe(1);
|
|
109
|
-
expect(rows[0]!.id).toBe(attachment.id);
|
|
110
|
-
|
|
111
|
-
// Single-use: the ticket is gone after one spend — uniform 404, no
|
|
112
|
-
// oracle distinguishing "spent" from "never existed".
|
|
113
|
-
const secondRes = await routeReq(
|
|
114
|
-
new Request(mint.url, { method: "PUT", headers: { "content-type": "image/png" }, body: new Uint8Array([9]) }),
|
|
115
|
-
);
|
|
116
|
-
expect(secondRes.status).toBe(404);
|
|
117
|
-
const secondBody = (await secondRes.json()) as any;
|
|
118
|
-
expect(secondBody.error_type).toBe("not_found");
|
|
119
|
-
expect(typeof secondBody.how_to).toBe("string");
|
|
120
|
-
|
|
121
|
-
// A second upload wasn't registered.
|
|
122
|
-
expect((await store.getAttachments(note.id)).length).toBe(1);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
test("POST also spends an upload ticket (accepted alongside the advertised PUT)", async () => {
|
|
126
|
-
const vaultName = freshVault("tickets-upload-post");
|
|
127
|
-
const store = getVaultStore(vaultName);
|
|
128
|
-
const note = await store.createNote("# T\n", { path: "t" });
|
|
129
|
-
const mint = await callTool(vaultName, "request-attachment-upload", {
|
|
130
|
-
note: note.id,
|
|
131
|
-
filename: "a.txt",
|
|
132
|
-
size_bytes: 3,
|
|
133
|
-
});
|
|
134
|
-
const res = await routeReq(
|
|
135
|
-
new Request(mint.url, { method: "POST", headers: { "content-type": "text/plain" }, body: new Uint8Array([1, 2, 3]) }),
|
|
136
|
-
);
|
|
137
|
-
expect(res.status).toBe(201);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
test("size-cap: an upload exceeding the ticket's declared size_bytes is rejected 413 and no row is created", async () => {
|
|
141
|
-
const vaultName = freshVault("tickets-size-cap");
|
|
142
|
-
const store = getVaultStore(vaultName);
|
|
143
|
-
const note = await store.createNote("# T\n", { path: "t" });
|
|
144
|
-
const mint = await callTool(vaultName, "request-attachment-upload", {
|
|
145
|
-
note: note.id,
|
|
146
|
-
filename: "small.bin",
|
|
147
|
-
size_bytes: 3,
|
|
148
|
-
});
|
|
149
|
-
const res = await routeReq(
|
|
150
|
-
new Request(mint.url, {
|
|
151
|
-
method: "PUT",
|
|
152
|
-
headers: { "content-type": "application/octet-stream" },
|
|
153
|
-
body: new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]),
|
|
154
|
-
}),
|
|
155
|
-
);
|
|
156
|
-
expect(res.status).toBe(413);
|
|
157
|
-
const body = (await res.json()) as any;
|
|
158
|
-
expect(body.error_type).toBe("file_too_large");
|
|
159
|
-
expect(body.limit).toBe(3);
|
|
160
|
-
expect(typeof body.how_to).toBe("string");
|
|
161
|
-
expect((await store.getAttachments(note.id)).length).toBe(0);
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
test("a mismatched Content-Length header alone is rejected 413 before the body is even read", async () => {
|
|
165
|
-
const vaultName = freshVault("tickets-cl-cap");
|
|
166
|
-
const store = getVaultStore(vaultName);
|
|
167
|
-
const note = await store.createNote("# T\n", { path: "t" });
|
|
168
|
-
const mint = await callTool(vaultName, "request-attachment-upload", {
|
|
169
|
-
note: note.id,
|
|
170
|
-
filename: "small.bin",
|
|
171
|
-
size_bytes: 3,
|
|
172
|
-
});
|
|
173
|
-
const res = await routeReq(
|
|
174
|
-
new Request(mint.url, {
|
|
175
|
-
method: "PUT",
|
|
176
|
-
headers: { "content-type": "application/octet-stream", "content-length": "1000000" },
|
|
177
|
-
body: new Uint8Array([1, 2, 3]),
|
|
178
|
-
}),
|
|
179
|
-
);
|
|
180
|
-
expect(res.status).toBe(413);
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
test("expiry: a ticket past its expires_at is rejected 404 even though it was never spent", async () => {
|
|
184
|
-
const vaultName = freshVault("tickets-expiry");
|
|
185
|
-
const store = getVaultStore(vaultName);
|
|
186
|
-
const note = await store.createNote("# T\n", { path: "t" });
|
|
187
|
-
|
|
188
|
-
const provider = getSharedAttachmentTicketProvider();
|
|
189
|
-
const id = "e".repeat(64);
|
|
190
|
-
await provider.put({
|
|
191
|
-
id,
|
|
192
|
-
kind: "upload",
|
|
193
|
-
vaultName,
|
|
194
|
-
createdAt: Date.now() - 1000,
|
|
195
|
-
expiresAt: Date.now() - 1, // already expired
|
|
196
|
-
noteId: note.id,
|
|
197
|
-
filename: "late.bin",
|
|
198
|
-
mimeType: "application/octet-stream",
|
|
199
|
-
sizeBytes: 10,
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
const res = await routeReq(
|
|
203
|
-
new Request(`http://localhost:1940/vault/${vaultName}/tickets/${id}`, {
|
|
204
|
-
method: "PUT",
|
|
205
|
-
headers: { "content-type": "application/octet-stream" },
|
|
206
|
-
body: new Uint8Array([1]),
|
|
207
|
-
}),
|
|
208
|
-
);
|
|
209
|
-
expect(res.status).toBe(404);
|
|
210
|
-
expect((await store.getAttachments(note.id)).length).toBe(0);
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
test("wrong-vault scope: a ticket minted for vault A can't be spent against vault B's URL", async () => {
|
|
214
|
-
const vaultA = freshVault("tickets-vault-a");
|
|
215
|
-
const vaultB = freshVault("tickets-vault-b");
|
|
216
|
-
const storeA = getVaultStore(vaultA);
|
|
217
|
-
const note = await storeA.createNote("# T\n", { path: "t" });
|
|
218
|
-
|
|
219
|
-
const mint = await callTool(vaultA, "request-attachment-upload", {
|
|
220
|
-
note: note.id,
|
|
221
|
-
filename: "a.bin",
|
|
222
|
-
size_bytes: 3,
|
|
223
|
-
});
|
|
224
|
-
const ticketId = mint.url.split("/tickets/")[1] as string;
|
|
225
|
-
|
|
226
|
-
const crossVaultRes = await routeReq(
|
|
227
|
-
new Request(`http://localhost:1940/vault/${vaultB}/tickets/${ticketId}`, {
|
|
228
|
-
method: "PUT",
|
|
229
|
-
headers: { "content-type": "application/octet-stream" },
|
|
230
|
-
body: new Uint8Array([1, 2, 3]),
|
|
231
|
-
}),
|
|
232
|
-
);
|
|
233
|
-
expect(crossVaultRes.status).toBe(404);
|
|
234
|
-
|
|
235
|
-
// `take()` deletes on lookup regardless of which vault asked — a
|
|
236
|
-
// wrong-vault spend attempt still burns the ticket (single-use is
|
|
237
|
-
// enforced by the delete, vault-match is a SEPARATE check layered on
|
|
238
|
-
// top). Documenting this: the ticket is gone even for the correct
|
|
239
|
-
// vault after the cross-vault probe above.
|
|
240
|
-
const correctRes = await routeReq(
|
|
241
|
-
new Request(mint.url, {
|
|
242
|
-
method: "PUT",
|
|
243
|
-
headers: { "content-type": "application/octet-stream" },
|
|
244
|
-
body: new Uint8Array([1, 2, 3]),
|
|
245
|
-
}),
|
|
246
|
-
);
|
|
247
|
-
expect(correctRes.status).toBe(404);
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
test("transcribe: true rides through to the attachment row and stamps the note's transcribe_stub", async () => {
|
|
251
|
-
const vaultName = freshVault("tickets-transcribe");
|
|
252
|
-
const store = getVaultStore(vaultName);
|
|
253
|
-
const note = await store.createNote("# Voice memo\n", { path: "memo" });
|
|
254
|
-
|
|
255
|
-
const mint = await callTool(vaultName, "request-attachment-upload", {
|
|
256
|
-
note: note.id,
|
|
257
|
-
filename: "memo.wav",
|
|
258
|
-
size_bytes: 4,
|
|
259
|
-
transcribe: true,
|
|
260
|
-
});
|
|
261
|
-
const res = await routeReq(
|
|
262
|
-
new Request(mint.url, { method: "PUT", headers: { "content-type": "audio/wav" }, body: new Uint8Array([1, 2, 3, 4]) }),
|
|
263
|
-
);
|
|
264
|
-
expect(res.status).toBe(201);
|
|
265
|
-
const attachment = (await res.json()) as any;
|
|
266
|
-
expect(attachment.metadata.transcribe_status).toBe("pending");
|
|
267
|
-
expect(attachment.metadata.transcribe_origin).toBe("legacy");
|
|
268
|
-
|
|
269
|
-
const updatedNote = await store.getNote(note.id);
|
|
270
|
-
expect((updatedNote!.metadata as any)?.transcribe_stub).toBe(true);
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
test("blocked extension is refused at MINT — no ticket is ever created", async () => {
|
|
274
|
-
const vaultName = freshVault("tickets-blocked-ext");
|
|
275
|
-
const store = getVaultStore(vaultName);
|
|
276
|
-
const note = await store.createNote("# T\n", { path: "t" });
|
|
277
|
-
await expect(
|
|
278
|
-
callTool(vaultName, "request-attachment-upload", { note: note.id, filename: "evil.svg", size_bytes: 10 }),
|
|
279
|
-
).rejects.toThrow();
|
|
280
|
-
});
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
describe("attachment tickets — download lifecycle", () => {
|
|
284
|
-
test("mint → spend streams the exact bytes with the attachment's content-type", async () => {
|
|
285
|
-
const vaultName = freshVault("tickets-download");
|
|
286
|
-
const store = getVaultStore(vaultName);
|
|
287
|
-
const note = await store.createNote("# T\n", { path: "t" });
|
|
288
|
-
|
|
289
|
-
const uploadMint = await callTool(vaultName, "request-attachment-upload", {
|
|
290
|
-
note: note.id,
|
|
291
|
-
filename: "data.bin",
|
|
292
|
-
size_bytes: 4,
|
|
293
|
-
mime_type: "application/octet-stream",
|
|
294
|
-
});
|
|
295
|
-
const uploadRes = await routeReq(
|
|
296
|
-
new Request(uploadMint.url, {
|
|
297
|
-
method: "PUT",
|
|
298
|
-
headers: { "content-type": "application/octet-stream" },
|
|
299
|
-
body: new Uint8Array([10, 20, 30, 40]),
|
|
300
|
-
}),
|
|
301
|
-
);
|
|
302
|
-
const attachment = (await uploadRes.json()) as any;
|
|
303
|
-
|
|
304
|
-
const downloadMint = await callTool(vaultName, "request-attachment-download", {
|
|
305
|
-
attachment_id: attachment.id,
|
|
306
|
-
});
|
|
307
|
-
expect(downloadMint.method).toBe("GET");
|
|
308
|
-
expect(downloadMint.mime_type).toBe("application/octet-stream");
|
|
309
|
-
|
|
310
|
-
const downloadRes = await routeReq(new Request(downloadMint.url, { method: "GET" }));
|
|
311
|
-
expect(downloadRes.status).toBe(200);
|
|
312
|
-
expect(downloadRes.headers.get("content-type")).toBe("application/octet-stream");
|
|
313
|
-
expect(downloadRes.headers.get("x-content-type-options")).toBe("nosniff");
|
|
314
|
-
const body = new Uint8Array(await downloadRes.arrayBuffer());
|
|
315
|
-
expect(Array.from(body)).toEqual([10, 20, 30, 40]);
|
|
316
|
-
|
|
317
|
-
// Single-use on the download side too.
|
|
318
|
-
const secondRes = await routeReq(new Request(downloadMint.url, { method: "GET" }));
|
|
319
|
-
expect(secondRes.status).toBe(404);
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
test("path confinement: an attachment row pointing outside assetsDir can't be walked to via a download ticket", async () => {
|
|
323
|
-
const vaultName = freshVault("tickets-confinement");
|
|
324
|
-
const store = getVaultStore(vaultName);
|
|
325
|
-
const note = await store.createNote("# T\n", { path: "t" });
|
|
326
|
-
// A row with a traversal path could only arrive via a bug elsewhere
|
|
327
|
-
// (tickets themselves never accept a caller-supplied path) — this pins
|
|
328
|
-
// the download spend route's OWN confinement guard as defense-in-depth.
|
|
329
|
-
const attachment = await store.addAttachment(note.id, "../../../../etc/passwd", "text/plain");
|
|
330
|
-
|
|
331
|
-
const downloadMint = await callTool(vaultName, "request-attachment-download", {
|
|
332
|
-
attachment_id: attachment.id,
|
|
333
|
-
});
|
|
334
|
-
const res = await routeReq(new Request(downloadMint.url, { method: "GET" }));
|
|
335
|
-
expect(res.status).toBe(404);
|
|
336
|
-
});
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
describe("attachment tickets — discoverability", () => {
|
|
340
|
-
test("attachmentsInstructionBlock teaches the ticket tools when enabled, and omits them when not", () => {
|
|
341
|
-
const enabled = attachmentsInstructionBlock({ ticketsEnabled: true });
|
|
342
|
-
expect(enabled).toContain("request-attachment-upload");
|
|
343
|
-
expect(enabled).toContain("request-attachment-download");
|
|
344
|
-
expect(enabled).toContain("curl_example");
|
|
345
|
-
|
|
346
|
-
const disabled = attachmentsInstructionBlock({ ticketsEnabled: false });
|
|
347
|
-
expect(disabled).not.toContain("request-attachment-upload");
|
|
348
|
-
// The REST fallback recipe is always present, wired or not.
|
|
349
|
-
expect(disabled).toContain("/storage/upload");
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
test("bun's connect-time getServerInstruction includes the Attachments section and teaches the ticket tools", async () => {
|
|
353
|
-
const vaultName = freshVault("tickets-instructions");
|
|
354
|
-
const md = await getServerInstruction(vaultName);
|
|
355
|
-
expect(md).toContain("## Attachments");
|
|
356
|
-
expect(md).toContain("request-attachment-upload");
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
test("attachmentsInstructionBlock teaches read-attachment when readEnabled, and omits it when not", () => {
|
|
360
|
-
const enabled = attachmentsInstructionBlock({ ticketsEnabled: true, readEnabled: true });
|
|
361
|
-
expect(enabled).toContain("read-attachment");
|
|
362
|
-
|
|
363
|
-
const disabled = attachmentsInstructionBlock({ ticketsEnabled: true, readEnabled: false });
|
|
364
|
-
expect(disabled).not.toContain("read-attachment");
|
|
365
|
-
});
|
|
366
|
-
|
|
367
|
-
test("bun's connect-time getServerInstruction teaches read-attachment too", async () => {
|
|
368
|
-
const vaultName = freshVault("tickets-instructions-read");
|
|
369
|
-
const md = await getServerInstruction(vaultName);
|
|
370
|
-
expect(md).toContain("read-attachment");
|
|
371
|
-
});
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
describe("attachment ticket sweep (vault#612)", () => {
|
|
375
|
-
// Isolated instance — no risk of touching the one process-wide provider
|
|
376
|
-
// every OTHER test in this file (and every other test FILE, via
|
|
377
|
-
// getSharedAttachmentTicketProvider) also shares.
|
|
378
|
-
function ticketExpiringAt(id: string, expiresAt: number): AttachmentTicket {
|
|
379
|
-
return {
|
|
380
|
-
id,
|
|
381
|
-
kind: "download",
|
|
382
|
-
vaultName: "sweep-unit-test",
|
|
383
|
-
createdAt: expiresAt - 60_000,
|
|
384
|
-
expiresAt,
|
|
385
|
-
attachmentId: "att-1",
|
|
386
|
-
};
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
test("sweepExpired drops only expired-unspent tickets and returns the count dropped", async () => {
|
|
390
|
-
const provider = new InProcessAttachmentTicketProvider();
|
|
391
|
-
const now = Date.now();
|
|
392
|
-
await provider.put(ticketExpiringAt("expired-1", now - 5000));
|
|
393
|
-
await provider.put(ticketExpiringAt("expired-2", now - 1));
|
|
394
|
-
await provider.put(ticketExpiringAt("fresh-1", now + 60_000));
|
|
395
|
-
expect(provider.size()).toBe(3);
|
|
396
|
-
|
|
397
|
-
const dropped = provider.sweepExpired(now);
|
|
398
|
-
expect(dropped).toBe(2);
|
|
399
|
-
expect(provider.size()).toBe(1);
|
|
400
|
-
|
|
401
|
-
// Dropped tickets are gone — take() returns null, same as "never existed".
|
|
402
|
-
expect(await provider.take("expired-1")).toBeNull();
|
|
403
|
-
expect(await provider.take("expired-2")).toBeNull();
|
|
404
|
-
// The unexpired ticket survived the sweep and is still spendable.
|
|
405
|
-
const fresh = await provider.take("fresh-1");
|
|
406
|
-
expect(fresh?.id).toBe("fresh-1");
|
|
407
|
-
});
|
|
408
|
-
|
|
409
|
-
test("a ticket expiring exactly `now` counts as expired (< comparison, not <=)", async () => {
|
|
410
|
-
const provider = new InProcessAttachmentTicketProvider();
|
|
411
|
-
const now = Date.now();
|
|
412
|
-
await provider.put(ticketExpiringAt("boundary", now));
|
|
413
|
-
expect(provider.sweepExpired(now)).toBe(0); // expiresAt === now is NOT yet expired
|
|
414
|
-
expect(provider.sweepExpired(now + 1)).toBe(1); // one ms later, it is
|
|
415
|
-
});
|
|
416
|
-
|
|
417
|
-
test("a no-op sweep (nothing expired) drops nothing", () => {
|
|
418
|
-
const provider = new InProcessAttachmentTicketProvider();
|
|
419
|
-
expect(provider.sweepExpired(Date.now())).toBe(0);
|
|
420
|
-
expect(provider.size()).toBe(0);
|
|
421
|
-
});
|
|
422
|
-
|
|
423
|
-
test("sweepExpiredAttachmentTickets delegates to the shared provider (unique ids — safe alongside concurrent tests)", async () => {
|
|
424
|
-
const provider = getSharedAttachmentTicketProvider();
|
|
425
|
-
const now = Date.now();
|
|
426
|
-
const expiredId = generateTicketId();
|
|
427
|
-
const freshId = generateTicketId();
|
|
428
|
-
await provider.put(ticketExpiringAt(expiredId, now - 1));
|
|
429
|
-
await provider.put(ticketExpiringAt(freshId, now + 60_000));
|
|
430
|
-
|
|
431
|
-
sweepExpiredAttachmentTickets(now);
|
|
432
|
-
|
|
433
|
-
expect(await provider.take(expiredId)).toBeNull();
|
|
434
|
-
const fresh = await provider.take(freshId);
|
|
435
|
-
expect(fresh?.id).toBe(freshId);
|
|
436
|
-
});
|
|
437
|
-
|
|
438
|
-
test("sweepExpiredAttachmentTickets always returns a number (the `?? 0` fallback path never throws)", () => {
|
|
439
|
-
// By this point in the suite the shared provider already exists (other
|
|
440
|
-
// tests above created it) — this doesn't re-prove the true
|
|
441
|
-
// never-created case in isolation, but pins the return type/no-throw
|
|
442
|
-
// contract the periodic sweep timer depends on every tick.
|
|
443
|
-
expect(typeof sweepExpiredAttachmentTickets()).toBe("number");
|
|
444
|
-
});
|
|
445
|
-
|
|
446
|
-
test("start/stop the periodic sweep: idempotent start, a short-interval real timer actually drops an expired ticket, clean stop", async () => {
|
|
447
|
-
// Poll via `size()`, NOT `take(id)` — `take()` deletes unconditionally
|
|
448
|
-
// on any lookup (expired or not; see its own doc comment), so polling
|
|
449
|
-
// with it would consume the ticket itself on the FIRST poll — before
|
|
450
|
-
// the timer ever fires — and the test would pass for the wrong reason.
|
|
451
|
-
const provider = getSharedAttachmentTicketProvider() as InstanceType<typeof InProcessAttachmentTicketProvider>;
|
|
452
|
-
const now = Date.now();
|
|
453
|
-
const id = generateTicketId();
|
|
454
|
-
await provider.put(ticketExpiringAt(id, now - 1)); // already expired
|
|
455
|
-
const baseline = provider.size();
|
|
456
|
-
|
|
457
|
-
startAttachmentTicketSweep(15); // 15ms — short enough to observe within the test timeout
|
|
458
|
-
startAttachmentTicketSweep(15); // idempotent — no-op, doesn't create a second timer
|
|
459
|
-
|
|
460
|
-
// Poll briefly rather than a single fixed sleep — bounded by the test
|
|
461
|
-
// runner's own timeout.
|
|
462
|
-
let sizeDropped = false;
|
|
463
|
-
for (let i = 0; i < 20 && !sizeDropped; i++) {
|
|
464
|
-
await new Promise((r) => setTimeout(r, 15));
|
|
465
|
-
if (provider.size() < baseline) sizeDropped = true;
|
|
466
|
-
}
|
|
467
|
-
stopAttachmentTicketSweep();
|
|
468
|
-
stopAttachmentTicketSweep(); // idempotent — no-op on an already-stopped sweep
|
|
469
|
-
|
|
470
|
-
expect(sizeDropped).toBe(true);
|
|
471
|
-
// Confirm it was genuinely OUR ticket the sweep dropped, not a
|
|
472
|
-
// coincidental size change from something else.
|
|
473
|
-
expect(await provider.take(id)).toBeNull();
|
|
474
|
-
});
|
|
475
|
-
});
|