@nobulex/sdk 0.1.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/dist/adapters/express.d.ts +322 -0
- package/dist/adapters/express.d.ts.map +1 -0
- package/dist/adapters/express.js +356 -0
- package/dist/adapters/express.js.map +1 -0
- package/dist/adapters/index.d.ts +15 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +15 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/langchain.d.ts +183 -0
- package/dist/adapters/langchain.d.ts.map +1 -0
- package/dist/adapters/langchain.js +203 -0
- package/dist/adapters/langchain.js.map +1 -0
- package/dist/adapters/vercel-ai.d.ts +122 -0
- package/dist/adapters/vercel-ai.d.ts.map +1 -0
- package/dist/adapters/vercel-ai.js +128 -0
- package/dist/adapters/vercel-ai.js.map +1 -0
- package/dist/benchmarks.d.ts +164 -0
- package/dist/benchmarks.d.ts.map +1 -0
- package/dist/benchmarks.js +327 -0
- package/dist/benchmarks.js.map +1 -0
- package/dist/benchmarks.test.d.ts +2 -0
- package/dist/benchmarks.test.d.ts.map +1 -0
- package/dist/benchmarks.test.js +71 -0
- package/dist/benchmarks.test.js.map +1 -0
- package/dist/conformance.d.ts +160 -0
- package/dist/conformance.d.ts.map +1 -0
- package/dist/conformance.js +1242 -0
- package/dist/conformance.js.map +1 -0
- package/dist/events.d.ts +176 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +208 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +384 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +695 -0
- package/dist/index.js.map +1 -0
- package/dist/index.test.d.ts +2 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +986 -0
- package/dist/index.test.js.map +1 -0
- package/dist/middleware.d.ts +104 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +222 -0
- package/dist/middleware.js.map +1 -0
- package/dist/middleware.test.d.ts +5 -0
- package/dist/middleware.test.d.ts.map +1 -0
- package/dist/middleware.test.js +735 -0
- package/dist/middleware.test.js.map +1 -0
- package/dist/plugins/auth.d.ts +49 -0
- package/dist/plugins/auth.d.ts.map +1 -0
- package/dist/plugins/auth.js +82 -0
- package/dist/plugins/auth.js.map +1 -0
- package/dist/plugins/cache.d.ts +40 -0
- package/dist/plugins/cache.d.ts.map +1 -0
- package/dist/plugins/cache.js +191 -0
- package/dist/plugins/cache.js.map +1 -0
- package/dist/plugins/index.d.ts +16 -0
- package/dist/plugins/index.d.ts.map +1 -0
- package/dist/plugins/index.js +12 -0
- package/dist/plugins/index.js.map +1 -0
- package/dist/plugins/metrics-plugin.d.ts +32 -0
- package/dist/plugins/metrics-plugin.d.ts.map +1 -0
- package/dist/plugins/metrics-plugin.js +61 -0
- package/dist/plugins/metrics-plugin.js.map +1 -0
- package/dist/plugins/plugins.test.d.ts +8 -0
- package/dist/plugins/plugins.test.d.ts.map +1 -0
- package/dist/plugins/plugins.test.js +640 -0
- package/dist/plugins/plugins.test.js.map +1 -0
- package/dist/plugins/retry-plugin.d.ts +55 -0
- package/dist/plugins/retry-plugin.d.ts.map +1 -0
- package/dist/plugins/retry-plugin.js +133 -0
- package/dist/plugins/retry-plugin.js.map +1 -0
- package/dist/telemetry.d.ts +183 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +241 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/types.d.ts +200 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/package.json +52 -0
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comprehensive tests for all Stele SDK plugins.
|
|
3
|
+
*
|
|
4
|
+
* Tests the caching, authentication, metrics, and retry middleware plugins
|
|
5
|
+
* using the MiddlewarePipeline from middleware.ts.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
8
|
+
import { MetricsRegistry, createMetricsRegistry } from '@nobulex/types';
|
|
9
|
+
import { MiddlewarePipeline } from '../middleware.js';
|
|
10
|
+
import { cachingMiddleware } from './cache.js';
|
|
11
|
+
import { authMiddleware } from './auth.js';
|
|
12
|
+
import { metricsMiddleware } from './metrics-plugin.js';
|
|
13
|
+
import { retryMiddleware, executeWithRetry } from './retry-plugin.js';
|
|
14
|
+
// ─── Test helpers ────────────────────────────────────────────────────────────
|
|
15
|
+
/** Create an async operation that returns its input. */
|
|
16
|
+
function echoOp(value) {
|
|
17
|
+
return async () => value;
|
|
18
|
+
}
|
|
19
|
+
/** Create an async operation that throws. */
|
|
20
|
+
function failingOp(message) {
|
|
21
|
+
return async () => {
|
|
22
|
+
throw new Error(message);
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
26
|
+
// Cache Plugin Tests
|
|
27
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
28
|
+
describe('cachingMiddleware', () => {
|
|
29
|
+
let pipeline;
|
|
30
|
+
let cachePlugin;
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
pipeline = new MiddlewarePipeline();
|
|
33
|
+
cachePlugin = cachingMiddleware({ maxSize: 10, ttlMs: 5000 });
|
|
34
|
+
pipeline.use(cachePlugin);
|
|
35
|
+
});
|
|
36
|
+
it('has the name "cache"', () => {
|
|
37
|
+
expect(cachePlugin.name).toBe('cache');
|
|
38
|
+
});
|
|
39
|
+
it('returns initial stats with zero values', () => {
|
|
40
|
+
const stats = cachePlugin.stats();
|
|
41
|
+
expect(stats).toEqual({
|
|
42
|
+
hits: 0,
|
|
43
|
+
misses: 0,
|
|
44
|
+
size: 0,
|
|
45
|
+
hitRate: 0,
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
it('caches verifyCovenant results on second call', async () => {
|
|
49
|
+
const verifyResult = { valid: true, checks: [] };
|
|
50
|
+
let callCount = 0;
|
|
51
|
+
const fn = async () => {
|
|
52
|
+
callCount++;
|
|
53
|
+
return verifyResult;
|
|
54
|
+
};
|
|
55
|
+
const args = {
|
|
56
|
+
doc: { id: 'cov-123', signature: 'sig-abc', constraints: "permit read on '/data'" },
|
|
57
|
+
};
|
|
58
|
+
// First call: cache miss
|
|
59
|
+
const result1 = await pipeline.execute('verifyCovenant', args, fn);
|
|
60
|
+
expect(result1).toEqual(verifyResult);
|
|
61
|
+
expect(callCount).toBe(1);
|
|
62
|
+
// Second call: cache hit
|
|
63
|
+
const result2 = await pipeline.execute('verifyCovenant', args, fn);
|
|
64
|
+
expect(result2).toEqual(verifyResult);
|
|
65
|
+
// The operation fn still runs (pipeline always runs fn), but after hook returns cached result
|
|
66
|
+
expect(callCount).toBe(2);
|
|
67
|
+
const stats = cachePlugin.stats();
|
|
68
|
+
expect(stats.hits).toBe(1);
|
|
69
|
+
expect(stats.misses).toBe(1);
|
|
70
|
+
});
|
|
71
|
+
it('caches evaluateAction results', async () => {
|
|
72
|
+
const evalResult = { permitted: true, matchedRule: null };
|
|
73
|
+
const args = {
|
|
74
|
+
doc: { constraints: "permit read on '/data'" },
|
|
75
|
+
action: 'read',
|
|
76
|
+
resource: '/data',
|
|
77
|
+
};
|
|
78
|
+
await pipeline.execute('evaluateAction', args, echoOp(evalResult));
|
|
79
|
+
const stats1 = cachePlugin.stats();
|
|
80
|
+
expect(stats1.misses).toBe(1);
|
|
81
|
+
expect(stats1.size).toBe(1);
|
|
82
|
+
// Second call with same args
|
|
83
|
+
await pipeline.execute('evaluateAction', args, echoOp(evalResult));
|
|
84
|
+
const stats2 = cachePlugin.stats();
|
|
85
|
+
expect(stats2.hits).toBe(1);
|
|
86
|
+
});
|
|
87
|
+
it('does not cache non-cacheable operations', async () => {
|
|
88
|
+
await pipeline.execute('createCovenant', { data: 'test' }, echoOp('created'));
|
|
89
|
+
const stats = cachePlugin.stats();
|
|
90
|
+
expect(stats.hits).toBe(0);
|
|
91
|
+
expect(stats.misses).toBe(0);
|
|
92
|
+
expect(stats.size).toBe(0);
|
|
93
|
+
});
|
|
94
|
+
it('tracks hit rate correctly', async () => {
|
|
95
|
+
const args = {
|
|
96
|
+
doc: { id: 'cov-1', signature: 'sig-1', constraints: "permit read on '/data'" },
|
|
97
|
+
};
|
|
98
|
+
// 1 miss
|
|
99
|
+
await pipeline.execute('verifyCovenant', args, echoOp({ valid: true }));
|
|
100
|
+
// 3 hits
|
|
101
|
+
await pipeline.execute('verifyCovenant', args, echoOp({ valid: true }));
|
|
102
|
+
await pipeline.execute('verifyCovenant', args, echoOp({ valid: true }));
|
|
103
|
+
await pipeline.execute('verifyCovenant', args, echoOp({ valid: true }));
|
|
104
|
+
const stats = cachePlugin.stats();
|
|
105
|
+
expect(stats.hits).toBe(3);
|
|
106
|
+
expect(stats.misses).toBe(1);
|
|
107
|
+
expect(stats.hitRate).toBe(0.75);
|
|
108
|
+
});
|
|
109
|
+
it('evicts LRU entries when maxSize is exceeded', async () => {
|
|
110
|
+
// maxSize is 10
|
|
111
|
+
for (let i = 0; i < 12; i++) {
|
|
112
|
+
await pipeline.execute('verifyCovenant', { doc: { id: `cov-${i}`, signature: `sig-${i}`, constraints: 'test' } }, echoOp({ valid: true, index: i }));
|
|
113
|
+
}
|
|
114
|
+
const stats = cachePlugin.stats();
|
|
115
|
+
// Should have evicted 2 entries (12 inserts, max 10)
|
|
116
|
+
expect(stats.size).toBe(10);
|
|
117
|
+
});
|
|
118
|
+
it('expires entries after TTL', async () => {
|
|
119
|
+
// Use a very short TTL
|
|
120
|
+
const shortCache = cachingMiddleware({ maxSize: 100, ttlMs: 50 });
|
|
121
|
+
const shortPipeline = new MiddlewarePipeline();
|
|
122
|
+
shortPipeline.use(shortCache);
|
|
123
|
+
const args = {
|
|
124
|
+
doc: { id: 'cov-ttl', signature: 'sig-ttl', constraints: 'test' },
|
|
125
|
+
};
|
|
126
|
+
// First call: miss
|
|
127
|
+
await shortPipeline.execute('verifyCovenant', args, echoOp({ valid: true }));
|
|
128
|
+
expect(shortCache.stats().misses).toBe(1);
|
|
129
|
+
// Wait for TTL to expire
|
|
130
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
131
|
+
// Should be a miss again (expired)
|
|
132
|
+
await shortPipeline.execute('verifyCovenant', args, echoOp({ valid: true }));
|
|
133
|
+
expect(shortCache.stats().misses).toBe(2);
|
|
134
|
+
expect(shortCache.stats().hits).toBe(0);
|
|
135
|
+
});
|
|
136
|
+
it('clear() flushes all entries and resets stats', async () => {
|
|
137
|
+
const args = {
|
|
138
|
+
doc: { id: 'cov-clear', signature: 'sig-clear', constraints: 'test' },
|
|
139
|
+
};
|
|
140
|
+
await pipeline.execute('verifyCovenant', args, echoOp({ valid: true }));
|
|
141
|
+
await pipeline.execute('verifyCovenant', args, echoOp({ valid: true }));
|
|
142
|
+
expect(cachePlugin.stats().size).toBe(1);
|
|
143
|
+
expect(cachePlugin.stats().hits).toBe(1);
|
|
144
|
+
cachePlugin.clear();
|
|
145
|
+
const stats = cachePlugin.stats();
|
|
146
|
+
expect(stats.hits).toBe(0);
|
|
147
|
+
expect(stats.misses).toBe(0);
|
|
148
|
+
expect(stats.size).toBe(0);
|
|
149
|
+
expect(stats.hitRate).toBe(0);
|
|
150
|
+
});
|
|
151
|
+
it('uses default options when none provided', () => {
|
|
152
|
+
const defaultCache = cachingMiddleware();
|
|
153
|
+
expect(defaultCache.name).toBe('cache');
|
|
154
|
+
expect(defaultCache.stats().size).toBe(0);
|
|
155
|
+
});
|
|
156
|
+
it('caches different operations independently', async () => {
|
|
157
|
+
const verifyArgs = {
|
|
158
|
+
doc: { id: 'cov-1', signature: 'sig-1', constraints: "permit read on '/data'" },
|
|
159
|
+
};
|
|
160
|
+
const evalArgs = {
|
|
161
|
+
doc: { constraints: "permit read on '/data'" },
|
|
162
|
+
action: 'read',
|
|
163
|
+
resource: '/data',
|
|
164
|
+
};
|
|
165
|
+
await pipeline.execute('verifyCovenant', verifyArgs, echoOp({ valid: true }));
|
|
166
|
+
await pipeline.execute('evaluateAction', evalArgs, echoOp({ permitted: true }));
|
|
167
|
+
expect(cachePlugin.stats().size).toBe(2);
|
|
168
|
+
expect(cachePlugin.stats().misses).toBe(2);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
172
|
+
// Auth Plugin Tests
|
|
173
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
174
|
+
describe('authMiddleware', () => {
|
|
175
|
+
it('has the name "auth"', () => {
|
|
176
|
+
const mw = authMiddleware({ apiKey: 'test-key' });
|
|
177
|
+
expect(mw.name).toBe('auth');
|
|
178
|
+
});
|
|
179
|
+
it('throws if neither apiKey nor keyPair is provided', () => {
|
|
180
|
+
expect(() => authMiddleware({})).toThrow('authMiddleware requires at least one of apiKey or keyPair');
|
|
181
|
+
});
|
|
182
|
+
// ── API Key Auth ─────────────────────────────────────────────────────
|
|
183
|
+
describe('API key authentication', () => {
|
|
184
|
+
it('allows operations with a valid API key', async () => {
|
|
185
|
+
const pipeline = new MiddlewarePipeline();
|
|
186
|
+
pipeline.use(authMiddleware({ apiKey: 'my-secret-key' }));
|
|
187
|
+
const result = await pipeline.execute('createCovenant', {}, echoOp('created'));
|
|
188
|
+
expect(result).toBe('created');
|
|
189
|
+
});
|
|
190
|
+
it('injects auth metadata on success', async () => {
|
|
191
|
+
const pipeline = new MiddlewarePipeline();
|
|
192
|
+
pipeline.use(authMiddleware({ apiKey: 'my-key' }));
|
|
193
|
+
let capturedMetadata;
|
|
194
|
+
pipeline.use({
|
|
195
|
+
name: 'metaCapture',
|
|
196
|
+
async before(ctx) {
|
|
197
|
+
capturedMetadata = { ...ctx.metadata };
|
|
198
|
+
return { proceed: true };
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
await pipeline.execute('createCovenant', {}, echoOp('ok'));
|
|
202
|
+
expect(capturedMetadata).toBeDefined();
|
|
203
|
+
expect(capturedMetadata.authenticated).toBe(true);
|
|
204
|
+
expect(capturedMetadata.authMethod).toBe('apiKey');
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
// ── Key Pair Auth ────────────────────────────────────────────────────
|
|
208
|
+
describe('key pair authentication', () => {
|
|
209
|
+
const validKeyPair = {
|
|
210
|
+
publicKeyHex: 'abcdef0123456789',
|
|
211
|
+
privateKey: new Uint8Array(32).fill(1),
|
|
212
|
+
};
|
|
213
|
+
it('allows operations with a valid key pair', async () => {
|
|
214
|
+
const pipeline = new MiddlewarePipeline();
|
|
215
|
+
pipeline.use(authMiddleware({ keyPair: validKeyPair }));
|
|
216
|
+
const result = await pipeline.execute('verifyCovenant', {}, echoOp('verified'));
|
|
217
|
+
expect(result).toBe('verified');
|
|
218
|
+
});
|
|
219
|
+
it('injects key pair auth metadata', async () => {
|
|
220
|
+
const pipeline = new MiddlewarePipeline();
|
|
221
|
+
pipeline.use(authMiddleware({ keyPair: validKeyPair }));
|
|
222
|
+
let capturedMetadata;
|
|
223
|
+
pipeline.use({
|
|
224
|
+
name: 'metaCapture',
|
|
225
|
+
async before(ctx) {
|
|
226
|
+
capturedMetadata = { ...ctx.metadata };
|
|
227
|
+
return { proceed: true };
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
await pipeline.execute('createCovenant', {}, echoOp('ok'));
|
|
231
|
+
expect(capturedMetadata.authenticated).toBe(true);
|
|
232
|
+
expect(capturedMetadata.authMethod).toBe('keyPair');
|
|
233
|
+
expect(capturedMetadata.publicKeyHex).toBe('abcdef0123456789');
|
|
234
|
+
});
|
|
235
|
+
it('accepts 64-byte private keys', async () => {
|
|
236
|
+
const pipeline = new MiddlewarePipeline();
|
|
237
|
+
pipeline.use(authMiddleware({
|
|
238
|
+
keyPair: {
|
|
239
|
+
publicKeyHex: 'abcdef',
|
|
240
|
+
privateKey: new Uint8Array(64).fill(2),
|
|
241
|
+
},
|
|
242
|
+
}));
|
|
243
|
+
const result = await pipeline.execute('op', {}, echoOp('ok'));
|
|
244
|
+
expect(result).toBe('ok');
|
|
245
|
+
});
|
|
246
|
+
it('rejects key pair with empty public key', async () => {
|
|
247
|
+
const pipeline = new MiddlewarePipeline();
|
|
248
|
+
pipeline.use(authMiddleware({
|
|
249
|
+
keyPair: {
|
|
250
|
+
publicKeyHex: '',
|
|
251
|
+
privateKey: new Uint8Array(32).fill(1),
|
|
252
|
+
},
|
|
253
|
+
}));
|
|
254
|
+
await expect(pipeline.execute('op', {}, echoOp('ok'))).rejects.toThrow('Authentication required');
|
|
255
|
+
});
|
|
256
|
+
it('rejects key pair with invalid private key size', async () => {
|
|
257
|
+
const pipeline = new MiddlewarePipeline();
|
|
258
|
+
pipeline.use(authMiddleware({
|
|
259
|
+
keyPair: {
|
|
260
|
+
publicKeyHex: 'abcdef',
|
|
261
|
+
privateKey: new Uint8Array(16).fill(1),
|
|
262
|
+
},
|
|
263
|
+
}));
|
|
264
|
+
await expect(pipeline.execute('op', {}, echoOp('ok'))).rejects.toThrow('Authentication required');
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
// ── Configurable operations ──────────────────────────────────────────
|
|
268
|
+
describe('requiredFor configuration', () => {
|
|
269
|
+
it('only requires auth for specified operations', async () => {
|
|
270
|
+
const pipeline = new MiddlewarePipeline();
|
|
271
|
+
pipeline.use(authMiddleware({
|
|
272
|
+
apiKey: 'my-key',
|
|
273
|
+
requiredFor: ['createCovenant', 'verifyCovenant'],
|
|
274
|
+
}));
|
|
275
|
+
// This operation is not in requiredFor, should pass without auth check
|
|
276
|
+
const result = await pipeline.execute('evaluateAction', {}, echoOp('evaluated'));
|
|
277
|
+
expect(result).toBe('evaluated');
|
|
278
|
+
});
|
|
279
|
+
it('enforces auth for operations in requiredFor', async () => {
|
|
280
|
+
const pipeline = new MiddlewarePipeline();
|
|
281
|
+
pipeline.use(authMiddleware({
|
|
282
|
+
apiKey: 'valid-key',
|
|
283
|
+
requiredFor: ['createCovenant'],
|
|
284
|
+
}));
|
|
285
|
+
const result = await pipeline.execute('createCovenant', {}, echoOp('created'));
|
|
286
|
+
expect(result).toBe('created');
|
|
287
|
+
});
|
|
288
|
+
it('allows unrestricted operations without auth', async () => {
|
|
289
|
+
const pipeline = new MiddlewarePipeline();
|
|
290
|
+
pipeline.use(authMiddleware({
|
|
291
|
+
keyPair: {
|
|
292
|
+
publicKeyHex: '',
|
|
293
|
+
privateKey: new Uint8Array(16), // Invalid key pair
|
|
294
|
+
},
|
|
295
|
+
requiredFor: ['createCovenant'],
|
|
296
|
+
}));
|
|
297
|
+
// evaluateAction not in requiredFor, should pass
|
|
298
|
+
const result = await pipeline.execute('evaluateAction', {}, echoOp('ok'));
|
|
299
|
+
expect(result).toBe('ok');
|
|
300
|
+
});
|
|
301
|
+
it('blocks auth-required operations with invalid credentials', async () => {
|
|
302
|
+
const pipeline = new MiddlewarePipeline();
|
|
303
|
+
pipeline.use(authMiddleware({
|
|
304
|
+
keyPair: {
|
|
305
|
+
publicKeyHex: '',
|
|
306
|
+
privateKey: new Uint8Array(16),
|
|
307
|
+
},
|
|
308
|
+
requiredFor: ['createCovenant'],
|
|
309
|
+
}));
|
|
310
|
+
await expect(pipeline.execute('createCovenant', {}, echoOp('ok'))).rejects.toThrow('Authentication required for operation "createCovenant"');
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
// ── Both auth methods ────────────────────────────────────────────────
|
|
314
|
+
describe('dual authentication', () => {
|
|
315
|
+
it('succeeds with API key even if key pair is invalid', async () => {
|
|
316
|
+
const pipeline = new MiddlewarePipeline();
|
|
317
|
+
pipeline.use(authMiddleware({
|
|
318
|
+
apiKey: 'valid-key',
|
|
319
|
+
keyPair: {
|
|
320
|
+
publicKeyHex: '',
|
|
321
|
+
privateKey: new Uint8Array(16),
|
|
322
|
+
},
|
|
323
|
+
}));
|
|
324
|
+
const result = await pipeline.execute('op', {}, echoOp('ok'));
|
|
325
|
+
expect(result).toBe('ok');
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
330
|
+
// Metrics Plugin Tests
|
|
331
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
332
|
+
describe('metricsMiddleware', () => {
|
|
333
|
+
let pipeline;
|
|
334
|
+
let registry;
|
|
335
|
+
let metricsPlugin;
|
|
336
|
+
beforeEach(() => {
|
|
337
|
+
pipeline = new MiddlewarePipeline();
|
|
338
|
+
registry = createMetricsRegistry();
|
|
339
|
+
metricsPlugin = metricsMiddleware({ registry, prefix: 'test' });
|
|
340
|
+
pipeline.use(metricsPlugin);
|
|
341
|
+
});
|
|
342
|
+
it('has the name "metrics"', () => {
|
|
343
|
+
expect(metricsPlugin.name).toBe('metrics');
|
|
344
|
+
});
|
|
345
|
+
it('exposes the metrics registry', () => {
|
|
346
|
+
expect(metricsPlugin.registry).toBe(registry);
|
|
347
|
+
});
|
|
348
|
+
it('creates its own registry when none provided', () => {
|
|
349
|
+
const mw = metricsMiddleware();
|
|
350
|
+
expect(mw.registry).toBeInstanceOf(MetricsRegistry);
|
|
351
|
+
});
|
|
352
|
+
it('uses default prefix "stele" when none provided', () => {
|
|
353
|
+
const mw = metricsMiddleware();
|
|
354
|
+
const pipeline2 = new MiddlewarePipeline();
|
|
355
|
+
pipeline2.use(mw);
|
|
356
|
+
// Execute an operation to create the metrics
|
|
357
|
+
pipeline2.execute('op', {}, echoOp('ok'));
|
|
358
|
+
const snapshot = mw.registry.getAll();
|
|
359
|
+
expect(snapshot.counters).toHaveProperty('stele.operations.total');
|
|
360
|
+
});
|
|
361
|
+
// ── Counters ─────────────────────────────────────────────────────────
|
|
362
|
+
describe('operation counters', () => {
|
|
363
|
+
it('increments total operations counter', async () => {
|
|
364
|
+
await pipeline.execute('op1', {}, echoOp('ok'));
|
|
365
|
+
await pipeline.execute('op2', {}, echoOp('ok'));
|
|
366
|
+
await pipeline.execute('op3', {}, echoOp('ok'));
|
|
367
|
+
const counter = registry.counter('test.operations.total');
|
|
368
|
+
expect(counter.get()).toBe(3);
|
|
369
|
+
});
|
|
370
|
+
it('increments error counter on failure', async () => {
|
|
371
|
+
await expect(pipeline.execute('op', {}, failingOp('boom'))).rejects.toThrow('boom');
|
|
372
|
+
const errorCounter = registry.counter('test.operations.errors');
|
|
373
|
+
expect(errorCounter.get()).toBe(1);
|
|
374
|
+
});
|
|
375
|
+
it('does not increment error counter on success', async () => {
|
|
376
|
+
await pipeline.execute('op', {}, echoOp('ok'));
|
|
377
|
+
const errorCounter = registry.counter('test.operations.errors');
|
|
378
|
+
expect(errorCounter.get()).toBe(0);
|
|
379
|
+
});
|
|
380
|
+
it('tracks total and error counters independently', async () => {
|
|
381
|
+
await pipeline.execute('op1', {}, echoOp('ok'));
|
|
382
|
+
await expect(pipeline.execute('op2', {}, failingOp('boom'))).rejects.toThrow();
|
|
383
|
+
await pipeline.execute('op3', {}, echoOp('ok'));
|
|
384
|
+
const totalCounter = registry.counter('test.operations.total');
|
|
385
|
+
const errorCounter = registry.counter('test.operations.errors');
|
|
386
|
+
expect(totalCounter.get()).toBe(3);
|
|
387
|
+
expect(errorCounter.get()).toBe(1);
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
// ── Histogram ────────────────────────────────────────────────────────
|
|
391
|
+
describe('duration histogram', () => {
|
|
392
|
+
it('records operation duration', async () => {
|
|
393
|
+
await pipeline.execute('op', {}, async () => {
|
|
394
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
395
|
+
return 'done';
|
|
396
|
+
});
|
|
397
|
+
const histogram = registry.histogram('test.operations.duration');
|
|
398
|
+
const snapshot = histogram.get();
|
|
399
|
+
expect(snapshot.count).toBe(1);
|
|
400
|
+
expect(snapshot.min).toBeGreaterThanOrEqual(0);
|
|
401
|
+
});
|
|
402
|
+
it('records duration for multiple operations', async () => {
|
|
403
|
+
for (let i = 0; i < 5; i++) {
|
|
404
|
+
await pipeline.execute('op', {}, echoOp('ok'));
|
|
405
|
+
}
|
|
406
|
+
const histogram = registry.histogram('test.operations.duration');
|
|
407
|
+
const snapshot = histogram.get();
|
|
408
|
+
expect(snapshot.count).toBe(5);
|
|
409
|
+
});
|
|
410
|
+
it('records duration even on error', async () => {
|
|
411
|
+
await expect(pipeline.execute('op', {}, failingOp('fail'))).rejects.toThrow();
|
|
412
|
+
const histogram = registry.histogram('test.operations.duration');
|
|
413
|
+
const snapshot = histogram.get();
|
|
414
|
+
expect(snapshot.count).toBe(1);
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
// ── Gauge ────────────────────────────────────────────────────────────
|
|
418
|
+
describe('active operations gauge', () => {
|
|
419
|
+
it('returns to zero after successful operation', async () => {
|
|
420
|
+
await pipeline.execute('op', {}, echoOp('ok'));
|
|
421
|
+
const gauge = registry.gauge('test.operations.active');
|
|
422
|
+
expect(gauge.get()).toBe(0);
|
|
423
|
+
});
|
|
424
|
+
it('returns to zero after failed operation', async () => {
|
|
425
|
+
await expect(pipeline.execute('op', {}, failingOp('boom'))).rejects.toThrow();
|
|
426
|
+
const gauge = registry.gauge('test.operations.active');
|
|
427
|
+
expect(gauge.get()).toBe(0);
|
|
428
|
+
});
|
|
429
|
+
it('increments during operation', async () => {
|
|
430
|
+
let gaugeValueDuringOp;
|
|
431
|
+
await pipeline.execute('op', {}, async () => {
|
|
432
|
+
const gauge = registry.gauge('test.operations.active');
|
|
433
|
+
gaugeValueDuringOp = gauge.get();
|
|
434
|
+
return 'done';
|
|
435
|
+
});
|
|
436
|
+
expect(gaugeValueDuringOp).toBe(1);
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
// ── Snapshot ─────────────────────────────────────────────────────────
|
|
440
|
+
describe('full snapshot', () => {
|
|
441
|
+
it('provides a complete metrics snapshot', async () => {
|
|
442
|
+
await pipeline.execute('op1', {}, echoOp('ok'));
|
|
443
|
+
await expect(pipeline.execute('op2', {}, failingOp('fail'))).rejects.toThrow();
|
|
444
|
+
const snapshot = registry.getAll();
|
|
445
|
+
expect(snapshot.counters['test.operations.total']).toBe(2);
|
|
446
|
+
expect(snapshot.counters['test.operations.errors']).toBe(1);
|
|
447
|
+
expect(snapshot.gauges['test.operations.active']).toBe(0);
|
|
448
|
+
expect(snapshot.histograms['test.operations.duration']).toBeDefined();
|
|
449
|
+
expect(snapshot.histograms['test.operations.duration'].count).toBe(2);
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
454
|
+
// Retry Plugin Tests
|
|
455
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
456
|
+
describe('retryMiddleware', () => {
|
|
457
|
+
it('has the name "retry"', () => {
|
|
458
|
+
const mw = retryMiddleware();
|
|
459
|
+
expect(mw.name).toBe('retry');
|
|
460
|
+
});
|
|
461
|
+
it('initializes retryCount in metadata', async () => {
|
|
462
|
+
const pipeline = new MiddlewarePipeline();
|
|
463
|
+
pipeline.use(retryMiddleware());
|
|
464
|
+
let capturedMetadata;
|
|
465
|
+
pipeline.use({
|
|
466
|
+
name: 'metaCapture',
|
|
467
|
+
async before(ctx) {
|
|
468
|
+
capturedMetadata = { ...ctx.metadata };
|
|
469
|
+
return { proceed: true };
|
|
470
|
+
},
|
|
471
|
+
});
|
|
472
|
+
await pipeline.execute('op', {}, echoOp('ok'));
|
|
473
|
+
expect(capturedMetadata).toBeDefined();
|
|
474
|
+
expect(capturedMetadata.retryCount).toBe(0);
|
|
475
|
+
});
|
|
476
|
+
it('records retry count on error', async () => {
|
|
477
|
+
const pipeline = new MiddlewarePipeline();
|
|
478
|
+
const retryPlugin = retryMiddleware({ maxRetries: 2, baseDelayMs: 1 });
|
|
479
|
+
pipeline.use(retryPlugin);
|
|
480
|
+
let retryCount;
|
|
481
|
+
pipeline.use({
|
|
482
|
+
name: 'retryCapture',
|
|
483
|
+
async onError(ctx) {
|
|
484
|
+
retryCount = ctx.metadata.retryCount;
|
|
485
|
+
},
|
|
486
|
+
});
|
|
487
|
+
await expect(pipeline.execute('op', {}, failingOp('boom'))).rejects.toThrow('boom');
|
|
488
|
+
// The retry middleware records count but can't re-invoke in onError
|
|
489
|
+
expect(retryCount).toBeDefined();
|
|
490
|
+
});
|
|
491
|
+
it('uses default options when none provided', () => {
|
|
492
|
+
const mw = retryMiddleware();
|
|
493
|
+
expect(mw.name).toBe('retry');
|
|
494
|
+
expect(mw.before).toBeDefined();
|
|
495
|
+
expect(mw.after).toBeDefined();
|
|
496
|
+
expect(mw.onError).toBeDefined();
|
|
497
|
+
});
|
|
498
|
+
it('passes through on success', async () => {
|
|
499
|
+
const pipeline = new MiddlewarePipeline();
|
|
500
|
+
pipeline.use(retryMiddleware());
|
|
501
|
+
const result = await pipeline.execute('op', {}, echoOp('success'));
|
|
502
|
+
expect(result).toBe('success');
|
|
503
|
+
});
|
|
504
|
+
it('respects shouldRetry predicate', async () => {
|
|
505
|
+
const pipeline = new MiddlewarePipeline();
|
|
506
|
+
pipeline.use(retryMiddleware({
|
|
507
|
+
maxRetries: 3,
|
|
508
|
+
baseDelayMs: 1,
|
|
509
|
+
shouldRetry: (error) => error.message.includes('retryable'),
|
|
510
|
+
}));
|
|
511
|
+
// Non-retryable error
|
|
512
|
+
await expect(pipeline.execute('op', {}, failingOp('fatal error'))).rejects.toThrow('fatal error');
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
// ─── executeWithRetry standalone function ────────────────────────────────────
|
|
516
|
+
describe('executeWithRetry', () => {
|
|
517
|
+
it('returns result on first success', async () => {
|
|
518
|
+
const { result, retryCount } = await executeWithRetry(async () => 'hello', { maxRetries: 3, baseDelayMs: 1 });
|
|
519
|
+
expect(result).toBe('hello');
|
|
520
|
+
expect(retryCount).toBe(0);
|
|
521
|
+
});
|
|
522
|
+
it('retries on failure and succeeds eventually', async () => {
|
|
523
|
+
let attempts = 0;
|
|
524
|
+
const { result, retryCount } = await executeWithRetry(async () => {
|
|
525
|
+
attempts++;
|
|
526
|
+
if (attempts < 3) {
|
|
527
|
+
throw new Error('transient');
|
|
528
|
+
}
|
|
529
|
+
return 'recovered';
|
|
530
|
+
}, { maxRetries: 5, baseDelayMs: 1 });
|
|
531
|
+
expect(result).toBe('recovered');
|
|
532
|
+
expect(retryCount).toBe(2);
|
|
533
|
+
expect(attempts).toBe(3);
|
|
534
|
+
});
|
|
535
|
+
it('throws after exhausting max retries', async () => {
|
|
536
|
+
let attempts = 0;
|
|
537
|
+
await expect(executeWithRetry(async () => {
|
|
538
|
+
attempts++;
|
|
539
|
+
throw new Error('always fails');
|
|
540
|
+
}, { maxRetries: 3, baseDelayMs: 1 })).rejects.toThrow('always fails');
|
|
541
|
+
expect(attempts).toBe(4); // 1 initial + 3 retries
|
|
542
|
+
});
|
|
543
|
+
it('respects shouldRetry predicate', async () => {
|
|
544
|
+
let attempts = 0;
|
|
545
|
+
await expect(executeWithRetry(async () => {
|
|
546
|
+
attempts++;
|
|
547
|
+
throw new Error('non-retryable');
|
|
548
|
+
}, {
|
|
549
|
+
maxRetries: 5,
|
|
550
|
+
baseDelayMs: 1,
|
|
551
|
+
shouldRetry: (err) => !err.message.includes('non-retryable'),
|
|
552
|
+
})).rejects.toThrow('non-retryable');
|
|
553
|
+
// Should not retry at all
|
|
554
|
+
expect(attempts).toBe(1);
|
|
555
|
+
});
|
|
556
|
+
it('uses exponential backoff', async () => {
|
|
557
|
+
const startTime = Date.now();
|
|
558
|
+
let attempts = 0;
|
|
559
|
+
await expect(executeWithRetry(async () => {
|
|
560
|
+
attempts++;
|
|
561
|
+
throw new Error('fail');
|
|
562
|
+
}, { maxRetries: 2, baseDelayMs: 10 })).rejects.toThrow('fail');
|
|
563
|
+
const elapsed = Date.now() - startTime;
|
|
564
|
+
expect(attempts).toBe(3); // 1 initial + 2 retries
|
|
565
|
+
// Should have some delay (backoff), but with jitter it's not deterministic
|
|
566
|
+
// Just verify it didn't execute instantly
|
|
567
|
+
expect(elapsed).toBeGreaterThanOrEqual(0);
|
|
568
|
+
});
|
|
569
|
+
it('uses default options when none provided', async () => {
|
|
570
|
+
const { result, retryCount } = await executeWithRetry(async () => 42);
|
|
571
|
+
expect(result).toBe(42);
|
|
572
|
+
expect(retryCount).toBe(0);
|
|
573
|
+
});
|
|
574
|
+
it('handles non-Error exceptions', async () => {
|
|
575
|
+
let attempts = 0;
|
|
576
|
+
await expect(executeWithRetry(async () => {
|
|
577
|
+
attempts++;
|
|
578
|
+
throw 'string error';
|
|
579
|
+
}, { maxRetries: 1, baseDelayMs: 1 })).rejects.toThrow('string error');
|
|
580
|
+
expect(attempts).toBe(2);
|
|
581
|
+
});
|
|
582
|
+
});
|
|
583
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
584
|
+
// Plugin Composition Tests
|
|
585
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
586
|
+
describe('Plugin composition', () => {
|
|
587
|
+
it('composes auth + metrics + cache together', async () => {
|
|
588
|
+
const pipeline = new MiddlewarePipeline();
|
|
589
|
+
const registry = createMetricsRegistry();
|
|
590
|
+
pipeline.use(authMiddleware({ apiKey: 'key' }));
|
|
591
|
+
pipeline.use(metricsMiddleware({ registry, prefix: 'composed' }));
|
|
592
|
+
pipeline.use(cachingMiddleware({ maxSize: 100, ttlMs: 5000 }));
|
|
593
|
+
const result = await pipeline.execute('verifyCovenant', { doc: { id: 'cov-1', signature: 'sig-1', constraints: 'test' } }, echoOp({ valid: true }));
|
|
594
|
+
expect(result).toEqual({ valid: true });
|
|
595
|
+
// Metrics should have been recorded
|
|
596
|
+
const totalCounter = registry.counter('composed.operations.total');
|
|
597
|
+
expect(totalCounter.get()).toBe(1);
|
|
598
|
+
});
|
|
599
|
+
it('auth blocks unauthenticated operations in composed pipeline', async () => {
|
|
600
|
+
const pipeline = new MiddlewarePipeline();
|
|
601
|
+
const registry = createMetricsRegistry();
|
|
602
|
+
pipeline.use(metricsMiddleware({ registry, prefix: 'composed' }));
|
|
603
|
+
pipeline.use(authMiddleware({
|
|
604
|
+
keyPair: { publicKeyHex: '', privateKey: new Uint8Array(16) },
|
|
605
|
+
}));
|
|
606
|
+
await expect(pipeline.execute('createCovenant', {}, echoOp('ok'))).rejects.toThrow('Authentication required');
|
|
607
|
+
// Metrics should still track the failed operation
|
|
608
|
+
const totalCounter = registry.counter('composed.operations.total');
|
|
609
|
+
expect(totalCounter.get()).toBe(1);
|
|
610
|
+
const errorCounter = registry.counter('composed.operations.errors');
|
|
611
|
+
expect(errorCounter.get()).toBe(1);
|
|
612
|
+
});
|
|
613
|
+
it('composes all four plugins together', async () => {
|
|
614
|
+
const pipeline = new MiddlewarePipeline();
|
|
615
|
+
const registry = createMetricsRegistry();
|
|
616
|
+
pipeline.use(authMiddleware({ apiKey: 'valid-key' }));
|
|
617
|
+
pipeline.use(metricsMiddleware({ registry, prefix: 'all' }));
|
|
618
|
+
pipeline.use(retryMiddleware({ maxRetries: 1, baseDelayMs: 1 }));
|
|
619
|
+
pipeline.use(cachingMiddleware({ maxSize: 100, ttlMs: 5000 }));
|
|
620
|
+
const result = await pipeline.execute('evaluateAction', { doc: { constraints: "permit read on '/data'" }, action: 'read', resource: '/data' }, echoOp({ permitted: true }));
|
|
621
|
+
expect(result).toEqual({ permitted: true });
|
|
622
|
+
const totalCounter = registry.counter('all.operations.total');
|
|
623
|
+
expect(totalCounter.get()).toBe(1);
|
|
624
|
+
});
|
|
625
|
+
it('metrics tracks duration across composed middleware', async () => {
|
|
626
|
+
const pipeline = new MiddlewarePipeline();
|
|
627
|
+
const registry = createMetricsRegistry();
|
|
628
|
+
pipeline.use(metricsMiddleware({ registry, prefix: 'timing' }));
|
|
629
|
+
pipeline.use(cachingMiddleware({ maxSize: 10, ttlMs: 5000 }));
|
|
630
|
+
await pipeline.execute('op', {}, async () => {
|
|
631
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
632
|
+
return 'done';
|
|
633
|
+
});
|
|
634
|
+
const histogram = registry.histogram('timing.operations.duration');
|
|
635
|
+
const snapshot = histogram.get();
|
|
636
|
+
expect(snapshot.count).toBe(1);
|
|
637
|
+
expect(snapshot.min).toBeGreaterThanOrEqual(0);
|
|
638
|
+
});
|
|
639
|
+
});
|
|
640
|
+
//# sourceMappingURL=plugins.test.js.map
|