@creedspace/sdk 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,400 @@
1
+ /**
2
+ * Creed Space SDK Tests
3
+ */
4
+
5
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
6
+ import {
7
+ createClient,
8
+ CreedError,
9
+ computeArgsHash,
10
+ isTokenExpired,
11
+ type DecideRequest,
12
+ type AllowDecision,
13
+ type DenyDecision,
14
+ } from './index';
15
+
16
+ // Mock fetch for testing
17
+ const mockFetch = vi.fn();
18
+
19
+ describe('createClient', () => {
20
+ beforeEach(() => {
21
+ mockFetch.mockClear();
22
+ });
23
+
24
+ it('throws error if API key is missing', () => {
25
+ expect(() => createClient({ apiKey: '' })).toThrow(CreedError);
26
+ expect(() => createClient({ apiKey: '' })).toThrow('API key is required');
27
+ });
28
+
29
+ it('creates client with valid API key', () => {
30
+ const client = createClient({ apiKey: 'crd_test_123' });
31
+ expect(client).toBeDefined();
32
+ expect(client.decide).toBeDefined();
33
+ expect(client.authorize).toBeDefined();
34
+ expect(client.audit).toBeDefined();
35
+ expect(client.status).toBeDefined();
36
+ });
37
+
38
+ it('uses default baseUrl when not specified', () => {
39
+ const client = createClient({
40
+ apiKey: 'crd_test_123',
41
+ fetch: mockFetch,
42
+ });
43
+
44
+ mockFetch.mockResolvedValueOnce({
45
+ ok: true,
46
+ json: () => Promise.resolve({ service: 'creed-pdp', version: '1.0.0' }),
47
+ });
48
+
49
+ client.status();
50
+
51
+ expect(mockFetch).toHaveBeenCalledWith(
52
+ 'https://api.creed.space/v1/status',
53
+ expect.any(Object)
54
+ );
55
+ });
56
+
57
+ it('uses custom baseUrl when specified', () => {
58
+ const client = createClient({
59
+ apiKey: 'crd_test_123',
60
+ baseUrl: 'https://custom.api.com',
61
+ fetch: mockFetch,
62
+ });
63
+
64
+ mockFetch.mockResolvedValueOnce({
65
+ ok: true,
66
+ json: () => Promise.resolve({ service: 'creed-pdp' }),
67
+ });
68
+
69
+ client.status();
70
+
71
+ expect(mockFetch).toHaveBeenCalledWith(
72
+ 'https://custom.api.com/v1/status',
73
+ expect.any(Object)
74
+ );
75
+ });
76
+ });
77
+
78
+ describe('client.decide', () => {
79
+ const mockFetchLocal = vi.fn();
80
+
81
+ beforeEach(() => {
82
+ mockFetchLocal.mockClear();
83
+ });
84
+
85
+ it('sends correct request format', async () => {
86
+ const client = createClient({
87
+ apiKey: 'crd_test_123',
88
+ fetch: mockFetchLocal,
89
+ });
90
+
91
+ const allowResponse: AllowDecision = {
92
+ decision: 'ALLOW',
93
+ runId: 'run_123',
94
+ actionId: 'action_456',
95
+ toolCallId: 'tc_789',
96
+ argsHash: 'sha256:abc123',
97
+ risk: { score: 0.1, labels: [] },
98
+ decisionToken: 'eyJ...',
99
+ expiresAt: '2025-01-01T00:05:00Z',
100
+ };
101
+
102
+ mockFetchLocal.mockResolvedValueOnce({
103
+ ok: true,
104
+ json: () => Promise.resolve(allowResponse),
105
+ });
106
+
107
+ await client.decide({
108
+ toolName: 'send_email',
109
+ arguments: { to: 'user@example.com' },
110
+ });
111
+
112
+ expect(mockFetchLocal).toHaveBeenCalledWith(
113
+ 'https://api.creed.space/v1/decide',
114
+ expect.objectContaining({
115
+ method: 'POST',
116
+ headers: expect.objectContaining({
117
+ 'Content-Type': 'application/json',
118
+ Authorization: 'Bearer crd_test_123',
119
+ 'X-Creed-SDK': 'typescript/1.0.0',
120
+ }),
121
+ })
122
+ );
123
+
124
+ const callBody = JSON.parse(mockFetchLocal.mock.calls[0][1].body);
125
+ expect(callBody).toEqual({
126
+ tool_name: 'send_email',
127
+ arguments: { to: 'user@example.com' },
128
+ constitution_id: 'default',
129
+ context: {},
130
+ });
131
+ });
132
+
133
+ it('invokes onAllow callback for ALLOW decision', async () => {
134
+ const client = createClient({
135
+ apiKey: 'crd_test_123',
136
+ fetch: mockFetchLocal,
137
+ });
138
+
139
+ const allowResponse: AllowDecision = {
140
+ decision: 'ALLOW',
141
+ runId: 'run_123',
142
+ actionId: 'action_456',
143
+ toolCallId: 'tc_789',
144
+ argsHash: 'sha256:abc123',
145
+ risk: { score: 0.1, labels: [] },
146
+ decisionToken: 'eyJ...',
147
+ expiresAt: '2025-01-01T00:05:00Z',
148
+ };
149
+
150
+ mockFetchLocal.mockResolvedValueOnce({
151
+ ok: true,
152
+ json: () => Promise.resolve(allowResponse),
153
+ });
154
+
155
+ const onAllow = vi.fn();
156
+ const onDeny = vi.fn();
157
+
158
+ await client.decide({
159
+ toolName: 'send_email',
160
+ arguments: { to: 'user@example.com' },
161
+ onAllow,
162
+ onDeny,
163
+ });
164
+
165
+ expect(onAllow).toHaveBeenCalledWith(allowResponse);
166
+ expect(onDeny).not.toHaveBeenCalled();
167
+ });
168
+
169
+ it('invokes onDeny callback for DENY decision', async () => {
170
+ const client = createClient({
171
+ apiKey: 'crd_test_123',
172
+ fetch: mockFetchLocal,
173
+ });
174
+
175
+ const denyResponse: DenyDecision = {
176
+ decision: 'DENY',
177
+ runId: 'run_123',
178
+ actionId: 'action_456',
179
+ toolCallId: 'tc_789',
180
+ argsHash: 'sha256:abc123',
181
+ risk: { score: 0.9, labels: ['high_risk'] },
182
+ reasons: ['Tool not allowed by constitution'],
183
+ guidance: {
184
+ message: 'This tool is blocked',
185
+ suggestion: 'Use a different approach',
186
+ },
187
+ };
188
+
189
+ mockFetchLocal.mockResolvedValueOnce({
190
+ ok: true,
191
+ json: () => Promise.resolve(denyResponse),
192
+ });
193
+
194
+ const onAllow = vi.fn();
195
+ const onDeny = vi.fn();
196
+
197
+ await client.decide({
198
+ toolName: 'delete_all',
199
+ arguments: {},
200
+ onAllow,
201
+ onDeny,
202
+ });
203
+
204
+ expect(onDeny).toHaveBeenCalledWith(denyResponse);
205
+ expect(onAllow).not.toHaveBeenCalled();
206
+ });
207
+
208
+ it('returns result even without callbacks', async () => {
209
+ const client = createClient({
210
+ apiKey: 'crd_test_123',
211
+ fetch: mockFetchLocal,
212
+ });
213
+
214
+ const allowResponse: AllowDecision = {
215
+ decision: 'ALLOW',
216
+ runId: 'run_123',
217
+ actionId: 'action_456',
218
+ toolCallId: 'tc_789',
219
+ argsHash: 'sha256:abc123',
220
+ risk: { score: 0.1, labels: [] },
221
+ decisionToken: 'eyJ...',
222
+ expiresAt: '2025-01-01T00:05:00Z',
223
+ };
224
+
225
+ mockFetchLocal.mockResolvedValueOnce({
226
+ ok: true,
227
+ json: () => Promise.resolve(allowResponse),
228
+ });
229
+
230
+ const result = await client.decide({
231
+ toolName: 'send_email',
232
+ arguments: { to: 'user@example.com' },
233
+ });
234
+
235
+ expect(result).toEqual(allowResponse);
236
+ });
237
+ });
238
+
239
+ describe('client.authorize', () => {
240
+ const mockFetchLocal = vi.fn();
241
+
242
+ it('sends correct authorization request', async () => {
243
+ const client = createClient({
244
+ apiKey: 'crd_test_123',
245
+ fetch: mockFetchLocal,
246
+ });
247
+
248
+ mockFetchLocal.mockResolvedValueOnce({
249
+ ok: true,
250
+ json: () =>
251
+ Promise.resolve({
252
+ authorized: true,
253
+ claims: {
254
+ actionId: 'action_456',
255
+ toolName: 'send_email',
256
+ toolCallId: 'tc_789',
257
+ argsHash: 'sha256:abc123',
258
+ runId: 'run_123',
259
+ decision: 'ALLOW',
260
+ issuedAt: '2025-01-01T00:00:00Z',
261
+ expiresAt: '2025-01-01T00:05:00Z',
262
+ },
263
+ message: 'Token is valid',
264
+ }),
265
+ });
266
+
267
+ const result = await client.authorize({
268
+ decisionToken: 'eyJ...',
269
+ toolName: 'send_email',
270
+ });
271
+
272
+ expect(result.authorized).toBe(true);
273
+ expect(result.claims?.toolName).toBe('send_email');
274
+ });
275
+ });
276
+
277
+ describe('client.audit', () => {
278
+ const mockFetchLocal = vi.fn();
279
+
280
+ it('sends correct audit request', async () => {
281
+ const client = createClient({
282
+ apiKey: 'crd_test_123',
283
+ fetch: mockFetchLocal,
284
+ });
285
+
286
+ mockFetchLocal.mockResolvedValueOnce({
287
+ ok: true,
288
+ json: () =>
289
+ Promise.resolve({
290
+ runId: 'run_123',
291
+ eventCount: 2,
292
+ events: [
293
+ { seq: 0, type: 'action_created', timestamp: '2025-01-01T00:00:00Z', data: {}, hash: 'abc' },
294
+ { seq: 1, type: 'decision_made', timestamp: '2025-01-01T00:00:01Z', data: {}, hash: 'def' },
295
+ ],
296
+ integrity: { chain: 'sha256:...', verified: true },
297
+ }),
298
+ });
299
+
300
+ const result = await client.audit({
301
+ runId: 'run_123',
302
+ limit: 10,
303
+ });
304
+
305
+ expect(result.runId).toBe('run_123');
306
+ expect(result.events).toHaveLength(2);
307
+ expect(result.integrity.verified).toBe(true);
308
+ });
309
+ });
310
+
311
+ describe('error handling', () => {
312
+ it('throws CreedError on non-OK response', async () => {
313
+ const mockFetchLocal = vi.fn();
314
+ const client = createClient({
315
+ apiKey: 'crd_test_123',
316
+ fetch: mockFetchLocal,
317
+ });
318
+
319
+ mockFetchLocal.mockResolvedValueOnce({
320
+ ok: false,
321
+ status: 401,
322
+ text: () => Promise.resolve('Unauthorized'),
323
+ });
324
+
325
+ try {
326
+ await client.status();
327
+ expect.fail('Should have thrown');
328
+ } catch (error) {
329
+ expect(error).toBeInstanceOf(CreedError);
330
+ expect((error as CreedError).code).toBe('REQUEST_FAILED');
331
+ expect((error as CreedError).statusCode).toBe(401);
332
+ }
333
+ });
334
+
335
+ it('throws CreedError on network error', async () => {
336
+ const mockFetchLocal = vi.fn();
337
+ const client = createClient({
338
+ apiKey: 'crd_test_123',
339
+ fetch: mockFetchLocal,
340
+ });
341
+
342
+ mockFetchLocal.mockRejectedValueOnce(new Error('Network failure'));
343
+
344
+ try {
345
+ await client.status();
346
+ expect.fail('Should have thrown');
347
+ } catch (error) {
348
+ expect(error).toBeInstanceOf(CreedError);
349
+ expect((error as CreedError).code).toBe('NETWORK_ERROR');
350
+ }
351
+ });
352
+ });
353
+
354
+ describe('computeArgsHash', () => {
355
+ it('produces consistent hash for same arguments', async () => {
356
+ const args = { to: 'user@example.com', subject: 'Hello' };
357
+ const hash1 = await computeArgsHash(args);
358
+ const hash2 = await computeArgsHash(args);
359
+ expect(hash1).toBe(hash2);
360
+ });
361
+
362
+ it('produces different hash for different arguments', async () => {
363
+ const hash1 = await computeArgsHash({ a: 1 });
364
+ const hash2 = await computeArgsHash({ a: 2 });
365
+ expect(hash1).not.toBe(hash2);
366
+ });
367
+
368
+ it('hash starts with sha256: prefix', async () => {
369
+ const hash = await computeArgsHash({ test: true });
370
+ expect(hash.startsWith('sha256:')).toBe(true);
371
+ });
372
+
373
+ it('produces canonical hash regardless of key order', async () => {
374
+ const hash1 = await computeArgsHash({ a: 1, b: 2 });
375
+ const hash2 = await computeArgsHash({ b: 2, a: 1 });
376
+ expect(hash1).toBe(hash2);
377
+ });
378
+ });
379
+
380
+ describe('isTokenExpired', () => {
381
+ it('returns true for malformed tokens', () => {
382
+ expect(isTokenExpired('')).toBe(true);
383
+ expect(isTokenExpired('invalid')).toBe(true);
384
+ expect(isTokenExpired('a.b')).toBe(true);
385
+ });
386
+
387
+ it('returns true for expired tokens', () => {
388
+ // Create a JWT with expired timestamp
389
+ const payload = { exp: Math.floor(Date.now() / 1000) - 3600 }; // 1 hour ago
390
+ const fakeToken = `header.${btoa(JSON.stringify(payload))}.signature`;
391
+ expect(isTokenExpired(fakeToken)).toBe(true);
392
+ });
393
+
394
+ it('returns false for valid tokens', () => {
395
+ // Create a JWT with future timestamp
396
+ const payload = { exp: Math.floor(Date.now() / 1000) + 3600 }; // 1 hour from now
397
+ const fakeToken = `header.${btoa(JSON.stringify(payload))}.signature`;
398
+ expect(isTokenExpired(fakeToken)).toBe(false);
399
+ });
400
+ });