← back to Dw Signup Fulfillment

scripts/selftest.js

289 lines

'use strict';
// Self-test for dw-signup-fulfillment. Everything runs in DRY_RUN — no live
// Shopify write, no real email, no webhook registration. It prints the exact
// Admin API calls + emails the service WOULD make, then exits 0 on success.
//
//   node scripts/selftest.js
//
// 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');

process.env.DRY_RUN = '1';
// 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-'));

const REPO = path.join(__dirname, '..');
// Seed the temp data dir with the real reps.json so round-robin uses the roster.
const dataDir = path.join(REPO, 'data');
fs.copyFileSync(path.join(dataDir, 'reps.json'), path.join(TMP, 'reps.json'));

// Point the libs at the temp data dir by monkey-patching module paths is fragile;
// instead we run against the real repo but reset the mutable files afterward.
// Simpler + honest: snapshot then restore data/rep-cursor.json + trade-applications.jsonl.
const cursorPath = path.join(dataDir, 'rep-cursor.json');
const appsPath = path.join(dataDir, 'trade-applications.jsonl');
const snap = {
  cursor: fs.existsSync(cursorPath) ? fs.readFileSync(cursorPath) : null,
  apps: fs.existsSync(appsPath) ? fs.readFileSync(appsPath) : null,
};
function restore() {
  if (snap.cursor === null) { try { fs.unlinkSync(cursorPath); } catch {} } else fs.writeFileSync(cursorPath, snap.cursor);
  if (snap.apps === null) { try { fs.unlinkSync(appsPath); } catch {} } else fs.writeFileSync(appsPath, snap.apps);
  try { fs.rmSync(TMP, { recursive: true, force: true }); } catch {}
}

const config = require('../lib/config');
const retailWebhook = require('../lib/retail-webhook'); // WIRED webhook handler (re-fetch auth → verify letter)
const shopify = require('../lib/shopify');
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');
const { createRateLimiter } = require('../lib/rate-limit');

