@celilo/e2e 0.20.1 → 0.20.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.
@@ -1,991 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
- import { mkdtempSync, rmSync } from 'node:fs';
3
- import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
5
- import { ADMIN_SCOPE, TokenAuth } from './auth';
6
- import { IntrospectionVerifier } from './introspection';
7
- import { createRateLimiter } from './rate-limit';
8
- import { startServer } from './server';
9
-
10
- let dataDir: string;
11
- let server: ReturnType<typeof startServer> | null = null;
12
- let baseUrl: string;
13
-
14
- beforeEach(() => {
15
- dataDir = mkdtempSync(join(tmpdir(), 'celilo-registry-server-test-'));
16
- server = startServer({
17
- dataDir,
18
- port: 0, // let Bun pick an ephemeral port
19
- pathPrefix: '',
20
- publicUrl: 'https://example.test',
21
- auth: new TokenAuth([{ token: 'valid-token', scope: ADMIN_SCOPE }]),
22
- });
23
- baseUrl = `http://localhost:${server.port}`;
24
- });
25
-
26
- afterEach(() => {
27
- server?.stop();
28
- server = null;
29
- rmSync(dataDir, { recursive: true, force: true });
30
- });
31
-
32
- // ── landing-page polite-301 ──────────────────────────────────────────────────
33
- // See apps/celilo/designs/REGISTRY_BROWSE_UI.md (decision D2). The bare
34
- // registry root redirects to /modules/ on the same origin (the celilo.computer
35
- // site), with a minimal HTML body for clients that don't follow redirects.
36
-
37
- describe('GET / (landing page)', () => {
38
- test('returns 301 with Location → /modules/ and HTML body', async () => {
39
- const res = await fetch(`${baseUrl}/`, { redirect: 'manual' });
40
- expect(res.status).toBe(301);
41
- expect(res.headers.get('location')).toBe('https://example.test/modules/');
42
- expect(res.headers.get('content-type')).toMatch(/text\/html/);
43
- const body = await res.text();
44
- expect(body).toContain('Celilo Module Registry');
45
- expect(body).toContain('https://example.test/modules/');
46
- expect(body).toContain('curl -fsSL https://example.test/install.sh | bash');
47
- });
48
-
49
- test('/index.html behaves the same as /', async () => {
50
- const res = await fetch(`${baseUrl}/index.html`, { redirect: 'manual' });
51
- expect(res.status).toBe(301);
52
- expect(res.headers.get('location')).toBe('https://example.test/modules/');
53
- });
54
- });
55
-
56
- // ── sparse index config ──────────────────────────────────────────────────────
57
-
58
- describe('GET /index/config.json', () => {
59
- test('returns dl + api pointing at publicUrl', async () => {
60
- const res = await fetch(`${baseUrl}/index/config.json`);
61
- expect(res.status).toBe(200);
62
- const body = (await res.json()) as { dl: string; api: string };
63
- expect(body.dl).toBe('https://example.test/api/v1/modules/{name}/{version}/download');
64
- expect(body.api).toBe('https://example.test');
65
- });
66
- });
67
-
68
- // ── sparse index path routing ─────────────────────────────────────────────────
69
-
70
- describe('GET /index/{path}/{name}', () => {
71
- test('404 for unknown module', async () => {
72
- const res = await fetch(`${baseUrl}/index/ho/me/homebridge`);
73
- expect(res.status).toBe(404);
74
- });
75
-
76
- test('returns NDJSON for a published module', async () => {
77
- // Publish first so we have something in the index
78
- const publishRes = await publish('homebridge', '1.0.0+1', 'valid-token');
79
- expect(publishRes.status).toBe(200);
80
-
81
- const res = await fetch(`${baseUrl}/index/ho/me/homebridge`);
82
- expect(res.status).toBe(200);
83
- expect(res.headers.get('content-type')).toContain('text/plain');
84
- const body = await res.text();
85
- const lines = body.trim().split('\n');
86
- expect(lines).toHaveLength(1);
87
- const entry = JSON.parse(lines[0]) as { name: string; vers: string; cksum: string };
88
- expect(entry.name).toBe('homebridge');
89
- expect(entry.vers).toBe('1.0.0+1');
90
- expect(entry.cksum).toMatch(/^sha256:[0-9a-f]{64}$/);
91
- });
92
- });
93
-
94
- // ── publish ──────────────────────────────────────────────────────────────────
95
-
96
- describe('PUT /api/v1/modules/new', () => {
97
- test('401 without token', async () => {
98
- const res = await publish('homebridge', '1.0.0+1', '');
99
- expect(res.status).toBe(401);
100
- });
101
-
102
- test('401 with wrong token', async () => {
103
- const res = await publish('homebridge', '1.0.0+1', 'wrong');
104
- expect(res.status).toBe(401);
105
- });
106
-
107
- test('200 with valid token, stores + indexes', async () => {
108
- const res = await publish('homebridge', '1.0.0+1', 'valid-token');
109
- expect(res.status).toBe(200);
110
-
111
- const meta = (await fetch(`${baseUrl}/api/v1/modules/homebridge`).then((r) => r.json())) as {
112
- name: string;
113
- versions: { num: string }[];
114
- };
115
- expect(meta.name).toBe('homebridge');
116
- expect(meta.versions).toHaveLength(1);
117
- expect(meta.versions[0].num).toBe('1.0.0+1');
118
- });
119
-
120
- test('409 on duplicate version', async () => {
121
- const first = await publish('homebridge', '1.0.0+1', 'valid-token');
122
- expect(first.status).toBe(200);
123
- const second = await publish('homebridge', '1.0.0+1', 'valid-token');
124
- expect(second.status).toBe(409);
125
- });
126
-
127
- test('400 on invalid module name (uppercase / underscore)', async () => {
128
- const res1 = await publish('MyModule', '1.0.0+1', 'valid-token');
129
- expect(res1.status).toBe(400);
130
- const res2 = await publish('my_module', '1.0.0+1', 'valid-token');
131
- expect(res2.status).toBe(400);
132
- });
133
-
134
- test('400 on path-traversal name', async () => {
135
- const res = await publish('../etc', '1.0.0+1', 'valid-token');
136
- expect(res.status).toBe(400);
137
- });
138
-
139
- test('400 on invalid version (freeform string)', async () => {
140
- const res = await publish('homebridge', '1.0.0', 'valid-token');
141
- expect(res.status).toBe(400);
142
- });
143
-
144
- test('400 on path-traversal version', async () => {
145
- // Blocks the #1 CRITICAL attack: token-holder with malicious vers trying
146
- // to escape the data dir via fs.writeFileSync path normalization.
147
- const res = await publish('homebridge', '../../../etc/cron.d/evil', 'valid-token');
148
- expect(res.status).toBe(400);
149
- });
150
-
151
- test('413 when metadata length exceeds the cap', async () => {
152
- // Craft a body that declares a huge metadata length (120KB > 64KB cap).
153
- const name = 'homebridge';
154
- const vers = '1.0.0+1';
155
- const fattenedMeta = JSON.stringify({ name, vers, pad: 'x'.repeat(120_000) });
156
- const meta = Buffer.from(fattenedMeta, 'utf-8');
157
- const file = Buffer.from('fake netapp bytes');
158
- const metaLen = Buffer.alloc(4);
159
- metaLen.writeUInt32LE(meta.length, 0);
160
- const fileLen = Buffer.alloc(4);
161
- fileLen.writeUInt32LE(file.length, 0);
162
- const body = Buffer.concat([metaLen, meta, fileLen, file]);
163
-
164
- const res = await fetch(`${baseUrl}/api/v1/modules/new`, {
165
- method: 'PUT',
166
- headers: { Authorization: 'valid-token' },
167
- body,
168
- });
169
- expect(res.status).toBe(413);
170
- });
171
-
172
- // Not exercising the Content-Length header check in a unit test — Bun's
173
- // fetch computes its own Content-Length and ignores the caller's override,
174
- // so we can't realistically lie about body size from the client side here.
175
- // The header-based early rejection still helps against clients that honor
176
- // user-supplied headers (curl, Python requests, etc.) and is covered by
177
- // manual verification / reading the code.
178
- });
179
-
180
- // ── search ───────────────────────────────────────────────────────────────────
181
-
182
- describe('GET /api/v1/modules (search)', () => {
183
- test('returns published modules with their captured description', async () => {
184
- // Phase 2 step 0 — description from manifest is round-tripped through
185
- // the publish payload into storage, then surfaced in the search response.
186
- const pub = await publish(
187
- 'homebridge',
188
- '1.0.0+1',
189
- 'valid-token',
190
- 'Apple HomeKit bridge for non-HomeKit devices',
191
- );
192
- expect(pub.status).toBe(200);
193
-
194
- const res = await fetch(`${baseUrl}/api/v1/modules`);
195
- expect(res.status).toBe(200);
196
- const body = (await res.json()) as {
197
- modules: Array<{ name: string; max_version: string; description: string }>;
198
- total: number;
199
- };
200
- const found = body.modules.find((m) => m.name === 'homebridge');
201
- expect(found).toBeTruthy();
202
- expect(found?.max_version).toBe('1.0.0+1');
203
- expect(found?.description).toBe('Apple HomeKit bridge for non-HomeKit devices');
204
- });
205
-
206
- test('returns empty description for publishes that omit it', async () => {
207
- // Backwards-compat — pre-Phase-2 publishes don't include description
208
- // and must still round-trip cleanly with description: ''.
209
- const pub = await publish('homebridge', '1.0.0+1', 'valid-token');
210
- expect(pub.status).toBe(200);
211
-
212
- const res = await fetch(`${baseUrl}/api/v1/modules`);
213
- const body = (await res.json()) as {
214
- modules: Array<{ name: string; description: string }>;
215
- };
216
- const found = body.modules.find((m) => m.name === 'homebridge');
217
- expect(found?.description).toBe('');
218
- });
219
-
220
- test('icon survives publish, the sparse index and a search read', async () => {
221
- // openspec/changes/module-icons D4 — the icon rides the same path
222
- // `description` does: publish metadata → index entry → browse endpoints.
223
- expect((await publish('homebridge', '1.0.0+1', 'valid-token', 'Bridge', '\u22c8')).status).toBe(
224
- 200,
225
- );
226
-
227
- const index = await fetch(`${baseUrl}/index/ho/me/homebridge`);
228
- expect(index.status).toBe(200);
229
- const line = JSON.parse((await index.text()).trim()) as { icon?: string };
230
- expect(line.icon).toBe('\u22c8');
231
-
232
- const res = await fetch(`${baseUrl}/api/v1/modules`);
233
- const body = (await res.json()) as { modules: Array<{ name: string; icon?: string }> };
234
- expect(body.modules.find((m) => m.name === 'homebridge')?.icon).toBe('\u22c8');
235
-
236
- const detail = await fetch(`${baseUrl}/api/v1/modules/homebridge`);
237
- expect(((await detail.json()) as { icon?: string }).icon).toBe('\u22c8');
238
- });
239
-
240
- test('an entry published without an icon reads back as undefined, not a throw', async () => {
241
- // The old-entry case D4 depends on: index lines written before the field
242
- // existed carry no `icon`, and a consumer falls back rather than failing.
243
- //
244
- // This test passes with or without the icon feature, so it is NOT a gate on
245
- // it — verified by running it against the pre-#1147 tree. It is here to
246
- // catch a future change that makes a missing `icon` throw or coerce to '',
247
- // which is what D4's fallback chain would break on. Do not count it as
248
- // coverage of the capture path; that is the test above.
249
- expect((await publish('homebridge', '1.0.0+1', 'valid-token')).status).toBe(200);
250
-
251
- const res = await fetch(`${baseUrl}/api/v1/modules`);
252
- const body = (await res.json()) as { modules: Array<{ name: string; icon?: string }> };
253
- const found = body.modules.find((m) => m.name === 'homebridge');
254
- expect(found).toBeTruthy();
255
- expect(found?.icon).toBeUndefined();
256
- });
257
-
258
- test('a multi-character icon is dropped while a valid one beside it is kept', async () => {
259
- // Publish metadata is arbitrary client JSON. The CLI refines the value at
260
- // manifest-parse time; the server keeps the index from carrying a string
261
- // that would blow out a fixed-width slot on the browse page.
262
- //
263
- // Both halves are asserted deliberately. Checking only that the bad value
264
- // is absent would pass just as well if the capture path were deleted
265
- // outright — the guard and the capture would cancel out and the test would
266
- // stay green having lost the feature. The kept glyph is what stops that.
267
- expect(
268
- (await publish('homebridge', '1.0.0+1', 'valid-token', 'Bridge', 'not-a-glyph')).status,
269
- ).toBe(200);
270
- expect((await publish('caddy', '1.0.0+1', 'valid-token', 'Proxy', '\u25cd')).status).toBe(200);
271
-
272
- const res = await fetch(`${baseUrl}/api/v1/modules`);
273
- const body = (await res.json()) as { modules: Array<{ name: string; icon?: string }> };
274
- expect(body.modules.find((m) => m.name === 'homebridge')?.icon).toBeUndefined();
275
- expect(body.modules.find((m) => m.name === 'caddy')?.icon).toBe('\u25cd');
276
- });
277
-
278
- test('409 when a second module declares a glyph the first holds, naming the holder', async () => {
279
- // module-icons D8 — the registry refuses a duplicate glyph. The error must
280
- // name the holding module: that is everything the author needs to pick a
281
- // distinct glyph, and it is what the design decided a refusal carries
282
- // instead of a server-suggested replacement.
283
- expect((await publish('homebridge', '1.0.0+1', 'valid-token', 'Bridge', '\u22c8')).status).toBe(
284
- 200,
285
- );
286
- const res = await publish('caddy', '1.0.0+1', 'valid-token', 'Proxy', '\u22c8');
287
- expect(res.status).toBe(409);
288
- const body = (await res.json()) as { errors: Array<{ detail: string }> };
289
- expect(body.errors[0]?.detail).toContain('homebridge');
290
- // The refusal fired before any state was written: no payload, no index line.
291
- expect((await fetch(`${baseUrl}/index/ca/dd/caddy`)).status).toBe(404);
292
- });
293
-
294
- test('a module republishing its own glyph across versions is allowed', async () => {
295
- // The holder is excluded from its own check — keeping your icon across
296
- // versions is the normal case, not a collision.
297
- expect((await publish('homebridge', '1.0.0+1', 'valid-token', 'Bridge', '\u22c8')).status).toBe(
298
- 200,
299
- );
300
- expect((await publish('homebridge', '1.0.1+1', 'valid-token', 'Bridge', '\u22c8')).status).toBe(
301
- 200,
302
- );
303
- });
304
-
305
- test('a module whose latest version drops the icon frees the glyph', async () => {
306
- // A module holds the glyph its LATEST non-yanked entry declares — the same
307
- // selection the browse endpoints read. Once its newest version declares
308
- // none, the glyph is free for the next publisher.
309
- expect((await publish('homebridge', '1.0.0+1', 'valid-token', 'Bridge', '\u22c8')).status).toBe(
310
- 200,
311
- );
312
- expect((await publish('homebridge', '1.0.1+1', 'valid-token', 'Bridge')).status).toBe(200);
313
- expect((await publish('caddy', '1.0.0+1', 'valid-token', 'Proxy', '\u22c8')).status).toBe(200);
314
- });
315
-
316
- test('total_downloads reflects actual download count, sort=downloads orders by it', async () => {
317
- // Publish two modules, hit one's download endpoint several times.
318
- // The search response should show those counts and sort=downloads
319
- // should put the more-downloaded one first.
320
- expect((await publish('alpha', '1.0.0+1', 'valid-token')).status).toBe(200);
321
- expect((await publish('bravo', '1.0.0+1', 'valid-token')).status).toBe(200);
322
-
323
- for (let i = 0; i < 3; i++) {
324
- const r = await fetch(`${baseUrl}/api/v1/modules/alpha/1.0.0+1/download`);
325
- expect(r.status).toBe(200);
326
- }
327
- const r = await fetch(`${baseUrl}/api/v1/modules/bravo/1.0.0+1/download`);
328
- expect(r.status).toBe(200);
329
-
330
- // Default sort: alphabetical; counts reflect downloads.
331
- const def = (await fetch(`${baseUrl}/api/v1/modules`).then((r) => r.json())) as {
332
- modules: Array<{ name: string; total_downloads: number }>;
333
- };
334
- const alpha = def.modules.find((m) => m.name === 'alpha');
335
- const bravo = def.modules.find((m) => m.name === 'bravo');
336
- expect(alpha?.total_downloads).toBe(3);
337
- expect(bravo?.total_downloads).toBe(1);
338
-
339
- // sort=downloads: alpha (3) before bravo (1).
340
- const byDl = (await fetch(`${baseUrl}/api/v1/modules?sort=downloads`).then((r) =>
341
- r.json(),
342
- )) as { modules: Array<{ name: string }> };
343
- const idxAlpha = byDl.modules.findIndex((m) => m.name === 'alpha');
344
- const idxBravo = byDl.modules.findIndex((m) => m.name === 'bravo');
345
- expect(idxAlpha).toBeGreaterThanOrEqual(0);
346
- expect(idxBravo).toBeGreaterThanOrEqual(0);
347
- expect(idxAlpha).toBeLessThan(idxBravo);
348
- });
349
- });
350
-
351
- // ── per-module metadata download count ───────────────────────────────────────
352
-
353
- describe('GET /api/v1/modules/{name} (metadata) — total_downloads', () => {
354
- test('returns the same count as the search endpoint', async () => {
355
- expect((await publish('homebridge', '1.0.0+1', 'valid-token')).status).toBe(200);
356
- await fetch(`${baseUrl}/api/v1/modules/homebridge/1.0.0+1/download`);
357
- await fetch(`${baseUrl}/api/v1/modules/homebridge/1.0.0+1/download`);
358
-
359
- const meta = (await fetch(`${baseUrl}/api/v1/modules/homebridge`).then((r) => r.json())) as {
360
- name: string;
361
- total_downloads: number;
362
- };
363
- expect(meta.total_downloads).toBe(2);
364
- });
365
- });
366
-
367
- // ── path-traversal on GET endpoints ──────────────────────────────────────────
368
-
369
- describe('GET endpoints reject traversal attempts', () => {
370
- // These verify the HIGH-severity fix: `name` / `version` from URL segments
371
- // can't reach the storage layer if they aren't valid module identifiers.
372
-
373
- test.each([
374
- '/index/..%2F..%2Fetc',
375
- '/index/ho/me/..%2Fpasswd',
376
- '/api/v1/modules/..%2F..%2Fetc',
377
- '/api/v1/modules/..%2F..%2Fetc/1.0.0+1/download',
378
- '/api/v1/modules/homebridge/..%2F..%2Fetc/download',
379
- '/api/v1/modules/homebridge/1.0.0+1%2F..%2F..%2Fetc/download',
380
- ])('404 for %s', async (path) => {
381
- const res = await fetch(`${baseUrl}${path}`);
382
- expect(res.status).toBe(404);
383
- });
384
-
385
- test('DELETE yank with traversal is rejected (401 before 404 is fine)', async () => {
386
- const res = await fetch(`${baseUrl}/api/v1/modules/..%2Fetc/1.0.0+1/yank`, {
387
- method: 'DELETE',
388
- headers: { Authorization: 'valid-token' },
389
- });
390
- expect([401, 404]).toContain(res.status);
391
- expect(res.status).not.toBe(200);
392
- });
393
- });
394
-
395
- // ── download ─────────────────────────────────────────────────────────────────
396
-
397
- describe('GET /api/v1/modules/{name}/{version}/download', () => {
398
- test('returns stored bytes after publish', async () => {
399
- await publish('homebridge', '1.0.0+1', 'valid-token');
400
- const res = await fetch(`${baseUrl}/api/v1/modules/homebridge/1.0.0+1/download`);
401
- expect(res.status).toBe(200);
402
- expect(res.headers.get('content-type')).toBe('application/octet-stream');
403
- const body = await res.arrayBuffer();
404
- expect(Buffer.from(body).toString()).toBe('fake netapp bytes');
405
- });
406
-
407
- test('404 for unknown version', async () => {
408
- const res = await fetch(`${baseUrl}/api/v1/modules/homebridge/9.9.9+1/download`);
409
- expect(res.status).toBe(404);
410
- });
411
- });
412
-
413
- // ── yank / unyank ────────────────────────────────────────────────────────────
414
-
415
- describe('DELETE /yank, PUT /unyank', () => {
416
- test('yank requires auth and flips the yanked flag', async () => {
417
- await publish('homebridge', '1.0.0+1', 'valid-token');
418
-
419
- const unauth = await fetch(`${baseUrl}/api/v1/modules/homebridge/1.0.0+1/yank`, {
420
- method: 'DELETE',
421
- });
422
- expect(unauth.status).toBe(401);
423
-
424
- const res = await fetch(`${baseUrl}/api/v1/modules/homebridge/1.0.0+1/yank`, {
425
- method: 'DELETE',
426
- headers: { Authorization: 'valid-token' },
427
- });
428
- expect(res.status).toBe(200);
429
-
430
- const indexRes = await fetch(`${baseUrl}/index/ho/me/homebridge`);
431
- const entry = JSON.parse((await indexRes.text()).trim()) as { yanked: boolean };
432
- expect(entry.yanked).toBe(true);
433
- });
434
- });
435
-
436
- // ── rate limiting ────────────────────────────────────────────────────────────
437
-
438
- describe('write endpoints are rate limited', () => {
439
- test('429 with Retry-After once the per-IP budget is spent', async () => {
440
- const tightDataDir = mkdtempSync(join(tmpdir(), 'celilo-registry-rl-'));
441
- const tight = startServer({
442
- dataDir: tightDataDir,
443
- port: 0,
444
- pathPrefix: '',
445
- publicUrl: 'https://example.test',
446
- auth: new TokenAuth([{ token: 'tok', scope: ADMIN_SCOPE }]),
447
- rateLimiter: createRateLimiter({ max: 2, windowMs: 60_000 }),
448
- });
449
- try {
450
- const b = `http://localhost:${tight.port}`;
451
- // Send 3 unauthenticated publishes — first two return 401 (past the
452
- // rate-limit gate), third trips the limiter → 429. Using 401 here is
453
- // fine because the rate-limiter runs BEFORE auth, by design.
454
- const mkBody = () => {
455
- const meta = Buffer.from(JSON.stringify({ name: 'x', vers: '1.0.0+1' }), 'utf-8');
456
- const file = Buffer.from('f');
457
- const metaLen = Buffer.alloc(4);
458
- metaLen.writeUInt32LE(meta.length, 0);
459
- const fileLen = Buffer.alloc(4);
460
- fileLen.writeUInt32LE(file.length, 0);
461
- return Buffer.concat([metaLen, meta, fileLen, file]);
462
- };
463
- const call = () => fetch(`${b}/api/v1/modules/new`, { method: 'PUT', body: mkBody() });
464
-
465
- const r1 = await call();
466
- expect(r1.status).toBe(401);
467
- const r2 = await call();
468
- expect(r2.status).toBe(401);
469
- const r3 = await call();
470
- expect(r3.status).toBe(429);
471
- expect(r3.headers.get('Retry-After')).not.toBeNull();
472
- } finally {
473
- tight.stop();
474
- rmSync(tightDataDir, { recursive: true, force: true });
475
- }
476
- });
477
- });
478
-
479
- // ── path prefix ──────────────────────────────────────────────────────────────
480
-
481
- describe('path prefix routing', () => {
482
- test('requests under /registry are accepted when pathPrefix is /registry', async () => {
483
- const prefixed = startServer({
484
- dataDir: mkdtempSync(join(tmpdir(), 'celilo-registry-prefix-')),
485
- port: 0,
486
- pathPrefix: '/registry',
487
- publicUrl: 'https://example.test/registry',
488
- auth: new TokenAuth([{ token: 'valid-token', scope: ADMIN_SCOPE }]),
489
- });
490
- try {
491
- const base = `http://localhost:${prefixed.port}`;
492
- const res = await fetch(`${base}/registry/index/config.json`);
493
- expect(res.status).toBe(200);
494
- const nope = await fetch(`${base}/index/config.json`);
495
- expect(nope.status).toBe(404);
496
- } finally {
497
- prefixed.stop();
498
- }
499
- });
500
- });
501
-
502
- // ── scoped publish tokens (ISS-0140) ──────────────────────────────────────────
503
-
504
- describe('POST /api/v1/modules/tokens/mint + scoped publish', () => {
505
- async function mint(repo: string, scope: string, token: string): Promise<Response> {
506
- return fetch(`${baseUrl}/api/v1/modules/tokens/mint`, {
507
- method: 'POST',
508
- headers: token ? { Authorization: token, 'Content-Type': 'application/json' } : {},
509
- body: JSON.stringify({ repo, scope }),
510
- });
511
- }
512
-
513
- test('admin token mints a scoped token; non-admin / no token is 401', async () => {
514
- const noTok = await mint('celilo/lunacycle', 'lunacycle', '');
515
- expect(noTok.status).toBe(401);
516
-
517
- const ok = await mint('celilo/lunacycle', 'lunacycle', 'valid-token');
518
- expect(ok.status).toBe(200);
519
- const body = (await ok.json()) as { ok: boolean; token: string; scope: string };
520
- expect(body.ok).toBe(true);
521
- expect(body.scope).toBe('lunacycle');
522
- expect(body.token.length).toBeGreaterThan(10);
523
- });
524
-
525
- test('a minted scoped token publishes ONLY its package', async () => {
526
- const minted = (await (await mint('celilo/lunacycle', 'lunacycle', 'valid-token')).json()) as {
527
- token: string;
528
- };
529
-
530
- // Can publish its own package…
531
- const ok = await publish('lunacycle', '1.0.0+1', minted.token);
532
- expect(ok.status).toBe(200);
533
-
534
- // …but NOT another module.
535
- const denied = await publish('caddy', '1.0.0+1', minted.token);
536
- expect(denied.status).toBe(401);
537
- });
538
-
539
- test('a scoped token cannot mint (not admin)', async () => {
540
- const minted = (await (await mint('celilo/lunacycle', 'lunacycle', 'valid-token')).json()) as {
541
- token: string;
542
- };
543
- const reMint = await mint('celilo/caddy', 'caddy', minted.token);
544
- expect(reMint.status).toBe(401);
545
- });
546
-
547
- test('re-minting rotates: the old token stops working, the new one publishes', async () => {
548
- const first = (await (await mint('celilo/lunacycle', 'lunacycle', 'valid-token')).json()) as {
549
- token: string;
550
- };
551
- const second = (await (await mint('celilo/lunacycle', 'lunacycle', 'valid-token')).json()) as {
552
- token: string;
553
- };
554
- expect(second.token).not.toBe(first.token);
555
-
556
- const oldDenied = await publish('lunacycle', '2.0.0+1', first.token);
557
- expect(oldDenied.status).toBe(401);
558
- const newOk = await publish('lunacycle', '2.0.0+1', second.token);
559
- expect(newOk.status).toBe(200);
560
- });
561
-
562
- test('revoke disables the token', async () => {
563
- const minted = (await (await mint('celilo/lunacycle', 'lunacycle', 'valid-token')).json()) as {
564
- token: string;
565
- };
566
- const revoke = await fetch(`${baseUrl}/api/v1/modules/tokens/revoke`, {
567
- method: 'POST',
568
- headers: { Authorization: 'valid-token', 'Content-Type': 'application/json' },
569
- body: JSON.stringify({ repo: 'celilo/lunacycle' }),
570
- });
571
- expect(revoke.status).toBe(200);
572
- const denied = await publish('lunacycle', '1.0.0+1', minted.token);
573
- expect(denied.status).toBe(401);
574
- });
575
-
576
- test('mint rejects an invalid scope (not a valid package name)', async () => {
577
- const res = await mint('celilo/lunacycle', 'Bad_Scope', 'valid-token');
578
- expect(res.status).toBe(400);
579
- });
580
- });
581
-
582
- // ─── helpers ──────────────────────────────────────────────────────────────────
583
-
584
- async function publish(
585
- name: string,
586
- vers: string,
587
- token: string,
588
- description?: string,
589
- icon?: string,
590
- ): Promise<Response> {
591
- const meta = Buffer.from(
592
- JSON.stringify({
593
- name,
594
- vers,
595
- ...(description ? { description } : {}),
596
- ...(icon ? { icon } : {}),
597
- }),
598
- 'utf-8',
599
- );
600
- const file = Buffer.from('fake netapp bytes');
601
- const metaLen = Buffer.alloc(4);
602
- metaLen.writeUInt32LE(meta.length, 0);
603
- const fileLen = Buffer.alloc(4);
604
- fileLen.writeUInt32LE(file.length, 0);
605
- const body = Buffer.concat([metaLen, meta, fileLen, file]);
606
-
607
- return fetch(`${baseUrl}/api/v1/modules/new`, {
608
- method: 'PUT',
609
- headers: token ? { Authorization: token } : {},
610
- body,
611
- });
612
- }
613
-
614
- // ── idp introspection verify-bridge (ce-s7e) ─────────────────────────────────
615
- // The real publish path with an idp identity token: the token is unknown to
616
- // the opaque set, so authorizePackage() verifies it via RFC 7662 introspection
617
- // against the idp. A fake idp server stands in for authentik; revoking a token
618
- // there (active:false) denies the next publish.
619
-
620
- describe('publish via idp introspection (ce-s7e)', () => {
621
- let idp: ReturnType<typeof Bun.serve> | null = null;
622
- let idpServer: ReturnType<typeof startServer> | null = null;
623
- let idpDataDir: string;
624
- let idpBaseUrl: string;
625
- // token → introspection claims the fake idp reports for it.
626
- const claimsByToken = new Map<string, Record<string, unknown>>();
627
-
628
- beforeEach(() => {
629
- claimsByToken.clear();
630
- idp = Bun.serve({
631
- port: 0,
632
- async fetch(req) {
633
- const form = new URLSearchParams(await req.text());
634
- const token = form.get('token') ?? '';
635
- const claims = claimsByToken.get(token) ?? { active: false };
636
- return Response.json(claims);
637
- },
638
- });
639
- idpDataDir = mkdtempSync(join(tmpdir(), 'celilo-registry-idp-test-'));
640
- idpServer = startServer({
641
- dataDir: idpDataDir,
642
- port: 0,
643
- pathPrefix: '',
644
- publicUrl: 'https://example.test',
645
- // Opaque admin token still works → backward-compat coverage below.
646
- auth: new TokenAuth([{ token: 'opaque-admin', scope: ADMIN_SCOPE }]),
647
- introspection: new IntrospectionVerifier({
648
- endpoint: `http://localhost:${idp.port}/introspect`,
649
- clientId: 'celilo-registry',
650
- clientSecret: 'sec',
651
- adminGroup: 'celilo-admins',
652
- publisherGroup: 'celilo-authors',
653
- }),
654
- });
655
- idpBaseUrl = `http://localhost:${idpServer.port}`;
656
- });
657
-
658
- afterEach(() => {
659
- idpServer?.stop();
660
- idpServer = null;
661
- idp?.stop();
662
- idp = null;
663
- rmSync(idpDataDir, { recursive: true, force: true });
664
- });
665
-
666
- function publishTo(token: string, name = 'homebridge', vers = '1.0.0+1'): Promise<Response> {
667
- const meta = Buffer.from(JSON.stringify({ name, vers }), 'utf-8');
668
- const file = Buffer.from('fake netapp bytes');
669
- const metaLen = Buffer.alloc(4);
670
- metaLen.writeUInt32LE(meta.length, 0);
671
- const fileLen = Buffer.alloc(4);
672
- fileLen.writeUInt32LE(file.length, 0);
673
- const body = Buffer.concat([metaLen, meta, fileLen, file]);
674
- return fetch(`${idpBaseUrl}/api/v1/modules/new`, {
675
- method: 'PUT',
676
- headers: { Authorization: `Bearer ${token}` },
677
- body,
678
- });
679
- }
680
-
681
- test('active idp token (admin group) authorizes publish', async () => {
682
- claimsByToken.set('idp-tok', { active: true, sub: 'alice', groups: ['celilo-admins'] });
683
- const res = await publishTo('idp-tok');
684
- expect(res.status).toBe(200);
685
- });
686
-
687
- test('active idp token not in admin group → 401', async () => {
688
- claimsByToken.set('idp-tok', { active: true, sub: 'bob', groups: ['users'] });
689
- const res = await publishTo('idp-tok');
690
- expect(res.status).toBe(401);
691
- });
692
-
693
- test('revoked-at-idp (active:false) → next publish denied 401', async () => {
694
- claimsByToken.set('idp-tok', { active: true, groups: ['celilo-admins'] });
695
- expect((await publishTo('idp-tok', 'homebridge', '1.0.0+1')).status).toBe(200);
696
- // Revoke at the idp — the token is now inactive.
697
- claimsByToken.set('idp-tok', { active: false });
698
- expect((await publishTo('idp-tok', 'homebridge', '1.0.0+2')).status).toBe(401);
699
- });
700
-
701
- test('opaque publish token still works (backward compat)', async () => {
702
- const res = await publishTo('opaque-admin');
703
- // 'opaque-admin' is the raw token; it authorizes via the local set,
704
- // never touching introspection.
705
- expect(res.status).toBe(200);
706
- });
707
-
708
- test('idp down (introspection throws) → fails closed 401', async () => {
709
- idp?.stop();
710
- idp = null;
711
- claimsByToken.set('idp-tok', { active: true, groups: ['celilo-admins'] });
712
- const res = await publishTo('idp-tok');
713
- expect(res.status).toBe(401);
714
- });
715
- });
716
-
717
- // ── module-owner table: hybrid group + owner authorization (ce-1ch) ──────────
718
- // A verified idp publisher may publish only module names it owns. The first
719
- // verified publisher of an unclaimed name claims it (first-publish-claims);
720
- // another publisher is then DENIED (confused-deputy defense). Admins publish or
721
- // reassign anything.
722
-
723
- describe('module-owner authorization (ce-1ch)', () => {
724
- let idp: ReturnType<typeof Bun.serve> | null = null;
725
- let srv: ReturnType<typeof startServer> | null = null;
726
- let dir: string;
727
- let url: string;
728
- const claimsByToken = new Map<string, Record<string, unknown>>();
729
-
730
- beforeEach(() => {
731
- claimsByToken.clear();
732
- idp = Bun.serve({
733
- port: 0,
734
- async fetch(req) {
735
- const form = new URLSearchParams(await req.text());
736
- const token = form.get('token') ?? '';
737
- return Response.json(claimsByToken.get(token) ?? { active: false });
738
- },
739
- });
740
- dir = mkdtempSync(join(tmpdir(), 'celilo-registry-owner-test-'));
741
- srv = startServer({
742
- dataDir: dir,
743
- port: 0,
744
- pathPrefix: '',
745
- publicUrl: 'https://example.test',
746
- auth: new TokenAuth([{ token: 'opaque-admin', scope: ADMIN_SCOPE }]),
747
- introspection: new IntrospectionVerifier({
748
- endpoint: `http://localhost:${idp.port}/introspect`,
749
- clientId: 'celilo-registry',
750
- clientSecret: 'sec',
751
- adminGroup: 'celilo-admins',
752
- publisherGroup: 'celilo-authors',
753
- }),
754
- });
755
- url = `http://localhost:${srv.port}`;
756
- });
757
-
758
- afterEach(() => {
759
- srv?.stop();
760
- srv = null;
761
- idp?.stop();
762
- idp = null;
763
- rmSync(dir, { recursive: true, force: true });
764
- });
765
-
766
- function publish(token: string, name: string, vers: string): Promise<Response> {
767
- const meta = Buffer.from(JSON.stringify({ name, vers }), 'utf-8');
768
- const file = Buffer.from('fake netapp bytes');
769
- const metaLen = Buffer.alloc(4);
770
- metaLen.writeUInt32LE(meta.length, 0);
771
- const fileLen = Buffer.alloc(4);
772
- fileLen.writeUInt32LE(file.length, 0);
773
- const body = Buffer.concat([metaLen, meta, fileLen, file]);
774
- return fetch(`${url}/api/v1/modules/new`, {
775
- method: 'PUT',
776
- headers: { Authorization: `Bearer ${token}` },
777
- body,
778
- });
779
- }
780
-
781
- test('publisher claims an unclaimed name on first publish', async () => {
782
- claimsByToken.set('alice-tok', { active: true, sub: 'alice', groups: ['celilo-authors'] });
783
- expect((await publish('alice-tok', 'homebridge', '1.0.0+1')).status).toBe(200);
784
- // The owner endpoint (admin) now shows alice owns it.
785
- const res = await fetch(`${url}/api/v1/modules/owners/homebridge`, {
786
- headers: { Authorization: 'Bearer opaque-admin' },
787
- });
788
- expect(res.status).toBe(200);
789
- const body = (await res.json()) as { owner: { ownerSub: string; sourceGroup: string } };
790
- expect(body.owner.ownerSub).toBe('alice');
791
- expect(body.owner.sourceGroup).toBe('celilo-authors');
792
- });
793
-
794
- test('owner may publish more versions of a name it owns', async () => {
795
- claimsByToken.set('alice-tok', { active: true, sub: 'alice', groups: ['celilo-authors'] });
796
- expect((await publish('alice-tok', 'homebridge', '1.0.0+1')).status).toBe(200);
797
- expect((await publish('alice-tok', 'homebridge', '1.0.0+2')).status).toBe(200);
798
- });
799
-
800
- test('CONFUSED DEPUTY: a different publisher cannot publish someone else’s name', async () => {
801
- claimsByToken.set('alice-tok', { active: true, sub: 'alice', groups: ['celilo-authors'] });
802
- claimsByToken.set('bob-tok', { active: true, sub: 'bob', groups: ['celilo-authors'] });
803
- expect((await publish('alice-tok', 'homebridge', '1.0.0+1')).status).toBe(200);
804
- // Bob is a valid publisher but does NOT own homebridge → denied.
805
- expect((await publish('bob-tok', 'homebridge', '1.0.0+2')).status).toBe(401);
806
- });
807
-
808
- test('an identity in neither publish group is denied even when verified', async () => {
809
- claimsByToken.set('carol-tok', { active: true, sub: 'carol', groups: ['users'] });
810
- expect((await publish('carol-tok', 'anything', '1.0.0+1')).status).toBe(401);
811
- });
812
-
813
- test('admin group may publish any name and claims it', async () => {
814
- claimsByToken.set('admin-tok', { active: true, sub: 'ops', groups: ['celilo-admins'] });
815
- expect((await publish('admin-tok', 'caddy', '1.0.0+1')).status).toBe(200);
816
- const res = await fetch(`${url}/api/v1/modules/owners/caddy`, {
817
- headers: { Authorization: 'Bearer opaque-admin' },
818
- });
819
- expect(((await res.json()) as { owner: { ownerSub: string } }).owner.ownerSub).toBe('ops');
820
- });
821
-
822
- test('admin reassign lets a new owner publish the name', async () => {
823
- claimsByToken.set('alice-tok', { active: true, sub: 'alice', groups: ['celilo-authors'] });
824
- claimsByToken.set('bob-tok', { active: true, sub: 'bob', groups: ['celilo-authors'] });
825
- expect((await publish('alice-tok', 'homebridge', '1.0.0+1')).status).toBe(200);
826
- // Bob denied before reassignment.
827
- expect((await publish('bob-tok', 'homebridge', '1.0.0+2')).status).toBe(401);
828
- // Admin reassigns homebridge to bob.
829
- const reassign = await fetch(`${url}/api/v1/modules/owners/homebridge`, {
830
- method: 'POST',
831
- headers: { Authorization: 'Bearer opaque-admin', 'Content-Type': 'application/json' },
832
- body: JSON.stringify({ ownerSub: 'bob' }),
833
- });
834
- expect(reassign.status).toBe(200);
835
- // Now bob may publish.
836
- expect((await publish('bob-tok', 'homebridge', '1.0.0+2')).status).toBe(200);
837
- });
838
-
839
- test('owner list endpoint requires admin', async () => {
840
- const anon = await fetch(`${url}/api/v1/modules/owners`);
841
- expect(anon.status).toBe(401);
842
- const ok = await fetch(`${url}/api/v1/modules/owners`, {
843
- headers: { Authorization: 'Bearer opaque-admin' },
844
- });
845
- expect(ok.status).toBe(200);
846
- expect(((await ok.json()) as { owners: unknown[] }).owners).toEqual([]);
847
- });
848
-
849
- test('owner reassign requires admin (a plain publisher cannot)', async () => {
850
- claimsByToken.set('alice-tok', { active: true, sub: 'alice', groups: ['celilo-authors'] });
851
- const res = await fetch(`${url}/api/v1/modules/owners/homebridge`, {
852
- method: 'POST',
853
- headers: { Authorization: 'Bearer alice-tok', 'Content-Type': 'application/json' },
854
- body: JSON.stringify({ ownerSub: 'alice' }),
855
- });
856
- expect(res.status).toBe(401);
857
- });
858
-
859
- test('owner show for an unclaimed name → 404', async () => {
860
- const res = await fetch(`${url}/api/v1/modules/owners/never-claimed`, {
861
- headers: { Authorization: 'Bearer opaque-admin' },
862
- });
863
- expect(res.status).toBe(404);
864
- });
865
-
866
- test('an idp admin token may drive the owner endpoints too', async () => {
867
- claimsByToken.set('admin-tok', { active: true, sub: 'ops', groups: ['celilo-admins'] });
868
- const res = await fetch(`${url}/api/v1/modules/owners`, {
869
- headers: { Authorization: 'Bearer admin-tok' },
870
- });
871
- expect(res.status).toBe(200);
872
- });
873
- });
874
-
875
- // ── sweep (reclaiming disk from superseded build revisions) ──────────────────
876
-
877
- describe('POST /api/v1/modules/sweep', () => {
878
- function requestSweep(token: string, body: unknown = {}): Promise<Response> {
879
- return fetch(`${baseUrl}/api/v1/modules/sweep`, {
880
- method: 'POST',
881
- headers: token ? { Authorization: token, 'Content-Type': 'application/json' } : {},
882
- body: JSON.stringify(body),
883
- });
884
- }
885
-
886
- test('no token is 401 — a sweep deletes, so it is admin-only', async () => {
887
- const res = await requestSweep('');
888
- expect(res.status).toBe(401);
889
- });
890
-
891
- test('a scoped (non-admin) token is 401', async () => {
892
- const mint = await fetch(`${baseUrl}/api/v1/modules/tokens/mint`, {
893
- method: 'POST',
894
- headers: { Authorization: 'valid-token', 'Content-Type': 'application/json' },
895
- body: JSON.stringify({ repo: 'celilo/homebridge', scope: 'homebridge' }),
896
- });
897
- const { token } = (await mint.json()) as { token: string };
898
-
899
- const res = await requestSweep(token);
900
- expect(res.status).toBe(401);
901
- });
902
-
903
- test('removes superseded revisions and leaves the download path working', async () => {
904
- for (const rev of [1, 2, 3]) await publish('homebridge', `1.0.0+${rev}`, 'valid-token');
905
-
906
- const res = await requestSweep('valid-token');
907
- expect(res.status).toBe(200);
908
- const body = (await res.json()) as { ok: boolean; removedCount: number };
909
- expect(body.ok).toBe(true);
910
- expect(body.removedCount).toBe(2);
911
-
912
- // The surviving revision downloads; a swept one is gone from BOTH the
913
- // index and the store, so nothing advertises a 404.
914
- expect((await fetch(`${baseUrl}/api/v1/modules/homebridge/1.0.0+3/download`)).status).toBe(200);
915
- expect((await fetch(`${baseUrl}/api/v1/modules/homebridge/1.0.0+1/download`)).status).toBe(404);
916
-
917
- const index = await fetch(`${baseUrl}/index/ho/me/homebridge`);
918
- const versions = (await index.text())
919
- .split('\n')
920
- .filter(Boolean)
921
- .map((line) => (JSON.parse(line) as { vers: string }).vers);
922
- expect(versions).toEqual(['1.0.0+3']);
923
- });
924
-
925
- test('dry_run reports the plan without deleting anything', async () => {
926
- for (const rev of [1, 2]) await publish('homebridge', `1.0.0+${rev}`, 'valid-token');
927
-
928
- const res = await requestSweep('valid-token', { dry_run: true });
929
- const body = (await res.json()) as { removedCount: number; dryRun: boolean };
930
- expect(body.removedCount).toBe(1);
931
- expect(body.dryRun).toBe(true);
932
- expect((await fetch(`${baseUrl}/api/v1/modules/homebridge/1.0.0+1/download`)).status).toBe(200);
933
- });
934
-
935
- test('rejects a keep_build_revisions that would delete a whole release', async () => {
936
- const res = await requestSweep('valid-token', { keep_build_revisions: 0 });
937
- expect(res.status).toBe(400);
938
- });
939
-
940
- test('honours a larger keep_build_revisions', async () => {
941
- for (const rev of [1, 2, 3, 4]) await publish('homebridge', `1.0.0+${rev}`, 'valid-token');
942
- const res = await requestSweep('valid-token', { keep_build_revisions: 2 });
943
- const body = (await res.json()) as { removedCount: number };
944
- expect(body.removedCount).toBe(2);
945
- });
946
-
947
- test('coexists with a module actually named "sweep"', async () => {
948
- await publish('sweep', '1.0.0+1', 'valid-token');
949
- const res = await requestSweep('valid-token');
950
- expect(res.status).toBe(200);
951
- // …and the module is still reachable by its own GET route.
952
- expect((await fetch(`${baseUrl}/api/v1/modules/sweep`)).status).toBe(200);
953
- });
954
- });
955
-
956
- describe('POST /api/v1/modules/sweep — a damaged store', () => {
957
- function requestSweep(token: string, body: unknown = {}): Promise<Response> {
958
- return fetch(`${baseUrl}/api/v1/modules/sweep`, {
959
- method: 'POST',
960
- headers: { Authorization: token, 'Content-Type': 'application/json' },
961
- body: JSON.stringify(body),
962
- });
963
- }
964
-
965
- test('reclaims a half-written publish, freeing the version to be published again', async () => {
966
- // handlePublish stores the package and appends the index with no rollback
967
- // between them, so an ENOSPC or EACCES in the gap leaves an orphan. The
968
- // immutability check reads the filesystem, so that version then cannot be
969
- // published at all — this is the only repair path in the product.
970
- await publish('homebridge', '1.0.0+1', 'valid-token');
971
- const rmIndex = await fetch(`${baseUrl}/api/v1/modules/homebridge/1.0.0+1/yank`, {
972
- method: 'DELETE',
973
- headers: { Authorization: 'valid-token' },
974
- });
975
- expect(rmIndex.status).toBe(200);
976
-
977
- // Simulate the crash: index line gone, payload left behind.
978
- rmSync(join(dataDir, 'index'), { recursive: true, force: true });
979
-
980
- const blocked = await publish('homebridge', '1.0.0+1', 'valid-token');
981
- expect(blocked.status).toBe(409);
982
-
983
- const swept = await requestSweep('valid-token');
984
- const body = (await swept.json()) as { orphanCount: number };
985
- expect(body.orphanCount).toBe(1);
986
-
987
- // The version is publishable again.
988
- const retry = await publish('homebridge', '1.0.0+1', 'valid-token');
989
- expect(retry.status).toBe(200);
990
- });
991
- });