automation-ingest-e2e.test.mjs
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