function hr(title) { console.log('\n' + '='.repeat(72) + '\n' + title + '\n' + '='.repeat(72)); }
function ok(msg) { console.log('  ✔ ' + msg); }
function fail(msg) { console.log('  x FAIL: ' + msg); failures++; }
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: signed-in account → Shopify Function grants exactly ' + config.FREE_SAMPLE_COUNT + ' lifetime samples; email verification remains identity/engagement only');
  if (!config.DRY_RUN) { fail('DRY_RUN is OFF — refusing to run selftest that would make live writes'); return; }

  // ---------------------------------------------------------------------------
  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;
  const _at = shopify.addTags, _sm = shopify.setCustomerMetafield;
  shopify.getCustomer = async () => ({ ok: true, json: { customer: REAL } });
  shopify.addTags = async (id, tags) => ({ ok: true, dryRun: true, method: 'PUT', url: `dry://customers/${id}`, body: { tags } });
  shopify.setCustomerMetafield = async (id, metafield) => ({ ok: true, dryRun: true, method: 'POST', url: `dry://customers/${id}/metafields`, body: { metafield } });
  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('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 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-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

  // ---------------------------------------------------------------------------
  hr('(b) forged / unknown customer id → REJECTED (the re-fetch IS the auth)');
  shopify.getCustomer = async () => ({ ok: true, json: {} }); // customer not found
  const res2 = await retailWebhook.handleCustomerCreate({ id: 999999, email: 'x@y.com' });
  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-sent customer → skipped (idempotent — one verify letter ever)');
  shopify.getCustomer = async () => ({ ok: true, json: { customer: REAL } });
  shopify.getCustomerMetafield = async () => 'true'; // sample_verify_sent already set
  const res3 = await retailWebhook.handleCustomerCreate({ id: 8675309 });
  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; // reads restored; writes stay stubbed through trade test

  // ---------------------------------------------------------------------------
  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');
  const created = trade.apply({ email: 'Studio@BigDesignCo.com', business_name: 'Big Design Co', resale_cert: 'CA-RESALE-99887', phone: '310-555-0142', shopify_customer_id: 5551234 });
  console.log('  applied: ' + JSON.stringify(created));
  if (created.status === 'pending' && created.created_at) ok('application stored pending with created_at ' + created.created_at);
  else fail('apply() did not produce a pending record with created_at');

  const pendingBefore = trade.listPending().length;
  const approve = await trade.approve(created.id);
  console.log('  approve result: ' + JSON.stringify(approve, null, 2));
  if (!approve.ok && approve.simulated && approve.dryRun && approve.status === 'pending') ok('approval explicitly simulated; no entitlement or email claimed');
  else fail('dry-run approval must report a pending simulation: ' + JSON.stringify(approve));
  if (approve.steps.length === 0 && trade.get(created.id).status === 'pending' && trade.listPending().length === pendingBefore) ok('dry run leaves application pending and performs no approval side effects');
  else fail('dry run unexpectedly advanced approval');

  // ---------------------------------------------------------------------------
  // TK-11185 — server-side find-or-create at apply-time so every public application is
  // born LINKED and approvable (the old public path left shopify_customer_id:null →
  // approve() hard-failed cannot_resolve_customer → "I filled it out and nothing happened").
  hr('(c2) TK-11185 — applyAndLink find-or-create + graceful degrade + approve resolves');

  // createCustomer is DRY_RUN-safe (graphql mutation short-circuits) — no stub, no network.
  const ccDry = await shopify.createCustomer('dry@run.test', { firstName: 'Dry', phone: '310-555-0000' });
  if (ccDry.ok && ccDry.created && ccDry.dryRun && /^\d+$/.test(String(ccDry.id))) ok('createCustomer is DRY_RUN-safe → synthetic numeric id ' + ccDry.id + ' (no live write)');
  else fail('createCustomer not DRY_RUN-safe: ' + JSON.stringify(ccDry));

  // createCustomer error branches — inject graphql/findCustomerByEmail via the deps seam
  // so the taken/phone-retry paths run WITHOUT a live call (the DRY_RUN happy path above
  // never exercises these branches; createCustomer calls the module-internal fns directly).
  // "Email has already been taken" → re-resolve by email and reuse that id.
  const ccTaken = await shopify.createCustomer('taken@studio.com', {}, {
    graphql: async () => ({ ok: true, json: { data: { customerCreate: { customer: null, userErrors: [{ field: 'email', message: 'Email has already been taken' }] } } } }),
    findCustomerByEmail: async () => '55',
  });
  if (ccTaken.ok && ccTaken.id === '55' && ccTaken.created === false && ccTaken.via === 'existing_taken') ok('createCustomer: "email taken" → re-resolves + reuses existing id 55 (no dup)');
  else fail('createCustomer taken-branch failed: ' + JSON.stringify(ccTaken));
  // Phone-format userError → retry once WITHOUT phone, then succeed. First call returns a
  // phone error, second call (no phone) returns a created customer.
  let gqlCall = 0;
  const ccPhone = await shopify.createCustomer('phone@studio.com', { firstName: 'Pat', phone: 'not-a-phone' }, {
    graphql: async (q, vars) => {
      gqlCall++;
      if (gqlCall === 1 && vars.input.phone) return { ok: true, json: { data: { customerCreate: { customer: null, userErrors: [{ field: 'phone', message: 'Phone is invalid' }] } } } };
      return { ok: true, json: { data: { customerCreate: { customer: { id: 'gid://shopify/Customer/900123', email: vars.input.email }, userErrors: [] } } } };
    },
  });
  if (ccPhone.ok && ccPhone.id === '900123' && ccPhone.created === true && gqlCall === 2) ok('createCustomer: bad phone → retries once WITHOUT phone → created id 900123');
  else fail('createCustomer phone-retry failed: ' + JSON.stringify(ccPhone) + ' calls=' + gqlCall);

  // Stub the resolver per-scenario so linkage is deterministic + makes NO live call.
  const _foc = shopify.findOrCreateCustomer;

  // Scenario 1 — NEW account: no existing customer → create + stamp.
  shopify.findOrCreateCustomer = async () => ({ ok: true, id: '700000001', created: true, via: 'created' });
  const s1 = await trade.applyAndLink({ email: 'New@Studio.com', business_name: 'New Studio', contact_name: 'Nadia Newman' });
  if (s1.linkage.ok && s1.app.shopify_customer_id === '700000001' && s1.app.link_status === 'linked' && s1.app.link_created === true) ok('new-account: created + stamped id 700000001, link_status=linked');
  else fail('new-account link failed: ' + JSON.stringify(s1));

  // Scenario 2 — EXISTING account: reuse the found id, do NOT create.
  shopify.findOrCreateCustomer = async () => ({ ok: true, id: '42', created: false, via: 'existing' });
  const s2 = await trade.applyAndLink({ email: 'Repeat@Studio.com', business_name: 'Repeat Studio' });
  if (s2.linkage.ok && s2.app.shopify_customer_id === '42' && s2.app.link_created === false && s2.app.link_via === 'existing') ok('existing-account: reused id 42 by resolved customer id (not email-guessing), no create');
  else fail('existing-account reuse failed: ' + JSON.stringify(s2));

  // Scenario 3 — Shopify create FAILS → still persist (never a black hole), unlinked + link_error.
  shopify.findOrCreateCustomer = async () => ({ ok: false, id: null, error: 'user_errors' });
  const s3 = await trade.applyAndLink({ email: 'Fails@Studio.com', business_name: 'Fails Studio' });
  const s3persisted = trade.get(s3.app.id);
  if (s3persisted && s3.app.shopify_customer_id === null && s3.app.link_status === 'unlinked' && s3.app.link_error === 'user_errors') ok('create-failure: application STILL persisted (unlinked, link_error recorded) — applicant sees success, no black hole');
  else fail('graceful degrade failed: ' + JSON.stringify(s3));

  // Scenario 4 — a linked app remains pending under honest approval simulation.
  shopify.findOrCreateCustomer = async () => ({ ok: true, id: '800000009', created: true, via: 'created' });
  const s4 = await trade.applyAndLink({ email: 'Approve@Me.com', business_name: 'Approve Me Co' });
  shopify.getCustomer = async () => ({ ok: true, json: { customer: { id: 800000009, tags: 'trade' } } }); // addTags merge read
  const s4ap = await trade.approve(s4.app.id);
  shopify.getCustomer = _gc; // restore
  if (!s4ap.ok && s4ap.simulated && s4ap.status === 'pending' && trade.get(s4.app.id).shopify_customer_id === '800000009') ok('linked customer ID is preserved while dry-run approval remains pending');
  else fail('linked application simulation failed: ' + JSON.stringify(s4ap));

  shopify.findOrCreateCustomer = _foc; // restore

  // ---------------------------------------------------------------------------
  // TK-11185 — /trade/apply per-IP rate-limit (the intake now creates a real Shopify
  // customer per POST, so it's throttled like the webhook to block spray abuse).
  hr('(c3) TK-11185 — public /trade/apply rate-limiter trips past the per-IP max');
  const lim = createRateLimiter({ windowMs: 60000, max: 3 });
  const seq = [lim('1.2.3.4'), lim('1.2.3.4'), lim('1.2.3.4'), lim('1.2.3.4')]; // 3 allowed, 4th trips
  if (JSON.stringify(seq) === JSON.stringify([false, false, false, true])) ok('limiter allows first 3 in-window, TRIPS the 4th (returns 429-worthy true)');
  else fail('limiter did not trip on the 4th: ' + JSON.stringify(seq));
  if (lim('9.9.9.9') === false) ok('a different IP is independent (own bucket, not throttled by 1.2.3.4)');
  else fail('limiter leaked across IPs');
  // The real config wiring: default max is 5/hour and both endpoints share the util.
  if (config.TRADE_APPLY_RATE_MAX === 5 && config.TRADE_APPLY_RATE_WINDOW_MS === 3600000) ok('config default = 5 applications per IP per hour');
  else fail('unexpected trade-apply rate config: max=' + config.TRADE_APPLY_RATE_MAX + ' window=' + config.TRADE_APPLY_RATE_WINDOW_MS);

  // ---------------------------------------------------------------------------
  // TK-11190 — recovery script hardening: fail-loud --apply guard + idempotent --send-only.
  hr('(c4) TK-11190 — recover-stuck-apps: fail-loud guard + --send-only idempotency');
  const cp = require('child_process');
  const script = path.join(__dirname, 'recover-stuck-apps.js');
  // Guard: `--apply` while DRY_RUN=1 must exit non-zero BEFORE any write (the footgun fix).
  let guardTripped = false, guardMsg = '';
  try {
    cp.execFileSync('node', [script, '--apply', '--file', '/tmp/tk11190-guard-should-not-read.jsonl'],
      { env: { ...process.env, DRY_RUN: '1' }, stdio: ['ignore', 'pipe', 'pipe'] });
  } catch (e) { guardTripped = true; guardMsg = String((e.stderr || '') + (e.stdout || '')); }
  if (guardTripped && /REFUSING --apply while DRY_RUN/.test(guardMsg)) ok('--apply while DRY_RUN=1 → exits non-zero + refuses BEFORE any Shopify call/write');
  else fail('fail-loud guard did not trip: tripped=' + guardTripped + ' msg=' + guardMsg.slice(0, 120));

  // --send-only: targets ONLY linked+not-emailed; a DRY preview does NOT consume the flag.
  const soFix = path.join(os.tmpdir(), 'tk11190-selftest-sendonly.jsonl');
  fs.writeFileSync(soFix, [
    JSON.stringify({ id: 'L1', email: 'need@x.com', status: 'pending', created_at: '2026-07-10T10:00:00Z', shopify_customer_id: '999001', link_status: 'linked' }),
    JSON.stringify({ id: 'L2', email: 'done@x.com', status: 'pending', created_at: '2026-07-11T10:00:00Z', shopify_customer_id: '999002', link_status: 'linked', recovery_emailed: true }),
    // TK-11377: a DECIDED application must never receive the activation letter.
    JSON.stringify({ id: 'L3', email: 'approved@x.com', status: 'approved', created_at: '2026-07-12T10:00:00Z', decided_at: '2026-07-13T10:00:00Z', decision: 'approved', shopify_customer_id: '999003', link_status: 'linked' }),
    JSON.stringify({ id: 'U3', email: 'unl@x.com', status: 'pending', created_at: '2026-07-12T10:00:00Z', shopify_customer_id: null }),
  ].join('\n') + '\n');
  const soOut = cp.execFileSync('node', [script, '--send-only', '--file', soFix],
    { env: { ...process.env, DRY_RUN: '1' }, encoding: 'utf8' });
  const soRows = fs.readFileSync(soFix, 'utf8').split('\n').filter(Boolean).map(JSON.parse);
  const l1After = soRows.find((r) => r.id === 'L1');
  if (/not-yet-emailed=1\b/.test(soOut)) ok('--send-only targets ONLY linked+PENDING+not-emailed (1 of 3 linked; already-emailed + unlinked + DECIDED excluded)');
  else fail('--send-only selection wrong: ' + soOut.split('\n').find((l) => /not-yet-emailed/.test(l)));
  // TK-11377 — an approved applicant must not be offered the "activate your account" letter.
  const soSent = [...soOut.matchAll(/\[email:designer\]\s+(\S+)/g)].map((m) => m[1]);
  if (!soSent.includes('approved@x.com') && /SKIPPING 1 already-decided/.test(soOut)) ok('--send-only EXCLUDES a decided (approved) application and says so');
  else fail('--send-only would email an already-decided application (TK-11377 regression)');
  if (!l1After.recovery_emailed) ok('--send-only DRY preview does NOT set recovery_emailed → idempotent (a real run still sends)');
  else fail('DRY preview wrongly consumed the recovery_emailed flag');
  try { fs.unlinkSync(soFix); } catch {}

  // ---------------------------------------------------------------------------
  hr('(d) fixed assignment to the DW House Account');
  const picks = [];
  for (let i = 0; i < 5; i++) picks.push(reps.assignRep());
  const house = reps.houseAccount();
  console.log('  house account = ' + house.name + ' <' + house.email + '>');
  console.log('  5 consecutive assignments: ' + picks.map(p => p.name).join(', '));
  // Every assignment must be the SAME single house account (no rotation).
  if (picks.every(p => p.name === house.name && p.email === house.email)) ok('all assignments go to the single DW House Account (no round-robin)');
  else fail('assignment was not the fixed house account');
  if (house.id === 'dw-house') ok('house account id = dw-house'); else fail('unexpected house id');

  shopify.addTags = _at; shopify.setCustomerMetafield = _sm;

  hr('SUMMARY');
  if (failures === 0) { console.log('  ALL CHECKS PASSED — DRY_RUN, nothing written to Shopify, no email sent.'); }
  else { console.log('  ' + failures + ' CHECK(S) FAILED.'); }
}

main()
  .then(() => { restore(); process.exit(failures === 0 ? 0 : 1); })
  .catch(e => { console.error('SELFTEST ERROR:', e); restore(); process.exit(2); });