← back to Dw Signup Fulfillment
scripts/selftest.js
162 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:
// (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.
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;
// 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 (secret-less re-fetch auth)
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 trade = require('../lib/trade');
const reps = require('../lib/reps');
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 (WIRED): unique GIFT CARD (' + config.FREE_SAMPLE_COUNT + ' × $' + config.SAMPLE_PRICE + ' = $' + config.SAMPLE_GIFT_VALUE + ') emailed per signup');
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');
// 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
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');
// ---------------------------------------------------------------------------
hr('(a3) freshness gate — an OLD existing customer is NOT gifted (anti mass-mint)');
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));
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-issued customer → skipped (idempotent — one gift ever)');
shopify.getCustomer = async () => ({ ok: true, json: { customer: REAL } });
shopify.getCustomerMetafield = async () => 'true'; // flag 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));
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('(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.status === 'approved') ok('approved; assigned rep = ' + approve.rep.name + ' <' + approve.rep.email + '>');
else fail('approve failed');
const tagStep = approve.steps.find(s => s.step === 'tag_trade');
if (tagStep && tagStep.result && tagStep.result.WOULD) ok('WOULD tag customer `trade`: ' + tagStep.result.WOULD);
else fail('no trade-tag step');
const mfStep = approve.steps.find(s => s.step === 'set_metafield');
if (mfStep && mfStep.result && mfStep.result.WOULD) ok('WOULD set custom.assigned_rep metafield: ' + mfStep.result.WOULD);
else fail('no metafield step');
const repMailStep = approve.steps.find(s => s.step === 'email_rep');
if (repMailStep && repMailStep.dryRun) ok('WOULD email rep ' + repMailStep.to + ' (dry-run)'); else fail('no rep email');
const appMailStep = approve.steps.find(s => s.step === 'email_applicant');
if (appMailStep && appMailStep.dryRun) ok('WOULD email applicant ' + appMailStep.to + ' (dry-run)'); else fail('no applicant email');
if (trade.listPending().length === pendingBefore - 1) ok('application moved out of pending queue');
else fail('application still pending after approve');
// ---------------------------------------------------------------------------
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');
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); });