@antasphere/chassis-server 1.2.0 → 1.4.0

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.
@@ -0,0 +1,151 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { DEFAULT_GRANT_SWEEP_DIALS, HubGrantService } from '../../src/identity/hub-grant.js';
3
+ import type { Logger } from '../../src/logger.js';
4
+
5
+ /**
6
+ * PRDCT-3584. A person back after a coffee used to meet the hub's token
7
+ * endpoint inside their first request. The sweep renews, off any request,
8
+ * the token of every recently seen person whose token is about to expire,
9
+ * through `refresh()` alone, and forgets the people not seen for an hour.
10
+ */
11
+
12
+ const silent = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as unknown as Logger;
13
+
14
+ function service(nowRef: { t: number }) {
15
+ // The constructor only stores its options; nothing here touches the db
16
+ // (the refresh is stubbed below), so the dummies never run.
17
+ const svc = new HubGrantService({
18
+ db: {} as never,
19
+ issuerUrl: 'http://hub.test',
20
+ clientId: 'tool-x',
21
+ clientSecret: 'secret',
22
+ tokenResource: null,
23
+ key: async () => 'k',
24
+ connectionString: 'postgres://unused',
25
+ logger: silent,
26
+ dials: {
27
+ accessSkewMs: 60_000,
28
+ tokenTimeoutMs: 5_000,
29
+ introspectTimeoutMs: 5_000,
30
+ lockWatchdogMs: 12_000
31
+ },
32
+ now: () => nowRef.t
33
+ });
34
+ const refresh = vi.spyOn(svc, 'renew').mockResolvedValue({ kind: 'ok', accessToken: 'renewed' });
35
+ return { svc, refresh };
36
+ }
37
+
38
+ const MIN = 60_000;
39
+
40
+ describe('HubGrantService.sweepExpiring', () => {
41
+ it('renews a recently seen person whose cached token expires within the window', async () => {
42
+ const now = { t: 1_000_000 };
43
+ const { svc, refresh } = service(now);
44
+ svc.prime('alice', 'tok', new Date(now.t + 15 * MIN));
45
+ await svc.accessToken('alice'); // seen
46
+ now.t += 13 * MIN; // 2 min left: inside renewAheadMs (3 min)
47
+ expect(await svc.sweepExpiring()).toBe(1);
48
+ expect(refresh).toHaveBeenCalledTimes(1);
49
+ expect(refresh).toHaveBeenCalledWith('alice', DEFAULT_GRANT_SWEEP_DIALS.renewAheadMs);
50
+ });
51
+
52
+ it('leaves a token that still has time, and a person it never saw', async () => {
53
+ const now = { t: 1_000_000 };
54
+ const { svc, refresh } = service(now);
55
+ svc.prime('alice', 'tok', new Date(now.t + 15 * MIN));
56
+ await svc.accessToken('alice');
57
+ svc.prime('bob', 'tok', new Date(now.t + 1 * MIN)); // primed, never read: not seen
58
+ now.t += 5 * MIN; // alice: 10 min left
59
+ expect(await svc.sweepExpiring()).toBe(0);
60
+ expect(refresh).not.toHaveBeenCalled();
61
+ });
62
+
63
+ it('forgets a person not seen within the active window instead of renewing them', async () => {
64
+ const now = { t: 1_000_000 };
65
+ const { svc, refresh } = service(now);
66
+ svc.prime('alice', 'tok', new Date(now.t + 15 * MIN));
67
+ await svc.accessToken('alice');
68
+ now.t += 61 * MIN; // seen 61 min ago, token long expired
69
+ expect(await svc.sweepExpiring()).toBe(0);
70
+ expect(refresh).not.toHaveBeenCalled();
71
+ // and she is gone: a later pass with a fresh token in cache still skips her
72
+ svc.prime('alice', 'tok2', new Date(now.t + 2 * MIN));
73
+ expect(await svc.sweepExpiring()).toBe(0);
74
+ });
75
+
76
+ it('never throws when a renewal fails, and says so', async () => {
77
+ const now = { t: 1_000_000 };
78
+ const { svc, refresh } = service(now);
79
+ refresh.mockRejectedValueOnce(new Error('boom'));
80
+ svc.prime('alice', 'tok', new Date(now.t + 2 * MIN));
81
+ await svc.accessToken('alice');
82
+ expect(await svc.sweepExpiring()).toBe(1);
83
+ expect(silent.warn).toHaveBeenCalled();
84
+ });
85
+
86
+ it('runs on a timer that skips while draining and can be stopped', async () => {
87
+ vi.useFakeTimers();
88
+ try {
89
+ const now = { t: 1_000_000 };
90
+ const { svc, refresh } = service(now);
91
+ svc.prime('alice', 'tok', new Date(now.t + 2 * MIN));
92
+ await svc.accessToken('alice');
93
+ let draining = true;
94
+ const sweep = svc.startSweep({ isDraining: () => draining, dials: { intervalMs: 1_000 } });
95
+ // the timer never holds the process open (verifier round 5, gap d)
96
+ expect(sweep.timer.hasRef()).toBe(false);
97
+ await vi.advanceTimersByTimeAsync(1_500);
98
+ expect(refresh).not.toHaveBeenCalled();
99
+ draining = false;
100
+ await vi.advanceTimersByTimeAsync(1_000);
101
+ expect(refresh).toHaveBeenCalledTimes(1);
102
+ sweep.stop();
103
+ svc.prime('alice', 'tok', new Date(now.t + 2 * MIN));
104
+ await vi.advanceTimersByTimeAsync(5_000);
105
+ expect(refresh).toHaveBeenCalledTimes(1);
106
+ } finally {
107
+ vi.useRealTimers();
108
+ }
109
+ });
110
+
111
+ it('skips a person whose acquisition is already in flight (verifier round 5, gap a)', async () => {
112
+ const now = { t: 1_000_000 };
113
+ const { svc, refresh } = service(now);
114
+ svc.prime('alice', 'tok', new Date(now.t + 2 * MIN));
115
+ await svc.accessToken('alice');
116
+ // a request's acquisition is running: the sweep must not stack a rotation behind it
117
+ (svc as unknown as { inFlight: Map<string, Promise<unknown>> }).inFlight.set(
118
+ 'alice',
119
+ new Promise(() => {})
120
+ );
121
+ expect(await svc.sweepExpiring()).toBe(0);
122
+ expect(refresh).not.toHaveBeenCalled();
123
+ });
124
+
125
+ it('never runs two passes at once: a slow renewal holds the next tick (verifier round 5, gap e)', async () => {
126
+ vi.useFakeTimers();
127
+ try {
128
+ const now = { t: 1_000_000 };
129
+ const { svc, refresh } = service(now);
130
+ refresh.mockImplementation(
131
+ () => new Promise((resolve) => setTimeout(() => resolve({ kind: 'ok', accessToken: 'r' }), 2_500))
132
+ );
133
+ svc.prime('alice', 'tok', new Date(now.t + 2 * MIN));
134
+ await svc.accessToken('alice');
135
+ const sweep = svc.startSweep({ dials: { intervalMs: 1_000 } });
136
+ await vi.advanceTimersByTimeAsync(3_200); // ticks at 1 s, 2 s, 3 s; the renewal from the first still runs
137
+ expect(refresh).toHaveBeenCalledTimes(1);
138
+ sweep.stop();
139
+ } finally {
140
+ vi.useRealTimers();
141
+ }
142
+ });
143
+
144
+ it('keeps the default dials readable', () => {
145
+ expect(DEFAULT_GRANT_SWEEP_DIALS).toEqual({
146
+ intervalMs: 60_000,
147
+ renewAheadMs: 180_000,
148
+ activeWithinMs: 3_600_000
149
+ });
150
+ });
151
+ });
@@ -0,0 +1,227 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
2
+ import { Hono } from 'hono';
3
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { brotliCompressSync, gzipSync } from 'node:zlib';
7
+ import {
8
+ IMMUTABLE_CACHE_CONTROL,
9
+ REVALIDATE_CACHE_CONTROL,
10
+ acceptedEncodings,
11
+ loadSpaFallback,
12
+ staticAssets,
13
+ staticCacheControl
14
+ } from '@antasphere/chassis-server/middleware';
15
+
16
+ /**
17
+ * PRDCT-3584. The dashboard build went out with no cache policy and no
18
+ * compression, so every visit re-downloaded it. Three properties:
19
+ *
20
+ * 1. a content-hashed asset (`/_app/immutable/*`) is cached for a year and
21
+ * marked immutable; nothing else ever is;
22
+ * 2. a precompressed sibling is served when the request accepts its
23
+ * encoding, with `Content-Encoding` and `Vary`, and the plain file when
24
+ * it does not;
25
+ * 3. a path that is not a file falls through to the SPA fallback, which is
26
+ * revalidated on every use.
27
+ */
28
+
29
+ const CHUNK = 'export const answer = 42; '.repeat(40);
30
+ let publicDir: string;
31
+ let app: Hono;
32
+
33
+ beforeAll(() => {
34
+ publicDir = mkdtempSync(join(tmpdir(), 'chassis-static-'));
35
+ mkdirSync(join(publicDir, '_app', 'immutable', 'chunks'), { recursive: true });
36
+ const chunk = join(publicDir, '_app', 'immutable', 'chunks', 'abc123.js');
37
+ writeFileSync(chunk, CHUNK);
38
+ writeFileSync(`${chunk}.br`, brotliCompressSync(Buffer.from(CHUNK)));
39
+ writeFileSync(`${chunk}.gz`, gzipSync(Buffer.from(CHUNK)));
40
+ // a hashed asset with no compressed sibling
41
+ writeFileSync(join(publicDir, '_app', 'immutable', 'chunks', 'plain.js'), CHUNK);
42
+ writeFileSync(join(publicDir, 'index.html'), '<!doctype html><title>t</title>');
43
+ writeFileSync(join(publicDir, 'favicon.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
44
+
45
+ app = new Hono();
46
+ app.use('*', staticAssets({ publicDir }));
47
+ app.get('*', (c) => {
48
+ c.header('Cache-Control', REVALIDATE_CACHE_CONTROL);
49
+ return c.html('<!doctype html><title>fallback</title>');
50
+ });
51
+ });
52
+
53
+ afterAll(() => {
54
+ rmSync(publicDir, { recursive: true, force: true });
55
+ });
56
+
57
+ describe('staticCacheControl', () => {
58
+ it('marks only the content-hashed prefix immutable', () => {
59
+ expect(staticCacheControl('/_app/immutable/chunks/abc123.js')).toBe(IMMUTABLE_CACHE_CONTROL);
60
+ expect(staticCacheControl('/_app/immutable/entry/start.js')).toBe(IMMUTABLE_CACHE_CONTROL);
61
+ expect(staticCacheControl('/_app/version.json')).toBe(REVALIDATE_CACHE_CONTROL);
62
+ expect(staticCacheControl('/index.html')).toBe(REVALIDATE_CACHE_CONTROL);
63
+ expect(staticCacheControl('/favicon.png')).toBe(REVALIDATE_CACHE_CONTROL);
64
+ expect(staticCacheControl('/')).toBe(REVALIDATE_CACHE_CONTROL);
65
+ });
66
+ });
67
+
68
+ describe('staticAssets', () => {
69
+ it('serves a hashed asset immutable for a year', async () => {
70
+ const res = await app.request('/_app/immutable/chunks/plain.js');
71
+ expect(res.status).toBe(200);
72
+ expect(res.headers.get('cache-control')).toBe(IMMUTABLE_CACHE_CONTROL);
73
+ expect(res.headers.get('content-encoding')).toBeNull();
74
+ expect(res.headers.get('vary')).toBe('Accept-Encoding');
75
+ expect(await res.text()).toBe(CHUNK);
76
+ });
77
+
78
+ it('serves the brotli sibling when the request accepts br', async () => {
79
+ const res = await app.request('/_app/immutable/chunks/abc123.js', {
80
+ headers: { 'accept-encoding': 'gzip, deflate, br, zstd' }
81
+ });
82
+ expect(res.status).toBe(200);
83
+ expect(res.headers.get('content-encoding')).toBe('br');
84
+ expect(res.headers.get('vary')).toContain('Accept-Encoding');
85
+ expect(res.headers.get('cache-control')).toBe(IMMUTABLE_CACHE_CONTROL);
86
+ expect(res.headers.get('content-type')).toContain('javascript');
87
+ const body = Buffer.from(await res.arrayBuffer());
88
+ expect(body.equals(brotliCompressSync(Buffer.from(CHUNK)))).toBe(true);
89
+ });
90
+
91
+ it('serves the gzip sibling when only gzip is accepted', async () => {
92
+ const res = await app.request('/_app/immutable/chunks/abc123.js', {
93
+ headers: { 'accept-encoding': 'gzip' }
94
+ });
95
+ expect(res.headers.get('content-encoding')).toBe('gzip');
96
+ const body = Buffer.from(await res.arrayBuffer());
97
+ expect(body.equals(gzipSync(Buffer.from(CHUNK)))).toBe(true);
98
+ });
99
+
100
+ it('serves the plain file when no encoding is accepted', async () => {
101
+ const res = await app.request('/_app/immutable/chunks/abc123.js');
102
+ expect(res.headers.get('content-encoding')).toBeNull();
103
+ expect(await res.text()).toBe(CHUNK);
104
+ });
105
+
106
+ it('revalidates every static file outside the hashed prefix', async () => {
107
+ const icon = await app.request('/favicon.png');
108
+ expect(icon.status).toBe(200);
109
+ expect(icon.headers.get('cache-control')).toBe(REVALIDATE_CACHE_CONTROL);
110
+ });
111
+
112
+ it('leaves the application HTML itself to the fallback', async () => {
113
+ for (const path of ['/', '/index.html']) {
114
+ const res = await app.request(path);
115
+ expect(res.status, path).toBe(200);
116
+ expect(res.headers.get('cache-control'), path).toBe(REVALIDATE_CACHE_CONTROL);
117
+ expect(await res.text(), path).toContain('fallback');
118
+ }
119
+ });
120
+
121
+ it('falls through to the SPA fallback on an application route, revalidated', async () => {
122
+ const res = await app.request('/login');
123
+ expect(res.status).toBe(200);
124
+ expect(res.headers.get('cache-control')).toBe(REVALIDATE_CACHE_CONTROL);
125
+ expect(await res.text()).toContain('fallback');
126
+ });
127
+
128
+ it('never marks a non-hashed response immutable', async () => {
129
+ for (const path of ['/', '/index.html', '/favicon.png', '/login', '/_app/version.json']) {
130
+ const res = await app.request(path);
131
+ expect(res.headers.get('cache-control'), path).not.toContain('immutable');
132
+ }
133
+ });
134
+ });
135
+
136
+ describe('the SPA fallback', () => {
137
+ const html = '<!doctype html><title>app</title><script>1</script>';
138
+ let dir: string;
139
+ let fallbackApp: Hono;
140
+ let etag: string;
141
+
142
+ beforeAll(async () => {
143
+ dir = mkdtempSync(join(tmpdir(), 'chassis-fallback-'));
144
+ writeFileSync(join(dir, 'index.html'), html);
145
+ writeFileSync(join(dir, 'index.html.br'), brotliCompressSync(Buffer.from(html)));
146
+ // no .gz sibling on purpose: a gzip-only client gets the plain HTML
147
+ const fallback = await loadSpaFallback(dir, html);
148
+ etag = fallback.etag;
149
+ fallbackApp = new Hono();
150
+ fallbackApp.use('*', staticAssets({ publicDir: dir }));
151
+ fallbackApp.get('*', (c) => fallback.respond(c));
152
+ });
153
+
154
+ afterAll(() => {
155
+ rmSync(dir, { recursive: true, force: true });
156
+ });
157
+
158
+ it('answers a deep link with the plain HTML, a strong ETag and no-cache', async () => {
159
+ const res = await fallbackApp.request('/settings/members');
160
+ expect(res.status).toBe(200);
161
+ expect(res.headers.get('content-type')).toContain('text/html');
162
+ expect(res.headers.get('cache-control')).toBe(REVALIDATE_CACHE_CONTROL);
163
+ expect(res.headers.get('etag')).toBe(etag);
164
+ expect(res.headers.get('content-encoding')).toBeNull();
165
+ expect(res.headers.get('vary')).toBe('Accept-Encoding');
166
+ expect(await res.text()).toBe(html);
167
+ });
168
+
169
+ it('answers 304 to a matching If-None-Match, with no body and the HTML type', async () => {
170
+ const res = await fallbackApp.request('/settings/members', { headers: { 'if-none-match': etag } });
171
+ expect(res.status).toBe(304);
172
+ expect(await res.text()).toBe('');
173
+ expect(res.headers.get('etag')).toBe(etag);
174
+ expect(res.headers.get('cache-control')).toBe(REVALIDATE_CACHE_CONTROL);
175
+ expect(res.headers.get('content-type')).toContain('text/html');
176
+ });
177
+
178
+ it('answers the root and /index.html with the validator too', async () => {
179
+ for (const path of ['/', '/index.html']) {
180
+ const res = await fallbackApp.request(path);
181
+ expect(res.status, path).toBe(200);
182
+ expect(res.headers.get('etag'), path).toBe(etag);
183
+ const again = await fallbackApp.request(path, { headers: { 'if-none-match': etag } });
184
+ expect(again.status, path).toBe(304);
185
+ }
186
+ });
187
+
188
+ it('serves the brotli sibling to a client that accepts br, under its own validator', async () => {
189
+ const res = await fallbackApp.request('/login', {
190
+ headers: { 'accept-encoding': 'gzip, deflate, br;q=1.0' }
191
+ });
192
+ expect(res.status).toBe(200);
193
+ expect(res.headers.get('content-encoding')).toBe('br');
194
+ const variant = res.headers.get('etag')!;
195
+ expect(variant).not.toBe(etag);
196
+ expect(variant.endsWith('-br"')).toBe(true);
197
+ const body = Buffer.from(await res.arrayBuffer());
198
+ expect(body.equals(brotliCompressSync(Buffer.from(html)))).toBe(true);
199
+ const again = await fallbackApp.request('/login', {
200
+ headers: { 'accept-encoding': 'br', 'if-none-match': variant }
201
+ });
202
+ expect(again.status).toBe(304);
203
+ // the plain validator never confirms the compressed body
204
+ const cross = await fallbackApp.request('/login', {
205
+ headers: { 'accept-encoding': 'br', 'if-none-match': etag }
206
+ });
207
+ expect(cross.status).toBe(200);
208
+ });
209
+
210
+ it('falls back to the plain HTML when the accepted encoding has no sibling', async () => {
211
+ const res = await fallbackApp.request('/login', { headers: { 'accept-encoding': 'gzip' } });
212
+ expect(res.status).toBe(200);
213
+ expect(res.headers.get('content-encoding')).toBeNull();
214
+ expect(await res.text()).toBe(html);
215
+ });
216
+
217
+ it('reads Accept-Encoding tokens with their weights stripped', () => {
218
+ expect([...acceptedEncodings('gzip, deflate, br;q=1.0, zstd')]).toEqual([
219
+ 'gzip',
220
+ 'deflate',
221
+ 'br',
222
+ 'zstd'
223
+ ]);
224
+ expect([...acceptedEncodings(undefined)]).toEqual([]);
225
+ expect(acceptedEncodings(' BR ').has('br')).toBe(true);
226
+ });
227
+ });