@ssheleg/sheleg-dev 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -77,7 +77,7 @@ export const INVARIANTS = [
77
77
  fixtures: [PIXEL_BROWSER, CAPI_SERVER],
78
78
  states: 'one id, generated once and used by both sides, or the revenue counts twice',
79
79
  breaks: ['shared-event-id'],
80
- run({ sink, emitter }) {
80
+ run({ sink, emitter, assert }) {
81
81
  runPurchase({ sink, emitter }, CLEARED());
82
82
  // The browser side is read from the SHIPPED fixture rather than from the sink. That
83
83
  // is deliberate: it keeps this assertion independent of whether the browser event
@@ -95,7 +95,7 @@ export const INVARIANTS = [
95
95
  fixtures: [PIXEL_BROWSER, CAPI_SERVER],
96
96
  states: 'Meta compares the name as well as the id, and the name is the half that is missed',
97
97
  breaks: ['exact-event-name'],
98
- run({ sink, emitter }) {
98
+ run({ sink, emitter, assert }) {
99
99
  runPurchase({ sink, emitter }, CLEARED());
100
100
  const pixelName = fixture(PIXEL_BROWSER)[1]; // the browser contract, not the sink
101
101
  const serverName = sink.capi[0].data[0].event_name;
@@ -110,7 +110,7 @@ export const INVARIANTS = [
110
110
  fixtures: [CAPI_FORBIDDEN],
111
111
  states: 'the thank-you page has no idea whether the money moved, so it reports nothing',
112
112
  breaks: ['webhook-sourced'],
113
- run({ sink, emitter }) {
113
+ run({ sink, emitter, assert }) {
114
114
  runPurchase({ sink, emitter }, NOT_CLEARED());
115
115
  assert.equal(sink.capi.length, 0,
116
116
  'a Conversions API purchase was sent for a charge that has not cleared — this is '
@@ -125,12 +125,16 @@ export const INVARIANTS = [
125
125
  fixtures: [PIXEL_BROWSER],
126
126
  states: 'the browser event stays and stays subordinate — it carries the click ids',
127
127
  breaks: ['keep-browser-event'],
128
- run({ sink, emitter }) {
128
+ run({ sink, emitter, assert }) {
129
129
  runPurchase({ sink, emitter }, CLEARED());
130
130
  assert.equal(sink.pixel.length, 1,
131
131
  'no browser event fired: the click id, the consent state and the session are lost, '
132
132
  + 'and only the browser had them');
133
- assert.equal(sink.pixel[0][0], 'track');
133
+ // `|| []` on purpose: with no browser event at all this must FAIL rather than throw a
134
+ // TypeError, because an assertion the runner never reached is one no mutant has been
135
+ // watched breaking — which is how it sat unmeasured until 2026-08-20.
136
+ assert.equal((sink.pixel[0] || [])[0], 'track',
137
+ 'the browser event is not a `track` call — the pixel contract is `fbq(\'track\', …)`');
134
138
  },
135
139
  },
136
140
  {
@@ -138,7 +142,7 @@ export const INVARIANTS = [
138
142
  fixtures: [CAPI_SERVER],
139
143
  states: 'advanced matching sends hashes, never the address itself',
140
144
  breaks: ['hashed-identifiers'],
141
- run({ sink, emitter }) {
145
+ run({ sink, emitter, assert }) {
142
146
  runPurchase({ sink, emitter }, CLEARED());
143
147
  const userData = sink.capi[0].data[0].user_data;
144
148
  for (const value of userData.em) {
@@ -156,7 +160,7 @@ export const INVARIANTS = [
156
160
  // Broad by construction: it compares the whole body, so every rule that changes any
157
161
  // field turns it red. It is here so a reader can trust the fixtures they copy.
158
162
  breaks: ['shared-event-id', 'exact-event-name', 'hashed-identifiers', 'keep-browser-event'],
159
- run({ sink, emitter }) {
163
+ run({ sink, emitter, assert }) {
160
164
  runPurchase({ sink, emitter }, CLEARED());
161
165
  assert.deepEqual(sink.capi[0], fixture(CAPI_SERVER));
162
166
  assert.deepEqual(sink.pixel[0], fixture(PIXEL_BROWSER));
@@ -164,46 +168,166 @@ export const INVARIANTS = [
164
168
  },
165
169
  ];
166
170
 
171
+ // ------------------------------------------------- one assertion, one measurement
172
+ //
173
+ // Why the assertions are not called straight through `node:assert`. Until 2026-08-20 the
174
+ // runner returned on the FIRST throw and the matrix compared one row per invariant, so an
175
+ // invariant was measured as a single unit: any assertion that was not the sole
176
+ // discriminator could be replaced by `assert.ok(true)` and nothing went red. Measured that
177
+ // day in this pack too — the PII guard below (*"user_data carries something with an @ in
178
+ // it"*, advertised by name at `references/meta-linkedin.md`) was neutered and
179
+ // `--self-test` stayed at exit 0, because the `assert.match` above it discriminated the
180
+ // same rule.
181
+ //
182
+ // So the `assert` an invariant receives is a RECORDER: same API, but a failure is
183
+ // remembered instead of thrown and the assertions after it still run. Each call site is one
184
+ // assertion with its own identity — its line in this file — and `--self-test` asks the
185
+ // question per assertion rather than per invariant.
186
+ //
187
+ // `assert.unmutated.equal(...)` marks an assertion NO mutant in this pack varies: it pins
188
+ // something the rules do not touch, so it has never been watched failing and the verdict
189
+ // counts it apart rather than as evidence. Measured, not asserted — the self-test fails
190
+ // both when a plain assertion turns out unbreakable and when an `unmutated` one turns out
191
+ // breakable.
192
+ //
193
+ // Duplicated from `stripe-billing/fixtures/assert-money-invariants.mjs` for the reason
194
+ // given at the top: a reader installs one skill, not six.
195
+
196
+ const SELF = import.meta.url;
197
+ const METHODS = ['equal', 'notEqual', 'deepEqual', 'notDeepEqual', 'ok', 'match', 'doesNotMatch'];
198
+
199
+ /**
200
+ * The line in THIS file that called the recorder.
201
+ *
202
+ * `captureStackTrace(holder, boundary)` drops `boundary` and everything above it, so the
203
+ * top frame is the assertion's own line — no frame counting, which is what makes the
204
+ * identity survive a refactor of the recorder itself.
205
+ */
206
+ function siteOf(boundary) {
207
+ const holder = {};
208
+ Error.captureStackTrace(holder, boundary);
209
+ const frame = (holder.stack || '').split('\n').slice(1).find((f) => f.includes(SELF));
210
+ const m = frame && frame.match(/:(\d+):\d+\)?\s*$/);
211
+ return m ? Number(m[1]) : 0;
212
+ }
213
+
214
+ function recorder() {
215
+ const seen = new Map(); // line -> { site, unmutated, ok, why }
216
+
217
+ function record(site, unmutated, name, args) {
218
+ let ok = true;
219
+ let why = null;
220
+ try {
221
+ assert[name](...args);
222
+ } catch (error) {
223
+ ok = false;
224
+ why = error.message.split('\n')[0];
225
+ }
226
+ const prev = seen.get(site);
227
+ if (prev) {
228
+ prev.ok = prev.ok && ok;
229
+ prev.why = prev.why || why;
230
+ } else {
231
+ seen.set(site, { site, unmutated, ok, why });
232
+ }
233
+ }
234
+
235
+ const bind = (unmutated) => {
236
+ const api = {};
237
+ for (const name of METHODS) {
238
+ const call = (...args) => record(siteOf(call), unmutated, name, args);
239
+ api[name] = call;
240
+ }
241
+ return api;
242
+ };
243
+
244
+ const api = bind(false);
245
+ api.unmutated = bind(true);
246
+ return { api, seen };
247
+ }
248
+
167
249
  // ------------------------------------------------------------------------- runners
168
250
 
169
251
  async function runOne(invariant, without) {
170
- const context = harness(without);
252
+ const { api, seen } = recorder();
253
+ const { sink, emitter } = harness(without);
254
+ let threw = null;
171
255
  try {
172
- await invariant.run(context);
173
- return null;
256
+ await invariant.run({ sink, emitter, assert: api });
174
257
  } catch (error) {
175
- return error.message.split('\n')[0];
258
+ // A real error — a read off a row the mutant never wrote, say. It aborts the remaining
259
+ // assertions, and it is still the invariant going red.
260
+ threw = error.message.split('\n')[0];
176
261
  }
262
+ const sites = [...seen.values()];
263
+ return {
264
+ threw,
265
+ sites,
266
+ failed: sites.filter((s) => !s.ok),
267
+ broke: Boolean(threw) || sites.some((s) => !s.ok),
268
+ };
177
269
  }
178
270
 
179
271
  async function runAll() {
180
272
  let failed = 0;
273
+ let asserted = 0;
181
274
  for (const invariant of INVARIANTS) {
182
- const why = await runOne(invariant, []);
183
- if (why) {
275
+ const result = await runOne(invariant, []);
276
+ asserted += result.sites.length;
277
+ if (result.broke) {
184
278
  failed += 1;
185
- console.error(`FAIL ${invariant.id}\n ${why}`);
279
+ console.error(`FAIL ${invariant.id}`);
280
+ for (const s of result.failed) console.error(` :${s.site} ${s.why}`);
281
+ if (result.threw) console.error(` threw: ${result.threw}`);
186
282
  } else {
187
- console.log(`pass ${invariant.id} — ${invariant.states}`);
283
+ console.log(`pass ${invariant.id} (${result.sites.length} assertions) — ${invariant.states}`);
188
284
  }
189
285
  }
190
286
  if (failed) {
191
287
  console.error(`\n${failed} of ${INVARIANTS.length} deduplication assertions failed`);
192
288
  return 1;
193
289
  }
194
- console.log(`\nOK: ${INVARIANTS.length} deduplication assertions hold against the emitter`);
290
+ console.log(`\nOK: ${INVARIANTS.length} deduplication invariants hold against the emitter `
291
+ + `(${asserted} assertions)`);
195
292
  return 0;
196
293
  }
197
294
 
198
295
  async function selfTest() {
199
296
  const problems = [];
200
297
  const matrix = [];
298
+ const coverage = []; // one row per ASSERTION: which mutants turn that call site red
201
299
  for (const invariant of INVARIANTS) {
300
+ // The clean run is the census: it names every assertion this invariant executes. One
301
+ // that records nothing has a run() which never took the recorder, so nothing about it
302
+ // is measured — the failure mode of the injection itself.
303
+ const clean = await runOne(invariant, []);
304
+ if (clean.broke) {
305
+ problems.push(`${invariant.id}: fails against the unmutated emitter `
306
+ + `(${(clean.failed[0] && `:${clean.failed[0].site} ${clean.failed[0].why}`) || clean.threw})`);
307
+ }
308
+ if (!clean.sites.length) {
309
+ problems.push(`${invariant.id}: recorded no assertion — its run() does not take the `
310
+ + '`assert` the runner passes it, so nothing about it is measured');
311
+ }
312
+ const broken = new Map(clean.sites.map((s) => [s.site, []]));
313
+
202
314
  const broke = [];
203
315
  for (const rule of RULES) {
204
- if (await runOne(invariant, [rule])) broke.push(rule);
316
+ const r = await runOne(invariant, [rule]);
317
+ if (r.broke) broke.push(rule);
318
+ for (const s of r.failed) if (broken.has(s.site)) broken.get(s.site).push(rule);
205
319
  }
206
320
  matrix.push({ id: invariant.id, broke });
321
+
322
+ for (const site of clean.sites) {
323
+ coverage.push({
324
+ invariant: invariant.id,
325
+ site: site.site,
326
+ unmutated: site.unmutated,
327
+ by: broken.get(site.site) || [],
328
+ });
329
+ }
330
+
207
331
  const expected = [...invariant.breaks].sort().join(', ');
208
332
  const measured = [...broke].sort().join(', ');
209
333
  if (expected !== measured) {
@@ -215,12 +339,33 @@ async function selfTest() {
215
339
  }
216
340
 
217
341
  const width = Math.max(...matrix.map((row) => row.id.length));
218
- console.log(`\nassertion x mutant — ${RULES.length} mutants, `
219
- + 'and which removed rule turns each assertion red\n');
342
+ console.log(`\ninvariant x mutant — ${RULES.length} mutants, `
343
+ + 'and which removed rule turns each invariant red\n');
220
344
  for (const row of matrix) {
221
345
  console.log(` ${row.id.padEnd(width)} ${row.broke.join(', ') || '(nothing)'}`);
222
346
  }
223
347
 
348
+ // The per-ASSERTION half, and the one the table above cannot give: an invariant goes red
349
+ // as a unit, so a dead assertion inside a live one is invisible there.
350
+ console.log('\nassertion x mutant — every call site, and what turns IT red\n');
351
+ for (const row of coverage) {
352
+ const label = `${row.invariant}:${row.site}`.padEnd(width + 6);
353
+ if (row.unmutated) {
354
+ console.log(` ${label} unmutated — no rule varies what it checks`);
355
+ if (row.by.length) {
356
+ problems.push(`${row.invariant} :${row.site} is declared assert.unmutated and `
357
+ + `[${row.by.join(', ')}] breaks it — it discriminates, so drop the .unmutated`);
358
+ }
359
+ } else {
360
+ console.log(` ${label} ${row.by.join(', ') || '(nothing)'}`);
361
+ if (!row.by.length) {
362
+ problems.push(`${row.invariant} :${row.site} is broken by no mutant — the assertion `
363
+ + 'cannot fail. Either it is not testing anything, or no rule varies what it '
364
+ + 'checks and it belongs on assert.unmutated, which the verdict counts apart');
365
+ }
366
+ }
367
+ }
368
+
224
369
  console.log('\nisolation — the fixture that separates each rule from every other\n');
225
370
  for (const rule of RULES) {
226
371
  const isolating = matrix.filter((row) => row.broke.length === 1 && row.broke[0] === rule);
@@ -237,7 +382,10 @@ async function selfTest() {
237
382
  for (const p of problems) console.error(` - ${p}`);
238
383
  return 1;
239
384
  }
240
- console.log(`\nOK: ${INVARIANTS.length} assertions, each watched failing; `
385
+ const pinned = coverage.filter((r) => r.unmutated).length;
386
+ console.log(`\nOK: ${INVARIANTS.length} invariants over ${coverage.length} assertions — `
387
+ + `${coverage.length - pinned} watched failing ONE CALL SITE AT A TIME against `
388
+ + `${RULES.length} mutants; ${pinned} declared unmutated and measured unbreakable; `
241
389
  + `${RULES.length} rules, each isolated by a fixture`);
242
390
  return 0;
243
391
  }
@@ -102,8 +102,12 @@ by accident, because the wrapper accepts whatever you hand it and hashes it
102
102
  without judgement. SHA-256 does **not** make a value safe to send: hashing is a
103
103
  matching mechanism, not a permission. That the wrapper receives a hash at all is checkable:
104
104
  `identifiers-reach-the-server-hashed` in `fixtures/assert-dedup-contract.mjs` requires every
105
- `user_data.em` entry to be 64 hex characters and refuses anything with an `@` in it. What may
106
- be hashed in the first place is the table below, and no assertion decides it.
105
+ `user_data.em` entry to be 64 hex characters and refuses anything with an `@` in it. **Both
106
+ halves are separately watched failing**, which they were not until 2026-08-20: the pack's
107
+ `--self-test` measured one row per invariant, so the `@` guard could be neutered while the
108
+ hex-shape assertion above it kept the invariant red, and this paragraph advertised a control
109
+ nothing had ever seen fail. The matrix is now per call site. What may be hashed in the first
110
+ place is the table below, and no assertion decides it.
107
111
 
108
112
  Meta's Business Tools Terms prohibit sharing data that "includes or is based on"
109
113
  any of these, and the prohibition covers the Pixel, the Conversions API, the
@@ -39,15 +39,13 @@ and anything else that issues an invoice and calls you back.
39
39
  - [Status mapping — and finality](#status-mapping--and-finality)
40
40
  - [Webhook signature verification](#webhook-signature-verification)
41
41
  - [Idempotent webhook processing](#idempotent-webhook-processing)
42
- - [IP allowlisting behind a proxy](#ip-allowlisting-behind-a-proxy)
43
- - [CSRF exemption for callback routes](#csrf-exemption-for-callback-routes)
42
+ - [Callback route hardening](#callback-route-hardening)
44
43
  - [The conversion buffer](#the-conversion-buffer)
45
44
  - [Reconciliation fields](#reconciliation-fields)
46
45
  - [Crediting: the amount waterfall](#crediting-the-amount-waterfall)
47
46
  - [Refunds and AML holds](#refunds-and-aml-holds)
48
47
  - [The test/live credential boundary](#the-testlive-credential-boundary)
49
- - [Local development](#local-development)
50
- - [Test matrix](#test-matrix)
48
+ - [Local development and the test matrix](#local-development-and-the-test-matrix)
51
49
  - [Security checklist](#security-checklist)
52
50
  - [Common pitfalls](#common-pitfalls)
53
51
 
@@ -192,46 +190,17 @@ the read.
192
190
 
193
191
  ---
194
192
 
195
- ## IP allowlisting behind a proxy
193
+ ## Callback route hardening
196
194
 
197
- Signature verification is the real gate; the allowlist is defence in depth and
198
- cheap. It is also the single most common cause of "webhooks stopped working
199
- after we moved to a load balancer".
195
+ Two things about the *request* rather than about the money in it, and each one is the
196
+ usual cause of "webhooks stopped working after we moved to a load balancer": reading the
197
+ caller IP from the proxy hop you actually own instead of `x-forwarded-for[0]`, and
198
+ exempting the **one** callback path from CSRF by exact match rather than by prefix — a
199
+ prefix on `/api/payments` exempts the checkout endpoint too, which is where the money is.
200
200
 
201
- ```ts
202
- function callerIp(req): string | null {
203
- // Trust ONLY the hop your own infrastructure appends.
204
- const xff = req.headers['x-forwarded-for'];
205
- if (typeof xff === 'string') {
206
- const hops = xff.split(',').map((s) => s.trim());
207
- return hops[hops.length - TRUSTED_PROXY_COUNT] ?? null;
208
- }
209
- return req.socket.remoteAddress ?? null;
210
- }
211
- ```
212
-
213
- Taking `xff[0]` trusts a header the client controls — anyone can claim to be the
214
- gateway. Count from the right by the number of proxies you actually run, and
215
- keep that number in configuration, because it changes when infrastructure does.
216
-
217
- Allowlist the provider's published callback IPs, log a rejection with the
218
- observed IP, and **never** fall back to "allow if the header is missing".
219
-
220
- ---
221
-
222
- ## CSRF exemption for callback routes
223
-
224
- A gateway cannot present your CSRF token. Exempt the callback path explicitly
225
- and narrowly:
226
-
227
- ```ts
228
- // middleware.ts (Next.js)
229
- export const config = { matcher: ['/((?!api/payments/webhook).*)'] };
230
- ```
231
-
232
- Exempt the **one** path, by exact match, and make it the only route in your app
233
- that skips CSRF. A prefix match on `/api/payments` exempts the checkout endpoint
234
- too, which is where the money is.
201
+ Both, with the code:
202
+ [`references/callback-route-hardening.md`](references/callback-route-hardening.md).
203
+ Signature verification above is the real gate; these are defence in depth.
235
204
 
236
205
  ---
237
206
 
@@ -374,45 +343,13 @@ boundary* and *Residual exposure*.
374
343
 
375
344
  ---
376
345
 
377
- ## Local development
378
-
379
- Two paths, and you want both:
380
-
381
- **Skip the gateway.** A `SKIP_BILLING=true` branch that credits immediately and
382
- logs loudly. Everyone building a feature that merely *touches* checkout should
383
- use this. Make it impossible in production: assert `NODE_ENV !== 'production'`
384
- at the branch, not just in config.
385
-
386
- **Real gateway, tunnelled callback.** Callbacks cannot reach `localhost`:
387
-
388
- ```bash
389
- cloudflared tunnel --url http://localhost:3000
390
- # → https://random-name.trycloudflare.com → set as the callback base URL, restart the app
391
- ```
392
-
393
- Then keep a **signed mock callback** in your test tooling — a script that builds
394
- a real payload, signs it with the dev key and POSTs it. This is what makes the
395
- sad paths (`wrong_amount`, `refund_fail`, `locked`) testable at all; you cannot
396
- ask a gateway to underpay you on demand.
397
-
398
- ---
399
-
400
- ## Test matrix
401
-
402
- Cover these, and plant a defect against each before believing the green:
346
+ ## Local development and the test matrix
403
347
 
404
- | Case | What it catches |
405
- |---|---|
406
- | valid signature | the happy path |
407
- | tampered signature | constant-time compare, 403 not 500 |
408
- | signature over a payload containing a URL | the escaping trap |
409
- | duplicate delivery of a settling status | idempotency guard |
410
- | `paid_over` | crediting the received amount, not the invoiced |
411
- | `wrong_amount` | no full credit on a partial payment |
412
- | out-of-order delivery (final, then earlier) | the `notIn` guard, not timestamps |
413
- | callback from a non-allowlisted IP | proxy-aware extraction |
414
- | `refund_process` then `refund_fail` | refund is a state machine |
415
- | `locked` | no credit, no auto-cancel |
348
+ Two development paths you want both of — a `SKIP_BILLING=true` branch that is impossible
349
+ in production, and a real gateway behind a tunnel with a signed mock callback for the sad
350
+ paths a provider will not produce on demand — plus the ten cases to cover before believing
351
+ a green suite, each with the defect it catches:
352
+ [`references/testing-and-local-dev.md`](references/testing-and-local-dev.md).
416
353
 
417
354
  ---
418
355
 
@@ -0,0 +1,55 @@
1
+ # Callback route hardening — the proxy hop and the CSRF hole
2
+
3
+ Split out of `SKILL.md` on 2026-08-20, when its body measured ~4894 tokens against the
4
+ house working limit of 4750. Both sections below are web plumbing rather than payment
5
+ logic — they are about the request that carries a callback, not about the money in it —
6
+ which is why they are the seam. Signature verification (`SKILL.md`, *Webhook signature
7
+ verification*) is the real gate; these two are defence in depth, and each has a failure
8
+ mode that looks like "webhooks stopped working" rather than like a security event.
9
+
10
+ ---
11
+
12
+ ## IP allowlisting behind a proxy
13
+
14
+ Signature verification is the real gate; the allowlist is defence in depth and
15
+ cheap. It is also the single most common cause of "webhooks stopped working
16
+ after we moved to a load balancer".
17
+
18
+ ```ts
19
+ function callerIp(req): string | null {
20
+ // Trust ONLY the hop your own infrastructure appends.
21
+ const xff = req.headers['x-forwarded-for'];
22
+ if (typeof xff === 'string') {
23
+ const hops = xff.split(',').map((s) => s.trim());
24
+ return hops[hops.length - TRUSTED_PROXY_COUNT] ?? null;
25
+ }
26
+ return req.socket.remoteAddress ?? null;
27
+ }
28
+ ```
29
+
30
+ Taking `xff[0]` trusts a header the client controls — anyone can claim to be the
31
+ gateway. Count from the right by the number of proxies you actually run, and
32
+ keep that number in configuration, because it changes when infrastructure does.
33
+
34
+ Allowlist the provider's published callback IPs, log a rejection with the
35
+ observed IP, and **never** fall back to "allow if the header is missing".
36
+
37
+ ## CSRF exemption for callback routes
38
+
39
+ A gateway cannot present your CSRF token. Exempt the callback path explicitly
40
+ and narrowly:
41
+
42
+ ```ts
43
+ // middleware.ts (Next.js)
44
+ export const config = { matcher: ['/((?!api/payments/webhook).*)'] };
45
+ ```
46
+
47
+ Exempt the **one** path, by exact match, and make it the only route in your app
48
+ that skips CSRF. A prefix match on `/api/payments` exempts the checkout endpoint
49
+ too, which is where the money is.
50
+
51
+ ---
52
+
53
+ Back to [`SKILL.md`](../SKILL.md) — *Webhook signature verification* is the gate these two
54
+ sit behind, and *Test matrix* in
55
+ [`testing-and-local-dev.md`](testing-and-local-dev.md) carries the non-allowlisted-IP case.
@@ -0,0 +1,60 @@
1
+ # Testing and local development
2
+
3
+ Split out of `SKILL.md` on 2026-08-20 for the same reason as
4
+ [`callback-route-hardening.md`](callback-route-hardening.md): the body was ~4894 tokens
5
+ against a 4750 working limit, and the house answer at that point is a split rather than a
6
+ trim. This is the operational half — how you run the thing on a laptop, and what you have
7
+ to cover before believing a green suite.
8
+
9
+ `stripe-billing` ships the same pair of subjects at
10
+ [`../../stripe-billing/references/testing-and-local-dev.md`](../../stripe-billing/references/testing-and-local-dev.md),
11
+ and its *Mutation testing* section is the argument this matrix assumes: plant a defect
12
+ against every row before believing it.
13
+
14
+ ---
15
+
16
+ ## Local development
17
+
18
+ Two paths, and you want both:
19
+
20
+ **Skip the gateway.** A `SKIP_BILLING=true` branch that credits immediately and
21
+ logs loudly. Everyone building a feature that merely *touches* checkout should
22
+ use this. Make it impossible in production: assert `NODE_ENV !== 'production'`
23
+ at the branch, not just in config.
24
+
25
+ **Real gateway, tunnelled callback.** Callbacks cannot reach `localhost`:
26
+
27
+ ```bash
28
+ cloudflared tunnel --url http://localhost:3000
29
+ # → https://random-name.trycloudflare.com → set as the callback base URL, restart the app
30
+ ```
31
+
32
+ Then keep a **signed mock callback** in your test tooling — a script that builds
33
+ a real payload, signs it with the dev key and POSTs it. This is what makes the
34
+ sad paths (`wrong_amount`, `refund_fail`, `locked`) testable at all; you cannot
35
+ ask a gateway to underpay you on demand.
36
+
37
+ ---
38
+
39
+ ## Test matrix
40
+
41
+ Cover these, and plant a defect against each before believing the green:
42
+
43
+ | Case | What it catches |
44
+ |---|---|
45
+ | valid signature | the happy path |
46
+ | tampered signature | constant-time compare, 403 not 500 |
47
+ | signature over a payload containing a URL | the escaping trap |
48
+ | duplicate delivery of a settling status | idempotency guard |
49
+ | `paid_over` | crediting the received amount, not the invoiced |
50
+ | `wrong_amount` | no full credit on a partial payment |
51
+ | out-of-order delivery (final, then earlier) | the `notIn` guard, not timestamps |
52
+ | callback from a non-allowlisted IP | proxy-aware extraction |
53
+ | `refund_process` then `refund_fail` | refund is a state machine |
54
+ | `locked` | no credit, no auto-cancel |
55
+
56
+ ---
57
+
58
+ Back to [`SKILL.md`](../SKILL.md). The provider-specific half — one credential, no sandbox
59
+ host, and what "test mode" actually toggles — is
60
+ [`heleket-provider.md`](heleket-provider.md), *The test/live boundary*.