automation-ingest-e2e.test.mjs
189 lines 6.3 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 10 days ago
1 /**
2 * AIP-b e2e: HTTP session CRUD + agent ingest → 201 envelope.
3 */
4 import { describe, it, before, after } from 'node:test';
5 import assert from 'node:assert/strict';
6 import fs from 'node:fs';
7 import os from 'node:os';
8 import path from 'node:path';
9 import http from 'node:http';
10 import express from 'express';
11 import jwt from 'jsonwebtoken';
12 import {
13 processAutomationIngest,
14 sendIngestError,
15 normalizeRuleForSave,
16 MAX_USER_RULES,
17 listPackTemplates,
18 } from '../lib/automation-ingest-policy.mjs';
19 import {
20 loadIngestRulesForSub,
21 saveIngestRulesForSub,
22 getIngestIdempotency,
23 putIngestIdempotency,
24 } from '../hub/gateway/automation-ingest-store.mjs';
25 import { createProposal } from '../hub/proposals-store.mjs';
26 import { writeNote } from '../lib/write.mjs';
27 import { readNote } from '../lib/vault.mjs';
28 import { loadReviewTriggers } from '../lib/hub-proposal-review-triggers.mjs';
29 import { agentScopesPermitMethod } from '../hub/lib/agent-credential-core.mjs';
30 import { effectiveRequestPath } from '../hub/gateway/request-path.mjs';
31
32 const SECRET = 'aip-e2e-secret-value-32-bytes-ok!!';
33 let tmp;
34 let vault;
35 let server;
36 let base;
37
38 function sessionToken() {
39 return jwt.sign({ sub: 'github:e2e', type: 'session', role: 'editor' }, SECRET, { expiresIn: '1h' });
40 }
41
42 function agentToken(scopes) {
43 return jwt.sign(
44 {
45 sub: 'github:e2e',
46 type: 'agent_access',
47 typ: 'kt_agent_access',
48 aud: 'knowtation-hub-rest',
49 scopes,
50 vault_ids: ['default'],
51 cid: 'cid-e2e',
52 agent: 'videofactory-trend-agent',
53 },
54 SECRET,
55 { expiresIn: '15m' }
56 );
57 }
58
59 function auth(req, res, next) {
60 const h = req.headers.authorization || '';
61 if (!h.startsWith('Bearer ')) return res.status(401).json({ error: 'no', code: 'UNAUTHORIZED' });
62 try {
63 req.user = jwt.verify(h.slice(7), SECRET);
64 next();
65 } catch {
66 res.status(401).json({ error: 'bad', code: 'UNAUTHORIZED' });
67 }
68 }
69
70 before(async () => {
71 tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'aip-e2e-'));
72 vault = path.join(tmp, 'vault');
73 fs.mkdirSync(vault, { recursive: true });
74 const app = express();
75 app.use(express.json());
76 app.use((req, _res, next) => {
77 req.vault_id = String(req.headers['x-vault-id'] || 'default');
78 next();
79 });
80
81 app.get('/api/v1/automation/ingest-rules', auth, async (req, res) => {
82 if (req.user.type !== 'session') return res.status(401).json({ error: 'no', code: 'UNAUTHORIZED' });
83 const loaded = await loadIngestRulesForSub(req.user.sub, tmp);
84 res.json(loaded);
85 });
86 app.put('/api/v1/automation/ingest-rules', auth, async (req, res) => {
87 if (req.user.type !== 'session') return res.status(401).json({ error: 'no', code: 'UNAUTHORIZED' });
88 const list = Array.isArray(req.body.rules) ? req.body.rules : [];
89 if (list.length > MAX_USER_RULES) return res.status(400).json({ error: 'max 32 rules', code: 'BAD_REQUEST' });
90 const rules = list.map((row) => normalizeRuleForSave(row));
91 await saveIngestRulesForSub(req.user.sub, rules, tmp);
92 res.json({ rules, templates: listPackTemplates() });
93 });
94 app.post('/api/v1/automation/ingest', auth, async (req, res) => {
95 if (req.user.type === 'agent_access') {
96 if (!agentScopesPermitMethod(req.user.scopes, req.method, effectiveRequestPath(req))) {
97 return res.status(401).json({ error: 'no', code: 'UNAUTHORIZED' });
98 }
99 }
100 try {
101 const loaded = await loadIngestRulesForSub(req.user.sub, tmp);
102 const out = await processAutomationIngest({
103 rawBody: req.body,
104 idempotencyHeader: req.headers['x-ingest-idempotency-key'],
105 actor: {
106 sub: req.user.sub,
107 vaultId: req.vault_id,
108 credentialId: req.user.cid || null,
109 credentialName: req.user.agent || null,
110 evaluationRequired: false,
111 sessionBound: req.user.type === 'session',
112 },
113 rules: loaded.rules,
114 triggers: loadReviewTriggers(tmp),
115 io: {
116 getIdempotency: (k) => getIngestIdempotency(k, tmp),
117 putIdempotency: (k, e) => putIngestIdempotency(k, e, tmp),
118 appendAudit: async () => {},
119 runBilling: async () => true,
120 readExistingNote: async (p) => {
121 try { return readNote(vault, p); } catch { return null; }
122 },
123 writeNote: async (p, payload) => writeNote(vault, p, payload),
124 createProposal: async (payload) => createProposal(tmp, payload),
125 markProposalApproved: async () => ({ ok: true }),
126 },
127 });
128 res.status(out.status).json(out.body);
129 } catch (e) {
130 sendIngestError(res, e);
131 }
132 });
133
134 server = http.createServer(app);
135 await new Promise((r) => server.listen(0, r));
136 base = `http://127.0.0.1:${server.address().port}`;
137 });
138
139 after(() => {
140 if (server) server.close();
141 if (tmp) fs.rmSync(tmp, { recursive: true, force: true });
142 });
143
144 describe('automation ingest e2e', () => {
145 it('session CRUD + agent ingest 201', async () => {
146 const session = sessionToken();
147 const put = await fetch(`${base}/api/v1/automation/ingest-rules`, {
148 method: 'PUT',
149 headers: { Authorization: `Bearer ${session}`, 'Content-Type': 'application/json' },
150 body: JSON.stringify({
151 rules: [
152 {
153 label: 'e2e review',
154 disposition: 'review_queue',
155 enabled: true,
156 match: { credential_name: 'videofactory-trend-agent', path_prefix: 'inbox/trends/' },
157 content_class: 'research',
158 },
159 ],
160 }),
161 });
162 assert.equal(put.status, 200);
163 const listed = await (await fetch(`${base}/api/v1/automation/ingest-rules`, {
164 headers: { Authorization: `Bearer ${session}` },
165 })).json();
166 assert.equal(listed.rules.length, 1);
167
168 const agent = agentToken(['ingest:automation', 'vault:read']);
169 const ingest = await fetch(`${base}/api/v1/automation/ingest`, {
170 method: 'POST',
171 headers: {
172 Authorization: `Bearer ${agent}`,
173 'Content-Type': 'application/json',
174 'X-Vault-Id': 'default',
175 },
176 body: JSON.stringify({
177 path: 'inbox/trends/e2e.md',
178 body: 'trend',
179 source_fingerprint: 'e2e-finger-01',
180 content_class: 'research',
181 }),
182 });
183 assert.equal(ingest.status, 201);
184 const env = await ingest.json();
185 assert.equal(env.outcome, 'proposal');
186 assert.equal(env.replayed, false);
187 assert.ok(env.proposal_id);
188 });
189 });
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 10 days ago