automation-ingest-unit.test.mjs
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6
docs: record AIP-b SD-21 land (KN #308)
Human
10 days ago
| 1 | /** |
| 2 | * AIP-b unit: router, match, scopes, D3/D9/D10/D15. |
| 3 | */ |
| 4 | import { describe, it } from 'node:test'; |
| 5 | import assert from 'node:assert/strict'; |
| 6 | import { |
| 7 | routeAutomationIngest, |
| 8 | isIngestContractBody, |
| 9 | normalizeIngestBody, |
| 10 | idempotencyKeyFromRequest, |
| 11 | normalizeRuleForSave, |
| 12 | listPackTemplates, |
| 13 | } from '../lib/automation-ingest-policy.mjs'; |
| 14 | import { |
| 15 | agentScopesPermitMethod, |
| 16 | applyScopeCeiling, |
| 17 | DEFAULT_AGENT_SCOPES, |
| 18 | ALLOWED_AGENT_SCOPES, |
| 19 | } from '../hub/lib/agent-credential-core.mjs'; |
| 20 | |
| 21 | function rule(partial) { |
| 22 | return normalizeRuleForSave({ |
| 23 | label: partial.label || 'r', |
| 24 | priority: partial.priority ?? 100, |
| 25 | disposition: partial.disposition || 'review_queue', |
| 26 | content_class: partial.content_class || 'research', |
| 27 | enabled: partial.enabled !== false, |
| 28 | match: partial.match || { path_prefix: 'inbox/trends/' }, |
| 29 | rule_id: partial.rule_id, |
| 30 | }); |
| 31 | } |
| 32 | |
| 33 | describe('automation ingest unit', () => { |
| 34 | it('normalize + contract body', () => { |
| 35 | const n = normalizeIngestBody({ |
| 36 | path: '/inbox/trends/a.md', |
| 37 | body: 'hello', |
| 38 | source_fingerprint: 'sha256:abcdef12', |
| 39 | content_class: 'research', |
| 40 | }); |
| 41 | assert.equal(n.path, 'inbox/trends/a.md'); |
| 42 | assert.equal(n.source, 'automation_ingest'); |
| 43 | assert.equal(isIngestContractBody({ source_fingerprint: 'sha256:abcdef12', ingest: true }), true); |
| 44 | assert.equal(isIngestContractBody({ source_fingerprint: 'sha256:abcdef12', content_class: 'research' }), true); |
| 45 | assert.equal(isIngestContractBody({ source_fingerprint: 'sha256:abcdef12' }), false); |
| 46 | assert.equal(isIngestContractBody({ ingest: true }), false); |
| 47 | }); |
| 48 | |
| 49 | it('first-match by priority then rule_id', () => { |
| 50 | const a = rule({ rule_id: 'ingr_aaaaaaaaaaaaaaaa', priority: 50, label: 'a', disposition: 'direct_note' }); |
| 51 | const b = rule({ rule_id: 'ingr_bbbbbbbbbbbbbbbb', priority: 10, label: 'b', disposition: 'review_queue' }); |
| 52 | const routed = routeAutomationIngest( |
| 53 | { path: 'inbox/trends/x.md', body: 'x', content_class: 'research', credential_name: 'bot' }, |
| 54 | [a, b] |
| 55 | ); |
| 56 | assert.equal(routed.rule_id, b.rule_id); |
| 57 | assert.equal(routed.disposition, 'review_queue'); |
| 58 | }); |
| 59 | |
| 60 | it('D3 empty match rejected on save', () => { |
| 61 | assert.throws( |
| 62 | () => normalizeRuleForSave({ label: 'empty', disposition: 'review_queue', match: {} }), |
| 63 | (e) => e.code === 'INGEST_RULE_MATCH_EMPTY' |
| 64 | ); |
| 65 | }); |
| 66 | |
| 67 | it('D9 elevated override forces review_queue', () => { |
| 68 | const r = rule({ |
| 69 | rule_id: 'ingr_cccccccccccccccc', |
| 70 | disposition: 'direct_note', |
| 71 | match: { path_prefix: 'legal/' }, |
| 72 | }); |
| 73 | const routed = routeAutomationIngest( |
| 74 | { |
| 75 | path: 'legal/secret.md', |
| 76 | body: 'reset password for admin', |
| 77 | content_class: 'ops', |
| 78 | triggers: { |
| 79 | literal_phrases: [{ match: 'reset password', review_severity: 'elevated' }], |
| 80 | path_prefixes: [], |
| 81 | label_any: [], |
| 82 | }, |
| 83 | }, |
| 84 | [r] |
| 85 | ); |
| 86 | assert.equal(routed.disposition, 'review_queue'); |
| 87 | assert.equal(routed.elevated_override, true); |
| 88 | }); |
| 89 | |
| 90 | it('D10 evaluation block rewrites auto-apply for agents', () => { |
| 91 | const r = rule({ |
| 92 | rule_id: 'ingr_dddddddddddddddd', |
| 93 | disposition: 'proposal_auto_apply', |
| 94 | match: { path_prefix: 'inbox/trends/' }, |
| 95 | }); |
| 96 | const routed = routeAutomationIngest( |
| 97 | { |
| 98 | path: 'inbox/trends/x.md', |
| 99 | body: 'x', |
| 100 | content_class: 'research', |
| 101 | evaluationRequired: true, |
| 102 | sessionBound: false, |
| 103 | }, |
| 104 | [r] |
| 105 | ); |
| 106 | assert.equal(routed.disposition, 'review_queue'); |
| 107 | assert.equal(routed.evaluation_block, true); |
| 108 | }); |
| 109 | |
| 110 | it('D15 idempotency key prefers header', () => { |
| 111 | assert.equal(idempotencyKeyFromRequest('headerkey12', 'fingerprint12'), 'headerkey12'); |
| 112 | assert.equal(idempotencyKeyFromRequest(' ', 'fingerprint12'), 'fingerprint12'); |
| 113 | }); |
| 114 | |
| 115 | it('scope allowlist: ingest path needs ingest:automation; propose cannot ingest; ingest cannot approve', () => { |
| 116 | assert.equal(ALLOWED_AGENT_SCOPES.includes('ingest:automation'), true); |
| 117 | assert.deepEqual([...DEFAULT_AGENT_SCOPES], ['propose', 'vault:read']); |
| 118 | const ingest = ['ingest:automation', 'vault:read']; |
| 119 | const propose = ['propose', 'vault:read']; |
| 120 | assert.equal(agentScopesPermitMethod(ingest, 'POST', 'api/v1/automation/ingest'), true); |
| 121 | assert.equal(agentScopesPermitMethod(propose, 'POST', 'api/v1/automation/ingest'), false); |
| 122 | assert.equal(agentScopesPermitMethod(ingest, 'POST', 'api/v1/proposals/abc/approve'), false); |
| 123 | assert.equal(agentScopesPermitMethod(ingest, 'GET', 'api/v1/automation/ingest-rules'), false); |
| 124 | assert.equal(agentScopesPermitMethod(['vault:write'], 'POST', 'api/v1/automation/ingest'), true); |
| 125 | const ceiling = applyScopeCeiling(['ingest:automation', 'vault:read'], ['vault:read']); |
| 126 | assert.ok(ceiling.includes('ingest:automation')); |
| 127 | assert.ok(!ceiling.includes('vault:write')); |
| 128 | }); |
| 129 | |
| 130 | it('pack templates exist and are disabled', () => { |
| 131 | const t = listPackTemplates(); |
| 132 | assert.equal(t.length, 3); |
| 133 | assert.ok(t.every((x) => x.enabled === false)); |
| 134 | }); |
| 135 | }); |
File History
1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6
docs: record AIP-b SD-21 land (KN #308)
Human
10 days ago