@ontrails/core 1.0.0-beta.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.
- package/.turbo/turbo-build.log +1 -0
- package/.turbo/turbo-lint.log +3 -0
- package/.turbo/turbo-typecheck.log +1 -0
- package/CHANGELOG.md +15 -0
- package/README.md +179 -0
- package/dist/adapters.d.ts +39 -0
- package/dist/adapters.d.ts.map +1 -0
- package/dist/adapters.js +2 -0
- package/dist/adapters.js.map +1 -0
- package/dist/blob-ref.d.ts +20 -0
- package/dist/blob-ref.d.ts.map +1 -0
- package/dist/blob-ref.js +22 -0
- package/dist/blob-ref.js.map +1 -0
- package/dist/branded.d.ts +36 -0
- package/dist/branded.d.ts.map +1 -0
- package/dist/branded.js +89 -0
- package/dist/branded.js.map +1 -0
- package/dist/collections.d.ts +31 -0
- package/dist/collections.d.ts.map +1 -0
- package/dist/collections.js +60 -0
- package/dist/collections.js.map +1 -0
- package/dist/context.d.ts +10 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +15 -0
- package/dist/context.js.map +1 -0
- package/dist/derive.d.ts +33 -0
- package/dist/derive.d.ts.map +1 -0
- package/dist/derive.js +122 -0
- package/dist/derive.js.map +1 -0
- package/dist/errors.d.ts +83 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +142 -0
- package/dist/errors.js.map +1 -0
- package/dist/event.d.ts +45 -0
- package/dist/event.d.ts.map +1 -0
- package/dist/event.js +17 -0
- package/dist/event.js.map +1 -0
- package/dist/fetch.d.ts +15 -0
- package/dist/fetch.d.ts.map +1 -0
- package/dist/fetch.js +102 -0
- package/dist/fetch.js.map +1 -0
- package/dist/guards.d.ts +17 -0
- package/dist/guards.d.ts.map +1 -0
- package/dist/guards.js +25 -0
- package/dist/guards.js.map +1 -0
- package/dist/health.d.ts +18 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +5 -0
- package/dist/health.js.map +1 -0
- package/dist/hike.d.ts +36 -0
- package/dist/hike.d.ts.map +1 -0
- package/dist/hike.js +20 -0
- package/dist/hike.js.map +1 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +38 -0
- package/dist/index.js.map +1 -0
- package/dist/job.d.ts +24 -0
- package/dist/job.d.ts.map +1 -0
- package/dist/job.js +17 -0
- package/dist/job.js.map +1 -0
- package/dist/layer.d.ts +17 -0
- package/dist/layer.d.ts.map +1 -0
- package/dist/layer.js +21 -0
- package/dist/layer.js.map +1 -0
- package/dist/path-security.d.ts +28 -0
- package/dist/path-security.d.ts.map +1 -0
- package/dist/path-security.js +63 -0
- package/dist/path-security.js.map +1 -0
- package/dist/patterns/bulk.d.ts +15 -0
- package/dist/patterns/bulk.d.ts.map +1 -0
- package/dist/patterns/bulk.js +14 -0
- package/dist/patterns/bulk.js.map +1 -0
- package/dist/patterns/change.d.ts +10 -0
- package/dist/patterns/change.d.ts.map +1 -0
- package/dist/patterns/change.js +10 -0
- package/dist/patterns/change.js.map +1 -0
- package/dist/patterns/date-range.d.ts +10 -0
- package/dist/patterns/date-range.d.ts.map +1 -0
- package/dist/patterns/date-range.js +10 -0
- package/dist/patterns/date-range.js.map +1 -0
- package/dist/patterns/index.d.ts +9 -0
- package/dist/patterns/index.d.ts.map +1 -0
- package/dist/patterns/index.js +9 -0
- package/dist/patterns/index.js.map +1 -0
- package/dist/patterns/pagination.d.ts +18 -0
- package/dist/patterns/pagination.d.ts.map +1 -0
- package/dist/patterns/pagination.js +18 -0
- package/dist/patterns/pagination.js.map +1 -0
- package/dist/patterns/progress.d.ts +11 -0
- package/dist/patterns/progress.d.ts.map +1 -0
- package/dist/patterns/progress.js +11 -0
- package/dist/patterns/progress.js.map +1 -0
- package/dist/patterns/sorting.d.ts +13 -0
- package/dist/patterns/sorting.d.ts.map +1 -0
- package/dist/patterns/sorting.js +10 -0
- package/dist/patterns/sorting.js.map +1 -0
- package/dist/patterns/status.d.ts +15 -0
- package/dist/patterns/status.d.ts.map +1 -0
- package/dist/patterns/status.js +9 -0
- package/dist/patterns/status.js.map +1 -0
- package/dist/patterns/timestamps.d.ts +10 -0
- package/dist/patterns/timestamps.d.ts.map +1 -0
- package/dist/patterns/timestamps.js +10 -0
- package/dist/patterns/timestamps.js.map +1 -0
- package/dist/redaction/index.d.ts +4 -0
- package/dist/redaction/index.d.ts.map +1 -0
- package/dist/redaction/index.js +3 -0
- package/dist/redaction/index.js.map +1 -0
- package/dist/redaction/patterns.d.ts +9 -0
- package/dist/redaction/patterns.d.ts.map +1 -0
- package/dist/redaction/patterns.js +39 -0
- package/dist/redaction/patterns.js.map +1 -0
- package/dist/redaction/redactor.d.ts +27 -0
- package/dist/redaction/redactor.d.ts.map +1 -0
- package/dist/redaction/redactor.js +89 -0
- package/dist/redaction/redactor.js.map +1 -0
- package/dist/resilience.d.ts +34 -0
- package/dist/resilience.d.ts.map +1 -0
- package/dist/resilience.js +164 -0
- package/dist/resilience.js.map +1 -0
- package/dist/result.d.ts +57 -0
- package/dist/result.d.ts.map +1 -0
- package/dist/result.js +145 -0
- package/dist/result.js.map +1 -0
- package/dist/serialization.d.ts +27 -0
- package/dist/serialization.d.ts.map +1 -0
- package/dist/serialization.js +115 -0
- package/dist/serialization.js.map +1 -0
- package/dist/topo.d.ts +18 -0
- package/dist/topo.d.ts.map +1 -0
- package/dist/topo.js +74 -0
- package/dist/topo.js.map +1 -0
- package/dist/trail.d.ts +83 -0
- package/dist/trail.d.ts.map +1 -0
- package/dist/trail.js +16 -0
- package/dist/trail.js.map +1 -0
- package/dist/types.d.ts +46 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/validate-topo.d.ts +24 -0
- package/dist/validate-topo.d.ts.map +1 -0
- package/dist/validate-topo.js +108 -0
- package/dist/validate-topo.js.map +1 -0
- package/dist/validation.d.ts +27 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +134 -0
- package/dist/validation.js.map +1 -0
- package/dist/workspace.d.ts +25 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +57 -0
- package/dist/workspace.js.map +1 -0
- package/package.json +21 -0
- package/src/__tests__/blob-ref.test.ts +103 -0
- package/src/__tests__/branded.test.ts +148 -0
- package/src/__tests__/collections.test.ts +126 -0
- package/src/__tests__/context.test.ts +66 -0
- package/src/__tests__/derive.test.ts +159 -0
- package/src/__tests__/errors.test.ts +309 -0
- package/src/__tests__/event.test.ts +82 -0
- package/src/__tests__/fetch.test.ts +217 -0
- package/src/__tests__/guards.test.ts +102 -0
- package/src/__tests__/hike.test.ts +117 -0
- package/src/__tests__/job.test.ts +98 -0
- package/src/__tests__/layer.test.ts +224 -0
- package/src/__tests__/path-security.test.ts +114 -0
- package/src/__tests__/patterns.test.ts +273 -0
- package/src/__tests__/redaction.test.ts +244 -0
- package/src/__tests__/resilience.test.ts +246 -0
- package/src/__tests__/result.test.ts +155 -0
- package/src/__tests__/serialization.test.ts +236 -0
- package/src/__tests__/topo.test.ts +184 -0
- package/src/__tests__/trail.test.ts +179 -0
- package/src/__tests__/validate-topo.test.ts +201 -0
- package/src/__tests__/validation.test.ts +283 -0
- package/src/__tests__/workspace.test.ts +183 -0
- package/src/adapters.ts +68 -0
- package/src/blob-ref.ts +39 -0
- package/src/branded.ts +135 -0
- package/src/collections.ts +99 -0
- package/src/context.ts +18 -0
- package/src/derive.ts +223 -0
- package/src/errors.ts +196 -0
- package/src/event.ts +77 -0
- package/src/fetch.ts +138 -0
- package/src/guards.ts +37 -0
- package/src/health.ts +23 -0
- package/src/hike.ts +77 -0
- package/src/index.ts +158 -0
- package/src/job.ts +20 -0
- package/src/layer.ts +44 -0
- package/src/path-security.ts +90 -0
- package/src/patterns/bulk.ts +16 -0
- package/src/patterns/change.ts +12 -0
- package/src/patterns/date-range.ts +12 -0
- package/src/patterns/index.ts +8 -0
- package/src/patterns/pagination.ts +22 -0
- package/src/patterns/progress.ts +13 -0
- package/src/patterns/sorting.ts +14 -0
- package/src/patterns/status.ts +11 -0
- package/src/patterns/timestamps.ts +12 -0
- package/src/redaction/index.ts +3 -0
- package/src/redaction/patterns.ts +47 -0
- package/src/redaction/redactor.ts +178 -0
- package/src/resilience.ts +234 -0
- package/src/result.ts +180 -0
- package/src/serialization.ts +183 -0
- package/src/topo.ts +123 -0
- package/src/trail.ts +130 -0
- package/src/types.ts +58 -0
- package/src/validate-topo.ts +151 -0
- package/src/validation.ts +182 -0
- package/src/workspace.ts +77 -0
- package/tsconfig.json +9 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/* oxlint-disable require-await -- adapter mocks and layer wrappers satisfy async interfaces without awaiting */
|
|
2
|
+
import { describe, test, expect } from 'bun:test';
|
|
3
|
+
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
IndexAdapter,
|
|
8
|
+
StorageAdapter,
|
|
9
|
+
CacheAdapter,
|
|
10
|
+
SearchOptions,
|
|
11
|
+
SearchResult,
|
|
12
|
+
StorageOptions,
|
|
13
|
+
} from '../adapters';
|
|
14
|
+
import type { HealthStatus, HealthResult, HealthCheck } from '../health';
|
|
15
|
+
import { composeLayers } from '../layer';
|
|
16
|
+
import type { Layer } from '../layer';
|
|
17
|
+
import { Result } from '../result';
|
|
18
|
+
import { trail } from '../trail';
|
|
19
|
+
import type { TrailContext } from '../types';
|
|
20
|
+
|
|
21
|
+
const stubCtx: TrailContext = {
|
|
22
|
+
requestId: 'test-layer',
|
|
23
|
+
signal: AbortSignal.timeout(5000),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const echoTrail = trail('echo', {
|
|
27
|
+
implementation: (input) => Result.ok({ value: input.value }),
|
|
28
|
+
input: z.object({ value: z.string() }),
|
|
29
|
+
markers: { domain: 'test' },
|
|
30
|
+
output: z.object({ value: z.string() }),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Layer tests
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
describe('Layer', () => {
|
|
38
|
+
test('single layer wraps implementation', async () => {
|
|
39
|
+
const prefixLayer: Layer = {
|
|
40
|
+
name: 'prefix',
|
|
41
|
+
wrap(_trail, impl) {
|
|
42
|
+
return async (input, ctx) => {
|
|
43
|
+
const result = await impl(input, ctx);
|
|
44
|
+
return result.map((out) => ({
|
|
45
|
+
...out,
|
|
46
|
+
value: `prefixed:${(out as { value: string }).value}`,
|
|
47
|
+
}));
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const wrapped = composeLayers(
|
|
53
|
+
[prefixLayer],
|
|
54
|
+
echoTrail,
|
|
55
|
+
echoTrail.implementation
|
|
56
|
+
);
|
|
57
|
+
const result = await wrapped({ value: 'hello' }, stubCtx);
|
|
58
|
+
|
|
59
|
+
expect(result.isOk()).toBe(true);
|
|
60
|
+
expect(result.unwrap()).toEqual({ value: 'prefixed:hello' });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('multiple layers compose in outermost-first order', async () => {
|
|
64
|
+
const log: string[] = [];
|
|
65
|
+
|
|
66
|
+
const outer: Layer = {
|
|
67
|
+
name: 'outer',
|
|
68
|
+
wrap(_trail, impl) {
|
|
69
|
+
return async (input, ctx) => {
|
|
70
|
+
log.push('outer:before');
|
|
71
|
+
const r = await impl(input, ctx);
|
|
72
|
+
log.push('outer:after');
|
|
73
|
+
return r;
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const inner: Layer = {
|
|
79
|
+
name: 'inner',
|
|
80
|
+
wrap(_trail, impl) {
|
|
81
|
+
return async (input, ctx) => {
|
|
82
|
+
log.push('inner:before');
|
|
83
|
+
const r = await impl(input, ctx);
|
|
84
|
+
log.push('inner:after');
|
|
85
|
+
return r;
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const wrapped = composeLayers(
|
|
91
|
+
[outer, inner],
|
|
92
|
+
echoTrail,
|
|
93
|
+
echoTrail.implementation
|
|
94
|
+
);
|
|
95
|
+
await wrapped({ value: 'x' }, stubCtx);
|
|
96
|
+
|
|
97
|
+
expect(log).toEqual([
|
|
98
|
+
'outer:before',
|
|
99
|
+
'inner:before',
|
|
100
|
+
'inner:after',
|
|
101
|
+
'outer:after',
|
|
102
|
+
]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('layer can short-circuit without calling inner implementation', async () => {
|
|
106
|
+
const shortCircuit: Layer = {
|
|
107
|
+
name: 'short-circuit',
|
|
108
|
+
wrap() {
|
|
109
|
+
return async () => Result.err(new Error('blocked'));
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const wrapped = composeLayers(
|
|
114
|
+
[shortCircuit],
|
|
115
|
+
echoTrail,
|
|
116
|
+
echoTrail.implementation
|
|
117
|
+
);
|
|
118
|
+
const result = await wrapped({ value: 'hello' }, stubCtx);
|
|
119
|
+
|
|
120
|
+
expect(result.isErr()).toBe(true);
|
|
121
|
+
const err = result as unknown as { error: Error };
|
|
122
|
+
expect(err.error.message).toBe('blocked');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('layer can inspect trail markers', () => {
|
|
126
|
+
let capturedDomain: unknown;
|
|
127
|
+
|
|
128
|
+
const inspectLayer: Layer = {
|
|
129
|
+
name: 'inspect',
|
|
130
|
+
wrap(t, impl) {
|
|
131
|
+
capturedDomain = t.markers?.['domain'];
|
|
132
|
+
return impl;
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
composeLayers([inspectLayer], echoTrail, echoTrail.implementation);
|
|
137
|
+
|
|
138
|
+
expect(capturedDomain).toBe('test');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('empty layers array returns implementation unchanged', () => {
|
|
142
|
+
const wrapped = composeLayers([], echoTrail, echoTrail.implementation);
|
|
143
|
+
expect(wrapped).toBe(echoTrail.implementation);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Health types (compile-time verification)
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
describe('health types', () => {
|
|
152
|
+
test('HealthResult satisfies the interface', () => {
|
|
153
|
+
const check: HealthCheck = {
|
|
154
|
+
latency: 12,
|
|
155
|
+
message: 'ok',
|
|
156
|
+
status: 'healthy',
|
|
157
|
+
};
|
|
158
|
+
const result: HealthResult = {
|
|
159
|
+
checks: { db: check },
|
|
160
|
+
status: 'healthy',
|
|
161
|
+
uptime: 3600,
|
|
162
|
+
version: '1.0.0',
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
expect(result.status).toBe('healthy');
|
|
166
|
+
expect(result.checks['db']?.status).toBe('healthy');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('HealthStatus union is exhaustive', () => {
|
|
170
|
+
const statuses: HealthStatus[] = ['healthy', 'degraded', 'unhealthy'];
|
|
171
|
+
expect(statuses).toHaveLength(3);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// Adapter types (compile-time verification)
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
describe('adapter types', () => {
|
|
180
|
+
test('IndexAdapter mock satisfies the interface', () => {
|
|
181
|
+
const mock: IndexAdapter = {
|
|
182
|
+
index: async () => Result.ok(),
|
|
183
|
+
remove: async () => Result.ok(),
|
|
184
|
+
search: async () => Result.ok([]),
|
|
185
|
+
};
|
|
186
|
+
expect(mock.index).toBeDefined();
|
|
187
|
+
expect(mock.search).toBeDefined();
|
|
188
|
+
expect(mock.remove).toBeDefined();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('StorageAdapter mock satisfies the interface', () => {
|
|
192
|
+
const mock: StorageAdapter = {
|
|
193
|
+
delete: async () => Result.ok(),
|
|
194
|
+
get: async () => Result.ok('value'),
|
|
195
|
+
has: async () => Result.ok(true),
|
|
196
|
+
set: async () => Result.ok(),
|
|
197
|
+
};
|
|
198
|
+
expect(mock.has).toBeDefined();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('CacheAdapter mock satisfies the interface', () => {
|
|
202
|
+
const mock: CacheAdapter = {
|
|
203
|
+
clear: async () => Result.ok(),
|
|
204
|
+
delete: async () => Result.ok(),
|
|
205
|
+
get: async () => Result.ok(),
|
|
206
|
+
set: async () => Result.ok(),
|
|
207
|
+
};
|
|
208
|
+
expect(mock.clear).toBeDefined();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('SearchOptions and SearchResult satisfy their shapes', () => {
|
|
212
|
+
const opts: SearchOptions = { filters: { tag: 'a' }, limit: 10, offset: 0 };
|
|
213
|
+
const hit: SearchResult = {
|
|
214
|
+
document: { title: 'x' },
|
|
215
|
+
id: '1',
|
|
216
|
+
score: 0.95,
|
|
217
|
+
};
|
|
218
|
+
const sopts: StorageOptions = { ttl: 5000 };
|
|
219
|
+
|
|
220
|
+
expect(opts.limit).toBe(10);
|
|
221
|
+
expect(hit.score).toBe(0.95);
|
|
222
|
+
expect(sopts.ttl).toBe(5000);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
import { resolve, join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { PermissionError } from '../errors.js';
|
|
5
|
+
import { securePath, isPathSafe, resolveSafePath } from '../path-security.js';
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// securePath
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
describe('securePath', () => {
|
|
12
|
+
const base = '/project/workspace';
|
|
13
|
+
|
|
14
|
+
test('resolves a simple relative path', () => {
|
|
15
|
+
const result = securePath(base, 'src/index.ts');
|
|
16
|
+
expect(result.isOk()).toBe(true);
|
|
17
|
+
expect(result.unwrap()).toBe(resolve(base, 'src/index.ts'));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('resolves nested paths', () => {
|
|
21
|
+
const result = securePath(base, 'src/../lib/utils.ts');
|
|
22
|
+
expect(result.isOk()).toBe(true);
|
|
23
|
+
expect(result.unwrap()).toBe(resolve(base, 'lib/utils.ts'));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('accepts the base directory itself', () => {
|
|
27
|
+
const result = securePath(base, '.');
|
|
28
|
+
expect(result.isOk()).toBe(true);
|
|
29
|
+
expect(result.unwrap()).toBe(resolve(base));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('rejects path traversal with ..', () => {
|
|
33
|
+
const result = securePath(base, '../../etc/passwd');
|
|
34
|
+
expect(result.isErr()).toBe(true);
|
|
35
|
+
const err = result as unknown as { error: PermissionError };
|
|
36
|
+
expect(err.error).toBeInstanceOf(PermissionError);
|
|
37
|
+
expect(err.error.message).toContain('Path traversal detected');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('rejects absolute paths outside base', () => {
|
|
41
|
+
const result = securePath(base, '/etc/passwd');
|
|
42
|
+
expect(result.isErr()).toBe(true);
|
|
43
|
+
const err = result as unknown as { error: PermissionError };
|
|
44
|
+
expect(err.error).toBeInstanceOf(PermissionError);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('rejects sneaky traversal (subdir/../../..)', () => {
|
|
48
|
+
const result = securePath(base, 'src/../../..');
|
|
49
|
+
expect(result.isErr()).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('allows paths that mention .. but stay inside', () => {
|
|
53
|
+
const result = securePath(base, 'src/../src/file.ts');
|
|
54
|
+
expect(result.isOk()).toBe(true);
|
|
55
|
+
expect(result.unwrap()).toBe(resolve(base, 'src/file.ts'));
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// isPathSafe
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
describe('isPathSafe', () => {
|
|
64
|
+
const base = '/project/workspace';
|
|
65
|
+
|
|
66
|
+
test('returns true for child paths', () => {
|
|
67
|
+
expect(isPathSafe(base, 'src/index.ts')).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('returns true for the base directory', () => {
|
|
71
|
+
expect(isPathSafe(base, '.')).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('returns false for traversal', () => {
|
|
75
|
+
expect(isPathSafe(base, '../secret')).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('returns false for absolute paths outside base', () => {
|
|
79
|
+
expect(isPathSafe(base, '/tmp/evil')).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// resolveSafePath
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
describe('resolveSafePath', () => {
|
|
88
|
+
const base = '/project/workspace';
|
|
89
|
+
|
|
90
|
+
test('joins multiple segments safely', () => {
|
|
91
|
+
const result = resolveSafePath(base, 'src', 'lib', 'utils.ts');
|
|
92
|
+
expect(result.isOk()).toBe(true);
|
|
93
|
+
expect(result.unwrap()).toBe(join(resolve(base), 'src', 'lib', 'utils.ts'));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('rejects when segments escape the base', () => {
|
|
97
|
+
const result = resolveSafePath(base, 'src', '../../../etc');
|
|
98
|
+
expect(result.isErr()).toBe(true);
|
|
99
|
+
const err = result as unknown as { error: PermissionError };
|
|
100
|
+
expect(err.error).toBeInstanceOf(PermissionError);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('handles single segment', () => {
|
|
104
|
+
const result = resolveSafePath(base, 'file.txt');
|
|
105
|
+
expect(result.isOk()).toBe(true);
|
|
106
|
+
expect(result.unwrap()).toBe(resolve(base, 'file.txt'));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('handles empty segments', () => {
|
|
110
|
+
const result = resolveSafePath(base);
|
|
111
|
+
expect(result.isOk()).toBe(true);
|
|
112
|
+
expect(result.unwrap()).toBe(resolve(base));
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
paginationFields,
|
|
7
|
+
paginatedOutput,
|
|
8
|
+
bulkOutput,
|
|
9
|
+
timestampFields,
|
|
10
|
+
dateRangeFields,
|
|
11
|
+
sortFields,
|
|
12
|
+
statusFields,
|
|
13
|
+
changeOutput,
|
|
14
|
+
progressFields,
|
|
15
|
+
} from '../patterns/index.js';
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// paginationFields
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
describe('paginationFields', () => {
|
|
22
|
+
const schema = paginationFields();
|
|
23
|
+
|
|
24
|
+
test('applies defaults for empty input', () => {
|
|
25
|
+
const result = schema.parse({});
|
|
26
|
+
expect(result).toEqual({ limit: 20, offset: 0 });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('accepts explicit values', () => {
|
|
30
|
+
const result = schema.parse({ cursor: 'abc', limit: 50, offset: 10 });
|
|
31
|
+
expect(result).toEqual({ cursor: 'abc', limit: 50, offset: 10 });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('rejects non-number limit', () => {
|
|
35
|
+
expect(() => schema.parse({ limit: 'ten' })).toThrow();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('rejects non-string cursor', () => {
|
|
39
|
+
expect(() => schema.parse({ cursor: 123 })).toThrow();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// paginatedOutput
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
describe('paginatedOutput', () => {
|
|
48
|
+
const schema = paginatedOutput(z.string());
|
|
49
|
+
|
|
50
|
+
test('parses valid paginated data', () => {
|
|
51
|
+
const data = {
|
|
52
|
+
hasMore: true,
|
|
53
|
+
items: ['a', 'b'],
|
|
54
|
+
nextCursor: 'x',
|
|
55
|
+
total: 10,
|
|
56
|
+
};
|
|
57
|
+
expect(schema.parse(data)).toEqual(data);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('allows missing nextCursor', () => {
|
|
61
|
+
const data = { hasMore: false, items: [], total: 0 };
|
|
62
|
+
expect(schema.parse(data)).toEqual(data);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('rejects wrong item type', () => {
|
|
66
|
+
expect(() =>
|
|
67
|
+
schema.parse({ hasMore: false, items: [1], total: 1 })
|
|
68
|
+
).toThrow();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('rejects missing total', () => {
|
|
72
|
+
expect(() => schema.parse({ hasMore: false, items: [] })).toThrow();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// bulkOutput
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
describe('bulkOutput', () => {
|
|
81
|
+
const schema = bulkOutput(z.object({ id: z.number() }));
|
|
82
|
+
|
|
83
|
+
test('parses valid bulk result', () => {
|
|
84
|
+
const data = {
|
|
85
|
+
failed: 0,
|
|
86
|
+
items: [{ id: 1 }, { id: 2 }],
|
|
87
|
+
succeeded: 2,
|
|
88
|
+
};
|
|
89
|
+
expect(schema.parse(data)).toEqual(data);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('parses bulk result with errors', () => {
|
|
93
|
+
const data = {
|
|
94
|
+
errors: [{ index: 1, message: 'duplicate' }],
|
|
95
|
+
failed: 1,
|
|
96
|
+
items: [{ id: 1 }],
|
|
97
|
+
succeeded: 1,
|
|
98
|
+
};
|
|
99
|
+
expect(schema.parse(data)).toEqual(data);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('rejects missing succeeded field', () => {
|
|
103
|
+
expect(() => schema.parse({ failed: 0, items: [] })).toThrow();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('rejects invalid error shape', () => {
|
|
107
|
+
expect(() =>
|
|
108
|
+
schema.parse({
|
|
109
|
+
errors: [{ bad: true }],
|
|
110
|
+
failed: 0,
|
|
111
|
+
items: [],
|
|
112
|
+
succeeded: 0,
|
|
113
|
+
})
|
|
114
|
+
).toThrow();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// timestampFields
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
describe('timestampFields', () => {
|
|
123
|
+
const schema = timestampFields();
|
|
124
|
+
|
|
125
|
+
test('parses valid timestamps', () => {
|
|
126
|
+
const data = {
|
|
127
|
+
createdAt: '2024-01-01T00:00:00Z',
|
|
128
|
+
updatedAt: '2024-06-01T00:00:00Z',
|
|
129
|
+
};
|
|
130
|
+
expect(schema.parse(data)).toEqual(data);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('rejects missing createdAt', () => {
|
|
134
|
+
expect(() => schema.parse({ updatedAt: '2024-01-01' })).toThrow();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('rejects non-string values', () => {
|
|
138
|
+
expect(() => schema.parse({ createdAt: 123, updatedAt: 456 })).toThrow();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// dateRangeFields
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
describe('dateRangeFields', () => {
|
|
147
|
+
const schema = dateRangeFields();
|
|
148
|
+
|
|
149
|
+
test('parses empty object (both optional)', () => {
|
|
150
|
+
expect(schema.parse({})).toEqual({});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('parses with both fields', () => {
|
|
154
|
+
const data = { since: '2024-01-01', until: '2024-12-31' };
|
|
155
|
+
expect(schema.parse(data)).toEqual(data);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('parses with only since', () => {
|
|
159
|
+
expect(schema.parse({ since: '2024-01-01' })).toEqual({
|
|
160
|
+
since: '2024-01-01',
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('rejects non-string values', () => {
|
|
165
|
+
expect(() => schema.parse({ since: 42 })).toThrow();
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// sortFields
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
describe('sortFields', () => {
|
|
174
|
+
const schema = sortFields(['name', 'createdAt', 'updatedAt']);
|
|
175
|
+
|
|
176
|
+
test('applies default sortOrder', () => {
|
|
177
|
+
const result = schema.parse({});
|
|
178
|
+
expect(result).toEqual({ sortOrder: 'asc' });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('accepts valid sortBy and sortOrder', () => {
|
|
182
|
+
const result = schema.parse({ sortBy: 'name', sortOrder: 'desc' });
|
|
183
|
+
expect(result).toEqual({ sortBy: 'name', sortOrder: 'desc' });
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('rejects sortBy not in allowed list', () => {
|
|
187
|
+
expect(() => schema.parse({ sortBy: 'email' })).toThrow();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('rejects invalid sortOrder', () => {
|
|
191
|
+
expect(() => schema.parse({ sortOrder: 'up' })).toThrow();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// statusFields
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
describe('statusFields', () => {
|
|
200
|
+
const schema = statusFields();
|
|
201
|
+
|
|
202
|
+
test('parses each valid status', () => {
|
|
203
|
+
for (const s of [
|
|
204
|
+
'pending',
|
|
205
|
+
'running',
|
|
206
|
+
'completed',
|
|
207
|
+
'failed',
|
|
208
|
+
'cancelled',
|
|
209
|
+
] as const) {
|
|
210
|
+
expect(schema.parse({ status: s })).toEqual({ status: s });
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('rejects invalid status', () => {
|
|
215
|
+
expect(() => schema.parse({ status: 'unknown' })).toThrow();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('rejects missing status', () => {
|
|
219
|
+
expect(() => schema.parse({})).toThrow();
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
// changeOutput
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
describe('changeOutput', () => {
|
|
228
|
+
const schema = changeOutput(z.object({ name: z.string() }));
|
|
229
|
+
|
|
230
|
+
test('parses with both before and after', () => {
|
|
231
|
+
const data = { after: { name: 'new' }, before: { name: 'old' } };
|
|
232
|
+
expect(schema.parse(data)).toEqual(data);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('parses with only after (before is optional)', () => {
|
|
236
|
+
const data = { after: { name: 'created' } };
|
|
237
|
+
expect(schema.parse(data)).toEqual(data);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('rejects missing after', () => {
|
|
241
|
+
expect(() => schema.parse({ before: { name: 'old' } })).toThrow();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('rejects invalid schema shape', () => {
|
|
245
|
+
expect(() => schema.parse({ after: { name: 123 } })).toThrow();
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// progressFields
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
describe('progressFields', () => {
|
|
254
|
+
const schema = progressFields();
|
|
255
|
+
|
|
256
|
+
test('parses valid progress', () => {
|
|
257
|
+
const data = { current: 5, percentage: 50, total: 10 };
|
|
258
|
+
expect(schema.parse(data)).toEqual(data);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('allows missing percentage', () => {
|
|
262
|
+
const data = { current: 3, total: 10 };
|
|
263
|
+
expect(schema.parse(data)).toEqual(data);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('rejects missing current', () => {
|
|
267
|
+
expect(() => schema.parse({ total: 10 })).toThrow();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('rejects non-number values', () => {
|
|
271
|
+
expect(() => schema.parse({ current: 'five', total: 10 })).toThrow();
|
|
272
|
+
});
|
|
273
|
+
});
|