← back to Dw Signup Fulfillment
selftest: cover Option C (verify letter, token security, tag write); drop gift-card asserts
f2bce232686b2ceebeda087c74f1f2ee61250e80 · 2026-08-14 09:51:56 -0700 · Steve Abrams
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
Diff
commit f2bce232686b2ceebeda087c74f1f2ee61250e80
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Aug 14 09:51:56 2026 -0700
selftest: cover Option C (verify letter, token security, tag write); drop gift-card asserts
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
scripts/selftest.js | 85 ++++++++++++++++++++++++++++++-----------------------
1 file changed, 49 insertions(+), 36 deletions(-)
diff --git a/scripts/selftest.js b/scripts/selftest.js
index f5a692a..9d908f5 100644
--- a/scripts/selftest.js
+++ b/scripts/selftest.js
@@ -5,28 +5,28 @@
//
// node scripts/selftest.js
//
-// Exercises:
-// (a) the customers/create webhook with a fake payload + a VALID HMAC computed
-// from a test secret (proves HMAC verify accepts a correct sig and the
-// retail gift-card path fires),
-// (b) an invalid-HMAC webhook (proves it is rejected),
-// (c) /trade/apply then /admin/trade/:id/approve (proves the moderated flow +
-// tag + metafield + rep-notify + applicant email),
-// (d) round-robin across the whole rep roster (proves cursor rotation).
-
-// Force DRY_RUN on, pin a known webhook secret, use a throwaway data dir so we
-// never touch real trade-applications.jsonl / rep-cursor.json.
+// Exercises (Option C — verify → tag, 2026-08-14):
+// (a) the customers/create webhook with a forged payload email (proves the re-fetch
+// auth sends the verify LETTER to the REAL on-file email, not the attacker's),
+// (a3) freshness gate (an OLD existing customer is not mailed — anti mass-mail),
+// (b) a forged/unknown customer id → rejected,
+// (b2) already-sent customer → skipped (idempotent — one verify letter ever),
+// (b3) the verify letter offers the N free samples + carries the verify link,
+// (v) verify.js token security — mint/read round-trip, tamper, malformed — and
+// completeVerification applying the VERIFIED_TAG (the only Shopify write),
+// (c) /trade/apply then /admin/trade/:id/approve (moderated flow + trade tag),
+// (d) fixed assignment to the DW House Account.
+
+// Force DRY_RUN on, pin the verify secret for deterministic tokens, use a throwaway
+// data dir so we never touch real trade-applications.jsonl / rep-cursor.json.
const os = require('os');
const fs = require('fs');
const path = require('path');
-const TEST_SECRET = 'selftest-webhook-secret-abc123';
-const TEST_SHARED_CODE = 'DWSAMPLES3';
process.env.DRY_RUN = '1';
-process.env.SHOPIFY_WEBHOOK_SECRET = TEST_SECRET;
-// Pin the shared retail code so the retail path asserts it emails exactly that code
-// (in prod this is the code of the admin-created "DW Free Samples" discount).
-process.env.RETAIL_SHARED_CODE = TEST_SHARED_CODE;
+// Token secret: in DRY_RUN the verify lib uses its built-in dev secret, so mint→read
+// round-trips deterministically without pinning one here (also avoids a fake-secret
+// literal tripping the gitleaks pre-commit hook).
// Isolate persisted state to a temp dir so the roster is real but the cursor +
// applications are throwaway. We copy the seed reps.json in.
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'dwsf-selftest-'));
@@ -52,10 +52,10 @@ function restore() {
}
const config = require('../lib/config');
-const retailWebhook = require('../lib/retail-webhook'); // WIRED webhook handler (secret-less re-fetch auth)
+const retailWebhook = require('../lib/retail-webhook'); // WIRED webhook handler (re-fetch auth → verify letter)
const shopify = require('../lib/shopify');
-const giftcard = require('../lib/giftcard'); // WIRED retail path (FINAL — gift card, memo §2)
-const retailCode = require('../lib/retail-code'); // alternate (shared function code — needs admin discount)
+const verify = require('../lib/verify'); // WIRED retail path (Option C — verify → VERIFIED_TAG)
+const email = require('../lib/email');
const trade = require('../lib/trade');
const reps = require('../lib/reps');
@@ -67,30 +67,30 @@ let failures = 0;
async function main() {
console.log('DW signup fulfillment — SELFTEST (DRY_RUN=' + config.DRY_RUN + ')');
console.log('Store: ' + config.SHOP_DOMAIN + ' API ' + config.SHOPIFY_API_VERSION);
- console.log('Retail path (WIRED): unique GIFT CARD (' + config.FREE_SAMPLE_COUNT + ' × $' + config.SAMPLE_PRICE + ' = $' + config.SAMPLE_GIFT_VALUE + ') emailed per signup');
+ console.log('Retail path (WIRED): Option C — confirm email → tag `' + config.VERIFIED_TAG + '` → Regios makes ' + config.FREE_SAMPLE_COUNT + ' samples free (no gift card, no liability)');
if (!config.DRY_RUN) { fail('DRY_RUN is OFF — refusing to run selftest that would make live writes'); return; }
// ---------------------------------------------------------------------------
- hr('(a) webhook — secret-less re-fetch auth issues gift card to the REAL on-file email');
+ hr('(a) webhook — re-fetch auth sends the VERIFY LETTER to the REAL on-file email');
// Monkeypatch the Shopify client so the handler sees a "found" customer whose REAL
// on-file email DIFFERS from the (attacker-controlled) payload email.
const REAL = { id: 8675309, email: 'real-customer@onfile.com', first_name: 'Dana', created_at: new Date().toISOString() };
const _gc = shopify.getCustomer, _gm = shopify.getCustomerMetafield;
shopify.getCustomer = async () => ({ ok: true, json: { customer: REAL } });
- shopify.getCustomerMetafield = async () => null; // no gift flag yet
+ shopify.getCustomerMetafield = async () => null; // not sent yet
const res1 = await retailWebhook.handleCustomerCreate({ id: 8675309, email: 'ATTACKER@evil.com' });
console.log(' result: ' + JSON.stringify(res1, null, 2));
- if (res1.ok && res1.email === REAL.email) ok('issued to REAL on-file email (' + res1.email + '), NOT the payload/attacker email'); else fail('used the wrong email: ' + JSON.stringify(res1));
- if (res1.issued && res1.issued.path === 'gift_card' && res1.issued.value === 12.75) ok('issued a $12.75 gift card (WOULD POST gift_cards)'); else fail('gift card not issued: ' + JSON.stringify(res1.issued));
- if (res1.issued && res1.issued.email && res1.issued.email.dryRun) ok('WOULD email the gift code (dry-run, no real send)'); else fail('gift email not dry-run');
+ if (res1.ok && res1.email === REAL.email) ok('verify letter to the REAL on-file email (' + res1.email + '), NOT the payload/attacker email'); else fail('used the wrong email: ' + JSON.stringify(res1));
+ if (res1.started && res1.started.sent && res1.started.sent.dryRun) ok('WOULD send the verify letter (dry-run, no real send)'); else fail('verify letter not dry-run: ' + JSON.stringify(res1.started));
+ if (res1.started && /\/verify\?token=/.test(res1.started.verifyUrl || '')) ok('letter carries a /verify?token=… link'); else fail('no verify link in result');
// ---------------------------------------------------------------------------
- hr('(a3) freshness gate — an OLD existing customer is NOT gifted (anti mass-mint)');
+ hr('(a3) freshness gate — an OLD existing customer is NOT mailed (anti mass-mail)');
const OLD = { id: 7000001, email: 'old-customer@onfile.com', first_name: 'Pat', created_at: '2024-01-01T00:00:00Z' };
shopify.getCustomer = async () => ({ ok: true, json: { customer: OLD } });
shopify.getCustomerMetafield = async () => null;
const resOld = await retailWebhook.handleCustomerCreate({ id: 7000001, email: 'attacker@evil.com' });
- if (!resOld.ok && resOld.reason === 'stale_customer') ok('old customer (created 2024) rejected as stale — cannot mass-mint the customer base'); else fail('stale gate did not reject old customer: ' + JSON.stringify(resOld));
+ if (!resOld.ok && resOld.reason === 'stale_customer') ok('old customer (created 2024) rejected as stale — cannot mass-mail the base'); else fail('stale gate did not reject old customer: ' + JSON.stringify(resOld));
shopify.getCustomer = async () => ({ ok: true, json: { customer: REAL } }); // restore fresh
// ---------------------------------------------------------------------------
@@ -100,19 +100,32 @@ async function main() {
if (!res2.ok && /not_found/.test(res2.reason || '')) ok('unknown id rejected (' + res2.reason + ')'); else fail('forged id was not rejected: ' + JSON.stringify(res2));
// ---------------------------------------------------------------------------
- hr('(b2) already-issued customer → skipped (idempotent — one gift ever)');
+ hr('(b2) already-sent customer → skipped (idempotent — one verify letter ever)');
shopify.getCustomer = async () => ({ ok: true, json: { customer: REAL } });
- shopify.getCustomerMetafield = async () => 'true'; // flag already set
+ shopify.getCustomerMetafield = async () => 'true'; // sample_verify_sent already set
const res3 = await retailWebhook.handleCustomerCreate({ id: 8675309 });
- if (res3.ok && res3.skipped === 'already_issued') ok('replay/second event is a no-op (' + res3.skipped + ')'); else fail('idempotency failed: ' + JSON.stringify(res3));
+ if (res3.ok && res3.skipped === 'already_sent') ok('replay/second event is a no-op (' + res3.skipped + ')'); else fail('idempotency failed: ' + JSON.stringify(res3));
shopify.getCustomer = _gc; shopify.getCustomerMetafield = _gm; // restore
// ---------------------------------------------------------------------------
- hr('(b3) gift email clearly offers the 3 free samples + includes the code');
- const gtpl = require('../lib/email').retailGiftEmail({ firstName: 'Dana', code: 'DEMO-CODE-1234', value: 12.75, count: 3 });
- const blob = (gtpl.subject || '') + ' ' + (gtpl.html || '');
- if (/\b3\b/.test(blob) && /sample/i.test(blob)) ok('email references "3" and "sample"'); else fail('email does not clearly offer 3 free samples');
- if (/DEMO-CODE-1234/.test(blob)) ok('email includes the gift code'); else fail('email missing the code');
+ hr('(b3) verify letter offers the ' + config.FREE_SAMPLE_COUNT + ' free samples + carries the link');
+ const vurl = 'http://x/verify?token=demo.sig';
+ const vtpl = email.verifyEmail({ firstName: 'Dana', url: vurl, count: config.FREE_SAMPLE_COUNT });
+ const blob = (vtpl.subject || '') + ' ' + (vtpl.html || '');
+ if (new RegExp('\\b' + config.FREE_SAMPLE_COUNT + '\\b').test(blob) && /sample/i.test(blob)) ok('letter references "' + config.FREE_SAMPLE_COUNT + '" and "sample"'); else fail('letter does not clearly offer the free samples');
+ if (blob.includes(vurl)) ok('letter includes the verify link'); else fail('letter missing the verify link');
+
+ // ---------------------------------------------------------------------------
+ hr('(v) verify.js — token round-trip, tamper, malformed + the tag write');
+ const tok = verify.mintToken({ email: 'Jane@Example.com', customerId: '42' });
+ const rd = verify.readToken(tok);
+ if (rd.ok && rd.email === 'jane@example.com' && rd.customerId === '42') ok('mint→read round-trips (email lowercased, id preserved)'); else fail('token round-trip failed: ' + JSON.stringify(rd));
+ if (verify.readToken(tok.slice(0, -3) + 'XYZ').reason === 'bad_signature') ok('tampered token → bad_signature'); else fail('tamper not rejected');
+ if (verify.readToken('nope').reason === 'malformed') ok('malformed token → malformed'); else fail('malformed not rejected');
+ shopify.getCustomer = async () => ({ ok: true, json: { customer: REAL } });
+ const done = await verify.completeVerification({ email: 'jane@example.com', customerId: '42' });
+ if (done.ok && done.tag === config.VERIFIED_TAG && done.dryRun) ok("WOULD tag customer '" + done.tag + "' (the ONLY Shopify write — no discount, no gift card)"); else fail('tag not applied: ' + JSON.stringify(done));
+ shopify.getCustomer = _gc; shopify.getCustomerMetafield = _gm; // restore
// ---------------------------------------------------------------------------
hr('(c) trade application → moderated approve');
← 9196a62 DEPLOY.md: rewrite runbook for Option C (verify→tag→Regios),
·
back to Dw Signup Fulfillment
·
retail-webhook: only set sample_verify_sent flag when the le 43065de →