@indemn/cli 1.7.7 → 1.7.9
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 +5 -3
- package/dist/cli/auth.d.ts.map +1 -1
- package/dist/cli/auth.js +95 -62
- package/dist/cli/auth.js.map +1 -1
- package/dist/mcp/registry.d.ts +12 -0
- package/dist/mcp/registry.d.ts.map +1 -1
- package/dist/mcp/registry.js +36 -3
- package/dist/mcp/registry.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +15 -1
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tool-meta.d.ts.map +1 -1
- package/dist/mcp/tool-meta.js +47 -2
- package/dist/mcp/tool-meta.js.map +1 -1
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +139 -4
- package/dist/mcp/tools.js.map +1 -1
- package/dist/sdk/access.d.ts +60 -0
- package/dist/sdk/access.d.ts.map +1 -0
- package/dist/sdk/access.js +115 -0
- package/dist/sdk/access.js.map +1 -0
- package/dist/sdk/auth.d.ts +7 -0
- package/dist/sdk/auth.d.ts.map +1 -1
- package/dist/sdk/auth.js +21 -2
- package/dist/sdk/auth.js.map +1 -1
- package/dist/sdk/browser-login.d.ts +49 -0
- package/dist/sdk/browser-login.d.ts.map +1 -0
- package/dist/sdk/browser-login.js +159 -0
- package/dist/sdk/browser-login.js.map +1 -0
- package/dist/sdk/campaigns.d.ts +82 -3
- package/dist/sdk/campaigns.d.ts.map +1 -1
- package/dist/sdk/campaigns.js +106 -4
- package/dist/sdk/campaigns.js.map +1 -1
- package/dist/sdk/client.d.ts +2 -0
- package/dist/sdk/client.d.ts.map +1 -1
- package/dist/sdk/client.js +31 -3
- package/dist/sdk/client.js.map +1 -1
- package/dist/sdk/credentials.d.ts +2 -0
- package/dist/sdk/credentials.d.ts.map +1 -1
- package/dist/sdk/credentials.js +4 -0
- package/dist/sdk/credentials.js.map +1 -1
- package/dist/sdk/eval.d.ts +3 -3
- package/dist/sdk/eval.js +3 -3
- package/dist/sdk/types.d.ts +3 -0
- package/dist/sdk/types.d.ts.map +1 -1
- package/dist/sdk/types.js +0 -1
- package/dist/sdk/types.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/auth.ts +93 -63
- package/src/mcp/registry.ts +43 -4
- package/src/mcp/server.ts +20 -1
- package/src/mcp/tool-meta.ts +49 -2
- package/src/mcp/tools.ts +177 -5
- package/src/sdk/access.ts +143 -0
- package/src/sdk/auth.ts +23 -2
- package/src/sdk/browser-login.ts +193 -0
- package/src/sdk/campaigns.ts +125 -4
- package/src/sdk/client.ts +25 -3
- package/src/sdk/credentials.ts +5 -0
- package/src/sdk/eval.ts +3 -3
- package/src/sdk/types.ts +3 -0
- package/test/mcp/__snapshots__/tools-snapshot.test.ts.snap +7 -0
- package/test/mcp/access.test.ts +158 -0
- package/test/mcp/campaigns.test.ts +165 -0
- package/test/mcp/tools-snapshot.test.ts +2 -2
- package/test/sdk/auth.test.ts +2 -1
- package/test/sdk/browser-login.test.ts +147 -0
- package/test/sdk/campaigns.test.ts +76 -18
- package/test/sdk/client.test.ts +134 -1
- package/test/sdk/credentials.test.ts +6 -1
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import {
|
|
3
|
-
import { on, recordFetch } from '../helpers/fetch-recorder.js';
|
|
2
|
+
import { on, recordFetch, type RecordedCall } from '../helpers/fetch-recorder.js';
|
|
4
3
|
import { cleanEnv, useTempHome } from '../helpers/config-home.js';
|
|
5
4
|
import { jwtExpiringIn } from '../helpers/jwt.js';
|
|
6
5
|
import { IndemnClient } from '../../src/sdk/client.js';
|
|
7
6
|
import { CampaignsSDK } from '../../src/sdk/campaigns.js';
|
|
8
7
|
|
|
9
8
|
const ORG = 'aaaaaaaaaaaaaaaaaaaaaaaa';
|
|
9
|
+
const base = `/api/organization/${ORG}/campaigns-service`;
|
|
10
|
+
const seen = (calls: RecordedCall[]) => calls.map((c) => `${c.method} ${new URL(c.url).pathname.replace(base, '')}`);
|
|
10
11
|
|
|
11
|
-
describe('campaigns
|
|
12
|
+
describe('campaigns: reads, and writes to configuration only', () => {
|
|
12
13
|
let home: ReturnType<typeof useTempHome>;
|
|
13
14
|
beforeEach(() => {
|
|
14
15
|
cleanEnv();
|
|
@@ -18,26 +19,83 @@ describe('campaigns are read-only', () => {
|
|
|
18
19
|
});
|
|
19
20
|
afterEach(() => home.cleanup());
|
|
20
21
|
|
|
21
|
-
it('
|
|
22
|
-
const src = readFileSync(new URL('../../src/sdk/campaigns.ts', import.meta.url), 'utf8');
|
|
23
|
-
expect(src).not.toMatch(/copilot(Post|Put|Delete|RequestRaw|PostForm|Binary)/);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it('reads through the org proxy with GET only', async () => {
|
|
22
|
+
it('reads through the org proxy with GET, books included', async () => {
|
|
27
23
|
const { calls } = recordFetch([on('GET', '/campaigns-service/', { json: { total: 0, limit: 50, skip: 0, items: [] } })]);
|
|
28
24
|
const sdk = new CampaignsSDK(new IndemnClient());
|
|
29
|
-
await sdk.list(); await sdk.detail('c1', 'forecast', 30); await sdk.templates(); await sdk.template('t1', true);
|
|
30
|
-
expect(calls.
|
|
31
|
-
`GET /api/organization/${ORG}/campaigns-service/campaigns`,
|
|
32
|
-
`GET /api/organization/${ORG}/campaigns-service/campaigns/c1/forecast`,
|
|
33
|
-
`GET /api/organization/${ORG}/campaigns-service/templates`,
|
|
34
|
-
`GET /api/organization/${ORG}/campaigns-service/templates/t1/versions`,
|
|
35
|
-
]);
|
|
25
|
+
await sdk.list(); await sdk.detail('c1', 'forecast', 30); await sdk.templates(); await sdk.template('t1', true); await sdk.books(); await sdk.book('b1');
|
|
26
|
+
expect(seen(calls)).toEqual(['GET /campaigns', 'GET /campaigns/c1/forecast', 'GET /templates', 'GET /templates/t1/versions', 'GET /books', 'GET /books/b1']);
|
|
36
27
|
});
|
|
37
28
|
|
|
38
|
-
it('
|
|
29
|
+
it('never sends, runs, uploads, ticks or deletes: those paths are refused before any call', async () => {
|
|
39
30
|
const { calls } = recordFetch();
|
|
40
|
-
|
|
31
|
+
const sdk = new CampaignsSDK(new IndemnClient());
|
|
32
|
+
await expect(sdk.get('c1/../../communications/x/send')).rejects.toThrow(/not readable/);
|
|
33
|
+
const write = (sdk as unknown as { write: (m: string, p: string, b?: unknown) => Promise<unknown> }).write.bind(sdk);
|
|
34
|
+
for (const [method, path] of [['POST', '/communications/x/send'], ['POST', '/work-items/w/resolve'], ['POST', '/books/b/runs'], ['POST', '/books/b/uploads'],
|
|
35
|
+
['POST', '/campaigns/c/test-tick'], ['POST', '/campaigns/c/candidates/evaluate'], ['POST', '/enrollments/e/close'], ['POST', '/activity'],
|
|
36
|
+
['DELETE', '/campaigns/c'], ['PUT', '/campaigns/c'], ['POST', '/campaigns/c/../../communications/x/send']]) {
|
|
37
|
+
await expect(write(method, path, {})).rejects.toThrow(/not allowed from the CLI/);
|
|
38
|
+
}
|
|
41
39
|
expect(calls).toHaveLength(0);
|
|
42
40
|
});
|
|
41
|
+
|
|
42
|
+
it('a touchpoint is added, or merged into the one with its key; a template given without a version is pinned to its latest', async () => {
|
|
43
|
+
const draft = { audience: { field: 'status', op: 'eq', value: 'active' }, touchpoints: [{ key: 't1', channel: 'email', terminal_action: 'draft', when: { field: 'derived.days_out', op: 'lte', value: 60 } }], escalation_policy: { then: 'create_work_item' }, channel_policy: {} };
|
|
44
|
+
const { calls } = recordFetch([
|
|
45
|
+
on('GET', '/campaigns/c1', { json: { id: 'c1', name: 'Renewals', status: 'draft', draft } }),
|
|
46
|
+
on('GET', '/templates/tpl1/versions', { json: { total: 2, items: [{ version_n: 2, subject: 'S2', body: 'B2' }, { version_n: 1 }] } }),
|
|
47
|
+
on('PUT', '/campaigns/c1/draft', (c) => ({ json: c.body })),
|
|
48
|
+
]);
|
|
49
|
+
const sdk = new CampaignsSDK(new IndemnClient());
|
|
50
|
+
await sdk.setTouchpoint('c1', { key: 't2', channel: 'email', when: { field: 'derived.days_out', op: 'lte', value: 30 }, template_id: 'tpl1' });
|
|
51
|
+
await sdk.setTouchpoint('c1', { key: 't1', when: { field: 'derived.days_out', op: 'lte', value: 45 } });
|
|
52
|
+
const puts = calls.filter((c) => c.method === 'PUT').map((c) => c.body as typeof draft);
|
|
53
|
+
expect(puts[0].touchpoints).toEqual([draft.touchpoints[0], { key: 't2', channel: 'email', terminal_action: 'draft', when: { field: 'derived.days_out', op: 'lte', value: 30 }, template_id: 'tpl1', template_version_n: 2 }]);
|
|
54
|
+
expect(puts[0].audience).toEqual(draft.audience); // the rest of the draft is kept
|
|
55
|
+
expect(puts[1].touchpoints).toEqual([{ ...draft.touchpoints[0], when: { field: 'derived.days_out', op: 'lte', value: 45 } }]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('a draft change keeps its touchpoints; a template change starts from its latest version', async () => {
|
|
59
|
+
const draft = { audience: null, touchpoints: [{ key: 't1', channel: 'email' }], escalation_policy: {}, channel_policy: {} };
|
|
60
|
+
const { calls } = recordFetch([
|
|
61
|
+
on('GET', '/campaigns/c1', { json: { id: 'c1', draft } }),
|
|
62
|
+
on('PUT', '/campaigns/c1/draft', (c) => ({ json: c.body })),
|
|
63
|
+
on('GET', '/templates/tpl1/versions', { json: { items: [{ version_n: 3, subject: 'Hi {{ contact.first_name }}', body: 'Old body', html: '', uses_variables: [{ key: 'contact.first_name', required: true, example: 'Ana' }], selection_tags: {}, skills: {}, batch_row: '', published_by: 'u0' }] } }),
|
|
64
|
+
on('POST', '/templates/tpl1/versions', (c) => ({ json: { version_n: 4, ...(c.body as object) } })),
|
|
65
|
+
]);
|
|
66
|
+
const sdk = new CampaignsSDK(new IndemnClient());
|
|
67
|
+
await sdk.updateDraft('c1', { audience: { field: 'status', op: 'eq', value: 'active' } });
|
|
68
|
+
await sdk.updateTemplate('tpl1', { body: 'New body {{ record.policy.number }}', uses_variables: ['contact.first_name', 'record.policy.number'], change_summary: 'shorter' });
|
|
69
|
+
expect(calls.find((c) => c.method === 'PUT')!.body).toEqual({ ...draft, audience: { field: 'status', op: 'eq', value: 'active' } });
|
|
70
|
+
// Variables given as keys: a key already declared keeps its entry, a new one is declared as {key}.
|
|
71
|
+
expect(calls.find((c) => c.method === 'POST')!.body).toEqual({ subject: 'Hi {{ contact.first_name }}', body: 'New body {{ record.policy.number }}', html: '', uses_variables: [{ key: 'contact.first_name', required: true, example: 'Ana' }, { key: 'record.policy.number' }], selection_tags: {}, skills: {}, batch_row: '', change_summary: 'shorter' });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('writes each configuration call with its method, path and body', async () => {
|
|
75
|
+
const { calls } = recordFetch([
|
|
76
|
+
on('GET', '/template-skills/s1', { json: { id: 's1', name: 'Tone', instructions: 'Be brief.' } }),
|
|
77
|
+
on('GET', '/books/b1', { json: { id: 'b1', name: 'Book' } }),
|
|
78
|
+
(c) => (c.method === 'GET' ? undefined : { json: { id: 'x' } }),
|
|
79
|
+
]);
|
|
80
|
+
const sdk = new CampaignsSDK(new IndemnClient());
|
|
81
|
+
await sdk.createCampaign({ solution_id: 's', book_id: 'b', name: 'N', campaign_type: 'renewal' });
|
|
82
|
+
await sdk.publish('c1', 'first');
|
|
83
|
+
await sdk.setStatus('c1', 'paused');
|
|
84
|
+
await sdk.createTemplate({ solution_id: 's', name: 'T', channel: 'email', subject: 'S', body: 'B {{ record.account.name }}', uses_variables: ['record.account.name'] });
|
|
85
|
+
await sdk.createTemplateSkill({ solution_id: 's', key: 'tone', name: 'Tone', instructions: 'Be brief.' });
|
|
86
|
+
await sdk.updateTemplateSkill('s1', { instructions: 'Be warm.' });
|
|
87
|
+
await sdk.createBook({ solution_id: 's', name: 'Book' });
|
|
88
|
+
await sdk.updateBook('b1', { source: { kind: 'upload' }, identity_keys: ['policy.number'], display: { primary: ['policy.number'], secondary: ['insured.name'] } });
|
|
89
|
+
const writes = calls.filter((c) => c.method !== 'GET');
|
|
90
|
+
expect(seen(writes)).toEqual([
|
|
91
|
+
'POST /campaigns', 'POST /campaigns/c1/publish', 'POST /campaigns/c1/status', 'POST /templates', 'POST /template-skills',
|
|
92
|
+
'PUT /template-skills/s1', 'POST /books', 'PATCH /books/b1/source', 'PUT /books/b1/identity-keys', 'PUT /books/b1/display',
|
|
93
|
+
]);
|
|
94
|
+
expect(writes.map((c) => c.body)).toEqual([
|
|
95
|
+
{ solution_id: 's', book_id: 'b', name: 'N', campaign_type: 'renewal' }, { change_summary: 'first' }, { status: 'paused' },
|
|
96
|
+
{ solution_id: 's', name: 'T', channel: 'email', subject: 'S', body: 'B {{ record.account.name }}', uses_variables: [{ key: 'record.account.name' }] }, { solution_id: 's', key: 'tone', name: 'Tone', instructions: 'Be brief.' },
|
|
97
|
+
{ name: 'Tone', instructions: 'Be warm.' }, { solution_id: 's', name: 'Book' }, { source: { kind: 'upload' } }, // the service reads body.source
|
|
98
|
+
{ identity_keys: ['policy.number'] }, { display: { primary: ['policy.number'], secondary: ['insured.name'] } },
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
43
101
|
});
|
package/test/sdk/client.test.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
|
-
import { on, recordFetch } from '../helpers/fetch-recorder.js';
|
|
3
|
+
import { on, recordFetch, type RecordedCall } from '../helpers/fetch-recorder.js';
|
|
4
4
|
import { cleanEnv, useTempHome } from '../helpers/config-home.js';
|
|
5
|
+
import { jwtExpiringIn } from '../helpers/jwt.js';
|
|
5
6
|
import { IndemnClient } from '../../src/sdk/client.js';
|
|
7
|
+
import { runWithOrg } from '../../src/core/scope.js';
|
|
8
|
+
import { EXIT, SessionExpiredError } from '../../src/core/runtime.js';
|
|
6
9
|
|
|
7
10
|
const version = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
|
|
8
11
|
const DEV_KEY = `ind_dev_${'a'.repeat(64)}`;
|
|
@@ -91,3 +94,133 @@ describe('IndemnClient request path', () => {
|
|
|
91
94
|
expect(() => jarvisSocketUrl(new IndemnClient())).toThrow(/only to Indemn/);
|
|
92
95
|
});
|
|
93
96
|
});
|
|
97
|
+
|
|
98
|
+
describe('evaluations requests carry the credential', () => {
|
|
99
|
+
const ORG = 'aaaaaaaaaaaaaaaaaaaaaaaa';
|
|
100
|
+
const TWO = 'cccccccccccccccccccccccc';
|
|
101
|
+
let home: ReturnType<typeof useTempHome>;
|
|
102
|
+
beforeEach(() => {
|
|
103
|
+
cleanEnv();
|
|
104
|
+
home = useTempHome();
|
|
105
|
+
vi.spyOn(process, 'cwd').mockReturnValue(home.dir);
|
|
106
|
+
});
|
|
107
|
+
afterEach(() => home.cleanup());
|
|
108
|
+
|
|
109
|
+
const evalsCall = (calls: RecordedCall[]) => calls.find((c) => c.url.startsWith('https://devevaluations.indemn.ai/'))!;
|
|
110
|
+
|
|
111
|
+
it('sends a saved session as Bearer, and copilot and Darwin keep their own schemes', async () => {
|
|
112
|
+
const token = jwtExpiringIn(5 * 3_600_000);
|
|
113
|
+
IndemnClient.saveConfig({ environment: 'dev', user_id: 'u1', session: { token }, orgs: [{ id: ORG, name: 'QA A' }] });
|
|
114
|
+
const { calls } = recordFetch([on('GET', '/v1-agents', { json: [] })]);
|
|
115
|
+
const client = new IndemnClient();
|
|
116
|
+
await client.evalsGet('/api/v1/rubrics');
|
|
117
|
+
await client.copilotGet('/organization/:org_id/ai-studio/v2/bots');
|
|
118
|
+
await client.platformRequest('GET', '/v1-agents');
|
|
119
|
+
expect(calls.map((c) => c.headers.authorization)).toEqual([`Bearer ${token}`, `JWT ${token}`, `Bearer ${token}`]);
|
|
120
|
+
expect(calls[2].headers['x-organization-id']).toBe(ORG);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('sends an API key as Bearer (evaluations checks it and pins it to the key\'s org)', async () => {
|
|
124
|
+
vi.stubEnv('INDEMN_API_KEY', DEV_KEY);
|
|
125
|
+
const { calls } = recordFetch();
|
|
126
|
+
const client = new IndemnClient({ orgId: 'org1' });
|
|
127
|
+
await client.evalsPost('/api/v1/rubrics', { name: 'r' });
|
|
128
|
+
await client.copilotGet('/organization/:org_id/ai-studio/v2/bots');
|
|
129
|
+
expect(calls.map((c) => c.headers.authorization)).toEqual([`Bearer ${DEV_KEY}`, `Bearer ${DEV_KEY}`]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('passes on the session a host hands over (Darwin starting the toolbox for Jarvis)', async () => {
|
|
133
|
+
const token = jwtExpiringIn(5 * 3_600_000);
|
|
134
|
+
vi.stubEnv('INDEMN_TOKEN', `JWT ${token}`);
|
|
135
|
+
vi.stubEnv('INDEMN_ENV', 'dev');
|
|
136
|
+
const { calls } = recordFetch();
|
|
137
|
+
await new IndemnClient().evalsGet('/api/v1/runs/r1');
|
|
138
|
+
expect(evalsCall(calls).headers.authorization).toBe(`Bearer ${token}`);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('names the org once one is in effect: an MCP call\'s scope or the org this client already settled on', async () => {
|
|
142
|
+
vi.stubEnv('INDEMN_API_KEY', DEV_KEY);
|
|
143
|
+
const { calls } = recordFetch();
|
|
144
|
+
const client = new IndemnClient({ orgId: 'org1' });
|
|
145
|
+
await client.evalsGet('/api/v1/rubrics');
|
|
146
|
+
expect(calls[0].headers['x-organization-id']).toBeUndefined();
|
|
147
|
+
await runWithOrg('org-scoped', () => client.evalsGet('/api/v1/rubrics'));
|
|
148
|
+
expect(calls[1].headers['x-organization-id']).toBe('org-scoped');
|
|
149
|
+
await client.copilotGet('/organization/:org_id/ai-studio/v2/bots');
|
|
150
|
+
await client.evalsGet('/api/v1/rubrics');
|
|
151
|
+
expect(calls[3].headers['x-organization-id']).toBe('org1');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('never asks which org or looks one up just to call evaluations', async () => {
|
|
155
|
+
IndemnClient.saveConfig({ environment: 'dev', user_id: 'u1', session: { token: jwtExpiringIn(5 * 3_600_000) }, orgs: [{ id: ORG, name: 'QA A' }, { id: TWO, name: 'QA B' }] });
|
|
156
|
+
const { calls } = recordFetch();
|
|
157
|
+
await new IndemnClient().evalsGet('/api/v1/runs', { agent_id: 'a1' });
|
|
158
|
+
expect(calls).toHaveLength(1);
|
|
159
|
+
expect(calls[0].headers.authorization).toMatch(/^Bearer /);
|
|
160
|
+
expect(calls[0].headers['x-organization-id']).toBeUndefined();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('sends nothing without a credential or with auth: false', async () => {
|
|
164
|
+
const { calls } = recordFetch();
|
|
165
|
+
await new IndemnClient().evalsGet('/api/v1/rubrics');
|
|
166
|
+
expect(calls[0].headers.authorization).toBeUndefined();
|
|
167
|
+
expect(calls[0].headers['x-organization-id']).toBeUndefined();
|
|
168
|
+
|
|
169
|
+
vi.stubEnv('INDEMN_API_KEY', DEV_KEY);
|
|
170
|
+
const client = new IndemnClient();
|
|
171
|
+
// No evaluations caller passes auth: false today; the switch is honoured like copilot's.
|
|
172
|
+
await (client as unknown as { send: (b: string, m: string, u: string, o: object) => Promise<Response> })
|
|
173
|
+
.send('evaluations', 'GET', 'https://devevaluations.indemn.ai/api/v1/rubrics', { auth: false });
|
|
174
|
+
expect(calls[1].headers.authorization).toBeUndefined();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('refuses locally once a saved login has ended, like copilot', async () => {
|
|
178
|
+
IndemnClient.saveConfig({ environment: 'dev', user_id: 'u1', session: { token: jwtExpiringIn(-60_000), expires_at: new Date(Date.now() - 60_000).toISOString() } });
|
|
179
|
+
const { calls } = recordFetch();
|
|
180
|
+
await expect(new IndemnClient().evalsGet('/api/v1/rubrics')).rejects.toThrow(/indemn login/);
|
|
181
|
+
expect(calls).toHaveLength(0);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('turns a 401 from evaluations into "log in again", keeping the service\'s reason', async () => {
|
|
185
|
+
vi.stubEnv('INDEMN_API_KEY', DEV_KEY);
|
|
186
|
+
recordFetch([on('GET', '/api/v1/rubrics', { status: 401, json: { detail: 'Invalid or expired credential' } })]);
|
|
187
|
+
const unauth = await new IndemnClient().evalsGet('/api/v1/rubrics').catch((e) => e);
|
|
188
|
+
expect(unauth).toBeInstanceOf(SessionExpiredError);
|
|
189
|
+
expect(unauth.exitCode).toBe(EXIT.AUTH);
|
|
190
|
+
expect(unauth.message).toMatch(/Invalid or expired credential.*Run `indemn login`/);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('reports a 403 from evaluations as a denial with the service\'s reason, not as a login problem', async () => {
|
|
194
|
+
vi.stubEnv('INDEMN_API_KEY', DEV_KEY);
|
|
195
|
+
recordFetch([
|
|
196
|
+
on('POST', '/api/v1/rubrics', { status: 403, json: { detail: 'Access denied to this agent' } }),
|
|
197
|
+
on('GET', '/api/v1/test-sets', { status: 403, json: { detail: "API key's organization is no longer accessible" } }),
|
|
198
|
+
]);
|
|
199
|
+
const client = new IndemnClient();
|
|
200
|
+
const denied = await client.evalsPost('/api/v1/rubrics', { name: 'r' }).catch((e) => e);
|
|
201
|
+
expect(denied).not.toBeInstanceOf(SessionExpiredError);
|
|
202
|
+
expect(denied.exitCode).toBe(EXIT.ERROR);
|
|
203
|
+
expect(denied.message).toBe('The evaluations service denied access (Access denied to this agent).');
|
|
204
|
+
const gone = await client.evalsGet('/api/v1/test-sets').catch((e) => e);
|
|
205
|
+
expect(gone.message).toBe("The evaluations service denied access (API key's organization is no longer accessible).");
|
|
206
|
+
expect(gone.message).not.toMatch(/indemn login|Sign in/);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('a host\'s session is told to sign in to Copilot again on a 401', async () => {
|
|
210
|
+
vi.stubEnv('INDEMN_TOKEN', jwtExpiringIn(5 * 3_600_000));
|
|
211
|
+
vi.stubEnv('INDEMN_ENV', 'dev');
|
|
212
|
+
recordFetch([on('GET', '/api/v1/runs', { status: 401, json: { detail: 'expired' } })]);
|
|
213
|
+
await expect(new IndemnClient().evalsGet('/api/v1/runs')).rejects.toThrow(/Sign in to Copilot again/);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('never prints the evaluations credential with --debug', async () => {
|
|
217
|
+
vi.stubEnv('INDEMN_DEBUG', '1');
|
|
218
|
+
vi.stubEnv('INDEMN_API_KEY', DEV_KEY);
|
|
219
|
+
const writes: string[] = [];
|
|
220
|
+
vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: string) => { writes.push(String(chunk)); return true; }) as never);
|
|
221
|
+
recordFetch();
|
|
222
|
+
await new IndemnClient().evalsGet('/api/v1/rubrics');
|
|
223
|
+
expect(writes.join('')).toContain('GET https://devevaluations.indemn.ai/api/v1/rubrics');
|
|
224
|
+
expect(writes.join('')).not.toContain(DEV_KEY);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { copilotAuthHeader, darwinAuthHeader, resolveCredential } from '../../src/sdk/credentials.js';
|
|
2
|
+
import { copilotAuthHeader, darwinAuthHeader, evaluationsAuthHeader, resolveCredential } from '../../src/sdk/credentials.js';
|
|
3
3
|
import type { IndemnConfig } from '../../src/sdk/types.js';
|
|
4
4
|
|
|
5
5
|
const DEV_KEY = `ind_dev_${'a'.repeat(64)}`;
|
|
@@ -30,4 +30,9 @@ describe('auth headers', () => {
|
|
|
30
30
|
expect(darwinAuthHeader({ kind: 'token', token: 't', source: 'config', environment: 'dev' })).toBe('Bearer t');
|
|
31
31
|
expect(() => darwinAuthHeader({ kind: 'api_key', key: DEV_KEY, source: 'env', environment: 'dev' })).toThrow(/login session/);
|
|
32
32
|
});
|
|
33
|
+
|
|
34
|
+
it('sends the session or an API key to evaluations as Bearer', () => {
|
|
35
|
+
expect(evaluationsAuthHeader({ kind: 'token', token: 't', source: 'config', environment: 'dev' })).toBe('Bearer t');
|
|
36
|
+
expect(evaluationsAuthHeader({ kind: 'api_key', key: DEV_KEY, source: 'env', environment: 'dev' })).toBe(`Bearer ${DEV_KEY}`);
|
|
37
|
+
});
|
|
33
38
|
});
|