agent-credentials-unit.test.mjs
225 lines 8.8 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 10 days ago
1 /**
2 * Phase C + Lane D — unit tier: parse, hash, scopes, health fields, store envelope.
3 */
4
5 import { describe, it } from 'node:test';
6 import assert from 'node:assert/strict';
7 import fs from 'node:fs/promises';
8 import os from 'node:os';
9 import path from 'node:path';
10 import {
11 parseAgentCredential,
12 hashSecret,
13 mintCredential,
14 verifyCredential,
15 recordCredentialFailure,
16 listCredentialsForSub,
17 agentScopesPermitMethod,
18 assertAgentVaultAllowed,
19 normalizeAgentRequestPath,
20 AGENT_ACCESS_TTL_SECONDS,
21 DEFAULT_AGENT_SCOPES,
22 applyScopeCeiling,
23 } from '../hub/lib/agent-credential-core.mjs';
24 import {
25 createAgentCredentialStore,
26 AGENT_CREDENTIAL_STORE_INCONSISTENT,
27 } from '../hub/gateway/agent-credential-store.mjs';
28 import {
29 subFromVerifiedPayload,
30 isAgentAccessPayload,
31 resolveActorTokenClass,
32 mayApplyAdminAllowlistOverride,
33 } from '../hub/gateway/access-token-authz.mjs';
34 import { effectiveRequestPath } from '../hub/gateway/request-path.mjs';
35 import { readFile } from 'node:fs/promises';
36 import { fileURLToPath } from 'node:url';
37
38 const __dirname = path.dirname(fileURLToPath(import.meta.url));
39 const hubJs = await readFile(path.join(__dirname, '../web/hub/hub.js'), 'utf8');
40 const hubHtml = await readFile(path.join(__dirname, '../web/hub/index.html'), 'utf8');
41
42 describe('Phase C unit — agent credentials', () => {
43 it('parses kt_agent_ wire format and rejects browser-style refresh', () => {
44 const ok = parseAgentCredential('kt_agent_abc.def');
45 assert.equal(ok.id, 'abc');
46 assert.equal(ok.secret, 'def');
47 assert.equal(parseAgentCredential('abc.def'), null);
48 assert.equal(parseAgentCredential('kt_agent_'), null);
49 });
50
51 it('hashSecret is stable and non-reversible shape', () => {
52 const h = hashSecret('secret');
53 assert.equal(h, hashSecret('secret'));
54 assert.notEqual(h, 'secret');
55 });
56
57 it('access TTL is 900s', () => {
58 assert.equal(AGENT_ACCESS_TTL_SECONDS, 900);
59 });
60
61 it('default scopes are propose + vault:read', () => {
62 assert.deepEqual([...DEFAULT_AGENT_SCOPES], ['propose', 'vault:read']);
63 });
64
65 it('mint + verify round-trip without consume-on-use', () => {
66 const { records, credential, id } = mintCredential({}, {
67 sub: 'google:1',
68 name: 'trend',
69 vault_ids: ['default'],
70 scopes: ['propose', 'vault:read'],
71 now: 1_000_000,
72 });
73 const v1 = verifyCredential(records, credential, { now: 1_000_001 });
74 assert.equal(v1.ok, true);
75 assert.equal(v1.id, id);
76 const v2 = verifyCredential(v1.records, credential, { now: 1_000_002 });
77 assert.equal(v2.ok, true);
78 });
79
80 it('applyScopeCeiling keeps propose and drops write without role write', () => {
81 const withWrite = applyScopeCeiling(['propose', 'vault:write'], ['vault:read', 'vault:write']);
82 assert.ok(withWrite.includes('propose'));
83 assert.ok(withWrite.includes('vault:write'));
84 const capped = applyScopeCeiling(['propose', 'vault:write'], ['vault:read']);
85 assert.deepEqual(capped, ['propose']);
86 assert.throws(
87 () => applyScopeCeiling(['vault:write'], ['vault:read']),
88 (e) => e && e.code === 'AGENT_SCOPE_CEILING'
89 );
90 });
91
92 it('agentScopesPermitMethod allows propose create paths only', () => {
93 const scopes = ['propose', 'vault:read'];
94 assert.equal(agentScopesPermitMethod(scopes, 'GET', '/api/v1/notes'), true);
95 assert.equal(agentScopesPermitMethod(scopes, 'POST', '/api/v1/proposals'), true);
96 assert.equal(agentScopesPermitMethod(scopes, 'POST', 'api/v1/proposals'), true);
97 assert.equal(agentScopesPermitMethod(scopes, 'POST', '/api/v1/notes'), false);
98 assert.equal(agentScopesPermitMethod(scopes, 'POST', '/api/v1/proposals/x/approve'), false);
99 // Express mount bug: suffix-only path must not authorize propose (getUserId uses effectiveRequestPath).
100 assert.equal(agentScopesPermitMethod(scopes, 'POST', '/proposals'), false);
101 });
102
103 it('normalizeAgentRequestPath strips query and leading slash', () => {
104 assert.equal(normalizeAgentRequestPath('/api/v1/proposals?x=1'), 'api/v1/proposals');
105 });
106
107 it('assertAgentVaultAllowed enforces vault_ids', () => {
108 const payload = { type: 'agent_access', vault_ids: ['v1'] };
109 assert.equal(assertAgentVaultAllowed(payload, 'v1'), true);
110 assert.equal(assertAgentVaultAllowed(payload, 'default'), false);
111 assert.equal(assertAgentVaultAllowed({ type: 'session' }, 'default'), true);
112 });
113
114 it('subFromVerifiedPayload requires aud+typ for agent_access', () => {
115 const good = {
116 sub: 'google:1',
117 type: 'agent_access',
118 typ: 'kt_agent_access',
119 aud: 'knowtation-hub-rest',
120 scopes: ['propose', 'vault:read'],
121 };
122 assert.equal(subFromVerifiedPayload(good, { method: 'POST', path: '/api/v1/proposals' }), 'google:1');
123 assert.equal(
124 subFromVerifiedPayload({ ...good, aud: 'wrong' }, { method: 'POST', path: '/api/v1/proposals' }),
125 null
126 );
127 assert.equal(isAgentAccessPayload(good), true);
128 assert.equal(resolveActorTokenClass(good), 'agent_access');
129 assert.equal(mayApplyAdminAllowlistOverride(good), false);
130 });
131
132 it('effectiveRequestPath + subFromVerifiedPayload authorizes mounted /api/v1/proposals', () => {
133 const payload = {
134 sub: 'google:1',
135 type: 'agent_access',
136 typ: 'kt_agent_access',
137 aud: 'knowtation-hub-rest',
138 scopes: ['propose', 'vault:read'],
139 };
140 const req = { method: 'POST', baseUrl: '/api/v1', path: '/proposals', url: '/api/v1/proposals' };
141 const pathOnly = effectiveRequestPath(req);
142 assert.equal(pathOnly, '/api/v1/proposals');
143 assert.equal(subFromVerifiedPayload(payload, { method: req.method, path: pathOnly }), 'google:1');
144 });
145
146 it('list includes revoked_at and last_failure fields', () => {
147 const { records, credential, id } = mintCredential({}, {
148 sub: 'google:1',
149 name: 'health',
150 vault_ids: ['default'],
151 scopes: ['propose', 'vault:read'],
152 now: 1_000_000,
153 });
154 const bad = verifyCredential(records, credential.replace(/.$/, 'x'), { now: 1_000_001 });
155 assert.equal(bad.ok, false);
156 assert.ok(bad.records);
157 const list = listCredentialsForSub(bad.records, 'google:1');
158 assert.equal(list[0].id, id);
159 assert.equal(list[0].revoked_at, null);
160 assert.equal(list[0].last_failure_code, 'invalid');
161 assert.equal(list[0].last_failure_at, 1_000_001);
162 });
163
164 it('success does not clear last_failure fields', () => {
165 const { records, credential, id } = mintCredential({}, {
166 sub: 'google:1',
167 name: 'health2',
168 vault_ids: ['default'],
169 scopes: ['propose', 'vault:read'],
170 now: 1_000_000,
171 });
172 const failed = recordCredentialFailure(records, id, 'expired', 1_000_001);
173 const ok = verifyCredential(failed, credential, { now: 1_000_002 });
174 assert.equal(ok.ok, true);
175 const list = listCredentialsForSub(ok.records, 'google:1');
176 assert.equal(list[0].last_failure_code, 'expired');
177 assert.equal(list[0].last_used_at, 1_000_002);
178 });
179
180 it('recordCredentialFailure no-ops on unknown id and bad reason', () => {
181 const { records, id } = mintCredential({}, {
182 sub: 'google:1',
183 name: 'x',
184 vault_ids: ['default'],
185 scopes: ['propose', 'vault:read'],
186 });
187 const a = recordCredentialFailure(records, 'missing', 'invalid');
188 assert.deepEqual(a, records);
189 const b = recordCredentialFailure(records, id, 'bogus');
190 assert.deepEqual(b, records);
191 });
192
193 it('meta nonempty_seen + empty data throws inconsistent; parse errors throw', async () => {
194 const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'kt-agent-unit-'));
195 process.env.KNOWTATION_GATEWAY_DATA_DIR = dir;
196 try {
197 await fs.writeFile(
198 path.join(dir, 'hosted_agent_credentials.meta.json'),
199 JSON.stringify({ schema_version: 1, nonempty_seen: true, count: 1, updated_at: Date.now() }),
200 'utf8'
201 );
202 const store = createAgentCredentialStore();
203 await assert.rejects(() => store.list('google:1'), (e) => e.code === AGENT_CREDENTIAL_STORE_INCONSISTENT);
204
205 await fs.writeFile(path.join(dir, 'hosted_agent_credentials.json'), '{not json', 'utf8');
206 await assert.rejects(
207 () => store.list('google:1'),
208 (e) => e && e.code === 'AGENT_CREDENTIAL_STORE_UNAVAILABLE'
209 );
210 } finally {
211 delete process.env.KNOWTATION_GATEWAY_DATA_DIR;
212 await fs.rm(dir, { recursive: true, force: true });
213 }
214 });
215
216 it('Hub UI source has agent-cred-store-banner and locked copy strings', () => {
217 assert.ok(hubHtml.includes('id="agent-cred-store-banner"'));
218 assert.ok(hubJs.includes('agent-cred-store-banner'));
219 assert.ok(hubJs.includes('Agent credential store is inconsistent. Do not remint.'));
220 assert.ok(hubJs.includes('Agent credential store is temporarily unavailable. Do not remint. Retry.'));
221 assert.ok(hubJs.includes('Operator wipe required on the agent credential store.'));
222 assert.ok(hubJs.includes('data.code'));
223 assert.ok(hubJs.includes('data.store'));
224 });
225 });
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 10 days ago