@celilo/e2e 0.20.0 → 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.
- package/bin/e2e-bake-management +46 -5
- package/package.json +4 -3
- package/src/block-timing.ts +24 -3
- package/src/cli/build.test.ts +142 -3
- package/src/cli/build.ts +49 -7
- package/src/container-manager.ts +47 -1
- package/src/docker-compose-generator.ts +33 -3
- package/src/extract-failure.ts +36 -14
- package/src/live-stack.test.ts +171 -4
- package/src/live-stack.ts +285 -17
- package/src/module-host.test.ts +55 -1
- package/src/module-host.ts +31 -0
- package/src/netapp-staleness.test.ts +139 -0
- package/src/netapp-staleness.ts +62 -0
- package/src/network-builder.ts +19 -3
- package/src/registry-bundle.ts +14 -2
- package/src/runner-keep.test.ts +72 -0
- package/src/runner.ts +19 -2
- package/src/shared-infra.ts +7 -2
- package/src/source-fingerprint.ts +11 -0
- package/src/types.ts +21 -7
- package/registry-server/src/auth.test.ts +0 -76
- package/registry-server/src/bootstrap-packaging.test.ts +0 -71
- package/registry-server/src/introspection.test.ts +0 -243
- package/registry-server/src/module-owner-store.test.ts +0 -85
- package/registry-server/src/rate-limit.test.ts +0 -62
- package/registry-server/src/scoped-token-store.test.ts +0 -93
- package/registry-server/src/server.test.ts +0 -991
- package/registry-server/src/storage.test.ts +0 -152
- package/registry-server/src/sweep.test.ts +0 -326
- package/registry-server/src/validation.test.ts +0 -86
|
@@ -1,243 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
-
import { ADMIN_SCOPE } from './auth';
|
|
3
|
-
import { IntrospectionVerifier, claimToScope, introspectionConfigFromEnv } from './introspection';
|
|
4
|
-
|
|
5
|
-
const CONFIG = {
|
|
6
|
-
endpoint: 'https://idp.test/application/o/introspect/',
|
|
7
|
-
clientId: 'celilo-registry',
|
|
8
|
-
clientSecret: 'super-secret',
|
|
9
|
-
adminGroup: 'celilo-admins',
|
|
10
|
-
publisherGroup: 'celilo-authors',
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
/** Replace global fetch with a stub returning `body` at `status`; restore after. */
|
|
14
|
-
function stubFetch(handler: (url: string, init: RequestInit) => Response): void {
|
|
15
|
-
// @ts-expect-error - overriding the global for the duration of a test
|
|
16
|
-
globalThis.fetch = async (url: string | URL | Request, init?: RequestInit) =>
|
|
17
|
-
handler(String(url), init ?? {});
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const realFetch = globalThis.fetch;
|
|
21
|
-
afterEach(() => {
|
|
22
|
-
globalThis.fetch = realFetch;
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
function introspectResponse(payload: unknown, status = 200): Response {
|
|
26
|
-
return new Response(JSON.stringify(payload), {
|
|
27
|
-
status,
|
|
28
|
-
headers: { 'Content-Type': 'application/json' },
|
|
29
|
-
});
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
describe('claimToScope', () => {
|
|
33
|
-
test('admin-group member → ADMIN_SCOPE', () => {
|
|
34
|
-
expect(claimToScope({ active: true, groups: ['celilo-admins'] }, 'celilo-admins')).toBe(
|
|
35
|
-
ADMIN_SCOPE,
|
|
36
|
-
);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test('non-admin identity → null (owner table is ce-1ch)', () => {
|
|
40
|
-
expect(claimToScope({ active: true, groups: ['users'] }, 'celilo-admins')).toBeNull();
|
|
41
|
-
expect(claimToScope({ active: true }, 'celilo-admins')).toBeNull();
|
|
42
|
-
});
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
describe('introspectionConfigFromEnv', () => {
|
|
46
|
-
const KEYS = [
|
|
47
|
-
'OIDC_INTROSPECTION_ENDPOINT',
|
|
48
|
-
'OIDC_CLIENT_ID',
|
|
49
|
-
'OIDC_CLIENT_SECRET',
|
|
50
|
-
'REGISTRY_ADMIN_GROUP',
|
|
51
|
-
'REGISTRY_PUBLISHER_GROUP',
|
|
52
|
-
];
|
|
53
|
-
afterEach(() => {
|
|
54
|
-
for (const k of KEYS) delete process.env[k];
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
test('null when any required var is missing', () => {
|
|
58
|
-
expect(introspectionConfigFromEnv()).toBeNull();
|
|
59
|
-
process.env.OIDC_INTROSPECTION_ENDPOINT = 'https://idp.test/introspect/';
|
|
60
|
-
process.env.OIDC_CLIENT_ID = 'cid';
|
|
61
|
-
// secret still missing
|
|
62
|
-
expect(introspectionConfigFromEnv()).toBeNull();
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
test('config when all required vars present; admin group defaults', () => {
|
|
66
|
-
process.env.OIDC_INTROSPECTION_ENDPOINT = 'https://idp.test/introspect/';
|
|
67
|
-
process.env.OIDC_CLIENT_ID = 'cid';
|
|
68
|
-
process.env.OIDC_CLIENT_SECRET = 'sec';
|
|
69
|
-
expect(introspectionConfigFromEnv()).toEqual({
|
|
70
|
-
endpoint: 'https://idp.test/introspect/',
|
|
71
|
-
clientId: 'cid',
|
|
72
|
-
clientSecret: 'sec',
|
|
73
|
-
adminGroup: 'celilo-admins',
|
|
74
|
-
publisherGroup: 'celilo-authors',
|
|
75
|
-
});
|
|
76
|
-
process.env.REGISTRY_ADMIN_GROUP = 'ops';
|
|
77
|
-
expect(introspectionConfigFromEnv()?.adminGroup).toBe('ops');
|
|
78
|
-
process.env.REGISTRY_PUBLISHER_GROUP = 'authors';
|
|
79
|
-
expect(introspectionConfigFromEnv()?.publisherGroup).toBe('authors');
|
|
80
|
-
});
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
describe('IntrospectionVerifier.scopeOf', () => {
|
|
84
|
-
const verifier = new IntrospectionVerifier(CONFIG);
|
|
85
|
-
|
|
86
|
-
test('active admin-group token → ADMIN_SCOPE', async () => {
|
|
87
|
-
stubFetch(() => introspectResponse({ active: true, sub: 'alice', groups: ['celilo-admins'] }));
|
|
88
|
-
expect(await verifier.scopeOf('Bearer idp-token')).toBe(ADMIN_SCOPE);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
test('active non-admin token → null', async () => {
|
|
92
|
-
stubFetch(() => introspectResponse({ active: true, sub: 'bob', groups: ['users'] }));
|
|
93
|
-
expect(await verifier.scopeOf('idp-token')).toBeNull();
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
test('active:false → null (revoked at idp)', async () => {
|
|
97
|
-
stubFetch(() => introspectResponse({ active: false }));
|
|
98
|
-
expect(await verifier.scopeOf('idp-token')).toBeNull();
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
test('expired exp → null even if active', async () => {
|
|
102
|
-
const pastSec = Math.floor(Date.now() / 1000) - 60;
|
|
103
|
-
stubFetch(() => introspectResponse({ active: true, groups: ['celilo-admins'], exp: pastSec }));
|
|
104
|
-
expect(await verifier.scopeOf('idp-token')).toBeNull();
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
test('future exp → honored', async () => {
|
|
108
|
-
const futureSec = Math.floor(Date.now() / 1000) + 3600;
|
|
109
|
-
stubFetch(() =>
|
|
110
|
-
introspectResponse({ active: true, groups: ['celilo-admins'], exp: futureSec }),
|
|
111
|
-
);
|
|
112
|
-
expect(await verifier.scopeOf('idp-token')).toBe(ADMIN_SCOPE);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
test('non-2xx response → null (fail closed)', async () => {
|
|
116
|
-
stubFetch(() => introspectResponse({ error: 'boom' }, 500));
|
|
117
|
-
expect(await verifier.scopeOf('idp-token')).toBeNull();
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
test('fetch throws → null (fail closed)', async () => {
|
|
121
|
-
stubFetch(() => {
|
|
122
|
-
throw new Error('connection refused');
|
|
123
|
-
});
|
|
124
|
-
expect(await verifier.scopeOf('idp-token')).toBeNull();
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
test('malformed body (no active field) → null', async () => {
|
|
128
|
-
stubFetch(() => introspectResponse({ sub: 'alice' }));
|
|
129
|
-
expect(await verifier.scopeOf('idp-token')).toBeNull();
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
test('empty header → null without calling the idp', async () => {
|
|
133
|
-
let called = false;
|
|
134
|
-
stubFetch(() => {
|
|
135
|
-
called = true;
|
|
136
|
-
return introspectResponse({ active: true, groups: ['celilo-admins'] });
|
|
137
|
-
});
|
|
138
|
-
expect(await verifier.scopeOf('')).toBeNull();
|
|
139
|
-
expect(called).toBe(false);
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
test('authenticates with HTTP Basic client creds; sends the token in the body', async () => {
|
|
143
|
-
let seenAuth = '';
|
|
144
|
-
let seenBody = '';
|
|
145
|
-
stubFetch((_url, init) => {
|
|
146
|
-
seenAuth = new Headers(init.headers).get('Authorization') ?? '';
|
|
147
|
-
seenBody = String(init.body ?? '');
|
|
148
|
-
return introspectResponse({ active: true, groups: ['celilo-admins'] });
|
|
149
|
-
});
|
|
150
|
-
await verifier.scopeOf('Bearer the-token');
|
|
151
|
-
const expected = `Basic ${Buffer.from('celilo-registry:super-secret').toString('base64')}`;
|
|
152
|
-
expect(seenAuth).toBe(expected);
|
|
153
|
-
expect(seenBody).toContain('token=the-token');
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
test('does not log the token or client secret on failure', async () => {
|
|
157
|
-
const logs: string[] = [];
|
|
158
|
-
const realErr = console.error;
|
|
159
|
-
console.error = (...args: unknown[]) => {
|
|
160
|
-
logs.push(args.map(String).join(' '));
|
|
161
|
-
};
|
|
162
|
-
try {
|
|
163
|
-
stubFetch(() => {
|
|
164
|
-
throw new Error('boom');
|
|
165
|
-
});
|
|
166
|
-
await verifier.scopeOf('Bearer top-secret-token');
|
|
167
|
-
} finally {
|
|
168
|
-
console.error = realErr;
|
|
169
|
-
}
|
|
170
|
-
const joined = logs.join('\n');
|
|
171
|
-
expect(joined).not.toContain('top-secret-token');
|
|
172
|
-
expect(joined).not.toContain('super-secret');
|
|
173
|
-
});
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
describe('IntrospectionVerifier.identify (ce-1ch)', () => {
|
|
177
|
-
const verifier = new IntrospectionVerifier(CONFIG);
|
|
178
|
-
|
|
179
|
-
test('admin-group member → isAdmin, group=adminGroup', async () => {
|
|
180
|
-
stubFetch(() => introspectResponse({ active: true, sub: 'alice', groups: ['celilo-admins'] }));
|
|
181
|
-
expect(await verifier.identify('Bearer t')).toEqual({
|
|
182
|
-
sub: 'alice',
|
|
183
|
-
isAdmin: true,
|
|
184
|
-
isPublisher: false,
|
|
185
|
-
group: 'celilo-admins',
|
|
186
|
-
});
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
test('publisher-group member → isPublisher, group=publisherGroup', async () => {
|
|
190
|
-
stubFetch(() => introspectResponse({ active: true, sub: 'bob', groups: ['celilo-authors'] }));
|
|
191
|
-
expect(await verifier.identify('Bearer t')).toEqual({
|
|
192
|
-
sub: 'bob',
|
|
193
|
-
isAdmin: false,
|
|
194
|
-
isPublisher: true,
|
|
195
|
-
group: 'celilo-authors',
|
|
196
|
-
});
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
test('member of neither publish group → verified but no group', async () => {
|
|
200
|
-
stubFetch(() => introspectResponse({ active: true, sub: 'carol', groups: ['users'] }));
|
|
201
|
-
expect(await verifier.identify('Bearer t')).toEqual({
|
|
202
|
-
sub: 'carol',
|
|
203
|
-
isAdmin: false,
|
|
204
|
-
isPublisher: false,
|
|
205
|
-
group: undefined,
|
|
206
|
-
});
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
test('admin with no sub → identified (admins publish without an ownership anchor)', async () => {
|
|
210
|
-
stubFetch(() => introspectResponse({ active: true, groups: ['celilo-admins'] }));
|
|
211
|
-
expect(await verifier.identify('Bearer t')).toEqual({
|
|
212
|
-
sub: undefined,
|
|
213
|
-
isAdmin: true,
|
|
214
|
-
isPublisher: false,
|
|
215
|
-
group: 'celilo-admins',
|
|
216
|
-
});
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
test('active:false → null (fail closed)', async () => {
|
|
220
|
-
stubFetch(() => introspectResponse({ active: false }));
|
|
221
|
-
expect(await verifier.identify('Bearer t')).toBeNull();
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
test('expired exp → null even if active', async () => {
|
|
225
|
-
const pastSec = Math.floor(Date.now() / 1000) - 60;
|
|
226
|
-
stubFetch(() =>
|
|
227
|
-
introspectResponse({ active: true, sub: 'alice', groups: ['celilo-admins'], exp: pastSec }),
|
|
228
|
-
);
|
|
229
|
-
expect(await verifier.identify('Bearer t')).toBeNull();
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
test('admin group is configurable, not a literal', async () => {
|
|
233
|
-
const custom = new IntrospectionVerifier({
|
|
234
|
-
...CONFIG,
|
|
235
|
-
adminGroup: 'ops',
|
|
236
|
-
publisherGroup: 'devs',
|
|
237
|
-
});
|
|
238
|
-
stubFetch(() => introspectResponse({ active: true, sub: 'dan', groups: ['ops'] }));
|
|
239
|
-
const id = await custom.identify('Bearer t');
|
|
240
|
-
expect(id?.isAdmin).toBe(true);
|
|
241
|
-
expect(id?.group).toBe('ops');
|
|
242
|
-
});
|
|
243
|
-
});
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import {
|
|
3
|
-
type ModuleOwnerEntry,
|
|
4
|
-
type ModuleOwnerPersistence,
|
|
5
|
-
ModuleOwnerStore,
|
|
6
|
-
} from './module-owner-store';
|
|
7
|
-
|
|
8
|
-
/** In-memory persistence seam so the store is testable without the filesystem. */
|
|
9
|
-
function memPersistence(initial: ModuleOwnerEntry[] = []): ModuleOwnerPersistence & {
|
|
10
|
-
saved: ModuleOwnerEntry[];
|
|
11
|
-
} {
|
|
12
|
-
const state = { saved: [...initial] };
|
|
13
|
-
return {
|
|
14
|
-
saved: state.saved,
|
|
15
|
-
load: () => [...state.saved],
|
|
16
|
-
save(entries) {
|
|
17
|
-
state.saved.length = 0;
|
|
18
|
-
state.saved.push(...entries);
|
|
19
|
-
},
|
|
20
|
-
};
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const fixedNow = () => '2026-01-01T00:00:00.000Z';
|
|
24
|
-
|
|
25
|
-
describe('ModuleOwnerStore', () => {
|
|
26
|
-
test('claim records ownership of an unclaimed name', () => {
|
|
27
|
-
const p = memPersistence();
|
|
28
|
-
const store = new ModuleOwnerStore(p, fixedNow);
|
|
29
|
-
const entry = store.claim('homebridge', 'alice', 'celilo-authors');
|
|
30
|
-
expect(entry).toEqual({
|
|
31
|
-
moduleName: 'homebridge',
|
|
32
|
-
ownerSub: 'alice',
|
|
33
|
-
claimedAt: fixedNow(),
|
|
34
|
-
sourceGroup: 'celilo-authors',
|
|
35
|
-
});
|
|
36
|
-
expect(store.get('homebridge')?.ownerSub).toBe('alice');
|
|
37
|
-
expect(p.saved).toHaveLength(1);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
test('claim never steals an already-owned name (returns existing)', () => {
|
|
41
|
-
const store = new ModuleOwnerStore(memPersistence(), fixedNow);
|
|
42
|
-
store.claim('homebridge', 'alice', 'celilo-authors');
|
|
43
|
-
const second = store.claim('homebridge', 'bob', 'celilo-authors');
|
|
44
|
-
expect(second.ownerSub).toBe('alice');
|
|
45
|
-
expect(store.get('homebridge')?.ownerSub).toBe('alice');
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
test('reassign overwrites an existing owner (admin path)', () => {
|
|
49
|
-
const store = new ModuleOwnerStore(memPersistence(), fixedNow);
|
|
50
|
-
store.claim('homebridge', 'alice', 'celilo-authors');
|
|
51
|
-
const reassigned = store.reassign('homebridge', 'bob', 'admin-reassign');
|
|
52
|
-
expect(reassigned.ownerSub).toBe('bob');
|
|
53
|
-
expect(store.get('homebridge')?.ownerSub).toBe('bob');
|
|
54
|
-
// No duplicate rows for the same name.
|
|
55
|
-
expect(store.list().filter((e) => e.moduleName === 'homebridge')).toHaveLength(1);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
test('reassign creates a record when the name was unclaimed', () => {
|
|
59
|
-
const store = new ModuleOwnerStore(memPersistence(), fixedNow);
|
|
60
|
-
store.reassign('caddy', 'carol', 'admin-reassign');
|
|
61
|
-
expect(store.get('caddy')?.ownerSub).toBe('carol');
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
test('get returns undefined for an unclaimed name', () => {
|
|
65
|
-
const store = new ModuleOwnerStore(memPersistence(), fixedNow);
|
|
66
|
-
expect(store.get('nope')).toBeUndefined();
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
test('loads persisted entries on construction', () => {
|
|
70
|
-
const p = memPersistence([
|
|
71
|
-
{ moduleName: 'caddy', ownerSub: 'dan', claimedAt: fixedNow(), sourceGroup: 'celilo-admins' },
|
|
72
|
-
]);
|
|
73
|
-
const store = new ModuleOwnerStore(p, fixedNow);
|
|
74
|
-
expect(store.get('caddy')?.ownerSub).toBe('dan');
|
|
75
|
-
expect(store.list()).toHaveLength(1);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
test('list returns a copy (mutating it does not affect the store)', () => {
|
|
79
|
-
const store = new ModuleOwnerStore(memPersistence(), fixedNow);
|
|
80
|
-
store.claim('a', 'x', 'g');
|
|
81
|
-
const list = store.list();
|
|
82
|
-
list.pop();
|
|
83
|
-
expect(store.list()).toHaveLength(1);
|
|
84
|
-
});
|
|
85
|
-
});
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import { clientIp, createRateLimiter } from './rate-limit';
|
|
3
|
-
|
|
4
|
-
describe('createRateLimiter', () => {
|
|
5
|
-
test('allows up to max, then rejects', () => {
|
|
6
|
-
const limiter = createRateLimiter({ max: 3, windowMs: 60_000, now: () => 0 });
|
|
7
|
-
expect(limiter.take('1.1.1.1').ok).toBe(true);
|
|
8
|
-
expect(limiter.take('1.1.1.1').ok).toBe(true);
|
|
9
|
-
expect(limiter.take('1.1.1.1').ok).toBe(true);
|
|
10
|
-
const over = limiter.take('1.1.1.1');
|
|
11
|
-
expect(over.ok).toBe(false);
|
|
12
|
-
if (!over.ok) expect(over.retryAfterSec).toBeGreaterThan(0);
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
test('distinct IPs are tracked separately', () => {
|
|
16
|
-
const limiter = createRateLimiter({ max: 1, windowMs: 60_000, now: () => 0 });
|
|
17
|
-
expect(limiter.take('1.1.1.1').ok).toBe(true);
|
|
18
|
-
expect(limiter.take('2.2.2.2').ok).toBe(true); // different IP, fresh allowance
|
|
19
|
-
expect(limiter.take('1.1.1.1').ok).toBe(false);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
test('bucket resets after window elapses', () => {
|
|
23
|
-
let t = 0;
|
|
24
|
-
const limiter = createRateLimiter({ max: 1, windowMs: 1000, now: () => t });
|
|
25
|
-
expect(limiter.take('1.1.1.1').ok).toBe(true);
|
|
26
|
-
expect(limiter.take('1.1.1.1').ok).toBe(false);
|
|
27
|
-
t = 1001;
|
|
28
|
-
expect(limiter.take('1.1.1.1').ok).toBe(true);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
test('retryAfterSec reflects remaining window', () => {
|
|
32
|
-
const limiter = createRateLimiter({ max: 1, windowMs: 60_000, now: () => 0 });
|
|
33
|
-
limiter.take('1.1.1.1');
|
|
34
|
-
const r = limiter.take('1.1.1.1');
|
|
35
|
-
expect(r.ok).toBe(false);
|
|
36
|
-
if (!r.ok) {
|
|
37
|
-
expect(r.retryAfterSec).toBeLessThanOrEqual(60);
|
|
38
|
-
expect(r.retryAfterSec).toBeGreaterThan(0);
|
|
39
|
-
}
|
|
40
|
-
});
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
describe('clientIp', () => {
|
|
44
|
-
function req(headers: Record<string, string>): Request {
|
|
45
|
-
return new Request('http://x/', { headers });
|
|
46
|
-
}
|
|
47
|
-
const stubServer = {
|
|
48
|
-
requestIP: () => ({ address: '10.0.0.1' }),
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
test('uses X-Forwarded-For first entry when present', () => {
|
|
52
|
-
expect(clientIp(req({ 'x-forwarded-for': '1.1.1.1, 2.2.2.2' }), stubServer)).toBe('1.1.1.1');
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
test('falls back to Bun.Server.requestIP when XFF absent', () => {
|
|
56
|
-
expect(clientIp(req({}), stubServer)).toBe('10.0.0.1');
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test('returns "unknown" when neither available', () => {
|
|
60
|
-
expect(clientIp(req({}), { requestIP: () => null })).toBe('unknown');
|
|
61
|
-
});
|
|
62
|
-
});
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import { hashToken } from './auth';
|
|
3
|
-
import {
|
|
4
|
-
type ScopedTokenEntry,
|
|
5
|
-
type ScopedTokenPersistence,
|
|
6
|
-
ScopedTokenStore,
|
|
7
|
-
} from './scoped-token-store';
|
|
8
|
-
|
|
9
|
-
/** In-memory persistence so the store is testable without the filesystem. */
|
|
10
|
-
function memoryPersistence(seed: ScopedTokenEntry[] = []): ScopedTokenPersistence & {
|
|
11
|
-
saved: ScopedTokenEntry[];
|
|
12
|
-
} {
|
|
13
|
-
const state = { saved: [...seed] };
|
|
14
|
-
return {
|
|
15
|
-
saved: state.saved,
|
|
16
|
-
load() {
|
|
17
|
-
return [...state.saved];
|
|
18
|
-
},
|
|
19
|
-
save(entries) {
|
|
20
|
-
state.saved.length = 0;
|
|
21
|
-
state.saved.push(...entries);
|
|
22
|
-
},
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** Deterministic token generator (avoids randomness in assertions). */
|
|
27
|
-
function seqGen() {
|
|
28
|
-
let n = 0;
|
|
29
|
-
return () => `tok-${++n}`;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
describe('ScopedTokenStore.mint (ISS-0140)', () => {
|
|
33
|
-
test('mints a scoped token and persists only the hash', () => {
|
|
34
|
-
const p = memoryPersistence();
|
|
35
|
-
const store = new ScopedTokenStore(p, seqGen(), () => '2026-06-20T00:00:00Z');
|
|
36
|
-
const { token, entry, revokedHashes } = store.mint('celilo/lunacycle', 'lunacycle');
|
|
37
|
-
|
|
38
|
-
expect(token).toBe('tok-1');
|
|
39
|
-
expect(entry.scope).toBe('lunacycle');
|
|
40
|
-
expect(entry.repo).toBe('celilo/lunacycle');
|
|
41
|
-
expect(entry.hash).toBe(hashToken('tok-1'));
|
|
42
|
-
expect(revokedHashes).toEqual([]);
|
|
43
|
-
// Persisted form holds the hash, never the cleartext token.
|
|
44
|
-
expect(JSON.stringify(p.saved)).not.toContain('tok-1');
|
|
45
|
-
expect(p.saved).toHaveLength(1);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
test('re-minting for the same repo rotates the token (reconcile)', () => {
|
|
49
|
-
const p = memoryPersistence();
|
|
50
|
-
const store = new ScopedTokenStore(p, seqGen(), () => 't');
|
|
51
|
-
const first = store.mint('celilo/lunacycle', 'lunacycle');
|
|
52
|
-
const second = store.mint('celilo/lunacycle', 'lunacycle');
|
|
53
|
-
|
|
54
|
-
expect(second.token).toBe('tok-2');
|
|
55
|
-
expect(second.revokedHashes).toEqual([first.entry.hash]);
|
|
56
|
-
// Still exactly one active token for the repo.
|
|
57
|
-
expect(store.list().filter((e) => e.repo === 'celilo/lunacycle')).toHaveLength(1);
|
|
58
|
-
expect(store.list()).toHaveLength(1);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
test('different repos coexist independently', () => {
|
|
62
|
-
const store = new ScopedTokenStore(memoryPersistence(), seqGen(), () => 't');
|
|
63
|
-
store.mint('celilo/lunacycle', 'lunacycle');
|
|
64
|
-
store.mint('celilo/caddy', 'caddy');
|
|
65
|
-
expect(store.list()).toHaveLength(2);
|
|
66
|
-
});
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
describe('ScopedTokenStore.revoke', () => {
|
|
70
|
-
test('removes all tokens for a repo and returns their hashes', () => {
|
|
71
|
-
const store = new ScopedTokenStore(memoryPersistence(), seqGen(), () => 't');
|
|
72
|
-
const { entry } = store.mint('celilo/lunacycle', 'lunacycle');
|
|
73
|
-
const removed = store.revoke('celilo/lunacycle');
|
|
74
|
-
expect(removed).toEqual([entry.hash]);
|
|
75
|
-
expect(store.list()).toHaveLength(0);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
test('revoking an unknown repo is a no-op', () => {
|
|
79
|
-
const store = new ScopedTokenStore(memoryPersistence(), seqGen(), () => 't');
|
|
80
|
-
expect(store.revoke('celilo/nope')).toEqual([]);
|
|
81
|
-
});
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
describe('ScopedTokenStore startup load', () => {
|
|
85
|
-
test('loads persisted entries on construction', () => {
|
|
86
|
-
const seed: ScopedTokenEntry[] = [
|
|
87
|
-
{ hash: hashToken('x'), scope: 'caddy', repo: 'celilo/caddy', mintedAt: 't' },
|
|
88
|
-
];
|
|
89
|
-
const store = new ScopedTokenStore(memoryPersistence(seed), seqGen(), () => 't');
|
|
90
|
-
expect(store.list()).toHaveLength(1);
|
|
91
|
-
expect(store.list()[0]?.repo).toBe('celilo/caddy');
|
|
92
|
-
});
|
|
93
|
-
});
|