@indemn/cli 1.7.3 → 1.7.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.mcp.json +1 -1
- package/README.md +2 -2
- package/dist/cli/auth.d.ts.map +1 -1
- package/dist/cli/auth.js +10 -6
- package/dist/cli/auth.js.map +1 -1
- package/dist/cli/eval.d.ts.map +1 -1
- package/dist/cli/eval.js +8 -2
- package/dist/cli/eval.js.map +1 -1
- package/dist/cli/index.js +2 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/init.d.ts +1 -1
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +15 -5
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/kb.js +1 -1
- package/dist/cli/kb.js.map +1 -1
- package/dist/cli/utils/output.d.ts +0 -1
- package/dist/cli/utils/output.d.ts.map +1 -1
- package/dist/cli/utils/output.js +0 -4
- package/dist/cli/utils/output.js.map +1 -1
- package/dist/cli/voice.d.ts.map +1 -1
- package/dist/cli/voice.js +4 -1
- package/dist/cli/voice.js.map +1 -1
- package/dist/core/org-resolver.d.ts +4 -0
- package/dist/core/org-resolver.d.ts.map +1 -1
- package/dist/core/org-resolver.js +11 -0
- package/dist/core/org-resolver.js.map +1 -1
- package/dist/core/runtime.d.ts +9 -0
- package/dist/core/runtime.d.ts.map +1 -1
- package/dist/core/runtime.js +49 -0
- package/dist/core/runtime.js.map +1 -1
- package/dist/core/scope.d.ts +0 -1
- package/dist/core/scope.d.ts.map +1 -1
- package/dist/core/scope.js.map +1 -1
- package/dist/mcp/registry.d.ts.map +1 -1
- package/dist/mcp/registry.js +15 -2
- package/dist/mcp/registry.js.map +1 -1
- package/dist/mcp/server.d.ts +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +2 -19
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tool-meta.js +5 -5
- package/dist/mcp/tool-meta.js.map +1 -1
- package/dist/mcp/tools.js +6 -6
- package/dist/mcp/tools.js.map +1 -1
- package/dist/report/export.d.ts.map +1 -1
- package/dist/report/export.js +2 -3
- package/dist/report/export.js.map +1 -1
- package/dist/report/generate.d.ts.map +1 -1
- package/dist/report/generate.js +3 -3
- package/dist/report/generate.js.map +1 -1
- package/dist/sdk/auth.d.ts +8 -3
- package/dist/sdk/auth.d.ts.map +1 -1
- package/dist/sdk/auth.js +13 -5
- package/dist/sdk/auth.js.map +1 -1
- package/dist/sdk/chat.d.ts.map +1 -1
- package/dist/sdk/chat.js +1 -0
- package/dist/sdk/chat.js.map +1 -1
- package/dist/sdk/client.d.ts +11 -9
- package/dist/sdk/client.d.ts.map +1 -1
- package/dist/sdk/client.js +50 -67
- package/dist/sdk/client.js.map +1 -1
- package/dist/sdk/config-change.js +1 -1
- package/dist/sdk/config-change.js.map +1 -1
- package/dist/sdk/conversations.js +1 -1
- package/dist/sdk/conversations.js.map +1 -1
- package/dist/sdk/functions.js +2 -2
- package/dist/sdk/functions.js.map +1 -1
- package/dist/sdk/jarvis-chat.js +2 -2
- package/dist/sdk/jarvis-chat.js.map +1 -1
- package/dist/sdk/kb.d.ts +1 -0
- package/dist/sdk/kb.d.ts.map +1 -1
- package/dist/sdk/kb.js +9 -3
- package/dist/sdk/kb.js.map +1 -1
- package/dist/sdk/orgs.d.ts.map +1 -1
- package/dist/sdk/orgs.js +1 -6
- package/dist/sdk/orgs.js.map +1 -1
- package/dist/sdk/session.d.ts +2 -9
- package/dist/sdk/session.d.ts.map +1 -1
- package/dist/sdk/session.js +2 -27
- package/dist/sdk/session.js.map +1 -1
- package/dist/sdk/test-chat.d.ts.map +1 -1
- package/dist/sdk/test-chat.js +17 -6
- package/dist/sdk/test-chat.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/auth.ts +9 -6
- package/src/cli/eval.ts +6 -2
- package/src/cli/index.ts +2 -2
- package/src/cli/init.ts +14 -6
- package/src/cli/kb.ts +1 -1
- package/src/cli/utils/output.ts +0 -4
- package/src/cli/voice.ts +3 -1
- package/src/core/org-resolver.ts +18 -0
- package/src/core/runtime.ts +46 -0
- package/src/core/scope.ts +0 -1
- package/src/mcp/registry.ts +17 -2
- package/src/mcp/server.ts +2 -19
- package/src/mcp/tool-meta.ts +5 -5
- package/src/mcp/tools.ts +6 -6
- package/src/report/export.ts +2 -1
- package/src/report/generate.tsx +3 -2
- package/src/sdk/auth.ts +15 -5
- package/src/sdk/chat.ts +1 -0
- package/src/sdk/client.ts +50 -72
- package/src/sdk/config-change.ts +1 -1
- package/src/sdk/conversations.ts +1 -1
- package/src/sdk/functions.ts +2 -2
- package/src/sdk/jarvis-chat.ts +2 -2
- package/src/sdk/kb.ts +10 -4
- package/src/sdk/orgs.ts +1 -4
- package/src/sdk/session.ts +2 -25
- package/src/sdk/test-chat.ts +16 -6
- package/test/cli/init.test.ts +14 -0
- package/test/core/org-resolver.test.ts +38 -2
- package/test/core/scope.test.ts +12 -0
- package/test/helpers/mcp-pair.ts +10 -1
- package/test/mcp/darwin-fixes.test.ts +18 -17
- package/test/mcp/registry.test.ts +11 -0
- package/test/mcp/shaping.test.ts +18 -0
- package/test/mcp/tools-snapshot.test.ts +4 -0
- package/test/sdk/auth.test.ts +11 -2
- package/test/sdk/client.test.ts +13 -0
- package/test/sdk/config-change.test.ts +1 -1
- package/test/sdk/kb-upload.test.ts +21 -0
- package/test/sdk/kb.test.ts +8 -3
- package/test/sdk/orgs.test.ts +18 -0
- package/test/sdk/renewal.test.ts +43 -64
- package/test/sdk/session.test.ts +1 -23
- package/test/sdk/skills.test.ts +20 -18
- package/test/sdk/test-chat.test.ts +29 -0
|
@@ -11,6 +11,7 @@ import { join } from 'path';
|
|
|
11
11
|
|
|
12
12
|
const ORG = 'aaaaaaaaaaaaaaaaaaaaaaaa';
|
|
13
13
|
const AGENT = 'bbbbbbbbbbbbbbbbbbbbbbbb';
|
|
14
|
+
const agentFound = on('GET', `/v2/bots/${AGENT}`, { json: { success: true, data: [{ _id: AGENT, name: 'Bot' }] } }); // named in approvals
|
|
14
15
|
const approve = () => ({ action: 'accept' as const, content: { confirm: true } });
|
|
15
16
|
const ok = (data: unknown) => ({ json: { success: true, data } });
|
|
16
17
|
|
|
@@ -30,7 +31,7 @@ describe('Darwin E2E fixes', () => {
|
|
|
30
31
|
const { calls } = recordFetch([
|
|
31
32
|
on('GET', '/knowledge-bases/kb1', ok({ _id: 'kb1', name: 'Roadside' })),
|
|
32
33
|
on('POST', '/knowledge-bases/kb1/import', ok({ status: 'started', message: 'file import has been initiated' })),
|
|
33
|
-
|
|
34
|
+
agentFound]);
|
|
34
35
|
p = await pair();
|
|
35
36
|
const text = JSON.stringify({ type: 'file', sources: [{ url: 'https://store.test/org/1790869827796.txt', name: 'Battery jump starts.txt' }] });
|
|
36
37
|
const res = await p.call('import_kb', { kb_id: 'kb1', data: text });
|
|
@@ -48,7 +49,7 @@ describe('Darwin E2E fixes', () => {
|
|
|
48
49
|
const { calls } = recordFetch([
|
|
49
50
|
on('GET', '/tickets/t1', ok({ _id: 't1', ticket_ref: 'TKT-1', notes: 'Old note' })),
|
|
50
51
|
on('PUT', '/tickets/t1', ok({ _id: 't1', notes: 'Old note\nChecked with towing partner.', status: 'in_progress' })),
|
|
51
|
-
|
|
52
|
+
agentFound]);
|
|
52
53
|
p = await pair();
|
|
53
54
|
const res = await p.call('update_ticket', { ticket_id: 't1', status: 'in_progress', note: 'Checked with towing partner.' });
|
|
54
55
|
expect(res.isError).toBe(false);
|
|
@@ -56,7 +57,7 @@ describe('Darwin E2E fixes', () => {
|
|
|
56
57
|
});
|
|
57
58
|
|
|
58
59
|
it('eval_gate_verdict judges against 80% when no threshold is given', async () => {
|
|
59
|
-
recordFetch([on('GET', '/api/v1/runs/r1', { json: { run_id: 'r1', status: 'completed', total: 3, passed: 1 } })]);
|
|
60
|
+
recordFetch([on('GET', '/api/v1/runs/r1', { json: { run_id: 'r1', status: 'completed', total: 3, passed: 1 } }), agentFound]);
|
|
60
61
|
p = await pair();
|
|
61
62
|
const res = await p.call('eval_gate_verdict', { run_id: 'r1' });
|
|
62
63
|
expect(res.body.data).toMatchObject({ finished: true, pass: false, min_pass_rate: 0.8 });
|
|
@@ -64,7 +65,7 @@ describe('Darwin E2E fixes', () => {
|
|
|
64
65
|
});
|
|
65
66
|
|
|
66
67
|
it('add_holiday fills the type and the closed message the server requires', async () => {
|
|
67
|
-
const { calls } = recordFetch([on('PATCH', '/configurations/holidays', ok({ saved: true }))]);
|
|
68
|
+
const { calls } = recordFetch([on('PATCH', '/configurations/holidays', ok({ saved: true })), agentFound]);
|
|
68
69
|
p = await pair();
|
|
69
70
|
const res = await p.call('add_holiday', { date: '2026-12-31', name: "New Year's Eve" });
|
|
70
71
|
expect(res.isError).toBe(false);
|
|
@@ -80,7 +81,7 @@ describe('Darwin E2E fixes', () => {
|
|
|
80
81
|
recordFetch([
|
|
81
82
|
on('GET', '/ai-studio/v2/bots', ok([{ _id: AGENT, name: 'Rudra-voice-3', channels: ['VOICE'], connected_phone: '+12562897334' }, { _id: 'c', name: 'Web', channels: ['WEB'] }])),
|
|
82
83
|
on('GET', '/voice/configurations/numbers', ok([{ phoneNumber: '+18664857140', friendlyName: '(866) 485-7140', type: 'tollfree' }])),
|
|
83
|
-
|
|
84
|
+
agentFound]);
|
|
84
85
|
p = await pair();
|
|
85
86
|
const res = await p.call('list_agent_numbers');
|
|
86
87
|
expect(res.body.data).toEqual([
|
|
@@ -96,9 +97,9 @@ describe('Darwin E2E fixes', () => {
|
|
|
96
97
|
on('POST', `/bots/${AGENT}/functions/f1/parameters`, ok([{ _id: 'p1', name: 'zip_code', type: 'string', is_required: true }])),
|
|
97
98
|
on('PUT', `/bots/${AGENT}/functions/f1`, ok({ _id: 'f1', name: 'Weather', type: 'rest_api' })),
|
|
98
99
|
on('POST', `/bots/${AGENT}/functions`, ok({ _id: 'f1', name: 'Weather', type: 'rest_api' })),
|
|
99
|
-
|
|
100
|
+
agentFound]);
|
|
100
101
|
p = await pair();
|
|
101
|
-
const res = await p.call('create_function', {
|
|
102
|
+
const res = await p.call('create_function', { confirm: true,
|
|
102
103
|
agent_id: AGENT, type: 'rest_api', name: 'Weather', url: 'https://httpbin.org/get?zip={{zip_code}}', method: 'GET',
|
|
103
104
|
parameters: [{ name: 'zip_code', type: 'string', description: 'ZIP code', required: true }],
|
|
104
105
|
});
|
|
@@ -108,7 +109,7 @@ describe('Darwin E2E fixes', () => {
|
|
|
108
109
|
});
|
|
109
110
|
|
|
110
111
|
it('keeps the server\'s reason on a 403 instead of "Access denied"', async () => {
|
|
111
|
-
recordFetch([on('GET', '/tickets', { status: 403, json: { success: false, error: 'Tickets are not enabled for this organization.' } })]);
|
|
112
|
+
recordFetch([on('GET', '/tickets', { status: 403, json: { success: false, error: 'Tickets are not enabled for this organization.' } }), agentFound]);
|
|
112
113
|
p = await pair();
|
|
113
114
|
const res = await p.call('list_tickets');
|
|
114
115
|
expect(res.isError).toBe(true);
|
|
@@ -119,7 +120,7 @@ describe('Darwin E2E fixes', () => {
|
|
|
119
120
|
const { calls } = recordFetch([
|
|
120
121
|
on('GET', '/configurations', ok({ businessHoursConfig: { businessHoursEnabled: false } })),
|
|
121
122
|
on('PATCH', '/configurations/business-hours', ok({ success: true })),
|
|
122
|
-
|
|
123
|
+
agentFound]);
|
|
123
124
|
p = await pair();
|
|
124
125
|
const hours = [1, 2, 3, 4, 5].map((day) => ({ day, slots: [{ start: '08:00', end: '18:00' }] }));
|
|
125
126
|
const res = await p.call('set_business_hours', { hours: JSON.stringify(hours) });
|
|
@@ -137,7 +138,7 @@ describe('Darwin E2E fixes', () => {
|
|
|
137
138
|
on('GET', `/ai-studio/v2/bots/${AGENT}`, ok([{ _id: AGENT, name: 'Voice Test Agent' }])),
|
|
138
139
|
on('GET', '/evaluator-configs', { json: { items: [{ config_id: 'c1' }] } }),
|
|
139
140
|
on('POST', '/api/v1/evaluate-transcript', { json: { success: true, score: 0.9 } }),
|
|
140
|
-
|
|
141
|
+
agentFound]);
|
|
141
142
|
p = await pair();
|
|
142
143
|
const baseline = [{ sender: 'user', text: 'Hi' }, { sender: 'agent', text: 'Hello' }, { sender: 'system', text: 'ended' }];
|
|
143
144
|
const same = await p.call('evaluate_transcript', { agent_id: AGENT, baseline_transcript: baseline, replay_transcript: baseline });
|
|
@@ -153,7 +154,7 @@ describe('Darwin E2E fixes', () => {
|
|
|
153
154
|
it('list_kb_data leaves the chunks out unless asked', async () => {
|
|
154
155
|
recordFetch([on('GET', '/knowledge-bases/kb1/data-source', ok({
|
|
155
156
|
dataSources: [{ _id: 's1', type: 'text', question: 'Do you tow motorcycles?', answer: 'Yes', trained: true, chunks: [{ text: 'x' }], chunks_text: ['x'] }], total: 1, page: 1, pages: 1,
|
|
156
|
-
}))]);
|
|
157
|
+
})), agentFound]);
|
|
157
158
|
p = await pair();
|
|
158
159
|
const slim = await p.call('list_kb_data', { kb_id: 'kb1' });
|
|
159
160
|
expect(slim.body.data.dataSources[0]).toEqual({ _id: 's1', type: 'text', question: 'Do you tow motorcycles?', answer: 'Yes', trained: true });
|
|
@@ -169,7 +170,7 @@ describe('Darwin E2E fixes', () => {
|
|
|
169
170
|
on('GET', '/files/signed-url', (c) => (c.url.includes('filename=org%2F1790869575391.csv') || c.url.includes('filename=org/1790869575391.csv')
|
|
170
171
|
? { json: { success: true, data: { signedUrl: 'https://indemn-dev-attachments.s3.us-east-1.amazonaws.com/org/1790869575391.csv?X-Amz-Signature=abc' } } }
|
|
171
172
|
: { status: 404, json: { success: false } })),
|
|
172
|
-
|
|
173
|
+
agentFound]);
|
|
173
174
|
p = await pair();
|
|
174
175
|
const res = await p.call('export_kb', { kb_id: 'kb1' });
|
|
175
176
|
expect(res.body.data.download_url).toContain('X-Amz-Signature=abc');
|
|
@@ -184,7 +185,7 @@ describe('Darwin E2E fixes', () => {
|
|
|
184
185
|
on('GET', `/ai-studio/v2/bots/${AGENT}`, ok([{ _id: AGENT, name: 'Darwin QA Agent', description: 'Roadside help, short answers.' }])),
|
|
185
186
|
on('POST', '/ai-studio/v2/bots/clone', ok({ new_agent_id: COPY, new_agent_name: 'Darwin QA Clone', new_kb_ids: [] })),
|
|
186
187
|
on('PUT', `/ai-studio/bots/${COPY}`, ok({ _id: COPY, name: 'Darwin QA Clone', description: 'Roadside help, short answers.' })),
|
|
187
|
-
|
|
188
|
+
agentFound]);
|
|
188
189
|
p = await pair();
|
|
189
190
|
const res = await p.call('clone_agent', { agent_id: AGENT, name: 'Darwin QA Clone' });
|
|
190
191
|
expect(res.isError).toBe(false);
|
|
@@ -196,7 +197,7 @@ describe('Darwin E2E fixes', () => {
|
|
|
196
197
|
on('GET', '/knowledge-bases/kb1/data-source', ok({ dataSources: [{ _id: 's1', question: 'How late can I buy a policy?', answer: 'Up to 14 days before.' }], total: 1, page: 1, pages: 1 })),
|
|
197
198
|
on('GET', '/knowledge-bases/kb1', ok({ _id: 'kb1', name: 'Darwin KB Test' })),
|
|
198
199
|
on('PUT', '/knowledge-bases/kb1/data-source/s1', ok({ _id: 's1', question: 'How late can I buy a policy?', answer: 'Up to 7 days before.' })),
|
|
199
|
-
|
|
200
|
+
agentFound]);
|
|
200
201
|
p = await pair();
|
|
201
202
|
const res = await p.call('update_kb_data', { kb_id: 'kb1', source_id: 's1', answer: 'Up to 7 days before.' });
|
|
202
203
|
expect(res.isError).toBe(false);
|
|
@@ -209,7 +210,7 @@ describe('Darwin E2E fixes', () => {
|
|
|
209
210
|
recordFetch([
|
|
210
211
|
on('GET', '/audit-logs', ok([{ _id: 'a1', id_user: 'u1', action: 'update', resource_type: 'bot', resource_display: 'Darwin QA Agent', createdAt: '2026-10-01T10:00:00Z' }, { _id: 'a2', id_user: 'u9', action: 'create', createdAt: '2026-10-01T09:00:00Z' }])),
|
|
211
212
|
on('GET', '/projects-users', ok({ projectUsers: [{ role: 'owner', status: 'active', id_user: { _id: 'u1', email: 'ana@acme.com', firstname: 'Ana', lastname: 'Diaz' }, id_project: { name: 'default' } }] })),
|
|
212
|
-
|
|
213
|
+
agentFound]);
|
|
213
214
|
p = await pair();
|
|
214
215
|
const res = await p.call('list_audit_logs', {});
|
|
215
216
|
expect(res.isError).toBe(false);
|
|
@@ -221,7 +222,7 @@ describe('Darwin E2E fixes', () => {
|
|
|
221
222
|
const { calls } = recordFetch([
|
|
222
223
|
on('GET', `/ai-studio/v2/bots/${AGENT}`, ok([{ _id: AGENT, name: 'Darwin QA Agent' }])),
|
|
223
224
|
on('GET', '/projects-users', ok({ projectUsers: [{ role: 'admin', status: 'active', id_user: { _id: 'cccccccccccccccccccccccc', email: 'ana@acme.com', firstname: 'Ana', lastname: 'Diaz' }, id_project: { name: 'default' } }] })),
|
|
224
|
-
|
|
225
|
+
agentFound]);
|
|
225
226
|
p = await pair(() => ({ action: 'decline' as const }));
|
|
226
227
|
await p.call('transfer_agent_owner', { agent_id: AGENT, owner_id: 'cccccccccccccccccccccccc' });
|
|
227
228
|
expect(calls.filter((c) => c.method !== 'GET')).toHaveLength(0); // declined: nothing transferred
|
|
@@ -236,7 +237,7 @@ describe('Darwin E2E fixes', () => {
|
|
|
236
237
|
{ _id: 'f1', type: 'file', source: signed, status: 'success', source_details: { file_name: 'Battery jump starts.txt' } },
|
|
237
238
|
{ _id: 'u1', type: 'url', source: 'https://www.indemn.ai/?ref=1', status: 'success' },
|
|
238
239
|
] })),
|
|
239
|
-
|
|
240
|
+
agentFound]);
|
|
240
241
|
p = await pair(() => ({ action: 'decline' as const }));
|
|
241
242
|
await p.call('delete_kb_data', { kb_id: 'kb1', source_id: 'f1' });
|
|
242
243
|
expect(p.questions[0].message).toContain('"Battery jump starts.txt"');
|
|
@@ -166,4 +166,15 @@ describe('registry', () => {
|
|
|
166
166
|
expect(handler).not.toHaveBeenCalled();
|
|
167
167
|
await pair.close();
|
|
168
168
|
});
|
|
169
|
+
|
|
170
|
+
it('on a host without approval forms, confirm: true is taken only for a change already shown as a dry run', async () => {
|
|
171
|
+
const { guardDestructive } = await import('../../src/mcp/registry.js');
|
|
172
|
+
const server = new McpServer({ name: 't', version: '0' });
|
|
173
|
+
const first = await guardDestructive(server, 'delete_agent', { agent_id: 'a1', confirm: true }, 'Delete a1');
|
|
174
|
+
expect(first.proceed).toBe(false); // straight to confirm: shown as a dry run instead
|
|
175
|
+
const again = await guardDestructive(server, 'delete_agent', { confirm: true, agent_id: 'a1' }, 'Delete a1');
|
|
176
|
+
expect(again).toEqual({ proceed: true, byPerson: false });
|
|
177
|
+
const other = await guardDestructive(server, 'delete_agent', { agent_id: 'a2', confirm: true }, 'Delete a2');
|
|
178
|
+
expect(other.proceed).toBe(false); // a different change needs its own dry run
|
|
179
|
+
});
|
|
169
180
|
});
|
package/test/mcp/shaping.test.ts
CHANGED
|
@@ -32,6 +32,24 @@ describe('result shaping', () => {
|
|
|
32
32
|
await pair.close();
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
+
it('a page trimmed to fit never skips items: it says more exist and where to continue with a smaller limit', async () => {
|
|
36
|
+
const pair = await connectPair((server) => {
|
|
37
|
+
const reg = createRegistry(server, { context: () => ({ environment: 'dev' }), meta });
|
|
38
|
+
reg.tool('many', 'x', {}, async () => ok(Array.from({ length: 120 }, (i) => ({ i, pad: 'x'.repeat(4_000) }))));
|
|
39
|
+
});
|
|
40
|
+
for (const page of [1, 3]) { // a middle page and the last one
|
|
41
|
+
const res = await pair.call('many', { page });
|
|
42
|
+
const data = res.body.data;
|
|
43
|
+
expect(data.truncated).toBe(true);
|
|
44
|
+
expect(data.has_more).toBe(true); // items of this page were left out, even on the last page
|
|
45
|
+
const keep = data.returned as number;
|
|
46
|
+
const from = Math.floor(((page - 1) * 50) / keep) + 1; // the page holding this page's first item at limit `keep`
|
|
47
|
+
expect(data.next).toContain(`limit ${keep}`);
|
|
48
|
+
expect(data.next).toContain(`page ${from}`);
|
|
49
|
+
}
|
|
50
|
+
await pair.close();
|
|
51
|
+
});
|
|
52
|
+
|
|
35
53
|
it('refuses a paged tool that brings its own limit (it would never page)', async () => {
|
|
36
54
|
await connectPair((server) => {
|
|
37
55
|
const reg = createRegistry(server, { context: () => ({ environment: 'dev' }), meta });
|
|
@@ -24,6 +24,10 @@ describe('MCP tool list', () => {
|
|
|
24
24
|
for (const name of ['whoami', 'list_organizations', 'eval_quality_metrics']) {
|
|
25
25
|
expect(props(name), name).not.toContain('org_id');
|
|
26
26
|
}
|
|
27
|
+
// Writes that change what customers get or reach outside ask first on every host (a dry run, then confirm).
|
|
28
|
+
for (const name of ['delete_agent', 'create_function', 'test_function', 'add_kb_data', 'scrape_url', 'upload_kb_file']) {
|
|
29
|
+
expect(props(name), name).toContain('confirm');
|
|
30
|
+
}
|
|
27
31
|
// Test items and rubric rules have declared fields (Gemini cannot fill open objects).
|
|
28
32
|
const itemsOf = (name: string, field: string) => ((tools.find((t) => t.name === name)!.inputSchema as { properties: Record<string, { items?: { properties?: object } }> }).properties[field].items?.properties ?? {});
|
|
29
33
|
// ... and no nested objects: Gemini writes constructors for them that Google rejects.
|
package/test/sdk/auth.test.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { on, recordFetch } from '../helpers/fetch-recorder.js';
|
|
5
5
|
import { cleanEnv, useTempHome } from '../helpers/config-home.js';
|
|
6
6
|
import { jwtExpiringIn } from '../helpers/jwt.js';
|
|
7
|
-
import { login, logout, whoami } from '../../src/sdk/auth.js';
|
|
7
|
+
import { customHosts, login, logout, whoami } from '../../src/sdk/auth.js';
|
|
8
8
|
import { IndemnClient } from '../../src/sdk/client.js';
|
|
9
9
|
|
|
10
10
|
const A = { _id: 'aaaaaaaaaaaaaaaaaaaaaaaa', name: 'QA A' };
|
|
@@ -30,7 +30,8 @@ describe('token-first login', () => {
|
|
|
30
30
|
expect(result.orgs).toEqual([{ id: A._id, name: 'QA A' }, { id: B._id, name: 'QA B' }]);
|
|
31
31
|
|
|
32
32
|
const saved = JSON.parse(readFileSync(join(home.dir, '.indemn', 'config.json'), 'utf8'));
|
|
33
|
-
expect(saved.session).toMatchObject({ token
|
|
33
|
+
expect(saved.session).toMatchObject({ token });
|
|
34
|
+
expect(saved.session).not.toHaveProperty('refresh_token'); // nothing that outlives logout stays on disk
|
|
34
35
|
expect(saved.orgs).toHaveLength(2);
|
|
35
36
|
expect(saved).not.toHaveProperty('api_key');
|
|
36
37
|
expect(saved).not.toHaveProperty('org_id');
|
|
@@ -67,4 +68,12 @@ describe('token-first login', () => {
|
|
|
67
68
|
expect(text).not.toContain(token);
|
|
68
69
|
expect(text).not.toContain('r1');
|
|
69
70
|
});
|
|
71
|
+
|
|
72
|
+
it("whoami lists only the hosts that are not the platform's defaults", () => {
|
|
73
|
+
const defaults = { copilot: 'https://devcopilot.indemn.ai/api', evaluations: 'https://devevaluations.indemn.ai', middleware: 'https://devproxy.indemn.ai', platform: 'https://devplatform.indemn.ai/api/v1' };
|
|
74
|
+
expect(customHosts({ environment: 'dev', hosts: defaults })).toEqual([]);
|
|
75
|
+
expect(customHosts({ environment: 'dev', hosts: { ...defaults, copilot: 'http://localhost:3000/api', platform: 'http://localhost:8013/api/v1' } }))
|
|
76
|
+
.toEqual([['Copilot', 'http://localhost:3000/api'], ['V2 platform', 'http://localhost:8013/api/v1']]);
|
|
77
|
+
});
|
|
70
78
|
});
|
|
79
|
+
|
package/test/sdk/client.test.ts
CHANGED
|
@@ -77,4 +77,17 @@ describe('IndemnClient request path', () => {
|
|
|
77
77
|
expect(out).toContain('200');
|
|
78
78
|
expect(out).not.toContain(DEV_KEY);
|
|
79
79
|
});
|
|
80
|
+
|
|
81
|
+
it("sends the session only to Indemn's hosts or this machine", async () => {
|
|
82
|
+
vi.stubEnv('INDEMN_COPILOT_URL', 'https://evil.example.com/api');
|
|
83
|
+
expect(() => new IndemnClient()).toThrow(/only to Indemn/);
|
|
84
|
+
vi.stubEnv('INDEMN_COPILOT_URL', 'http://devcopilot.indemn.ai/api'); // not https
|
|
85
|
+
expect(() => new IndemnClient()).toThrow(/only to Indemn/);
|
|
86
|
+
vi.stubEnv('INDEMN_COPILOT_URL', 'http://localhost:3000/api');
|
|
87
|
+
expect(() => new IndemnClient()).not.toThrow();
|
|
88
|
+
vi.stubEnv('INDEMN_COPILOT_URL', 'https://devcopilot.indemn.ai/api');
|
|
89
|
+
const { jarvisSocketUrl } = await import('../../src/sdk/jarvis-chat.js');
|
|
90
|
+
vi.stubEnv('INDEMN_PLATFORM_WS_URL', 'wss://evil.example.com/ws');
|
|
91
|
+
expect(() => jarvisSocketUrl(new IndemnClient())).toThrow(/only to Indemn/);
|
|
92
|
+
});
|
|
80
93
|
});
|
|
@@ -138,5 +138,5 @@ describe('configuration changes', () => {
|
|
|
138
138
|
const ok = await pair.call('set_prompt', { agent_id: AGENT, prompt: 'new', confirm: true, confirm_published: true });
|
|
139
139
|
expect(ok.body.data.changed).toEqual(['system_prompt']);
|
|
140
140
|
await pair.close();
|
|
141
|
-
});
|
|
141
|
+
}, 15_000); // each status read waits for the unreachable V2 platform, and a change is previewed before it is confirmed
|
|
142
142
|
});
|
|
@@ -47,4 +47,25 @@ describe('adding a document to a knowledge base', () => {
|
|
|
47
47
|
await expect(new KBSDK(new IndemnClient()).uploadFile('kb1', big)).rejects.toThrow(/limit is 10 MB/);
|
|
48
48
|
expect(calls).toHaveLength(0);
|
|
49
49
|
});
|
|
50
|
+
|
|
51
|
+
it('never uploads a file from a hidden folder such as ~/.indemn (the session lives there)', async () => {
|
|
52
|
+
const { calls } = recordFetch();
|
|
53
|
+
const secret = join(home.dir, '.indemn', 'config.json');
|
|
54
|
+
await expect(new KBSDK(new IndemnClient()).uploadFile('kb1', secret)).rejects.toThrow(/never sent/);
|
|
55
|
+
const { FunctionsSDK } = await import('../../src/sdk/functions.js');
|
|
56
|
+
await expect(new FunctionsSDK(new IndemnClient()).generateFromDoc('a1', { filePath: secret })).rejects.toThrow(/never sent/);
|
|
57
|
+
expect(calls).toHaveLength(0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('refuses only secret places, not every folder whose name starts with a dot', async () => {
|
|
61
|
+
const { uploadable } = await import('../../src/core/runtime.js');
|
|
62
|
+
const { mkdirSync } = await import('node:fs');
|
|
63
|
+
const at = (...parts: string[]) => { const f = join(home.dir, ...parts); mkdirSync(join(f, '..'), { recursive: true }); writeFileSync(f, 'x'); return f; };
|
|
64
|
+
expect(() => uploadable(at('repo', '.claude', 'worktrees', 'w1', 'Report.pdf'))).not.toThrow(); // a Claude Code worktree
|
|
65
|
+
expect(() => uploadable(at('.local', 'share', 'docs', 'faq.txt'))).not.toThrow();
|
|
66
|
+
expect(() => uploadable(at('.ssh', 'id_rsa'))).toThrow(/never sent/);
|
|
67
|
+
expect(() => uploadable(at('.aws', 'credentials'))).toThrow(/never sent/);
|
|
68
|
+
expect(() => uploadable(at('repo', '.env'))).toThrow(/never sent/); // a dotfile, wherever it is
|
|
69
|
+
});
|
|
50
70
|
});
|
|
71
|
+
|
package/test/sdk/kb.test.ts
CHANGED
|
@@ -40,6 +40,11 @@ describe('knowledge bases', () => {
|
|
|
40
40
|
await expect(sdk().importKB('kb1', { type: 'url', sources: [{ url: 'https://x' }] })).rejects.toThrow('Import failed: unreachable url');
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
+
it('reports a website crawl that never started as a failure, not as running', async () => {
|
|
44
|
+
recordFetch([on('POST', '/knowledge-bases/kb1/import', { json: { success: true, data: { status: 'error', message: 'Failed to start scraping: URL is not reachable or invalid' } } })]);
|
|
45
|
+
await expect(sdk().scrapeUrl('kb1', 'https://nowhere.acme.test')).rejects.toThrow('Crawl failed: Failed to start scraping: URL is not reachable or invalid');
|
|
46
|
+
});
|
|
47
|
+
|
|
43
48
|
it('re-imports a source and fails loudly the same way', async () => {
|
|
44
49
|
const { calls } = recordFetch([on('PUT', '/data-source/ds1/re-import', { json: { success: true, data: { status: 'error', message: 'not a url' } } })]);
|
|
45
50
|
await expect(sdk().reimport('kb1', 'ds1')).rejects.toThrow('Re-import failed: not a url');
|
|
@@ -48,12 +53,12 @@ describe('knowledge bases', () => {
|
|
|
48
53
|
|
|
49
54
|
it('reads scrape status from the knowledge base sources', async () => {
|
|
50
55
|
recordFetch([on('GET', '/knowledge-bases/kb1', { json: { success: true, data: [{ _id: 'kb1', fileWebSources: [
|
|
51
|
-
{ _id: 's1', source: 'https://a', status: 'pending', source_details: { id_scrape_request: { status: 'started' } } },
|
|
56
|
+
{ _id: 's1', source: 'https://a', status: 'pending', source_details: { id_scrape_request: { _id: 'j1', status: 'started' } } },
|
|
52
57
|
{ _id: 's2', source: 'b.pdf', status: 'failed', error_message: 'bad pdf' },
|
|
53
58
|
] }] } })]);
|
|
54
59
|
expect(await sdk().scrapeStatus('kb1')).toEqual([
|
|
55
|
-
{ id: 's1', source: 'https://a', status: 'pending', job_status: 'started', error: undefined, completed_at: undefined },
|
|
56
|
-
{ id: 's2', source: 'b.pdf', status: 'failed', job_status: undefined, error: 'bad pdf', completed_at: undefined },
|
|
60
|
+
{ id: 's1', source: 'https://a', status: 'pending', job_id: 'j1', job_status: 'started', error: undefined, completed_at: undefined },
|
|
61
|
+
{ id: 's2', source: 'b.pdf', status: 'failed', job_id: undefined, job_status: undefined, error: 'bad pdf', completed_at: undefined },
|
|
57
62
|
]);
|
|
58
63
|
});
|
|
59
64
|
|
package/test/sdk/orgs.test.ts
CHANGED
|
@@ -22,6 +22,24 @@ describe('OrgsSDK', () => {
|
|
|
22
22
|
expect(IndemnClient.loadConfig()?.orgs).toEqual([{ id: A, name: 'QA A' }]);
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
+
it('acts in an org joined since login: looked up once, then saved', async () => {
|
|
26
|
+
const C = 'cccccccccccccccccccccccc';
|
|
27
|
+
IndemnClient.saveConfig({ orgs: [{ id: A, name: 'QA A' }] });
|
|
28
|
+
const { calls } = recordFetch([on('GET', '/organization', { json: { success: true, userOrganizations: [{ _id: A, name: 'QA A' }, { _id: C, name: 'QA C' }] } })]);
|
|
29
|
+
await expect(new IndemnClient().resolveOrg(C)).resolves.toMatchObject({ id: C, name: 'QA C', source: 'flag' });
|
|
30
|
+
expect(IndemnClient.loadConfig()?.orgs).toEqual([{ id: A, name: 'QA A' }, { id: C, name: 'QA C' }]);
|
|
31
|
+
await new IndemnClient().resolveOrg(C);
|
|
32
|
+
expect(calls).toHaveLength(1);
|
|
33
|
+
await expect(new IndemnClient().resolveOrg('dddddddddddddddddddddddd')).rejects.toThrow('Your orgs: QA A (aaaaaaaaaaaaaaaaaaaaaaaa), QA C (cccccccccccccccccccccccc).');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('an empty answer from copilot never replaces the orgs saved at login', async () => {
|
|
37
|
+
IndemnClient.saveConfig({ orgs: [{ id: A, name: 'QA A' }] });
|
|
38
|
+
recordFetch([on('GET', '/organization', { json: { success: true, userOrganizations: [] } })]);
|
|
39
|
+
await expect(new IndemnClient().resolveOrg('cccccccccccccccccccccccc')).rejects.toThrow('not a member of org cccccccccccccccccccccccc');
|
|
40
|
+
expect(IndemnClient.loadConfig()?.orgs).toEqual([{ id: A, name: 'QA A' }]);
|
|
41
|
+
});
|
|
42
|
+
|
|
25
43
|
it('lists members once per person with only safe fields', async () => {
|
|
26
44
|
const user = { _id: 'u9', email: 'x@y.z', firstname: 'Ada', lastname: 'L', password: 'hash' };
|
|
27
45
|
recordFetch([on('GET', `/organization/${A}/projects-users`, {
|
package/test/sdk/renewal.test.ts
CHANGED
|
@@ -16,7 +16,7 @@ function login(token: string, refresh = 'r1') {
|
|
|
16
16
|
});
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
describe('
|
|
19
|
+
describe('a saved login: 24 hours, never renewed, no refresh token kept', () => {
|
|
20
20
|
let home: ReturnType<typeof useTempHome>;
|
|
21
21
|
beforeEach(() => { cleanEnv(); home = useTempHome(); setAskers({}); });
|
|
22
22
|
afterEach(() => home.cleanup());
|
|
@@ -30,54 +30,6 @@ describe('session renewal', () => {
|
|
|
30
30
|
expect(calls[0].headers.authorization).toBe(`JWT ${token}`);
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
-
it('renews silently under an hour of life, revokes the old token, then sends the request', async () => {
|
|
34
|
-
const old = jwtExpiringIn(30 * 60_000);
|
|
35
|
-
const fresh = jwtExpiringIn(24 * 3_600_000, { jti: 'new' });
|
|
36
|
-
login(old);
|
|
37
|
-
const { calls } = recordFetch([
|
|
38
|
-
on('POST', 'securetoken.googleapis.com', { json: { id_token: 'id2', refresh_token: 'r2' } }),
|
|
39
|
-
on('POST', '/auth/firebase/signin', { json: { success: true, token: `JWT ${fresh}`, user: { _id: 'u1' } } }),
|
|
40
|
-
on('POST', '/auth/firebase/logout', { json: { success: true } }),
|
|
41
|
-
]);
|
|
42
|
-
await new IndemnClient().copilotGet(BOTS);
|
|
43
|
-
expect(calls.map((c) => `${c.method} ${new URL(c.url).pathname}`)).toEqual([
|
|
44
|
-
'POST /v1/token',
|
|
45
|
-
'POST /api/auth/firebase/signin',
|
|
46
|
-
'POST /api/auth/firebase/logout',
|
|
47
|
-
`GET /api${BOTS}`,
|
|
48
|
-
]);
|
|
49
|
-
expect(calls[2].headers.authorization).toBe(`JWT ${old}`);
|
|
50
|
-
expect(calls[3].headers.authorization).toBe(`JWT ${fresh}`);
|
|
51
|
-
expect(IndemnClient.loadConfig()?.session).toMatchObject({ token: fresh, refresh_token: 'r2' });
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it('with MFA and nobody to ask, stops with exit 3 and never sends the request', async () => {
|
|
55
|
-
login(jwtExpiringIn(10 * 60_000));
|
|
56
|
-
const { calls } = recordFetch([
|
|
57
|
-
on('POST', 'securetoken', { json: { id_token: 'id2', refresh_token: 'r2' } }),
|
|
58
|
-
on('POST', '/auth/firebase/signin', { json: { success: true, requiresMfa: true, mfaSessionId: 's1' } }),
|
|
59
|
-
]);
|
|
60
|
-
const err = await new IndemnClient().copilotGet(BOTS).catch((e) => e);
|
|
61
|
-
expect(err).toBeInstanceOf(SessionExpiredError);
|
|
62
|
-
expect(err.exitCode).toBe(3);
|
|
63
|
-
expect(calls.some((c) => c.url.includes('/ai-studio/'))).toBe(false);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('with MFA and a way to ask, asks once and continues', async () => {
|
|
67
|
-
login(jwtExpiringIn(10 * 60_000));
|
|
68
|
-
const ask = vi.fn(async () => '123456');
|
|
69
|
-
setAskers({ mfaCode: ask });
|
|
70
|
-
const fresh = jwtExpiringIn(24 * 3_600_000);
|
|
71
|
-
recordFetch([
|
|
72
|
-
on('POST', 'securetoken', { json: { id_token: 'id2', refresh_token: 'r2' } }),
|
|
73
|
-
on('POST', '/auth/firebase/signin', { json: { success: true, requiresMfa: true, mfaSessionId: 's1' } }),
|
|
74
|
-
on('POST', '/auth/firebase/mfa/verify', { json: { success: true, token: `JWT ${fresh}`, user: { _id: 'u1' } } }),
|
|
75
|
-
]);
|
|
76
|
-
await new IndemnClient().copilotGet(BOTS);
|
|
77
|
-
expect(ask).toHaveBeenCalledTimes(1);
|
|
78
|
-
expect(IndemnClient.loadConfig()?.session?.token).toBe(fresh);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
33
|
it('re-reads the saved login on every request', async () => {
|
|
82
34
|
login(jwtExpiringIn(5 * 3_600_000, { n: 1 }));
|
|
83
35
|
const { calls } = recordFetch();
|
|
@@ -89,27 +41,54 @@ describe('session renewal', () => {
|
|
|
89
41
|
expect(calls[1].headers.authorization).toBe(`JWT ${second}`);
|
|
90
42
|
});
|
|
91
43
|
|
|
92
|
-
it('
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
on('POST', '/auth/firebase/signin', { json: { success: true, token: `JWT ${jwtExpiringIn(24 * 3_600_000)}`, user: { _id: 'u1' } } }),
|
|
99
|
-
]);
|
|
100
|
-
await expect(new IndemnClient().copilotGet(BOTS)).resolves.toEqual([]);
|
|
101
|
-
expect(calls.filter((c) => c.url.includes('/ai-studio/'))).toHaveLength(2);
|
|
44
|
+
it('a login that has ended stops with log in again, without sending anything', async () => {
|
|
45
|
+
IndemnClient.saveConfig({ environment: 'dev', user_id: 'u1', email: 'qa@indemn.ai', session: { token: jwtExpiringIn(-60_000), expires_at: new Date(Date.now() - 60_000).toISOString() }, orgs: [{ id: ORG, name: 'QA A' }] });
|
|
46
|
+
const { calls } = recordFetch();
|
|
47
|
+
await expect(new IndemnClient().copilotGet(BOTS)).rejects.toThrow(/Run `indemn login`/);
|
|
48
|
+
expect(calls).toHaveLength(0);
|
|
49
|
+
});
|
|
102
50
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
]);
|
|
51
|
+
it('a login near its end is used as is: nothing renews it', async () => {
|
|
52
|
+
login(jwtExpiringIn(30 * 60_000));
|
|
53
|
+
const { calls } = recordFetch();
|
|
54
|
+
await new IndemnClient().copilotGet(BOTS);
|
|
55
|
+
expect(calls.map((c) => new URL(c.url).pathname)).toEqual([`/api${BOTS}`]); // no Google token call, no new sign-in
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('a 401 means log in again: no silent renewal', async () => {
|
|
59
|
+
login(jwtExpiringIn(5 * 3_600_000));
|
|
60
|
+
const { calls } = recordFetch([on('GET', '/ai-studio/', { status: 401, text: 'revoked' })]);
|
|
108
61
|
await expect(new IndemnClient().copilotGet(BOTS)).rejects.toBeInstanceOf(SessionExpiredError);
|
|
62
|
+
expect(calls.some((c) => c.url.includes('securetoken') || c.url.includes('/auth/firebase/'))).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('a refresh token saved by an earlier version is deleted on first use', async () => {
|
|
66
|
+
login(jwtExpiringIn(5 * 3_600_000), 'r-old');
|
|
67
|
+
recordFetch();
|
|
68
|
+
await new IndemnClient().copilotGet(BOTS);
|
|
69
|
+
expect(IndemnClient.loadConfig()?.session?.refresh_token).toBeUndefined();
|
|
70
|
+
expect(IndemnClient.loadConfig()?.session?.token).toBeTruthy();
|
|
109
71
|
});
|
|
110
72
|
|
|
111
73
|
it('with no login at all, exits 3', async () => {
|
|
112
74
|
recordFetch();
|
|
113
75
|
await expect(new IndemnClient().copilotGet(BOTS)).rejects.toMatchObject({ exitCode: 3 });
|
|
114
76
|
});
|
|
77
|
+
|
|
78
|
+
it("a host's session (Darwin) that ends or is rejected says to sign in to Copilot again, not indemn login", async () => {
|
|
79
|
+
vi.stubEnv('INDEMN_TOKEN', jwtExpiringIn(5 * 3_600_000));
|
|
80
|
+
vi.stubEnv('INDEMN_ENV', 'dev');
|
|
81
|
+
vi.stubEnv('INDEMN_ORG_ID', ORG);
|
|
82
|
+
recordFetch([on('GET', '/ai-studio/', { status: 401, text: 'revoked' })]);
|
|
83
|
+
const rejected = await new IndemnClient().copilotGet(BOTS).catch((e) => e);
|
|
84
|
+
expect(rejected).toBeInstanceOf(SessionExpiredError);
|
|
85
|
+
expect(rejected.message).toMatch(/Sign in to Copilot again/);
|
|
86
|
+
expect(rejected.message).not.toMatch(/indemn login/);
|
|
87
|
+
|
|
88
|
+
vi.stubEnv('INDEMN_TOKEN', jwtExpiringIn(-60_000)); // its 24 hours are over
|
|
89
|
+
const { calls } = recordFetch();
|
|
90
|
+
await expect(new IndemnClient().copilotGet(BOTS)).rejects.toThrow(/Sign in to Copilot again/);
|
|
91
|
+
expect(calls).toHaveLength(0);
|
|
92
|
+
});
|
|
115
93
|
});
|
|
94
|
+
|
package/test/sdk/session.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { on, recordFetch } from '../helpers/fetch-recorder.js';
|
|
3
3
|
import { fakeJwt, jwtExpiringIn } from '../helpers/jwt.js';
|
|
4
|
-
import { exchangeIdToken,
|
|
4
|
+
import { exchangeIdToken, tokenExpiry } from '../../src/sdk/session.js';
|
|
5
5
|
import { FIREBASE_CONFIGS } from '../../src/sdk/auth.js';
|
|
6
6
|
import { SessionExpiredError } from '../../src/core/runtime.js';
|
|
7
7
|
|
|
@@ -11,28 +11,6 @@ describe('token expiry', () => {
|
|
|
11
11
|
expect(tokenExpiry(fakeJwt({}))).toBeNull();
|
|
12
12
|
});
|
|
13
13
|
|
|
14
|
-
it('renews under one hour of life, not with five hours, never without an expiry', () => {
|
|
15
|
-
expect(needsRenewal({ token: jwtExpiringIn(30 * 60_000) })).toBe(true);
|
|
16
|
-
expect(needsRenewal({ token: jwtExpiringIn(5 * 3_600_000) })).toBe(false);
|
|
17
|
-
expect(needsRenewal({ token: fakeJwt({}) })).toBe(false);
|
|
18
|
-
expect(needsRenewal({ token: fakeJwt({}), expires_at: new Date(Date.now() - 1000).toISOString() })).toBe(true);
|
|
19
|
-
});
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
describe('refreshIdToken', () => {
|
|
23
|
-
it('posts the refresh token to Google with the environment web key', async () => {
|
|
24
|
-
const { calls } = recordFetch([on('POST', 'securetoken.googleapis.com', { json: { id_token: 'id2', refresh_token: 'r2' } })]);
|
|
25
|
-
await expect(refreshIdToken('dev', 'r1')).resolves.toEqual({ idToken: 'id2', refreshToken: 'r2' });
|
|
26
|
-
expect(calls[0].url).toBe(`https://securetoken.googleapis.com/v1/token?key=${FIREBASE_CONFIGS.dev.apiKey}`);
|
|
27
|
-
expect(calls[0].body).toBe('grant_type=refresh_token&refresh_token=r1');
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it('turns a rejected refresh token into a log-in-again error', async () => {
|
|
31
|
-
recordFetch([on('POST', 'securetoken', { status: 400, json: { error: { message: 'TOKEN_EXPIRED' } } })]);
|
|
32
|
-
const err = await refreshIdToken('production', 'dead').catch((e) => e);
|
|
33
|
-
expect(err).toBeInstanceOf(SessionExpiredError);
|
|
34
|
-
expect(err.exitCode).toBe(3);
|
|
35
|
-
});
|
|
36
14
|
});
|
|
37
15
|
|
|
38
16
|
describe('exchangeIdToken', () => {
|