← back to Wallco Ai
tests: 19 unit tests for shopify-bundle-webhook (HMAC verify + ingest + handler)
2229fb43140e42c66dbc545e6f77d6833a11504d · 2026-05-28 17:16:24 -0700 · Steve Abrams
Covers:
- verifyShopifyHmac: correct sig verifies, wrong sig fails, missing secret
returns null, tampered body fails verification, missing header fails
- extractCandidateLineItems: pulls product_id/line_item id/qty, skips items
with no product_id, returns [] for null/missing line_items
- mintToken: 32-char lowercase hex, two consecutive mints differ
- ingestPaidOrder (stubbed deps): mints one token per bundle line item with
unique tokens + stamps single note_attributes call, skips non-bundle items,
survives stamp errors, handles empty line_items
- makeWebhookHandler: HMAC good → 200 + ingest runs, HMAC bad → 401 + no
ingest, HMAC missing → 401, secret unset in production → 503, secret unset
in dev → still ingests, invalid JSON → 400, non-buffer body → 400
No DB / no network. Runs under plain node: `node tests/shopify-bundle-webhook.test.js`
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
A tests/shopify-bundle-webhook.test.js
Diff
commit 2229fb43140e42c66dbc545e6f77d6833a11504d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu May 28 17:16:24 2026 -0700
tests: 19 unit tests for shopify-bundle-webhook (HMAC verify + ingest + handler)
Covers:
- verifyShopifyHmac: correct sig verifies, wrong sig fails, missing secret
returns null, tampered body fails verification, missing header fails
- extractCandidateLineItems: pulls product_id/line_item id/qty, skips items
with no product_id, returns [] for null/missing line_items
- mintToken: 32-char lowercase hex, two consecutive mints differ
- ingestPaidOrder (stubbed deps): mints one token per bundle line item with
unique tokens + stamps single note_attributes call, skips non-bundle items,
survives stamp errors, handles empty line_items
- makeWebhookHandler: HMAC good → 200 + ingest runs, HMAC bad → 401 + no
ingest, HMAC missing → 401, secret unset in production → 503, secret unset
in dev → still ingests, invalid JSON → 400, non-buffer body → 400
No DB / no network. Runs under plain node: `node tests/shopify-bundle-webhook.test.js`
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
tests/shopify-bundle-webhook.test.js | 293 +++++++++++++++++++++++++++++++++++
1 file changed, 293 insertions(+)
diff --git a/tests/shopify-bundle-webhook.test.js b/tests/shopify-bundle-webhook.test.js
new file mode 100644
index 0000000..f30bec9
--- /dev/null
+++ b/tests/shopify-bundle-webhook.test.js
@@ -0,0 +1,293 @@
+// Unit tests for src/shopify-bundle-webhook.js
+// Run with: node tests/shopify-bundle-webhook.test.js
+//
+// Coverage:
+// - verifyShopifyHmac: correct sigs verify, wrong sigs fail, missing secret returns null
+// - extractCandidateLineItems: pulls product_id off each line item, skips junk
+// - mintToken: 32-char lowercase hex
+// - ingestPaidOrder (with stubbed deps): mints a row per bundle line item,
+// skips non-bundle line items, stamps note_attributes via the stubbed PUT
+// - makeWebhookHandler express integration: HMAC good → 200, HMAC bad → 401,
+// no secret in production → 503, valid body parses + invokes ingest
+
+'use strict';
+
+const assert = require('assert');
+const crypto = require('crypto');
+const wb = require('../src/shopify-bundle-webhook');
+
+const tests = [];
+function t(name, fn) { tests.push({ name, fn }); }
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Pure helpers
+// ─────────────────────────────────────────────────────────────────────────────
+
+t('verifyShopifyHmac: returns null when secret is unset', () => {
+ assert.strictEqual(wb.verifyShopifyHmac('body', 'sig', ''), null);
+ assert.strictEqual(wb.verifyShopifyHmac('body', 'sig', undefined), null);
+});
+
+t('verifyShopifyHmac: returns true for a correctly-signed body', () => {
+ const secret = 'shh-very-secret';
+ const body = Buffer.from(JSON.stringify({ id: 12345, line_items: [] }));
+ const sig = crypto.createHmac('sha256', secret).update(body).digest('base64');
+ assert.strictEqual(wb.verifyShopifyHmac(body, sig, secret), true);
+});
+
+t('verifyShopifyHmac: returns false for a wrong signature', () => {
+ const secret = 'shh-very-secret';
+ const body = Buffer.from('{"id":12345}');
+ const wrongSig = crypto.createHmac('sha256', 'other-secret').update(body).digest('base64');
+ assert.strictEqual(wb.verifyShopifyHmac(body, wrongSig, secret), false);
+});
+
+t('verifyShopifyHmac: returns false when body is tampered after signing', () => {
+ const secret = 'shh-very-secret';
+ const original = Buffer.from('{"id":12345}');
+ const sig = crypto.createHmac('sha256', secret).update(original).digest('base64');
+ const tampered = Buffer.from('{"id":99999}');
+ assert.strictEqual(wb.verifyShopifyHmac(tampered, sig, secret), false);
+});
+
+t('verifyShopifyHmac: returns false when header is missing but secret is set', () => {
+ assert.strictEqual(wb.verifyShopifyHmac('body', '', 'secret'), false);
+ assert.strictEqual(wb.verifyShopifyHmac('body', undefined, 'secret'), false);
+});
+
+t('extractCandidateLineItems: pulls product_id + line_item id + qty', () => {
+ const order = {
+ id: 1,
+ line_items: [
+ { id: 100, product_id: 7843826270259, quantity: 2, title: 'Cactus bundle' },
+ { id: 101, product_id: 7843830923315, quantity: 1, title: 'Designer zoo calm' },
+ { id: 102, /* no product_id */ quantity: 1, title: 'Custom item' },
+ ],
+ };
+ const out = wb.extractCandidateLineItems(order);
+ assert.strictEqual(out.length, 2);
+ assert.strictEqual(out[0].productId, 7843826270259);
+ assert.strictEqual(out[0].lineItemId, 100);
+ assert.strictEqual(out[0].quantity, 2);
+ assert.strictEqual(out[1].productId, 7843830923315);
+});
+
+t('extractCandidateLineItems: returns [] for null / missing line_items', () => {
+ assert.deepStrictEqual(wb.extractCandidateLineItems(null), []);
+ assert.deepStrictEqual(wb.extractCandidateLineItems({}), []);
+ assert.deepStrictEqual(wb.extractCandidateLineItems({ line_items: null }), []);
+});
+
+t('mintToken: emits 32-char lowercase hex (16 bytes)', () => {
+ const tk = wb.mintToken();
+ assert.strictEqual(tk.length, 32);
+ assert.match(tk, /^[0-9a-f]{32}$/);
+ // sanity: two consecutive mints aren't equal
+ assert.notStrictEqual(wb.mintToken(), wb.mintToken());
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// ingestPaidOrder — with stubbed deps so no DB / no network
+// ─────────────────────────────────────────────────────────────────────────────
+
+function makeDeps({ slugByProduct = {}, stampErr = false } = {}) {
+ const log = { inserts: [], stamps: [] };
+ return {
+ log,
+ fetchBundleSlug: async (productId) => slugByProduct[productId] || null,
+ insertTokenRow: (args) => { log.inserts.push(args); return args.token; },
+ stampOrderNoteAttributes: async (args) => {
+ log.stamps.push(args);
+ if (stampErr) throw new Error('stamp boom');
+ return true;
+ },
+ nowDate: () => '2026-05-28',
+ };
+}
+
+t('ingestPaidOrder: mints one token per bundle line item, skips non-bundle items', async () => {
+ const deps = makeDeps({ slugByProduct: { 7843826270259: 'cactus', 7843830923315: 'designer-zoo-calm' } });
+ const order = {
+ id: 5001,
+ email: 'buyer@example.com',
+ line_items: [
+ { id: 1, product_id: 7843826270259, quantity: 1, title: 'Cactus bundle' },
+ { id: 2, product_id: 7843830923315, quantity: 1, title: 'Designer zoo calm' },
+ { id: 3, product_id: 9999999999999, quantity: 1, title: 'A wallpaper roll (not a bundle)' },
+ ],
+ };
+ const out = await wb.ingestPaidOrder(order, deps);
+ assert.strictEqual(out.ok, true);
+ assert.strictEqual(out.tokens_minted, 2);
+ assert.strictEqual(deps.log.inserts.length, 2);
+ assert.strictEqual(deps.log.inserts[0].slug, 'cactus');
+ assert.strictEqual(deps.log.inserts[0].orderId, 5001);
+ assert.strictEqual(deps.log.inserts[0].email, 'buyer@example.com');
+ assert.strictEqual(deps.log.inserts[0].maxDownloads, 5);
+ assert.strictEqual(deps.log.inserts[0].days, 90);
+ assert.match(deps.log.inserts[0].token, /^[0-9a-f]{32}$/);
+ // Unique tokens per line item
+ assert.notStrictEqual(deps.log.inserts[0].token, deps.log.inserts[1].token);
+ // One stamp call carrying both note attributes
+ assert.strictEqual(deps.log.stamps.length, 1);
+ assert.strictEqual(deps.log.stamps[0].attrs.length, 2);
+ assert.strictEqual(deps.log.stamps[0].attrs[0].name, 'download_url_cactus');
+ assert.match(deps.log.stamps[0].attrs[0].value, /^https:\/\/wallco\.ai\/downloads\/cactus\/[0-9a-f]{32}\.zip$/);
+});
+
+t('ingestPaidOrder: returns tokens_minted=0 when no line items match a bundle', async () => {
+ const deps = makeDeps({ slugByProduct: {} }); // nothing maps
+ const order = {
+ id: 5002,
+ email: 'buyer@example.com',
+ line_items: [
+ { id: 1, product_id: 7843826270259, quantity: 1 },
+ ],
+ };
+ const out = await wb.ingestPaidOrder(order, deps);
+ assert.strictEqual(out.ok, true);
+ assert.strictEqual(out.tokens_minted, 0);
+ assert.strictEqual(deps.log.inserts.length, 0);
+ assert.strictEqual(deps.log.stamps.length, 0);
+});
+
+t('ingestPaidOrder: still returns ok even when stampOrderNoteAttributes throws', async () => {
+ const deps = makeDeps({ slugByProduct: { 7843826270259: 'cactus' }, stampErr: true });
+ const order = {
+ id: 5003,
+ email: 'buyer@example.com',
+ line_items: [{ id: 1, product_id: 7843826270259, quantity: 1 }],
+ };
+ const out = await wb.ingestPaidOrder(order, deps);
+ assert.strictEqual(out.ok, true);
+ assert.strictEqual(out.tokens_minted, 1);
+});
+
+t('ingestPaidOrder: handles empty line_items gracefully', async () => {
+ const deps = makeDeps();
+ const out = await wb.ingestPaidOrder({ id: 5004, line_items: [] }, deps);
+ assert.strictEqual(out.ok, true);
+ assert.strictEqual(out.tokens_minted, 0);
+ assert.strictEqual(out.reason, 'no_line_items');
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// makeWebhookHandler — Express handler shape
+// ─────────────────────────────────────────────────────────────────────────────
+
+function makeReqRes({ body, hmac }) {
+ const req = { body, headers: hmac == null ? {} : { 'x-shopify-hmac-sha256': hmac } };
+ const res = {
+ _status: 200, _body: null,
+ status(code) { this._status = code; return this; },
+ json(v) { this._body = v; return this; },
+ };
+ return { req, res };
+}
+
+t('handler: HMAC good → ingest runs and 200 returned', async () => {
+ const secret = 'webhook-test-secret-' + crypto.randomBytes(4).toString('hex');
+ const deps = makeDeps({ slugByProduct: { 7843826270259: 'cactus' } });
+ const handler = wb.makeWebhookHandler({ webhookSecret: secret, ...deps });
+ const order = { id: 7001, email: 'b@x.com', line_items: [{ id: 1, product_id: 7843826270259, quantity: 1 }] };
+ const raw = Buffer.from(JSON.stringify(order));
+ const sig = crypto.createHmac('sha256', secret).update(raw).digest('base64');
+ const { req, res } = makeReqRes({ body: raw, hmac: sig });
+ await handler(req, res);
+ assert.strictEqual(res._status, 200);
+ assert.strictEqual(res._body.ok, true);
+ assert.strictEqual(res._body.tokens_minted, 1);
+ assert.strictEqual(deps.log.inserts.length, 1);
+ assert.strictEqual(deps.log.inserts[0].orderId, 7001);
+});
+
+t('handler: HMAC bad → 401 + no ingest', async () => {
+ const secret = 'webhook-test-secret';
+ const deps = makeDeps({ slugByProduct: { 7843826270259: 'cactus' } });
+ const handler = wb.makeWebhookHandler({ webhookSecret: secret, ...deps });
+ const order = { id: 7002, line_items: [{ id: 1, product_id: 7843826270259, quantity: 1 }] };
+ const raw = Buffer.from(JSON.stringify(order));
+ // wrong key → wrong sig
+ const wrongSig = crypto.createHmac('sha256', 'other-key').update(raw).digest('base64');
+ const { req, res } = makeReqRes({ body: raw, hmac: wrongSig });
+ await handler(req, res);
+ assert.strictEqual(res._status, 401);
+ assert.strictEqual(deps.log.inserts.length, 0);
+});
+
+t('handler: HMAC missing → 401', async () => {
+ const secret = 'webhook-test-secret';
+ const deps = makeDeps();
+ const handler = wb.makeWebhookHandler({ webhookSecret: secret, ...deps });
+ const raw = Buffer.from('{"id":7003,"line_items":[]}');
+ const { req, res } = makeReqRes({ body: raw, hmac: undefined });
+ await handler(req, res);
+ assert.strictEqual(res._status, 401);
+});
+
+t('handler: secret unset in production → 503 (refuses to mint unsigned)', async () => {
+ const deps = makeDeps();
+ const saved = process.env.NODE_ENV;
+ process.env.NODE_ENV = 'production';
+ try {
+ const handler = wb.makeWebhookHandler({ webhookSecret: '', ...deps });
+ const raw = Buffer.from('{"id":1,"line_items":[]}');
+ const { req, res } = makeReqRes({ body: raw, hmac: undefined });
+ await handler(req, res);
+ assert.strictEqual(res._status, 503);
+ } finally {
+ if (saved == null) delete process.env.NODE_ENV; else process.env.NODE_ENV = saved;
+ }
+});
+
+t('handler: secret unset in dev → still ingests (NODE_ENV != production)', async () => {
+ const deps = makeDeps({ slugByProduct: { 7843826270259: 'cactus' } });
+ const saved = process.env.NODE_ENV;
+ delete process.env.NODE_ENV;
+ try {
+ const handler = wb.makeWebhookHandler({ webhookSecret: '', ...deps });
+ const order = { id: 7004, line_items: [{ id: 1, product_id: 7843826270259, quantity: 1 }] };
+ const raw = Buffer.from(JSON.stringify(order));
+ const { req, res } = makeReqRes({ body: raw, hmac: undefined });
+ await handler(req, res);
+ assert.strictEqual(res._status, 200);
+ assert.strictEqual(res._body.tokens_minted, 1);
+ } finally {
+ if (saved != null) process.env.NODE_ENV = saved;
+ }
+});
+
+t('handler: invalid JSON body → 400 (no ingest)', async () => {
+ const secret = 'webhook-test-secret';
+ const deps = makeDeps();
+ const handler = wb.makeWebhookHandler({ webhookSecret: secret, ...deps });
+ const raw = Buffer.from('not-json{');
+ const sig = crypto.createHmac('sha256', secret).update(raw).digest('base64');
+ const { req, res } = makeReqRes({ body: raw, hmac: sig });
+ await handler(req, res);
+ assert.strictEqual(res._status, 400);
+ assert.strictEqual(deps.log.inserts.length, 0);
+});
+
+t('handler: non-buffer body → 400', async () => {
+ const secret = 'webhook-test-secret';
+ const deps = makeDeps();
+ const handler = wb.makeWebhookHandler({ webhookSecret: secret, ...deps });
+ const { req, res } = makeReqRes({ body: { id: 1 }, hmac: 'sig' });
+ await handler(req, res);
+ assert.strictEqual(res._status, 400);
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Run
+// ─────────────────────────────────────────────────────────────────────────────
+
+(async () => {
+ let pass = 0, fail = 0;
+ for (const test of tests) {
+ try { await test.fn(); console.log('✓', test.name); pass++; }
+ catch (err) { console.error('✗', test.name, '—', err.message); fail++; }
+ }
+ console.log(`\n${pass}/${tests.length} shopify-bundle-webhook tests passed`);
+ if (fail) process.exit(1);
+})();
← d6de7c5 scripts: register-shopify-webhooks.js — idempotent orders/pa
·
back to Wallco Ai
·
feat(pdp): V2 Bento Grid theme — /design/:id/bento + /api/de c000130 →