@selvajs/server 0.2.0-beta.1
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/LICENSE +21 -0
- package/dist/compute/__tests__/client-cache.test.d.ts +11 -0
- package/dist/compute/__tests__/client-cache.test.d.ts.map +1 -0
- package/dist/compute/__tests__/client-cache.test.js +149 -0
- package/dist/compute/__tests__/client-cache.test.js.map +1 -0
- package/dist/compute/__tests__/rate-limit.test.d.ts +2 -0
- package/dist/compute/__tests__/rate-limit.test.d.ts.map +1 -0
- package/dist/compute/__tests__/rate-limit.test.js +62 -0
- package/dist/compute/__tests__/rate-limit.test.js.map +1 -0
- package/dist/compute/__tests__/remote-definition.test.d.ts +11 -0
- package/dist/compute/__tests__/remote-definition.test.d.ts.map +1 -0
- package/dist/compute/__tests__/remote-definition.test.js +114 -0
- package/dist/compute/__tests__/remote-definition.test.js.map +1 -0
- package/dist/compute/__tests__/safe-url.test.d.ts +2 -0
- package/dist/compute/__tests__/safe-url.test.d.ts.map +1 -0
- package/dist/compute/__tests__/safe-url.test.js +71 -0
- package/dist/compute/__tests__/safe-url.test.js.map +1 -0
- package/dist/compute/__tests__/solve-pipeline.test.d.ts +11 -0
- package/dist/compute/__tests__/solve-pipeline.test.d.ts.map +1 -0
- package/dist/compute/__tests__/solve-pipeline.test.js +245 -0
- package/dist/compute/__tests__/solve-pipeline.test.js.map +1 -0
- package/dist/compute/__tests__/transform-input.test.d.ts +14 -0
- package/dist/compute/__tests__/transform-input.test.d.ts.map +1 -0
- package/dist/compute/__tests__/transform-input.test.js +114 -0
- package/dist/compute/__tests__/transform-input.test.js.map +1 -0
- package/dist/compute/client-cache.d.ts +104 -0
- package/dist/compute/client-cache.d.ts.map +1 -0
- package/dist/compute/client-cache.js +147 -0
- package/dist/compute/client-cache.js.map +1 -0
- package/dist/compute/index.d.ts +8 -0
- package/dist/compute/index.d.ts.map +1 -0
- package/dist/compute/index.js +9 -0
- package/dist/compute/index.js.map +1 -0
- package/dist/compute/limits.d.ts +102 -0
- package/dist/compute/limits.d.ts.map +1 -0
- package/dist/compute/limits.js +76 -0
- package/dist/compute/limits.js.map +1 -0
- package/dist/compute/rate-limit.d.ts +49 -0
- package/dist/compute/rate-limit.d.ts.map +1 -0
- package/dist/compute/rate-limit.js +49 -0
- package/dist/compute/rate-limit.js.map +1 -0
- package/dist/compute/remote-definition.d.ts +53 -0
- package/dist/compute/remote-definition.d.ts.map +1 -0
- package/dist/compute/remote-definition.js +110 -0
- package/dist/compute/remote-definition.js.map +1 -0
- package/dist/compute/safe-url.d.ts +15 -0
- package/dist/compute/safe-url.d.ts.map +1 -0
- package/dist/compute/safe-url.js +195 -0
- package/dist/compute/safe-url.js.map +1 -0
- package/dist/compute/solve-pipeline.d.ts +127 -0
- package/dist/compute/solve-pipeline.d.ts.map +1 -0
- package/dist/compute/solve-pipeline.js +195 -0
- package/dist/compute/solve-pipeline.js.map +1 -0
- package/dist/compute/transform-input.d.ts +20 -0
- package/dist/compute/transform-input.d.ts.map +1 -0
- package/dist/compute/transform-input.js +47 -0
- package/dist/compute/transform-input.js.map +1 -0
- package/dist/definitions/__tests__/load-for-render.test.d.ts +8 -0
- package/dist/definitions/__tests__/load-for-render.test.d.ts.map +1 -0
- package/dist/definitions/__tests__/load-for-render.test.js +196 -0
- package/dist/definitions/__tests__/load-for-render.test.js.map +1 -0
- package/dist/definitions/__tests__/schema-extraction.test.d.ts +11 -0
- package/dist/definitions/__tests__/schema-extraction.test.d.ts.map +1 -0
- package/dist/definitions/__tests__/schema-extraction.test.js +115 -0
- package/dist/definitions/__tests__/schema-extraction.test.js.map +1 -0
- package/dist/definitions/definition-service.d.ts +77 -0
- package/dist/definitions/definition-service.d.ts.map +1 -0
- package/dist/definitions/definition-service.js +184 -0
- package/dist/definitions/definition-service.js.map +1 -0
- package/dist/definitions/index.d.ts +4 -0
- package/dist/definitions/index.d.ts.map +1 -0
- package/dist/definitions/index.js +8 -0
- package/dist/definitions/index.js.map +1 -0
- package/dist/definitions/load-for-render.d.ts +61 -0
- package/dist/definitions/load-for-render.d.ts.map +1 -0
- package/dist/definitions/load-for-render.js +165 -0
- package/dist/definitions/load-for-render.js.map +1 -0
- package/dist/definitions/schema-extraction.d.ts +22 -0
- package/dist/definitions/schema-extraction.d.ts.map +1 -0
- package/dist/definitions/schema-extraction.js +89 -0
- package/dist/definitions/schema-extraction.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/providers/__tests__/create-selva-providers.test.d.ts +7 -0
- package/dist/providers/__tests__/create-selva-providers.test.d.ts.map +1 -0
- package/dist/providers/__tests__/create-selva-providers.test.js +135 -0
- package/dist/providers/__tests__/create-selva-providers.test.js.map +1 -0
- package/dist/providers/create-selva-providers.d.ts +53 -0
- package/dist/providers/create-selva-providers.d.ts.map +1 -0
- package/dist/providers/create-selva-providers.js +152 -0
- package/dist/providers/create-selva-providers.js.map +1 -0
- package/dist/providers/index.d.ts +2 -0
- package/dist/providers/index.d.ts.map +1 -0
- package/dist/providers/index.js +6 -0
- package/dist/providers/index.js.map +1 -0
- package/package.json +60 -0
- package/src/compute/__tests__/client-cache.test.ts +164 -0
- package/src/compute/__tests__/rate-limit.test.ts +69 -0
- package/src/compute/__tests__/remote-definition.test.ts +140 -0
- package/src/compute/__tests__/safe-url.test.ts +72 -0
- package/src/compute/__tests__/solve-pipeline.test.ts +294 -0
- package/src/compute/__tests__/transform-input.test.ts +155 -0
- package/src/compute/client-cache.ts +278 -0
- package/src/compute/index.ts +48 -0
- package/src/compute/limits.ts +147 -0
- package/src/compute/rate-limit.ts +83 -0
- package/src/compute/remote-definition.ts +149 -0
- package/src/compute/safe-url.ts +186 -0
- package/src/compute/solve-pipeline.ts +324 -0
- package/src/compute/transform-input.ts +52 -0
- package/src/definitions/__tests__/load-for-render.test.ts +244 -0
- package/src/definitions/__tests__/schema-extraction.test.ts +137 -0
- package/src/definitions/definition-service.ts +243 -0
- package/src/definitions/index.ts +22 -0
- package/src/definitions/load-for-render.ts +272 -0
- package/src/definitions/schema-extraction.ts +117 -0
- package/src/index.ts +18 -0
- package/src/providers/__tests__/create-selva-providers.test.ts +172 -0
- package/src/providers/__tests__/fixtures/override-config.mjs +8 -0
- package/src/providers/create-selva-providers.ts +241 -0
- package/src/providers/index.ts +12 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `createClientCache` — the per-server warm-client LRU.
|
|
3
|
+
*
|
|
4
|
+
* `@selvajs/compute`'s `GrasshopperClient.create` does a real network preflight,
|
|
5
|
+
* so we mock the module: `create` records the config it was called with and
|
|
6
|
+
* returns a fake client whose `createScheduler` returns a disposable stub. That
|
|
7
|
+
* lets us assert the cache's own behavior (id-keying, LRU eviction, evict,
|
|
8
|
+
* disposeAll, the `X-Selva-Definition` header) without a live compute server.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
12
|
+
|
|
13
|
+
// --- Mock @selvajs/compute -------------------------------------------------
|
|
14
|
+
const createdConfigs: any[] = [];
|
|
15
|
+
const disposedSchedulers: any[] = [];
|
|
16
|
+
|
|
17
|
+
vi.mock('@selvajs/compute', () => {
|
|
18
|
+
class GrasshopperClient {
|
|
19
|
+
static async create(config: any) {
|
|
20
|
+
createdConfigs.push(config);
|
|
21
|
+
return new GrasshopperClient(config);
|
|
22
|
+
}
|
|
23
|
+
constructor(public config: any) {}
|
|
24
|
+
createScheduler() {
|
|
25
|
+
const scheduler = {
|
|
26
|
+
disposed: false,
|
|
27
|
+
dispose() {
|
|
28
|
+
this.disposed = true;
|
|
29
|
+
disposedSchedulers.push(this);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
return scheduler;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return { GrasshopperClient, enableDebugLogging: vi.fn() };
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
import { createClientCache, serverIdentity } from '../client-cache.js';
|
|
39
|
+
|
|
40
|
+
function baseConfig() {
|
|
41
|
+
return {
|
|
42
|
+
maxSolveDurationMs: 30_000,
|
|
43
|
+
cachesolve: true,
|
|
44
|
+
cacheerroredsolves: false,
|
|
45
|
+
reuseServerDefinitionCache: true,
|
|
46
|
+
debug: false,
|
|
47
|
+
debugVerbose: false
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const server = (id: string, over: Partial<{ serverUrl: string; apiKey: string }> = {}) => ({
|
|
52
|
+
id,
|
|
53
|
+
serverUrl: over.serverUrl ?? `http://compute-${id}:6500`,
|
|
54
|
+
apiKey: over.apiKey ?? `key-${id}`
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
createdConfigs.length = 0;
|
|
59
|
+
disposedSchedulers.length = 0;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('createClientCache — keying', () => {
|
|
63
|
+
it('keys on id: two calls for the same id build one client', async () => {
|
|
64
|
+
const cache = createClientCache(baseConfig());
|
|
65
|
+
const a = await cache.getClient(server('s1'));
|
|
66
|
+
const b = await cache.getClient(server('s1'));
|
|
67
|
+
expect(a).toBe(b);
|
|
68
|
+
expect(createdConfigs).toHaveLength(1);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('reuses the entry even when the URL/apiKey rotate under the same id', async () => {
|
|
72
|
+
// The whole point of ADR 0004 D1: identity is the id, so a rotated URL is
|
|
73
|
+
// the SAME entry (stale until evicted) — not a new client.
|
|
74
|
+
const cache = createClientCache(baseConfig());
|
|
75
|
+
const a = await cache.getClient(server('s1', { serverUrl: 'http://old:6500' }));
|
|
76
|
+
const b = await cache.getClient(server('s1', { serverUrl: 'http://new:6500' }));
|
|
77
|
+
expect(a).toBe(b);
|
|
78
|
+
expect(createdConfigs).toHaveLength(1);
|
|
79
|
+
// It kept the ORIGINAL connection details — proving eviction is required.
|
|
80
|
+
expect(createdConfigs[0].serverUrl).toBe('http://old:6500');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('builds distinct clients for distinct ids', async () => {
|
|
84
|
+
const cache = createClientCache(baseConfig());
|
|
85
|
+
const a = await cache.getClient(server('s1'));
|
|
86
|
+
const b = await cache.getClient(server('s2'));
|
|
87
|
+
expect(a).not.toBe(b);
|
|
88
|
+
expect(createdConfigs).toHaveLength(2);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('createClientCache — LRU eviction', () => {
|
|
93
|
+
it('evicts the least-recently-used entry past capacity and disposes its scheduler', async () => {
|
|
94
|
+
const cache = createClientCache({ ...baseConfig(), maxCachedClients: 2 });
|
|
95
|
+
const s1 = await cache.getClient(server('s1'));
|
|
96
|
+
await cache.getClient(server('s2'));
|
|
97
|
+
// Touch s1 so s2 becomes the LRU.
|
|
98
|
+
await cache.getClient(server('s1'));
|
|
99
|
+
await cache.getClient(server('s3')); // over capacity → evict s2
|
|
100
|
+
|
|
101
|
+
expect(disposedSchedulers).toHaveLength(1);
|
|
102
|
+
// s1 still warm (same entry, no rebuild); s2 rebuilt on next access.
|
|
103
|
+
expect(await cache.getClient(server('s1'))).toBe(s1);
|
|
104
|
+
expect(createdConfigs.filter((c) => c.serverUrl.includes('s2'))).toHaveLength(1);
|
|
105
|
+
await cache.getClient(server('s2'));
|
|
106
|
+
expect(createdConfigs.filter((c) => c.serverUrl.includes('s2'))).toHaveLength(2);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('createClientCache — explicit invalidation', () => {
|
|
111
|
+
it('evict(id) disposes the scheduler and forces a rebuild (config-write hook)', async () => {
|
|
112
|
+
const cache = createClientCache(baseConfig());
|
|
113
|
+
await cache.getClient(server('s1'));
|
|
114
|
+
cache.evict('s1');
|
|
115
|
+
expect(disposedSchedulers).toHaveLength(1);
|
|
116
|
+
await cache.getClient(server('s1'));
|
|
117
|
+
expect(createdConfigs).toHaveLength(2);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('evict accepts a ServerIdentity', async () => {
|
|
121
|
+
const cache = createClientCache(baseConfig());
|
|
122
|
+
await cache.getClient(server('s1'));
|
|
123
|
+
cache.evict(serverIdentity({ id: 's1' }));
|
|
124
|
+
expect(disposedSchedulers).toHaveLength(1);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('evict on an unknown id is a no-op', async () => {
|
|
128
|
+
const cache = createClientCache(baseConfig());
|
|
129
|
+
await cache.getClient(server('s1'));
|
|
130
|
+
cache.evict('nope');
|
|
131
|
+
expect(disposedSchedulers).toHaveLength(0);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('disposeAll disposes every warm scheduler and clears the cache', async () => {
|
|
135
|
+
const cache = createClientCache(baseConfig());
|
|
136
|
+
await cache.getClient(server('s1'));
|
|
137
|
+
await cache.getClient(server('s2'));
|
|
138
|
+
cache.disposeAll();
|
|
139
|
+
expect(disposedSchedulers).toHaveLength(2);
|
|
140
|
+
// Next access rebuilds.
|
|
141
|
+
await cache.getClient(server('s1'));
|
|
142
|
+
expect(createdConfigs).toHaveLength(3);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('createClientCache — X-Selva-Definition (ADR 0004 D2)', () => {
|
|
147
|
+
it('stamps the definition guid on the client headers when provided', async () => {
|
|
148
|
+
const cache = createClientCache(baseConfig());
|
|
149
|
+
await cache.getClient(server('s1'), { definitionGuid: 'guid-123' });
|
|
150
|
+
expect(createdConfigs[0].headers).toEqual({ 'X-Selva-Definition': 'guid-123' });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('omits the header entirely when no guid is given', async () => {
|
|
154
|
+
const cache = createClientCache(baseConfig());
|
|
155
|
+
await cache.getClient(server('s1'));
|
|
156
|
+
expect(createdConfigs[0].headers).toBeUndefined();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('serverIdentity', () => {
|
|
161
|
+
it('derives identity from the id', () => {
|
|
162
|
+
expect(serverIdentity({ id: 'abc' })).toBe('abc');
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { createComputeRateLimiter, type ComputeRateLimiter } from '../rate-limit.js';
|
|
3
|
+
|
|
4
|
+
const WINDOW_MS = 100_000;
|
|
5
|
+
const MAX_PER_WINDOW = 120;
|
|
6
|
+
|
|
7
|
+
describe('createComputeRateLimiter', () => {
|
|
8
|
+
let limiter: ComputeRateLimiter;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
limiter = createComputeRateLimiter({ windowMs: WINDOW_MS, maxPerWindow: MAX_PER_WINDOW });
|
|
12
|
+
vi.useFakeTimers();
|
|
13
|
+
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
vi.useRealTimers();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('allows up to maxPerWindow requests in the window', () => {
|
|
21
|
+
for (let i = 0; i < MAX_PER_WINDOW; i++) {
|
|
22
|
+
expect(limiter.check('user:alice').allowed).toBe(true);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('rejects the (MAX+1)th request with retryAfter set', () => {
|
|
27
|
+
for (let i = 0; i < MAX_PER_WINDOW; i++) limiter.check('user:alice');
|
|
28
|
+
const result = limiter.check('user:alice');
|
|
29
|
+
expect(result.allowed).toBe(false);
|
|
30
|
+
expect(result.retryAfter).toBeGreaterThan(0);
|
|
31
|
+
expect(result.retryAfter).toBeLessThanOrEqual(WINDOW_MS / 1000);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('isolates buckets per key', () => {
|
|
35
|
+
for (let i = 0; i < MAX_PER_WINDOW; i++) limiter.check('user:alice');
|
|
36
|
+
expect(limiter.check('user:alice').allowed).toBe(false);
|
|
37
|
+
expect(limiter.check('user:bob').allowed).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("share-link buckets don't collide with user buckets sharing an id", () => {
|
|
41
|
+
const id = '00000000-0000-0000-0000-000000000001';
|
|
42
|
+
for (let i = 0; i < MAX_PER_WINDOW; i++) limiter.check(`share:${id}`);
|
|
43
|
+
expect(limiter.check(`share:${id}`).allowed).toBe(false);
|
|
44
|
+
expect(limiter.check(`user:${id}`).allowed).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('rolls over when the window expires', () => {
|
|
48
|
+
for (let i = 0; i < MAX_PER_WINDOW; i++) limiter.check('user:alice');
|
|
49
|
+
expect(limiter.check('user:alice').allowed).toBe(false);
|
|
50
|
+
|
|
51
|
+
vi.advanceTimersByTime(WINDOW_MS + 1);
|
|
52
|
+
|
|
53
|
+
expect(limiter.check('user:alice').allowed).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('does not pre-increment a fresh bucket past the cap', () => {
|
|
57
|
+
// Regression guard: an off-by-one in the "no entry yet" branch could
|
|
58
|
+
// initialize at count=1 but treat the FIRST call as already capped.
|
|
59
|
+
expect(limiter.check('user:fresh').allowed).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('separate limiters own separate bucket state', () => {
|
|
63
|
+
const other = createComputeRateLimiter({ windowMs: WINDOW_MS, maxPerWindow: MAX_PER_WINDOW });
|
|
64
|
+
for (let i = 0; i < MAX_PER_WINDOW; i++) limiter.check('user:alice');
|
|
65
|
+
expect(limiter.check('user:alice').allowed).toBe(false);
|
|
66
|
+
// A different limiter instance is unaffected.
|
|
67
|
+
expect(other.check('user:alice').allowed).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `createRemoteDefinitionFetcher` — the SSRF-guarded, capped, TTL-cached
|
|
3
|
+
* remote `.gh` fetch (K3).
|
|
4
|
+
*
|
|
5
|
+
* `safe-url` is mocked so the host check is a controllable pass/fail (its own
|
|
6
|
+
* SSRF logic is covered in safe-url.test.ts), and `fetch` is stubbed per test to
|
|
7
|
+
* return scripted bodies/headers. The injected `now` clock makes the TTL cache
|
|
8
|
+
* deterministic without real time.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
12
|
+
|
|
13
|
+
// --- Mock the SSRF guard ---------------------------------------------------
|
|
14
|
+
const assertSafe = vi.fn(async (_url: string) => {});
|
|
15
|
+
vi.mock('../safe-url.js', () => ({
|
|
16
|
+
assertSafeRemoteDefinitionUrl: (url: string) => assertSafe(url)
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
import { createRemoteDefinitionFetcher, readBodyWithCap } from '../index.js';
|
|
20
|
+
|
|
21
|
+
/** A Response whose body streams the given bytes in one chunk. */
|
|
22
|
+
function streamResponse(bytes: Uint8Array, init: { contentLength?: string | null } = {}): Response {
|
|
23
|
+
const body = new ReadableStream<Uint8Array>({
|
|
24
|
+
start(controller) {
|
|
25
|
+
controller.enqueue(bytes);
|
|
26
|
+
controller.close();
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
const headers = new Headers();
|
|
30
|
+
if (init.contentLength !== null && init.contentLength !== undefined) {
|
|
31
|
+
headers.set('content-length', init.contentLength);
|
|
32
|
+
}
|
|
33
|
+
return new Response(body, { status: 200, headers });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const baseConfig = () => ({
|
|
37
|
+
maxBytes: 1024,
|
|
38
|
+
fetchTimeoutMs: 5_000,
|
|
39
|
+
cacheTtlMs: 60_000,
|
|
40
|
+
now: () => 1_000
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
let fetchMock: ReturnType<typeof vi.fn>;
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
assertSafe.mockClear();
|
|
47
|
+
assertSafe.mockResolvedValue(undefined);
|
|
48
|
+
fetchMock = vi.fn();
|
|
49
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
vi.unstubAllGlobals();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('createRemoteDefinitionFetcher — happy path', () => {
|
|
57
|
+
it('runs the SSRF guard and returns the fetched bytes', async () => {
|
|
58
|
+
const bytes = new Uint8Array([9, 8, 7]);
|
|
59
|
+
fetchMock.mockResolvedValue(streamResponse(bytes, { contentLength: '3' }));
|
|
60
|
+
const fetcher = createRemoteDefinitionFetcher(baseConfig());
|
|
61
|
+
|
|
62
|
+
const out = await fetcher.load('https://example.com/def.gh');
|
|
63
|
+
expect(out).toEqual(bytes);
|
|
64
|
+
expect(assertSafe).toHaveBeenCalledWith('https://example.com/def.gh');
|
|
65
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
66
|
+
'https://example.com/def.gh',
|
|
67
|
+
expect.objectContaining({ redirect: 'error' })
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('caches within TTL (one fetch) and refetches after TTL expires', async () => {
|
|
72
|
+
const bytes = new Uint8Array([1]);
|
|
73
|
+
// Fresh Response per call — a stream body can only be read once.
|
|
74
|
+
fetchMock.mockImplementation(async () => streamResponse(bytes, { contentLength: '1' }));
|
|
75
|
+
let clock = 1_000;
|
|
76
|
+
const fetcher = createRemoteDefinitionFetcher({ ...baseConfig(), now: () => clock });
|
|
77
|
+
|
|
78
|
+
await fetcher.load('https://example.com/a.gh');
|
|
79
|
+
await fetcher.load('https://example.com/a.gh'); // within TTL → cached
|
|
80
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
81
|
+
|
|
82
|
+
clock += 60_001; // past the 60s TTL
|
|
83
|
+
await fetcher.load('https://example.com/a.gh');
|
|
84
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('createRemoteDefinitionFetcher — rejections', () => {
|
|
89
|
+
it('does not fetch when the SSRF guard rejects', async () => {
|
|
90
|
+
assertSafe.mockRejectedValue(new Error('blocked private host'));
|
|
91
|
+
const fetcher = createRemoteDefinitionFetcher(baseConfig());
|
|
92
|
+
await expect(fetcher.load('http://169.254.169.254/')).rejects.toThrow('blocked private host');
|
|
93
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('throws on a non-2xx response', async () => {
|
|
97
|
+
fetchMock.mockResolvedValue(new Response('nope', { status: 404, statusText: 'Not Found' }));
|
|
98
|
+
const fetcher = createRemoteDefinitionFetcher(baseConfig());
|
|
99
|
+
await expect(fetcher.load('https://example.com/missing.gh')).rejects.toThrow('HTTP 404');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('rejects early when the declared content-length exceeds the cap', async () => {
|
|
103
|
+
fetchMock.mockResolvedValue(streamResponse(new Uint8Array([1, 2]), { contentLength: '99999' }));
|
|
104
|
+
const fetcher = createRemoteDefinitionFetcher({ ...baseConfig(), maxBytes: 10 });
|
|
105
|
+
await expect(fetcher.load('https://example.com/big.gh')).rejects.toThrow('exceeds size limit');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('rejects when the streamed body exceeds the cap despite a missing content-length', async () => {
|
|
109
|
+
// No content-length header — the streaming cap must still catch it.
|
|
110
|
+
fetchMock.mockResolvedValue(streamResponse(new Uint8Array(50), { contentLength: null }));
|
|
111
|
+
const fetcher = createRemoteDefinitionFetcher({ ...baseConfig(), maxBytes: 10 });
|
|
112
|
+
await expect(fetcher.load('https://example.com/lying.gh')).rejects.toThrow(
|
|
113
|
+
'exceeds size limit'
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('readBodyWithCap', () => {
|
|
119
|
+
it('reads a streamed body under the cap', async () => {
|
|
120
|
+
const bytes = new Uint8Array([1, 2, 3, 4]);
|
|
121
|
+
const out = await readBodyWithCap(streamResponse(bytes), 10, new AbortController());
|
|
122
|
+
expect(out).toEqual(bytes);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('aborts and throws once the running total crosses the cap', async () => {
|
|
126
|
+
const controller = new AbortController();
|
|
127
|
+
await expect(
|
|
128
|
+
readBodyWithCap(streamResponse(new Uint8Array(20)), 5, controller)
|
|
129
|
+
).rejects.toThrow('exceeds size limit');
|
|
130
|
+
expect(controller.signal.aborted).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('falls back to arrayBuffer when there is no stream body', async () => {
|
|
134
|
+
// A Response built from a string still exposes a body stream in undici, so
|
|
135
|
+
// construct one without a body to hit the fallback branch.
|
|
136
|
+
const noBody = new Response(null, { status: 200 });
|
|
137
|
+
const out = await readBodyWithCap(noBody, 10, new AbortController());
|
|
138
|
+
expect(out).toEqual(new Uint8Array(0));
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { isSafeRemoteDefinitionUrl } from '../safe-url.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* SSRF guard for `loadRemoteDefinition`. Each rejection row corresponds to
|
|
6
|
+
* an attack the helper exists to block — leaving any of them unsuppressed
|
|
7
|
+
* would let an authenticated user have the server fetch arbitrary
|
|
8
|
+
* intranet/cloud-metadata/loopback hosts on their behalf.
|
|
9
|
+
*
|
|
10
|
+
* Hostname-only — DNS rebinding is not addressed here. See the helper's
|
|
11
|
+
* file-level comment.
|
|
12
|
+
*/
|
|
13
|
+
describe('isSafeRemoteDefinitionUrl', () => {
|
|
14
|
+
it.each([
|
|
15
|
+
'https://example.com/foo.gh',
|
|
16
|
+
'http://example.com/foo.gh',
|
|
17
|
+
'https://cdn.example.com:443/path/to/file.ghx',
|
|
18
|
+
'https://1.2.3.4/file.gh',
|
|
19
|
+
// 172.32 is outside the 172.16-31 private range — must be allowed.
|
|
20
|
+
'https://172.32.0.1/file.gh',
|
|
21
|
+
// Edge of the private /16 — 192.169 is public.
|
|
22
|
+
'https://192.169.0.1/file.gh'
|
|
23
|
+
])('accepts public URL %j', (url) => {
|
|
24
|
+
expect(isSafeRemoteDefinitionUrl(url)).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it.each([
|
|
28
|
+
// Loopback
|
|
29
|
+
['http://localhost/foo', 'localhost'],
|
|
30
|
+
['http://localhost:8080/foo', 'localhost with port'],
|
|
31
|
+
['http://api.localhost/foo', '*.localhost'],
|
|
32
|
+
['http://127.0.0.1/foo', '127.0.0.1'],
|
|
33
|
+
['http://127.0.0.5/foo', '127/8'],
|
|
34
|
+
['http://0.0.0.0/foo', '0.0.0.0'],
|
|
35
|
+
['http://[::1]/foo', 'IPv6 loopback'],
|
|
36
|
+
// Cloud metadata service — the canonical reason this guard exists.
|
|
37
|
+
['http://169.254.169.254/latest/meta-data/', 'AWS/GCP/Azure metadata'],
|
|
38
|
+
['http://169.254.0.1/foo', '169.254/16 link-local'],
|
|
39
|
+
// IPv4 RFC1918 private ranges
|
|
40
|
+
['http://10.0.0.1/foo', '10/8'],
|
|
41
|
+
['http://10.255.255.255/foo', '10/8 upper'],
|
|
42
|
+
['http://192.168.0.1/foo', '192.168/16'],
|
|
43
|
+
['http://172.16.0.1/foo', '172.16 lower bound'],
|
|
44
|
+
['http://172.31.255.255/foo', '172.31 upper bound'],
|
|
45
|
+
['http://172.20.0.1/foo', '172.20 mid-range'],
|
|
46
|
+
// IPv6 unique-local + link-local
|
|
47
|
+
['http://[fc00::1]/foo', 'IPv6 unique-local fc'],
|
|
48
|
+
['http://[fd12:3456::1]/foo', 'IPv6 unique-local fd'],
|
|
49
|
+
['http://[fe80::1]/foo', 'IPv6 link-local'],
|
|
50
|
+
// IPv4-mapped IPv6 — must unwrap and apply the v4 rules.
|
|
51
|
+
['http://[::ffff:127.0.0.1]/foo', 'IPv4-mapped loopback'],
|
|
52
|
+
['http://[::ffff:169.254.169.254]/foo', 'IPv4-mapped metadata'],
|
|
53
|
+
['http://[::ffff:10.0.0.1]/foo', 'IPv4-mapped RFC1918'],
|
|
54
|
+
// Alternate IPv4 encodings that glibc/inet_aton resolve to loopback.
|
|
55
|
+
['http://2130706433/foo', 'integer loopback'],
|
|
56
|
+
['http://0x7f000001/foo', 'hex loopback'],
|
|
57
|
+
['http://0177.0.0.1/foo', 'octal loopback'],
|
|
58
|
+
['http://127.1/foo', 'short-form loopback'],
|
|
59
|
+
['http://0/foo', 'integer 0.0.0.0'],
|
|
60
|
+
// Alternate encodings of the cloud-metadata address.
|
|
61
|
+
['http://2852039166/foo', 'integer metadata 169.254.169.254'],
|
|
62
|
+
// Non-http(s) schemes
|
|
63
|
+
['file:///etc/passwd', 'file scheme'],
|
|
64
|
+
['gopher://evil/', 'gopher scheme'],
|
|
65
|
+
['javascript:alert(1)', 'javascript scheme'],
|
|
66
|
+
// Malformed
|
|
67
|
+
['not a url', 'unparseable'],
|
|
68
|
+
['', 'empty']
|
|
69
|
+
])('rejects %j (%s)', (url) => {
|
|
70
|
+
expect(isSafeRemoteDefinitionUrl(url)).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
});
|