@selvajs/server 0.2.0-beta.3 → 0.2.0-beta.5
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 +1 -1
- package/README.md +40 -0
- package/dist/compute/client-cache.d.ts +7 -0
- package/dist/compute/client-cache.d.ts.map +1 -1
- package/dist/compute/client-cache.js +12 -1
- package/dist/compute/client-cache.js.map +1 -1
- package/dist/compute/index.d.ts +2 -2
- package/dist/compute/index.d.ts.map +1 -1
- package/dist/compute/index.js +2 -2
- package/dist/compute/index.js.map +1 -1
- package/dist/compute/limits.d.ts +26 -5
- package/dist/compute/limits.d.ts.map +1 -1
- package/dist/compute/limits.js +51 -25
- package/dist/compute/limits.js.map +1 -1
- package/dist/compute/rate-limit.d.ts +40 -0
- package/dist/compute/rate-limit.d.ts.map +1 -1
- package/dist/compute/rate-limit.js +72 -1
- package/dist/compute/rate-limit.js.map +1 -1
- package/dist/compute/remote-definition.d.ts +7 -0
- package/dist/compute/remote-definition.d.ts.map +1 -1
- package/dist/compute/remote-definition.js +3 -1
- package/dist/compute/remote-definition.js.map +1 -1
- package/dist/compute/safe-url.d.ts +2 -1
- package/dist/compute/safe-url.d.ts.map +1 -1
- package/dist/compute/safe-url.js +31 -1
- package/dist/compute/safe-url.js.map +1 -1
- package/dist/compute/solve-cache-key.d.ts +5 -6
- package/dist/compute/solve-cache-key.d.ts.map +1 -1
- package/dist/compute/solve-cache-key.js +4 -3
- package/dist/compute/solve-cache-key.js.map +1 -1
- package/dist/compute/solve-cache-single-flight.d.ts +8 -1
- package/dist/compute/solve-cache-single-flight.d.ts.map +1 -1
- package/dist/compute/solve-cache-single-flight.js +4 -2
- package/dist/compute/solve-cache-single-flight.js.map +1 -1
- package/dist/compute/solve-pipeline.d.ts +31 -0
- package/dist/compute/solve-pipeline.d.ts.map +1 -1
- package/dist/compute/solve-pipeline.js +42 -1
- package/dist/compute/solve-pipeline.js.map +1 -1
- package/dist/definitions/load-for-render.d.ts +10 -1
- package/dist/definitions/load-for-render.d.ts.map +1 -1
- package/dist/definitions/load-for-render.js +33 -6
- package/dist/definitions/load-for-render.js.map +1 -1
- package/dist/logging/PinoLogger.d.ts +39 -0
- package/dist/logging/PinoLogger.d.ts.map +1 -0
- package/dist/logging/PinoLogger.js +189 -0
- package/dist/logging/PinoLogger.js.map +1 -0
- package/dist/logging/index.d.ts +3 -0
- package/dist/logging/index.d.ts.map +1 -0
- package/dist/logging/index.js +5 -0
- package/dist/logging/index.js.map +1 -0
- package/dist/logging/requestId.d.ts +45 -0
- package/dist/logging/requestId.d.ts.map +1 -0
- package/dist/logging/requestId.js +82 -0
- package/dist/logging/requestId.js.map +1 -0
- package/dist/providers/create-selva-providers.d.ts +11 -1
- package/dist/providers/create-selva-providers.d.ts.map +1 -1
- package/dist/providers/create-selva-providers.js +21 -8
- package/dist/providers/create-selva-providers.js.map +1 -1
- package/package.json +23 -19
- package/dist/access/__tests__/project-access-input.test.d.ts +0 -2
- package/dist/access/__tests__/project-access-input.test.d.ts.map +0 -1
- package/dist/access/__tests__/project-access-input.test.js +0 -146
- package/dist/access/__tests__/project-access-input.test.js.map +0 -1
- package/dist/compute/__tests__/client-cache.test.d.ts +0 -11
- package/dist/compute/__tests__/client-cache.test.d.ts.map +0 -1
- package/dist/compute/__tests__/client-cache.test.js +0 -218
- package/dist/compute/__tests__/client-cache.test.js.map +0 -1
- package/dist/compute/__tests__/definition-byte-cache.test.d.ts +0 -2
- package/dist/compute/__tests__/definition-byte-cache.test.d.ts.map +0 -1
- package/dist/compute/__tests__/definition-byte-cache.test.js +0 -107
- package/dist/compute/__tests__/definition-byte-cache.test.js.map +0 -1
- package/dist/compute/__tests__/rate-limit.test.d.ts +0 -2
- package/dist/compute/__tests__/rate-limit.test.d.ts.map +0 -1
- package/dist/compute/__tests__/rate-limit.test.js +0 -98
- package/dist/compute/__tests__/rate-limit.test.js.map +0 -1
- package/dist/compute/__tests__/remote-definition.test.d.ts +0 -11
- package/dist/compute/__tests__/remote-definition.test.d.ts.map +0 -1
- package/dist/compute/__tests__/remote-definition.test.js +0 -114
- package/dist/compute/__tests__/remote-definition.test.js.map +0 -1
- package/dist/compute/__tests__/safe-url.test.d.ts +0 -2
- package/dist/compute/__tests__/safe-url.test.d.ts.map +0 -1
- package/dist/compute/__tests__/safe-url.test.js +0 -71
- package/dist/compute/__tests__/safe-url.test.js.map +0 -1
- package/dist/compute/__tests__/solve-cache.test.d.ts +0 -13
- package/dist/compute/__tests__/solve-cache.test.d.ts.map +0 -1
- package/dist/compute/__tests__/solve-cache.test.js +0 -234
- package/dist/compute/__tests__/solve-cache.test.js.map +0 -1
- package/dist/compute/__tests__/solve-pipeline.test.d.ts +0 -11
- package/dist/compute/__tests__/solve-pipeline.test.d.ts.map +0 -1
- package/dist/compute/__tests__/solve-pipeline.test.js +0 -557
- package/dist/compute/__tests__/solve-pipeline.test.js.map +0 -1
- package/dist/compute/__tests__/transform-input.test.d.ts +0 -14
- package/dist/compute/__tests__/transform-input.test.d.ts.map +0 -1
- package/dist/compute/__tests__/transform-input.test.js +0 -120
- package/dist/compute/__tests__/transform-input.test.js.map +0 -1
- package/dist/definitions/__tests__/definition-service.test.d.ts +0 -14
- package/dist/definitions/__tests__/definition-service.test.d.ts.map +0 -1
- package/dist/definitions/__tests__/definition-service.test.js +0 -95
- package/dist/definitions/__tests__/definition-service.test.js.map +0 -1
- package/dist/definitions/__tests__/load-for-render.test.d.ts +0 -8
- package/dist/definitions/__tests__/load-for-render.test.d.ts.map +0 -1
- package/dist/definitions/__tests__/load-for-render.test.js +0 -196
- package/dist/definitions/__tests__/load-for-render.test.js.map +0 -1
- package/dist/definitions/__tests__/schema-extraction.test.d.ts +0 -11
- package/dist/definitions/__tests__/schema-extraction.test.d.ts.map +0 -1
- package/dist/definitions/__tests__/schema-extraction.test.js +0 -115
- package/dist/definitions/__tests__/schema-extraction.test.js.map +0 -1
- package/dist/http/__tests__/body-size.test.d.ts +0 -2
- package/dist/http/__tests__/body-size.test.d.ts.map +0 -1
- package/dist/http/__tests__/body-size.test.js +0 -24
- package/dist/http/__tests__/body-size.test.js.map +0 -1
- package/dist/http/__tests__/route-classifier.test.d.ts +0 -2
- package/dist/http/__tests__/route-classifier.test.d.ts.map +0 -1
- package/dist/http/__tests__/route-classifier.test.js +0 -54
- package/dist/http/__tests__/route-classifier.test.js.map +0 -1
- package/dist/http/__tests__/safe-redirect.test.d.ts +0 -2
- package/dist/http/__tests__/safe-redirect.test.d.ts.map +0 -1
- package/dist/http/__tests__/safe-redirect.test.js +0 -44
- package/dist/http/__tests__/safe-redirect.test.js.map +0 -1
- package/dist/http/__tests__/security-headers.test.d.ts +0 -2
- package/dist/http/__tests__/security-headers.test.d.ts.map +0 -1
- package/dist/http/__tests__/security-headers.test.js +0 -24
- package/dist/http/__tests__/security-headers.test.js.map +0 -1
- package/dist/ops/__tests__/semver.test.d.ts +0 -2
- package/dist/ops/__tests__/semver.test.d.ts.map +0 -1
- package/dist/ops/__tests__/semver.test.js +0 -38
- package/dist/ops/__tests__/semver.test.js.map +0 -1
- package/dist/providers/__tests__/create-selva-providers.test.d.ts +0 -7
- package/dist/providers/__tests__/create-selva-providers.test.d.ts.map +0 -1
- package/dist/providers/__tests__/create-selva-providers.test.js +0 -135
- package/dist/providers/__tests__/create-selva-providers.test.js.map +0 -1
- package/dist/tokens/__tests__/token-codec.test.d.ts +0 -2
- package/dist/tokens/__tests__/token-codec.test.d.ts.map +0 -1
- package/dist/tokens/__tests__/token-codec.test.js +0 -50
- package/dist/tokens/__tests__/token-codec.test.js.map +0 -1
- package/src/access/__tests__/project-access-input.test.ts +0 -175
- package/src/access/index.ts +0 -9
- package/src/access/project-access-input.ts +0 -145
- package/src/compute/__tests__/client-cache.test.ts +0 -244
- package/src/compute/__tests__/definition-byte-cache.test.ts +0 -129
- package/src/compute/__tests__/rate-limit.test.ts +0 -106
- package/src/compute/__tests__/remote-definition.test.ts +0 -140
- package/src/compute/__tests__/safe-url.test.ts +0 -72
- package/src/compute/__tests__/solve-cache.test.ts +0 -268
- package/src/compute/__tests__/solve-pipeline.test.ts +0 -660
- package/src/compute/__tests__/transform-input.test.ts +0 -162
- package/src/compute/client-cache.ts +0 -321
- package/src/compute/definition-byte-cache.ts +0 -168
- package/src/compute/index.ts +0 -82
- package/src/compute/limits.ts +0 -237
- package/src/compute/memory-solve-cache.ts +0 -0
- package/src/compute/rate-limit.ts +0 -110
- package/src/compute/remote-definition.ts +0 -149
- package/src/compute/safe-url.ts +0 -186
- package/src/compute/solve-cache-envelope.ts +0 -98
- package/src/compute/solve-cache-key.ts +0 -85
- package/src/compute/solve-cache-single-flight.ts +0 -59
- package/src/compute/solve-pipeline.ts +0 -603
- package/src/compute/transform-input.ts +0 -66
- package/src/definitions/__tests__/definition-service.test.ts +0 -131
- package/src/definitions/__tests__/load-for-render.test.ts +0 -244
- package/src/definitions/__tests__/schema-extraction.test.ts +0 -137
- package/src/definitions/definition-service.ts +0 -247
- package/src/definitions/index.ts +0 -22
- package/src/definitions/load-for-render.ts +0 -272
- package/src/definitions/schema-extraction.ts +0 -117
- package/src/errors/SentryErrorReporter.ts +0 -88
- package/src/errors/index.ts +0 -3
- package/src/http/__tests__/body-size.test.ts +0 -27
- package/src/http/__tests__/route-classifier.test.ts +0 -60
- package/src/http/__tests__/safe-redirect.test.ts +0 -47
- package/src/http/__tests__/security-headers.test.ts +0 -29
- package/src/http/body-size.ts +0 -24
- package/src/http/index.ts +0 -10
- package/src/http/redirect.ts +0 -16
- package/src/http/route-classifier.ts +0 -74
- package/src/http/security-headers.ts +0 -42
- package/src/index.ts +0 -27
- package/src/ops/__tests__/semver.test.ts +0 -42
- package/src/ops/index.ts +0 -3
- package/src/ops/semver.ts +0 -60
- package/src/providers/__tests__/create-selva-providers.test.ts +0 -172
- package/src/providers/__tests__/fixtures/override-config.mjs +0 -8
- package/src/providers/create-selva-providers.ts +0 -241
- package/src/providers/index.ts +0 -12
- package/src/tokens/__tests__/token-codec.test.ts +0 -63
- package/src/tokens/index.ts +0 -8
- package/src/tokens/token-codec.ts +0 -86
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { createDefinitionByteCache } from '../definition-byte-cache.js';
|
|
3
|
-
|
|
4
|
-
/** A `Uint8Array` of `n` bytes filled with a marker so equality is checkable. */
|
|
5
|
-
function bytes(n: number, marker = 0): Uint8Array {
|
|
6
|
-
return new Uint8Array(n).fill(marker);
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
describe('createDefinitionByteCache', () => {
|
|
10
|
-
it('serves a warm entry without re-invoking the loader (hit) and reports it', async () => {
|
|
11
|
-
const cache = createDefinitionByteCache(10 * 1024 * 1024);
|
|
12
|
-
const load = vi.fn(async () => bytes(1024, 7));
|
|
13
|
-
|
|
14
|
-
const first = await cache.getOrLoad('v-1', load).load();
|
|
15
|
-
const second = await cache.getOrLoad('v-1', load).load();
|
|
16
|
-
|
|
17
|
-
expect(first).toEqual(second);
|
|
18
|
-
expect(load).toHaveBeenCalledTimes(1); // second call served from cache
|
|
19
|
-
const s = cache.stats();
|
|
20
|
-
expect(s.hits).toBe(1);
|
|
21
|
-
expect(s.misses).toBe(1);
|
|
22
|
-
expect(s.entries).toBe(1);
|
|
23
|
-
expect(s.bytes).toBe(1024);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it('per-ref outcome distinguishes miss (loader) from hit (warm)', async () => {
|
|
27
|
-
const cache = createDefinitionByteCache(10 * 1024 * 1024);
|
|
28
|
-
const load = async () => bytes(512);
|
|
29
|
-
|
|
30
|
-
const refMiss = cache.getOrLoad('v-1', load);
|
|
31
|
-
await refMiss.load();
|
|
32
|
-
expect(refMiss.outcome).toEqual({ loaded: true, fromCache: false });
|
|
33
|
-
|
|
34
|
-
const refHit = cache.getOrLoad('v-1', load);
|
|
35
|
-
await refHit.load();
|
|
36
|
-
expect(refHit.outcome).toEqual({ loaded: true, fromCache: true });
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("a ref whose load() is never called leaves outcome.loaded false (the 'skipped' verdict)", () => {
|
|
40
|
-
const cache = createDefinitionByteCache(10 * 1024 * 1024);
|
|
41
|
-
const ref = cache.getOrLoad('v-1', async () => bytes(1));
|
|
42
|
-
// The scheduler served a pointer-known solve — load() never ran.
|
|
43
|
-
expect(ref.outcome).toEqual({ loaded: false, fromCache: false });
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it('evicts least-recently-used entries when the byte budget is exceeded', async () => {
|
|
47
|
-
// Budget holds ~2 of these 1 KB entries.
|
|
48
|
-
const cache = createDefinitionByteCache(2 * 1024);
|
|
49
|
-
const load = (marker: number) => async () => bytes(1024, marker);
|
|
50
|
-
|
|
51
|
-
await cache.getOrLoad('a', load(1)).load(); // [a]
|
|
52
|
-
await cache.getOrLoad('b', load(2)).load(); // [a,b]
|
|
53
|
-
// Touch 'a' so 'b' becomes the LRU victim.
|
|
54
|
-
await cache.getOrLoad('a', load(1)).load(); // hit → [b,a]
|
|
55
|
-
await cache.getOrLoad('c', load(3)).load(); // insert c → evict b → [a,c]
|
|
56
|
-
|
|
57
|
-
expect(cache.stats().entries).toBe(2);
|
|
58
|
-
expect(cache.stats().evictions).toBe(1);
|
|
59
|
-
expect(cache.stats().bytes).toBe(2 * 1024);
|
|
60
|
-
|
|
61
|
-
// 'a' survived (it was touched, so 'b' was the LRU victim) → served from
|
|
62
|
-
// cache without the loader. Checked BEFORE re-loading 'b', since bringing 'b'
|
|
63
|
-
// back would itself evict 'a' under the 2-entry budget.
|
|
64
|
-
const aLoad = vi.fn(load(1));
|
|
65
|
-
await cache.getOrLoad('a', aLoad).load();
|
|
66
|
-
expect(aLoad).toHaveBeenCalledTimes(0);
|
|
67
|
-
|
|
68
|
-
// 'b' was evicted → its next load calls the loader again (miss).
|
|
69
|
-
const bLoad = vi.fn(load(2));
|
|
70
|
-
await cache.getOrLoad('b', bLoad).load();
|
|
71
|
-
expect(bLoad).toHaveBeenCalledTimes(1);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it('never retains an entry larger than the whole budget (serves through, caches nothing)', async () => {
|
|
75
|
-
const cache = createDefinitionByteCache(1024);
|
|
76
|
-
const load = vi.fn(async () => bytes(4096));
|
|
77
|
-
|
|
78
|
-
const out = await cache.getOrLoad('big', load).load();
|
|
79
|
-
expect(out.byteLength).toBe(4096); // still served
|
|
80
|
-
expect(cache.stats().entries).toBe(0); // but not retained
|
|
81
|
-
expect(cache.stats().bytes).toBe(0);
|
|
82
|
-
|
|
83
|
-
// Every load re-fetches — nothing was cached.
|
|
84
|
-
await cache.getOrLoad('big', load).load();
|
|
85
|
-
expect(load).toHaveBeenCalledTimes(2);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it('keys on the passed version id — two versions that reused a fileKey never collide', async () => {
|
|
89
|
-
// The trap this cache exists to avoid: delete-latest-then-reupload reused a
|
|
90
|
-
// fileKey, but the version id (the KEY) is always fresh. Distinct ids →
|
|
91
|
-
// distinct entries, so the new version never serves the old bytes.
|
|
92
|
-
const cache = createDefinitionByteCache(10 * 1024 * 1024);
|
|
93
|
-
const oldBytes = bytes(1024, 1);
|
|
94
|
-
const newBytes = bytes(1024, 2);
|
|
95
|
-
|
|
96
|
-
const a = await cache.getOrLoad('version-old', async () => oldBytes).load();
|
|
97
|
-
const b = await cache.getOrLoad('version-new', async () => newBytes).load();
|
|
98
|
-
|
|
99
|
-
expect(a).toEqual(oldBytes);
|
|
100
|
-
expect(b).toEqual(newBytes);
|
|
101
|
-
expect(a).not.toEqual(b); // no cross-contamination despite a shared fileKey
|
|
102
|
-
expect(cache.stats().entries).toBe(2);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it('budget 0 disables caching entirely (every load hits the loader, nothing retained)', async () => {
|
|
106
|
-
const cache = createDefinitionByteCache(0);
|
|
107
|
-
const load = vi.fn(async () => bytes(16));
|
|
108
|
-
|
|
109
|
-
const ref1 = cache.getOrLoad('v-1', load);
|
|
110
|
-
await ref1.load();
|
|
111
|
-
await cache.getOrLoad('v-1', load).load();
|
|
112
|
-
|
|
113
|
-
expect(load).toHaveBeenCalledTimes(2);
|
|
114
|
-
expect(cache.stats().entries).toBe(0);
|
|
115
|
-
expect(cache.stats().bytes).toBe(0);
|
|
116
|
-
// Outcome still reports loaded, but never fromCache.
|
|
117
|
-
expect(ref1.outcome).toEqual({ loaded: true, fromCache: false });
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
it('re-storing the same key updates bytes without double-counting the budget', async () => {
|
|
121
|
-
const cache = createDefinitionByteCache(10 * 1024 * 1024);
|
|
122
|
-
// clear() to force a re-store path: first load caches, clear drops it, second caches again.
|
|
123
|
-
await cache.getOrLoad('v-1', async () => bytes(1024)).load();
|
|
124
|
-
expect(cache.stats().bytes).toBe(1024);
|
|
125
|
-
cache.clear();
|
|
126
|
-
expect(cache.stats().bytes).toBe(0);
|
|
127
|
-
expect(cache.stats().entries).toBe(0);
|
|
128
|
-
});
|
|
129
|
-
});
|
|
@@ -1,106 +0,0 @@
|
|
|
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
|
-
|
|
70
|
-
// peek + clear support failure-counting flows (login limiting): peek gates
|
|
71
|
-
// the attempt without spending budget, check records a failure, clear
|
|
72
|
-
// forgives on success.
|
|
73
|
-
describe('peek / clear', () => {
|
|
74
|
-
it('peek never increments the counter', () => {
|
|
75
|
-
for (let i = 0; i < 1000; i++) expect(limiter.peek('ip:1.2.3.4').allowed).toBe(true);
|
|
76
|
-
// Full budget still available after all those peeks.
|
|
77
|
-
for (let i = 0; i < MAX_PER_WINDOW; i++) {
|
|
78
|
-
expect(limiter.check('ip:1.2.3.4').allowed).toBe(true);
|
|
79
|
-
}
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it('peek reports a full bucket with retryAfter, still without mutating it', () => {
|
|
83
|
-
for (let i = 0; i < MAX_PER_WINDOW; i++) limiter.check('ip:1.2.3.4');
|
|
84
|
-
const result = limiter.peek('ip:1.2.3.4');
|
|
85
|
-
expect(result.allowed).toBe(false);
|
|
86
|
-
expect(result.retryAfter).toBeGreaterThan(0);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it('peek allows again once the window expires', () => {
|
|
90
|
-
for (let i = 0; i < MAX_PER_WINDOW; i++) limiter.check('ip:1.2.3.4');
|
|
91
|
-
expect(limiter.peek('ip:1.2.3.4').allowed).toBe(false);
|
|
92
|
-
vi.advanceTimersByTime(WINDOW_MS + 1);
|
|
93
|
-
expect(limiter.peek('ip:1.2.3.4').allowed).toBe(true);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it('clear drops only the given key', () => {
|
|
97
|
-
for (let i = 0; i < MAX_PER_WINDOW; i++) {
|
|
98
|
-
limiter.check('ip:1.2.3.4');
|
|
99
|
-
limiter.check('ip:5.6.7.8');
|
|
100
|
-
}
|
|
101
|
-
limiter.clear('ip:1.2.3.4');
|
|
102
|
-
expect(limiter.peek('ip:1.2.3.4').allowed).toBe(true);
|
|
103
|
-
expect(limiter.peek('ip:5.6.7.8').allowed).toBe(false);
|
|
104
|
-
});
|
|
105
|
-
});
|
|
106
|
-
});
|
|
@@ -1,140 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,72 +0,0 @@
|
|
|
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
|
-
});
|