@celilo/e2e 0.9.3 → 0.10.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/config/cpanel-host/cpanel-host-ca.crt +20 -0
- package/config/cpanel-host/docroot/.htaccess +14 -0
- package/config/cpanel-host/docroot/cms.html +4 -0
- package/config/cpanel-host/site-tls.crt +20 -0
- package/config/cpanel-host/site-tls.key +28 -0
- package/config/cpanel-host/site.conf +27 -0
- package/config/dns/com.zone +7 -0
- package/config/dns/knot-namecheap.conf +6 -0
- package/config/dns/tangohost.com.zone +14 -0
- package/config/resolver/unbound.conf +26 -0
- package/config/routing/fw-ext-routes.sh +16 -1
- package/config/routing/management-routes.sh +14 -4
- package/config/routing/minio-startup.sh +9 -0
- package/config/routing/public-sim-entrypoint.sh +24 -0
- package/config/routing/resolver-internal-routes.sh +10 -0
- package/docker/Dockerfile.apt-repo-sim +8 -0
- package/docker/Dockerfile.cpanel-host-sim +75 -0
- package/docker/Dockerfile.firewall +12 -0
- package/docker/Dockerfile.management +6 -0
- package/docker/Dockerfile.minio +7 -0
- package/docker/Dockerfile.npm-registry-sim +8 -0
- package/docker/Dockerfile.observer +11 -0
- package/docker/Dockerfile.registry +8 -0
- package/docker/Dockerfile.signal-cli +60 -0
- package/docker/Dockerfile.signal-release +44 -0
- package/docker/Dockerfile.signal-sim +17 -0
- package/docker/Dockerfile.target-machine +9 -0
- package/package.json +3 -3
- package/registry-server/package.json +17 -0
- package/registry-server/src/auth.test.ts +76 -0
- package/registry-server/src/auth.ts +105 -0
- package/registry-server/src/bootstrap-packaging.test.ts +71 -0
- package/registry-server/src/bootstrap.ts +250 -0
- package/registry-server/src/index.ts +16 -0
- package/registry-server/src/introspection.test.ts +247 -0
- package/registry-server/src/introspection.ts +204 -0
- package/registry-server/src/landing.ts +90 -0
- package/registry-server/src/module-owner-store.test.ts +85 -0
- package/registry-server/src/module-owner-store.ts +108 -0
- package/registry-server/src/rate-limit.test.ts +62 -0
- package/registry-server/src/rate-limit.ts +83 -0
- package/registry-server/src/scoped-token-store.test.ts +93 -0
- package/registry-server/src/scoped-token-store.ts +110 -0
- package/registry-server/src/server.test.ts +771 -0
- package/registry-server/src/server.ts +701 -0
- package/registry-server/src/storage.test.ts +148 -0
- package/registry-server/src/storage.ts +150 -0
- package/registry-server/src/validation.test.ts +86 -0
- package/registry-server/src/validation.ts +60 -0
- package/registry-server/tsconfig.json +15 -0
- package/scripts/stage-libsignal.ts +116 -0
- package/simulators/signal-cli/server.ts +328 -0
- package/src/cli/build.ts +7 -0
- package/src/cli/index.ts +6 -0
- package/src/container-manager.ts +47 -10
- package/src/docker-compose-generator.ts +120 -16
- package/src/exit-cleanup.test.ts +56 -0
- package/src/index.ts +7 -0
- package/src/network-builder.ts +66 -0
- package/src/public-sim-routes.test.ts +117 -0
- package/src/simulator-ips.ts +9 -0
- package/src/types.ts +71 -1
|
@@ -0,0 +1,771 @@
|
|
|
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('total_downloads reflects actual download count, sort=downloads orders by it', async () => {
|
|
221
|
+
// Publish two modules, hit one's download endpoint several times.
|
|
222
|
+
// The search response should show those counts and sort=downloads
|
|
223
|
+
// should put the more-downloaded one first.
|
|
224
|
+
expect((await publish('alpha', '1.0.0+1', 'valid-token')).status).toBe(200);
|
|
225
|
+
expect((await publish('bravo', '1.0.0+1', 'valid-token')).status).toBe(200);
|
|
226
|
+
|
|
227
|
+
for (let i = 0; i < 3; i++) {
|
|
228
|
+
const r = await fetch(`${baseUrl}/api/v1/modules/alpha/1.0.0+1/download`);
|
|
229
|
+
expect(r.status).toBe(200);
|
|
230
|
+
}
|
|
231
|
+
const r = await fetch(`${baseUrl}/api/v1/modules/bravo/1.0.0+1/download`);
|
|
232
|
+
expect(r.status).toBe(200);
|
|
233
|
+
|
|
234
|
+
// Default sort: alphabetical; counts reflect downloads.
|
|
235
|
+
const def = (await fetch(`${baseUrl}/api/v1/modules`).then((r) => r.json())) as {
|
|
236
|
+
modules: Array<{ name: string; total_downloads: number }>;
|
|
237
|
+
};
|
|
238
|
+
const alpha = def.modules.find((m) => m.name === 'alpha');
|
|
239
|
+
const bravo = def.modules.find((m) => m.name === 'bravo');
|
|
240
|
+
expect(alpha?.total_downloads).toBe(3);
|
|
241
|
+
expect(bravo?.total_downloads).toBe(1);
|
|
242
|
+
|
|
243
|
+
// sort=downloads: alpha (3) before bravo (1).
|
|
244
|
+
const byDl = (await fetch(`${baseUrl}/api/v1/modules?sort=downloads`).then((r) =>
|
|
245
|
+
r.json(),
|
|
246
|
+
)) as { modules: Array<{ name: string }> };
|
|
247
|
+
const idxAlpha = byDl.modules.findIndex((m) => m.name === 'alpha');
|
|
248
|
+
const idxBravo = byDl.modules.findIndex((m) => m.name === 'bravo');
|
|
249
|
+
expect(idxAlpha).toBeGreaterThanOrEqual(0);
|
|
250
|
+
expect(idxBravo).toBeGreaterThanOrEqual(0);
|
|
251
|
+
expect(idxAlpha).toBeLessThan(idxBravo);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// ── per-module metadata download count ───────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
describe('GET /api/v1/modules/{name} (metadata) — total_downloads', () => {
|
|
258
|
+
test('returns the same count as the search endpoint', async () => {
|
|
259
|
+
expect((await publish('homebridge', '1.0.0+1', 'valid-token')).status).toBe(200);
|
|
260
|
+
await fetch(`${baseUrl}/api/v1/modules/homebridge/1.0.0+1/download`);
|
|
261
|
+
await fetch(`${baseUrl}/api/v1/modules/homebridge/1.0.0+1/download`);
|
|
262
|
+
|
|
263
|
+
const meta = (await fetch(`${baseUrl}/api/v1/modules/homebridge`).then((r) => r.json())) as {
|
|
264
|
+
name: string;
|
|
265
|
+
total_downloads: number;
|
|
266
|
+
};
|
|
267
|
+
expect(meta.total_downloads).toBe(2);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// ── path-traversal on GET endpoints ──────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
describe('GET endpoints reject traversal attempts', () => {
|
|
274
|
+
// These verify the HIGH-severity fix: `name` / `version` from URL segments
|
|
275
|
+
// can't reach the storage layer if they aren't valid module identifiers.
|
|
276
|
+
|
|
277
|
+
test.each([
|
|
278
|
+
'/index/..%2F..%2Fetc',
|
|
279
|
+
'/index/ho/me/..%2Fpasswd',
|
|
280
|
+
'/api/v1/modules/..%2F..%2Fetc',
|
|
281
|
+
'/api/v1/modules/..%2F..%2Fetc/1.0.0+1/download',
|
|
282
|
+
'/api/v1/modules/homebridge/..%2F..%2Fetc/download',
|
|
283
|
+
'/api/v1/modules/homebridge/1.0.0+1%2F..%2F..%2Fetc/download',
|
|
284
|
+
])('404 for %s', async (path) => {
|
|
285
|
+
const res = await fetch(`${baseUrl}${path}`);
|
|
286
|
+
expect(res.status).toBe(404);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('DELETE yank with traversal is rejected (401 before 404 is fine)', async () => {
|
|
290
|
+
const res = await fetch(`${baseUrl}/api/v1/modules/..%2Fetc/1.0.0+1/yank`, {
|
|
291
|
+
method: 'DELETE',
|
|
292
|
+
headers: { Authorization: 'valid-token' },
|
|
293
|
+
});
|
|
294
|
+
expect([401, 404]).toContain(res.status);
|
|
295
|
+
expect(res.status).not.toBe(200);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// ── download ─────────────────────────────────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
describe('GET /api/v1/modules/{name}/{version}/download', () => {
|
|
302
|
+
test('returns stored bytes after publish', async () => {
|
|
303
|
+
await publish('homebridge', '1.0.0+1', 'valid-token');
|
|
304
|
+
const res = await fetch(`${baseUrl}/api/v1/modules/homebridge/1.0.0+1/download`);
|
|
305
|
+
expect(res.status).toBe(200);
|
|
306
|
+
expect(res.headers.get('content-type')).toBe('application/octet-stream');
|
|
307
|
+
const body = await res.arrayBuffer();
|
|
308
|
+
expect(Buffer.from(body).toString()).toBe('fake netapp bytes');
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('404 for unknown version', async () => {
|
|
312
|
+
const res = await fetch(`${baseUrl}/api/v1/modules/homebridge/9.9.9+1/download`);
|
|
313
|
+
expect(res.status).toBe(404);
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// ── yank / unyank ────────────────────────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
describe('DELETE /yank, PUT /unyank', () => {
|
|
320
|
+
test('yank requires auth and flips the yanked flag', async () => {
|
|
321
|
+
await publish('homebridge', '1.0.0+1', 'valid-token');
|
|
322
|
+
|
|
323
|
+
const unauth = await fetch(`${baseUrl}/api/v1/modules/homebridge/1.0.0+1/yank`, {
|
|
324
|
+
method: 'DELETE',
|
|
325
|
+
});
|
|
326
|
+
expect(unauth.status).toBe(401);
|
|
327
|
+
|
|
328
|
+
const res = await fetch(`${baseUrl}/api/v1/modules/homebridge/1.0.0+1/yank`, {
|
|
329
|
+
method: 'DELETE',
|
|
330
|
+
headers: { Authorization: 'valid-token' },
|
|
331
|
+
});
|
|
332
|
+
expect(res.status).toBe(200);
|
|
333
|
+
|
|
334
|
+
const indexRes = await fetch(`${baseUrl}/index/ho/me/homebridge`);
|
|
335
|
+
const entry = JSON.parse((await indexRes.text()).trim()) as { yanked: boolean };
|
|
336
|
+
expect(entry.yanked).toBe(true);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// ── rate limiting ────────────────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
describe('write endpoints are rate limited', () => {
|
|
343
|
+
test('429 with Retry-After once the per-IP budget is spent', async () => {
|
|
344
|
+
const tightDataDir = mkdtempSync(join(tmpdir(), 'celilo-registry-rl-'));
|
|
345
|
+
const tight = startServer({
|
|
346
|
+
dataDir: tightDataDir,
|
|
347
|
+
port: 0,
|
|
348
|
+
pathPrefix: '',
|
|
349
|
+
publicUrl: 'https://example.test',
|
|
350
|
+
auth: new TokenAuth([{ token: 'tok', scope: ADMIN_SCOPE }]),
|
|
351
|
+
rateLimiter: createRateLimiter({ max: 2, windowMs: 60_000 }),
|
|
352
|
+
});
|
|
353
|
+
try {
|
|
354
|
+
const b = `http://localhost:${tight.port}`;
|
|
355
|
+
// Send 3 unauthenticated publishes — first two return 401 (past the
|
|
356
|
+
// rate-limit gate), third trips the limiter → 429. Using 401 here is
|
|
357
|
+
// fine because the rate-limiter runs BEFORE auth, by design.
|
|
358
|
+
const mkBody = () => {
|
|
359
|
+
const meta = Buffer.from(JSON.stringify({ name: 'x', vers: '1.0.0+1' }), 'utf-8');
|
|
360
|
+
const file = Buffer.from('f');
|
|
361
|
+
const metaLen = Buffer.alloc(4);
|
|
362
|
+
metaLen.writeUInt32LE(meta.length, 0);
|
|
363
|
+
const fileLen = Buffer.alloc(4);
|
|
364
|
+
fileLen.writeUInt32LE(file.length, 0);
|
|
365
|
+
return Buffer.concat([metaLen, meta, fileLen, file]);
|
|
366
|
+
};
|
|
367
|
+
const call = () => fetch(`${b}/api/v1/modules/new`, { method: 'PUT', body: mkBody() });
|
|
368
|
+
|
|
369
|
+
const r1 = await call();
|
|
370
|
+
expect(r1.status).toBe(401);
|
|
371
|
+
const r2 = await call();
|
|
372
|
+
expect(r2.status).toBe(401);
|
|
373
|
+
const r3 = await call();
|
|
374
|
+
expect(r3.status).toBe(429);
|
|
375
|
+
expect(r3.headers.get('Retry-After')).not.toBeNull();
|
|
376
|
+
} finally {
|
|
377
|
+
tight.stop();
|
|
378
|
+
rmSync(tightDataDir, { recursive: true, force: true });
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// ── path prefix ──────────────────────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
describe('path prefix routing', () => {
|
|
386
|
+
test('requests under /registry are accepted when pathPrefix is /registry', async () => {
|
|
387
|
+
const prefixed = startServer({
|
|
388
|
+
dataDir: mkdtempSync(join(tmpdir(), 'celilo-registry-prefix-')),
|
|
389
|
+
port: 0,
|
|
390
|
+
pathPrefix: '/registry',
|
|
391
|
+
publicUrl: 'https://example.test/registry',
|
|
392
|
+
auth: new TokenAuth([{ token: 'valid-token', scope: ADMIN_SCOPE }]),
|
|
393
|
+
});
|
|
394
|
+
try {
|
|
395
|
+
const base = `http://localhost:${prefixed.port}`;
|
|
396
|
+
const res = await fetch(`${base}/registry/index/config.json`);
|
|
397
|
+
expect(res.status).toBe(200);
|
|
398
|
+
const nope = await fetch(`${base}/index/config.json`);
|
|
399
|
+
expect(nope.status).toBe(404);
|
|
400
|
+
} finally {
|
|
401
|
+
prefixed.stop();
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// ── scoped publish tokens (ISS-0140) ──────────────────────────────────────────
|
|
407
|
+
|
|
408
|
+
describe('POST /api/v1/modules/tokens/mint + scoped publish', () => {
|
|
409
|
+
async function mint(repo: string, scope: string, token: string): Promise<Response> {
|
|
410
|
+
return fetch(`${baseUrl}/api/v1/modules/tokens/mint`, {
|
|
411
|
+
method: 'POST',
|
|
412
|
+
headers: token ? { Authorization: token, 'Content-Type': 'application/json' } : {},
|
|
413
|
+
body: JSON.stringify({ repo, scope }),
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
test('admin token mints a scoped token; non-admin / no token is 401', async () => {
|
|
418
|
+
const noTok = await mint('celilo/lunacycle', 'lunacycle', '');
|
|
419
|
+
expect(noTok.status).toBe(401);
|
|
420
|
+
|
|
421
|
+
const ok = await mint('celilo/lunacycle', 'lunacycle', 'valid-token');
|
|
422
|
+
expect(ok.status).toBe(200);
|
|
423
|
+
const body = (await ok.json()) as { ok: boolean; token: string; scope: string };
|
|
424
|
+
expect(body.ok).toBe(true);
|
|
425
|
+
expect(body.scope).toBe('lunacycle');
|
|
426
|
+
expect(body.token.length).toBeGreaterThan(10);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test('a minted scoped token publishes ONLY its package', async () => {
|
|
430
|
+
const minted = (await (await mint('celilo/lunacycle', 'lunacycle', 'valid-token')).json()) as {
|
|
431
|
+
token: string;
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
// Can publish its own package…
|
|
435
|
+
const ok = await publish('lunacycle', '1.0.0+1', minted.token);
|
|
436
|
+
expect(ok.status).toBe(200);
|
|
437
|
+
|
|
438
|
+
// …but NOT another module.
|
|
439
|
+
const denied = await publish('caddy', '1.0.0+1', minted.token);
|
|
440
|
+
expect(denied.status).toBe(401);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test('a scoped token cannot mint (not admin)', async () => {
|
|
444
|
+
const minted = (await (await mint('celilo/lunacycle', 'lunacycle', 'valid-token')).json()) as {
|
|
445
|
+
token: string;
|
|
446
|
+
};
|
|
447
|
+
const reMint = await mint('celilo/caddy', 'caddy', minted.token);
|
|
448
|
+
expect(reMint.status).toBe(401);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test('re-minting rotates: the old token stops working, the new one publishes', async () => {
|
|
452
|
+
const first = (await (await mint('celilo/lunacycle', 'lunacycle', 'valid-token')).json()) as {
|
|
453
|
+
token: string;
|
|
454
|
+
};
|
|
455
|
+
const second = (await (await mint('celilo/lunacycle', 'lunacycle', 'valid-token')).json()) as {
|
|
456
|
+
token: string;
|
|
457
|
+
};
|
|
458
|
+
expect(second.token).not.toBe(first.token);
|
|
459
|
+
|
|
460
|
+
const oldDenied = await publish('lunacycle', '2.0.0+1', first.token);
|
|
461
|
+
expect(oldDenied.status).toBe(401);
|
|
462
|
+
const newOk = await publish('lunacycle', '2.0.0+1', second.token);
|
|
463
|
+
expect(newOk.status).toBe(200);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test('revoke disables the token', async () => {
|
|
467
|
+
const minted = (await (await mint('celilo/lunacycle', 'lunacycle', 'valid-token')).json()) as {
|
|
468
|
+
token: string;
|
|
469
|
+
};
|
|
470
|
+
const revoke = await fetch(`${baseUrl}/api/v1/modules/tokens/revoke`, {
|
|
471
|
+
method: 'POST',
|
|
472
|
+
headers: { Authorization: 'valid-token', 'Content-Type': 'application/json' },
|
|
473
|
+
body: JSON.stringify({ repo: 'celilo/lunacycle' }),
|
|
474
|
+
});
|
|
475
|
+
expect(revoke.status).toBe(200);
|
|
476
|
+
const denied = await publish('lunacycle', '1.0.0+1', minted.token);
|
|
477
|
+
expect(denied.status).toBe(401);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test('mint rejects an invalid scope (not a valid package name)', async () => {
|
|
481
|
+
const res = await mint('celilo/lunacycle', 'Bad_Scope', 'valid-token');
|
|
482
|
+
expect(res.status).toBe(400);
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
487
|
+
|
|
488
|
+
async function publish(
|
|
489
|
+
name: string,
|
|
490
|
+
vers: string,
|
|
491
|
+
token: string,
|
|
492
|
+
description?: string,
|
|
493
|
+
): Promise<Response> {
|
|
494
|
+
const meta = Buffer.from(
|
|
495
|
+
JSON.stringify({ name, vers, ...(description ? { description } : {}) }),
|
|
496
|
+
'utf-8',
|
|
497
|
+
);
|
|
498
|
+
const file = Buffer.from('fake netapp bytes');
|
|
499
|
+
const metaLen = Buffer.alloc(4);
|
|
500
|
+
metaLen.writeUInt32LE(meta.length, 0);
|
|
501
|
+
const fileLen = Buffer.alloc(4);
|
|
502
|
+
fileLen.writeUInt32LE(file.length, 0);
|
|
503
|
+
const body = Buffer.concat([metaLen, meta, fileLen, file]);
|
|
504
|
+
|
|
505
|
+
return fetch(`${baseUrl}/api/v1/modules/new`, {
|
|
506
|
+
method: 'PUT',
|
|
507
|
+
headers: token ? { Authorization: token } : {},
|
|
508
|
+
body,
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// ── idp introspection verify-bridge (ce-s7e) ─────────────────────────────────
|
|
513
|
+
// The real publish path with an idp identity token: the token is unknown to
|
|
514
|
+
// the opaque set, so authorizePackage() verifies it via RFC 7662 introspection
|
|
515
|
+
// against the idp. A fake idp server stands in for authentik; revoking a token
|
|
516
|
+
// there (active:false) denies the next publish.
|
|
517
|
+
|
|
518
|
+
describe('publish via idp introspection (ce-s7e)', () => {
|
|
519
|
+
let idp: ReturnType<typeof Bun.serve> | null = null;
|
|
520
|
+
let idpServer: ReturnType<typeof startServer> | null = null;
|
|
521
|
+
let idpDataDir: string;
|
|
522
|
+
let idpBaseUrl: string;
|
|
523
|
+
// token → introspection claims the fake idp reports for it.
|
|
524
|
+
const claimsByToken = new Map<string, Record<string, unknown>>();
|
|
525
|
+
|
|
526
|
+
beforeEach(() => {
|
|
527
|
+
claimsByToken.clear();
|
|
528
|
+
idp = Bun.serve({
|
|
529
|
+
port: 0,
|
|
530
|
+
async fetch(req) {
|
|
531
|
+
const form = new URLSearchParams(await req.text());
|
|
532
|
+
const token = form.get('token') ?? '';
|
|
533
|
+
const claims = claimsByToken.get(token) ?? { active: false };
|
|
534
|
+
return Response.json(claims);
|
|
535
|
+
},
|
|
536
|
+
});
|
|
537
|
+
idpDataDir = mkdtempSync(join(tmpdir(), 'celilo-registry-idp-test-'));
|
|
538
|
+
idpServer = startServer({
|
|
539
|
+
dataDir: idpDataDir,
|
|
540
|
+
port: 0,
|
|
541
|
+
pathPrefix: '',
|
|
542
|
+
publicUrl: 'https://example.test',
|
|
543
|
+
// Opaque admin token still works → backward-compat coverage below.
|
|
544
|
+
auth: new TokenAuth([{ token: 'opaque-admin', scope: ADMIN_SCOPE }]),
|
|
545
|
+
introspection: new IntrospectionVerifier({
|
|
546
|
+
endpoint: `http://localhost:${idp.port}/introspect`,
|
|
547
|
+
clientId: 'celilo-registry',
|
|
548
|
+
clientSecret: 'sec',
|
|
549
|
+
adminGroup: 'celilo-admins',
|
|
550
|
+
publisherGroup: 'celilo-authors',
|
|
551
|
+
}),
|
|
552
|
+
});
|
|
553
|
+
idpBaseUrl = `http://localhost:${idpServer.port}`;
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
afterEach(() => {
|
|
557
|
+
idpServer?.stop();
|
|
558
|
+
idpServer = null;
|
|
559
|
+
idp?.stop();
|
|
560
|
+
idp = null;
|
|
561
|
+
rmSync(idpDataDir, { recursive: true, force: true });
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
function publishTo(token: string, name = 'homebridge', vers = '1.0.0+1'): Promise<Response> {
|
|
565
|
+
const meta = Buffer.from(JSON.stringify({ name, vers }), 'utf-8');
|
|
566
|
+
const file = Buffer.from('fake netapp bytes');
|
|
567
|
+
const metaLen = Buffer.alloc(4);
|
|
568
|
+
metaLen.writeUInt32LE(meta.length, 0);
|
|
569
|
+
const fileLen = Buffer.alloc(4);
|
|
570
|
+
fileLen.writeUInt32LE(file.length, 0);
|
|
571
|
+
const body = Buffer.concat([metaLen, meta, fileLen, file]);
|
|
572
|
+
return fetch(`${idpBaseUrl}/api/v1/modules/new`, {
|
|
573
|
+
method: 'PUT',
|
|
574
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
575
|
+
body,
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
test('active idp token (admin group) authorizes publish', async () => {
|
|
580
|
+
claimsByToken.set('idp-tok', { active: true, sub: 'alice', groups: ['celilo-admins'] });
|
|
581
|
+
const res = await publishTo('idp-tok');
|
|
582
|
+
expect(res.status).toBe(200);
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
test('active idp token not in admin group → 401', async () => {
|
|
586
|
+
claimsByToken.set('idp-tok', { active: true, sub: 'bob', groups: ['users'] });
|
|
587
|
+
const res = await publishTo('idp-tok');
|
|
588
|
+
expect(res.status).toBe(401);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
test('revoked-at-idp (active:false) → next publish denied 401', async () => {
|
|
592
|
+
claimsByToken.set('idp-tok', { active: true, groups: ['celilo-admins'] });
|
|
593
|
+
expect((await publishTo('idp-tok', 'homebridge', '1.0.0+1')).status).toBe(200);
|
|
594
|
+
// Revoke at the idp — the token is now inactive.
|
|
595
|
+
claimsByToken.set('idp-tok', { active: false });
|
|
596
|
+
expect((await publishTo('idp-tok', 'homebridge', '1.0.0+2')).status).toBe(401);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
test('opaque publish token still works (backward compat)', async () => {
|
|
600
|
+
const res = await publishTo('opaque-admin');
|
|
601
|
+
// 'opaque-admin' is the raw token; it authorizes via the local set,
|
|
602
|
+
// never touching introspection.
|
|
603
|
+
expect(res.status).toBe(200);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
test('idp down (introspection throws) → fails closed 401', async () => {
|
|
607
|
+
idp?.stop();
|
|
608
|
+
idp = null;
|
|
609
|
+
claimsByToken.set('idp-tok', { active: true, groups: ['celilo-admins'] });
|
|
610
|
+
const res = await publishTo('idp-tok');
|
|
611
|
+
expect(res.status).toBe(401);
|
|
612
|
+
});
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
// ── module-owner table: hybrid group + owner authorization (ce-1ch) ──────────
|
|
616
|
+
// A verified idp publisher may publish only module names it owns. The first
|
|
617
|
+
// verified publisher of an unclaimed name claims it (first-publish-claims);
|
|
618
|
+
// another publisher is then DENIED (confused-deputy defense). Admins publish or
|
|
619
|
+
// reassign anything.
|
|
620
|
+
|
|
621
|
+
describe('module-owner authorization (ce-1ch)', () => {
|
|
622
|
+
let idp: ReturnType<typeof Bun.serve> | null = null;
|
|
623
|
+
let srv: ReturnType<typeof startServer> | null = null;
|
|
624
|
+
let dir: string;
|
|
625
|
+
let url: string;
|
|
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
|
+
return Response.json(claimsByToken.get(token) ?? { active: false });
|
|
636
|
+
},
|
|
637
|
+
});
|
|
638
|
+
dir = mkdtempSync(join(tmpdir(), 'celilo-registry-owner-test-'));
|
|
639
|
+
srv = startServer({
|
|
640
|
+
dataDir: dir,
|
|
641
|
+
port: 0,
|
|
642
|
+
pathPrefix: '',
|
|
643
|
+
publicUrl: 'https://example.test',
|
|
644
|
+
auth: new TokenAuth([{ token: 'opaque-admin', scope: ADMIN_SCOPE }]),
|
|
645
|
+
introspection: new IntrospectionVerifier({
|
|
646
|
+
endpoint: `http://localhost:${idp.port}/introspect`,
|
|
647
|
+
clientId: 'celilo-registry',
|
|
648
|
+
clientSecret: 'sec',
|
|
649
|
+
adminGroup: 'celilo-admins',
|
|
650
|
+
publisherGroup: 'celilo-authors',
|
|
651
|
+
}),
|
|
652
|
+
});
|
|
653
|
+
url = `http://localhost:${srv.port}`;
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
afterEach(() => {
|
|
657
|
+
srv?.stop();
|
|
658
|
+
srv = null;
|
|
659
|
+
idp?.stop();
|
|
660
|
+
idp = null;
|
|
661
|
+
rmSync(dir, { recursive: true, force: true });
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
function publish(token: string, name: string, vers: string): Promise<Response> {
|
|
665
|
+
const meta = Buffer.from(JSON.stringify({ name, vers }), 'utf-8');
|
|
666
|
+
const file = Buffer.from('fake netapp bytes');
|
|
667
|
+
const metaLen = Buffer.alloc(4);
|
|
668
|
+
metaLen.writeUInt32LE(meta.length, 0);
|
|
669
|
+
const fileLen = Buffer.alloc(4);
|
|
670
|
+
fileLen.writeUInt32LE(file.length, 0);
|
|
671
|
+
const body = Buffer.concat([metaLen, meta, fileLen, file]);
|
|
672
|
+
return fetch(`${url}/api/v1/modules/new`, {
|
|
673
|
+
method: 'PUT',
|
|
674
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
675
|
+
body,
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
test('publisher claims an unclaimed name on first publish', async () => {
|
|
680
|
+
claimsByToken.set('alice-tok', { active: true, sub: 'alice', groups: ['celilo-authors'] });
|
|
681
|
+
expect((await publish('alice-tok', 'homebridge', '1.0.0+1')).status).toBe(200);
|
|
682
|
+
// The owner endpoint (admin) now shows alice owns it.
|
|
683
|
+
const res = await fetch(`${url}/api/v1/modules/owners/homebridge`, {
|
|
684
|
+
headers: { Authorization: 'Bearer opaque-admin' },
|
|
685
|
+
});
|
|
686
|
+
expect(res.status).toBe(200);
|
|
687
|
+
const body = (await res.json()) as { owner: { ownerSub: string; sourceGroup: string } };
|
|
688
|
+
expect(body.owner.ownerSub).toBe('alice');
|
|
689
|
+
expect(body.owner.sourceGroup).toBe('celilo-authors');
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
test('owner may publish more versions of a name it owns', async () => {
|
|
693
|
+
claimsByToken.set('alice-tok', { active: true, sub: 'alice', groups: ['celilo-authors'] });
|
|
694
|
+
expect((await publish('alice-tok', 'homebridge', '1.0.0+1')).status).toBe(200);
|
|
695
|
+
expect((await publish('alice-tok', 'homebridge', '1.0.0+2')).status).toBe(200);
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
test('CONFUSED DEPUTY: a different publisher cannot publish someone else’s name', async () => {
|
|
699
|
+
claimsByToken.set('alice-tok', { active: true, sub: 'alice', groups: ['celilo-authors'] });
|
|
700
|
+
claimsByToken.set('bob-tok', { active: true, sub: 'bob', groups: ['celilo-authors'] });
|
|
701
|
+
expect((await publish('alice-tok', 'homebridge', '1.0.0+1')).status).toBe(200);
|
|
702
|
+
// Bob is a valid publisher but does NOT own homebridge → denied.
|
|
703
|
+
expect((await publish('bob-tok', 'homebridge', '1.0.0+2')).status).toBe(401);
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
test('an identity in neither publish group is denied even when verified', async () => {
|
|
707
|
+
claimsByToken.set('carol-tok', { active: true, sub: 'carol', groups: ['users'] });
|
|
708
|
+
expect((await publish('carol-tok', 'anything', '1.0.0+1')).status).toBe(401);
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
test('admin group may publish any name and claims it', async () => {
|
|
712
|
+
claimsByToken.set('admin-tok', { active: true, sub: 'ops', groups: ['celilo-admins'] });
|
|
713
|
+
expect((await publish('admin-tok', 'caddy', '1.0.0+1')).status).toBe(200);
|
|
714
|
+
const res = await fetch(`${url}/api/v1/modules/owners/caddy`, {
|
|
715
|
+
headers: { Authorization: 'Bearer opaque-admin' },
|
|
716
|
+
});
|
|
717
|
+
expect((await res.json() as { owner: { ownerSub: string } }).owner.ownerSub).toBe('ops');
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
test('admin reassign lets a new owner publish the name', async () => {
|
|
721
|
+
claimsByToken.set('alice-tok', { active: true, sub: 'alice', groups: ['celilo-authors'] });
|
|
722
|
+
claimsByToken.set('bob-tok', { active: true, sub: 'bob', groups: ['celilo-authors'] });
|
|
723
|
+
expect((await publish('alice-tok', 'homebridge', '1.0.0+1')).status).toBe(200);
|
|
724
|
+
// Bob denied before reassignment.
|
|
725
|
+
expect((await publish('bob-tok', 'homebridge', '1.0.0+2')).status).toBe(401);
|
|
726
|
+
// Admin reassigns homebridge to bob.
|
|
727
|
+
const reassign = await fetch(`${url}/api/v1/modules/owners/homebridge`, {
|
|
728
|
+
method: 'POST',
|
|
729
|
+
headers: { Authorization: 'Bearer opaque-admin', 'Content-Type': 'application/json' },
|
|
730
|
+
body: JSON.stringify({ ownerSub: 'bob' }),
|
|
731
|
+
});
|
|
732
|
+
expect(reassign.status).toBe(200);
|
|
733
|
+
// Now bob may publish.
|
|
734
|
+
expect((await publish('bob-tok', 'homebridge', '1.0.0+2')).status).toBe(200);
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
test('owner list endpoint requires admin', async () => {
|
|
738
|
+
const anon = await fetch(`${url}/api/v1/modules/owners`);
|
|
739
|
+
expect(anon.status).toBe(401);
|
|
740
|
+
const ok = await fetch(`${url}/api/v1/modules/owners`, {
|
|
741
|
+
headers: { Authorization: 'Bearer opaque-admin' },
|
|
742
|
+
});
|
|
743
|
+
expect(ok.status).toBe(200);
|
|
744
|
+
expect((await ok.json() as { owners: unknown[] }).owners).toEqual([]);
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
test('owner reassign requires admin (a plain publisher cannot)', async () => {
|
|
748
|
+
claimsByToken.set('alice-tok', { active: true, sub: 'alice', groups: ['celilo-authors'] });
|
|
749
|
+
const res = await fetch(`${url}/api/v1/modules/owners/homebridge`, {
|
|
750
|
+
method: 'POST',
|
|
751
|
+
headers: { Authorization: 'Bearer alice-tok', 'Content-Type': 'application/json' },
|
|
752
|
+
body: JSON.stringify({ ownerSub: 'alice' }),
|
|
753
|
+
});
|
|
754
|
+
expect(res.status).toBe(401);
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
test('owner show for an unclaimed name → 404', async () => {
|
|
758
|
+
const res = await fetch(`${url}/api/v1/modules/owners/never-claimed`, {
|
|
759
|
+
headers: { Authorization: 'Bearer opaque-admin' },
|
|
760
|
+
});
|
|
761
|
+
expect(res.status).toBe(404);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
test('an idp admin token may drive the owner endpoints too', async () => {
|
|
765
|
+
claimsByToken.set('admin-tok', { active: true, sub: 'ops', groups: ['celilo-admins'] });
|
|
766
|
+
const res = await fetch(`${url}/api/v1/modules/owners`, {
|
|
767
|
+
headers: { Authorization: 'Bearer admin-tok' },
|
|
768
|
+
});
|
|
769
|
+
expect(res.status).toBe(200);
|
|
770
|
+
});
|
|
771
|
+
});
|