[object Object]

← 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

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 →