TEST REPORT: Approval Gate, n8n pack v1.0.0
Run from a clean unzipped copy of the staging dist zip (fresh Python venv with aiosmtpd), by staging/run_approval_gate_clean.sh. Started 2026-10-22 18:56:14 BST (UTC+01:00), finished 2026-10-22 19:00:17 BST (UTC+01:00) (times from date at run time). Box: Linux 6.12.94+, Node v24.22.0, Python 3.13.5. n8n 2.43.7 (npm) on Node v24.22.0, task runner in internal mode (deprecated by n8n). Database runs: SQLite (n8n's default) and PostgreSQL 17.11 (Debian 17.11-0+deb13u1).
Result: 49/49 static checks + 81/81 end-to-end checks on SQLite + 81/81 end-to-end checks on PostgreSQL passed.
The zip that was unzipped and tested had sha256 6f60e368322f69423ca4d49e5be69fef28f9c936ff100678707f57934d592896. The shipped zip is the same build plus this TEST-REPORT.md; every other file is byte-identical (compared file by file when the shipped zip was built).
What is real and what is mocked
- Real: n8n 2.43.7 itself (
n8n start), the three workflow files exactly as shipped (imported withn8n import:workflow, published withn8n publish:workflow), n8n Data tables, the Wait node's signed resume URLs, n8n's webhook routing, and a real PostgreSQL 17.11 (Debian 17.11-0+deb13u1) server for the PostgreSQL run. - Mocked: Slack. A local HTTP server stands in for the Slack incoming webhook and records what was posted. Slack button clicks are built by the test and signed with a test signing secret using Slack's documented
v0:HMAC scheme; no request came from real Slack. - Mocked: email. A local SMTP sink (
aiosmtpd) receives all email; no email left the box. - Mocked: delivery webhook. For webhook-kind drafts the delivery target is the same local HTTP server (it can be told to answer 500).
- The test credentials and keys are throwaway strings set in
tests/harness.py. Each run uses a throwaway n8n home (and, for PostgreSQL, a throwaway database), deleted after a passing run.
Requirement → checks (names as printed below)
- Draft stored with sha256 and a state:
draft stored as Pending, send_status none;sha256 = sha256(canonical JSON) computed independently in Python - Approval refused if the draft changed:
payload edited in the table … -> approve refused 409 draft_changed;payload + hash both edited -> old email approval / old Slack button refused (409) - Hash re-checked just before sending:
accepted, then draft edited before send …;pre-send re-check blocked the send;pre-send re-check: nothing delivered to anyone - Expiry means no send:
draft nobody accepted expired (state Expired), nothing sent;approve after expiry -> refused;sweeper expired the orphan Pending row - GET / link preview can't approve (POST + nonce):
3 GETs + 1 HEAD of the review link changed nothing;GET on the decide URL … (404);POST approve without the link token (nonce) -> 403; forged / reused token -> 403 - Double approve sends once:
second approve (double click) -> 409;approve again via Slack -> 409; 3 rounds of 8 simultaneous approvals -> exactly one 200 and one delivery - Approver allowlist: Slack user and email address not on the allowlist -> 403, logged
- Decision log: who, when, hash, channel:
every log row has who, when (ISO UTC), channel and event;every accepted/rejected/sent/requested row carries the draft's sha256 - Tampered resume URL: altered / removed signature -> 401; signature on another execution id refused; GET on the genuine resume URL did not resume
- Replayed resume URL:
replayed resume URL (execution finished) -> refused by n8n (409); genuine resume URL with no decision -> fails closed (Expired, nothing sent) - Slack request authenticity: bad signature, 10-minute-old timestamp, body tampered after signing -> 401
1. Static validation: python3 tests/validate_workflow.py (49/49)
PASS approval-gate-1-request-wait-send.json: has name, nodes, connections
PASS approval-gate-1-request-wait-send.json: node names and ids unique
PASS approval-gate-1-request-wait-send.json: every node has type/typeVersion/position/parameters
PASS approval-gate-1-request-wait-send.json: connections reference existing nodes
PASS approval-gate-1-request-wait-send.json: every non-trigger node is connected
PASS approval-gate-1-request-wait-send.json: inactive on import
PASS approval-gate-1-request-wait-send.json: no secrets shipped (no credential data, keys or tokens in the file)
PASS approval-gate-1-request-wait-send.json: no Code node uses require() (no NODE_FUNCTION_ALLOW_BUILTIN needed)
PASS approval-gate-1-request-wait-send.json: every credential reference has id + name
PASS approval-gate-2-decisions.json: has name, nodes, connections
PASS approval-gate-2-decisions.json: node names and ids unique
PASS approval-gate-2-decisions.json: every node has type/typeVersion/position/parameters
PASS approval-gate-2-decisions.json: connections reference existing nodes
PASS approval-gate-2-decisions.json: every non-trigger node is connected
PASS approval-gate-2-decisions.json: inactive on import
PASS approval-gate-2-decisions.json: no secrets shipped (no credential data, keys or tokens in the file)
PASS approval-gate-2-decisions.json: no Code node uses require() (no NODE_FUNCTION_ALLOW_BUILTIN needed)
PASS approval-gate-2-decisions.json: every credential reference has id + name
PASS approval-gate-3-reminders-expiry.json: has name, nodes, connections
PASS approval-gate-3-reminders-expiry.json: node names and ids unique
PASS approval-gate-3-reminders-expiry.json: every node has type/typeVersion/position/parameters
PASS approval-gate-3-reminders-expiry.json: connections reference existing nodes
PASS approval-gate-3-reminders-expiry.json: every non-trigger node is connected
PASS approval-gate-3-reminders-expiry.json: inactive on import
PASS approval-gate-3-reminders-expiry.json: no secrets shipped (no credential data, keys or tokens in the file)
PASS approval-gate-3-reminders-expiry.json: no Code node uses require() (no NODE_FUNCTION_ALLOW_BUILTIN needed)
PASS approval-gate-3-reminders-expiry.json: every credential reference has id + name
PASS request webhook is POST with header auth
PASS review page webhook is GET and its path (to the 'page' outcome) contains no data-table write
PASS decision webhook accepts POST only
PASS Slack webhook is POST and keeps the raw body for signature checks
PASS Wait node resumes on POST webhook only, with a time limit at expires_at
PASS send claim is a compare-and-set on state=Accepted, send_status=none and both hashes = re-computed hash
PASS decision write is a compare-and-set on state=Pending, sha256 = approver's hash and not expired
PASS delivery nodes do not auto-retry (at most once)
PASS the 3 Config nodes ship the same shared settings
PASS n8n-nodes-base.code v2 exists in installed n8n
PASS n8n-nodes-base.crypto v2 exists in installed n8n
PASS n8n-nodes-base.dataTable v1.1 exists in installed n8n
PASS n8n-nodes-base.emailSend v2.1 exists in installed n8n
PASS n8n-nodes-base.httpRequest v4.2 exists in installed n8n
PASS n8n-nodes-base.if v2.2 exists in installed n8n
PASS n8n-nodes-base.manualTrigger v1 exists in installed n8n
PASS n8n-nodes-base.respondToWebhook v1.1 exists in installed n8n
PASS n8n-nodes-base.scheduleTrigger v1.2 exists in installed n8n
PASS n8n-nodes-base.set v3.4 exists in installed n8n
PASS n8n-nodes-base.switch v3.2 exists in installed n8n
PASS n8n-nodes-base.wait v1.1 exists in installed n8n
PASS n8n-nodes-base.webhook v2 exists in installed n8n
49/49 passed
2. End-to-end in real n8n, SQLite (n8n default): python3 tests/e2e_n8n.py (81/81)
The harness starts the local mocks, imports 4 credentials and the 3 workflows with test Config values, publishes them, runs n8n start, creates the two Data tables by running the shipped Run Once: Create Tables trigger, then drives the gate over HTTP. Lines marked (info) are observations, not checks.
n8n database: SQLite (n8n default)
PASS n8n import:credentials (4)
PASS n8n import:workflow approval-gate-1-request-wait-send.json
PASS n8n import:workflow approval-gate-2-decisions.json
PASS n8n import:workflow approval-gate-3-reminders-expiry.json
PASS n8n publish:workflow approval-gate-1-request-wait-send.json
PASS n8n publish:workflow approval-gate-2-decisions.json
PASS n8n publish:workflow approval-gate-3-reminders-expiry.json
PASS shipped 'Run Once: Create Tables' step created both data tables
PASS approval_drafts has the 19 documented columns
PASS expiry draft created (ttl 1.5 min, reminder after 0.25 min)
PASS request with wrong API key -> 403
PASS request with no API key -> 403
PASS invalid draft -> 400 with reasons
PASS rejected requests stored nothing
PASS shipped sample test-data/request-email.json accepted (201) and hashed as documented
PASS shipped sample test-data/request-webhook.json accepted (201) and hashed as documented
PASS valid request -> 201 with draft_id + sha256
PASS sha256 = sha256(canonical JSON) computed independently in Python
PASS draft stored as Pending, send_status none
PASS Slack approval message posted with Approve/Reject buttons pinned to the hash
PASS one review-link email per allowlisted approver (2)
PASS nothing delivered to the recipient yet
PASS GET review page (as a link preview would) -> 200 page with draft + full hash
PASS 3 GETs + 1 HEAD of the review link changed nothing (still Pending, nothing sent)
PASS GET on the decide URL with approve params -> not accepted (404), still Pending
PASS POST approve without the link token (nonce) -> 403
PASS POST approve with a forged token -> 403
PASS alice's token reused as bob -> 403
PASS still Pending and nothing sent after refused attempts
PASS POST approve with valid token + hash -> 200
PASS accepted draft delivered exactly once to its recipient, exact subject and body
PASS row: Accepted by alice via email, accepted_sha256 = sha256, send_status sent
PASS second approve (double click) -> 409
PASS approve again via Slack -> 409
PASS reject after approval -> 409
PASS replayed resume URL (execution finished) -> refused by n8n (409)
PASS double approve + replays: still exactly one delivery
PASS log for A: requested, accepted, sent (+ refusals), accepted once
PASS round 1: 8 simultaneous approvals (email x5, Slack x3) -> exactly one 200, no 5xx, one delivery, one 'accepted' + one 'sent' logged
PASS round 2: 8 simultaneous approvals (email x5, Slack x3) -> exactly one 200, no 5xx, one delivery, one 'accepted' + one 'sent' logged
PASS round 3: 8 simultaneous approvals (email x5, Slack x3) -> exactly one 200, no 5xx, one delivery, one 'accepted' + one 'sent' logged
PASS Slack approve by user not on the allowlist (valid signature) -> 403
PASS email approve with a correctly signed link for a non-allowlisted address -> 403
PASS Slack click with bad signature -> 401
PASS Slack click with 10-minute-old timestamp -> 401
PASS Slack body tampered after signing (user id swapped) -> 401
PASS still Pending, nothing sent
PASS allowlist refusals logged with who + channel + reason
PASS Slack reject by allowlisted user -> 200
PASS rejected: state Rejected by Slack user, nothing sent
PASS approve after reject -> 409, nothing sent
PASS payload edited in the table (hash column untouched) -> approve refused 409 draft_changed
PASS payload + hash both edited -> old email approval refused (409)
PASS payload + hash both edited -> old Slack button refused (409)
PASS edited draft: still Pending, nothing sent
PASS edited draft: refusals logged as draft_changed
PASS accepted, then draft edited before send: resume accepted by n8n (200)
PASS pre-send re-check blocked the send (send_status blocked)
PASS pre-send re-check: nothing delivered to anyone
PASS pre-send re-check: 'send_blocked' logged + Slack BLOCKED notice
PASS resume URL with altered signature -> 401
PASS resume URL with signature removed -> 401
(info) signature on another execution id returned HTTP 401
PASS signature moved to another execution id -> refused (401/404/409)
(info) GET on the genuine resume URL returned HTTP 404
PASS GET on the genuine resume URL (link preview) did not resume the run (still Pending, no 'expired' log)
PASS after tampered resumes: still Pending, nothing sent
PASS genuine resume URL hit with no approval -> fails closed: Expired, nothing sent
PASS logged as expired: resumed_without_decision
PASS webhook-kind draft accepted
PASS Slack approve by allowlisted user -> 200
PASS delivered once to the delivery webhook with X-Approval-Sha256 = accepted hash and the exact draft
PASS Slack decision notice names the approver and hash
PASS delivery endpoint returns 500 -> send_status failed, 'send_failed' logged, tried once (no retry)
PASS reminder fired by the sweeper before expiry (reminded = true, while still Pending)
PASS reminder posted to Slack and emailed to both approvers with links
PASS draft nobody accepted expired (state Expired), nothing sent
(info) draft X expired via: timeout
PASS expiry logged once + Slack 'expired, nothing sent' notice
PASS approve after expiry -> refused (409/410), nothing sent
PASS sweeper expired the orphan Pending row (decided_via sweeper)
PASS every log row has who, when (ISO UTC), channel and event
PASS every accepted/rejected/sent/requested row carries the draft's sha256
(info) executions: 68 success, 0 error/crashed
PASS no n8n execution ended in error during the run
81/81 checks passed (throwaway n8n home deleted)
3. End-to-end in real n8n, PostgreSQL: AG_DB=postgres python3 tests/e2e_n8n.py (81/81)
Same checks, with n8n's own database (and so the Data tables) on PostgreSQL 17.11 (Debian 17.11-0+deb13u1) instead of SQLite.
n8n database: PostgreSQL (throwaway database)
PASS n8n import:credentials (4)
PASS n8n import:workflow approval-gate-1-request-wait-send.json
PASS n8n import:workflow approval-gate-2-decisions.json
PASS n8n import:workflow approval-gate-3-reminders-expiry.json
PASS n8n publish:workflow approval-gate-1-request-wait-send.json
PASS n8n publish:workflow approval-gate-2-decisions.json
PASS n8n publish:workflow approval-gate-3-reminders-expiry.json
PASS shipped 'Run Once: Create Tables' step created both data tables
PASS approval_drafts has the 19 documented columns
PASS expiry draft created (ttl 1.5 min, reminder after 0.25 min)
PASS request with wrong API key -> 403
PASS request with no API key -> 403
PASS invalid draft -> 400 with reasons
PASS rejected requests stored nothing
PASS shipped sample test-data/request-email.json accepted (201) and hashed as documented
PASS shipped sample test-data/request-webhook.json accepted (201) and hashed as documented
PASS valid request -> 201 with draft_id + sha256
PASS sha256 = sha256(canonical JSON) computed independently in Python
PASS draft stored as Pending, send_status none
PASS Slack approval message posted with Approve/Reject buttons pinned to the hash
PASS one review-link email per allowlisted approver (2)
PASS nothing delivered to the recipient yet
PASS GET review page (as a link preview would) -> 200 page with draft + full hash
PASS 3 GETs + 1 HEAD of the review link changed nothing (still Pending, nothing sent)
PASS GET on the decide URL with approve params -> not accepted (404), still Pending
PASS POST approve without the link token (nonce) -> 403
PASS POST approve with a forged token -> 403
PASS alice's token reused as bob -> 403
PASS still Pending and nothing sent after refused attempts
PASS POST approve with valid token + hash -> 200
PASS accepted draft delivered exactly once to its recipient, exact subject and body
PASS row: Accepted by alice via email, accepted_sha256 = sha256, send_status sent
PASS second approve (double click) -> 409
PASS approve again via Slack -> 409
PASS reject after approval -> 409
PASS replayed resume URL (execution finished) -> refused by n8n (409)
PASS double approve + replays: still exactly one delivery
PASS log for A: requested, accepted, sent (+ refusals), accepted once
PASS round 1: 8 simultaneous approvals (email x5, Slack x3) -> exactly one 200, no 5xx, one delivery, one 'accepted' + one 'sent' logged
PASS round 2: 8 simultaneous approvals (email x5, Slack x3) -> exactly one 200, no 5xx, one delivery, one 'accepted' + one 'sent' logged
PASS round 3: 8 simultaneous approvals (email x5, Slack x3) -> exactly one 200, no 5xx, one delivery, one 'accepted' + one 'sent' logged
PASS Slack approve by user not on the allowlist (valid signature) -> 403
PASS email approve with a correctly signed link for a non-allowlisted address -> 403
PASS Slack click with bad signature -> 401
PASS Slack click with 10-minute-old timestamp -> 401
PASS Slack body tampered after signing (user id swapped) -> 401
PASS still Pending, nothing sent
PASS allowlist refusals logged with who + channel + reason
PASS Slack reject by allowlisted user -> 200
PASS rejected: state Rejected by Slack user, nothing sent
PASS approve after reject -> 409, nothing sent
PASS payload edited in the table (hash column untouched) -> approve refused 409 draft_changed
PASS payload + hash both edited -> old email approval refused (409)
PASS payload + hash both edited -> old Slack button refused (409)
PASS edited draft: still Pending, nothing sent
PASS edited draft: refusals logged as draft_changed
PASS accepted, then draft edited before send: resume accepted by n8n (200)
PASS pre-send re-check blocked the send (send_status blocked)
PASS pre-send re-check: nothing delivered to anyone
PASS pre-send re-check: 'send_blocked' logged + Slack BLOCKED notice
PASS resume URL with altered signature -> 401
PASS resume URL with signature removed -> 401
(info) signature on another execution id returned HTTP 401
PASS signature moved to another execution id -> refused (401/404/409)
(info) GET on the genuine resume URL returned HTTP 404
PASS GET on the genuine resume URL (link preview) did not resume the run (still Pending, no 'expired' log)
PASS after tampered resumes: still Pending, nothing sent
PASS genuine resume URL hit with no approval -> fails closed: Expired, nothing sent
PASS logged as expired: resumed_without_decision
PASS webhook-kind draft accepted
PASS Slack approve by allowlisted user -> 200
PASS delivered once to the delivery webhook with X-Approval-Sha256 = accepted hash and the exact draft
PASS Slack decision notice names the approver and hash
PASS delivery endpoint returns 500 -> send_status failed, 'send_failed' logged, tried once (no retry)
PASS reminder fired by the sweeper before expiry (reminded = true, while still Pending)
PASS reminder posted to Slack and emailed to both approvers with links
PASS draft nobody accepted expired (state Expired), nothing sent
(info) draft X expired via: timeout
PASS expiry logged once + Slack 'expired, nothing sent' notice
PASS approve after expiry -> refused (409/410), nothing sent
PASS sweeper expired the orphan Pending row (decided_via sweeper)
PASS every log row has who, when (ISO UTC), channel and event
PASS every accepted/rejected/sent/requested row carries the draft's sha256
(info) executions: 68 success, 0 error/crashed
PASS no n8n execution ended in error during the run
81/81 checks passed (throwaway n8n home deleted)
Not tested
- Real Slack (no Slack workspace or app was used; button clicks were signed by the test), real SMTP providers, and real delivery endpoints.
- The Slack app setup steps in SETUP.md (written from Slack's docs, not clicked through).
- n8n Cloud, n8n 1.x, queue mode with workers, and the external task-runner mode.
- n8n behind a public URL, reverse proxy or
WEBHOOK_URLother than localhost. - Concurrency beyond the 3 rounds of 8 simultaneous approvals in the test, and behaviour under heavy load.
- The case where n8n cannot reach its own resume URL after a decision: the draft is Accepted but the send waits for the run's expiry time (see SETUP.md, section 1).
- Real AI agents calling the request webhook (the tests post the shipped sample drafts and generated drafts).
Known limits (by design, see SETUP.md)
- An email approval is only as safe as the approver's mailbox; anyone with n8n editor access controls the gate; the webhooks have no rate limit.
- A failed send (SMTP or delivery webhook error) is marked
failedand is not retried automatically.