backend-manager 5.6.3 → 5.6.4

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.
package/CHANGELOG.md CHANGED
@@ -14,6 +14,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
14
14
  - `Fixed` for any bug fixes.
15
15
  - `Security` in case of vulnerabilities.
16
16
 
17
+ # [5.6.4] - 2026-06-11
18
+
19
+ ### Changed
20
+ - **Consent side effects moved off shared test accounts (journey-account isolation).** The marketing webhook suite's revoke-event tests (`test/routes/marketing/webhook.js`) repeatedly write `consent.marketing.status = 'revoked'` to their target account — persistent side-effect data that previously landed on the shared `basic` account, leaving it revoked for the remainder of every run (and, since the v5.6.3 library consent gate, changing `sync()`/`add()` behavior for every later suite touching it). They now target a dedicated `journey-webhook-revoke` account. The extended-mode lifecycle suite (`test/email/marketing-lifecycle.js`) likewise now syncs a dedicated `journey-marketing-sync` account (`_test.allow_*` prefix) instead of the shared `consent-granted` sentinel (which the signup + consent-lifecycle suites rely on). Its cleanup step also now deletes the contact the suite actually created — previously it deleted the ADMIN account's contact, which (post-v5.6.3) revoked admin's doc consent mid-run AND left the synced contact behind in SendGrid/Beehiiv after every extended run. `docs/test-framework.md`'s journey-account rule now lists `consent.marketing` writes as a trigger. Validated: marketing route suites pass (46 passing / 10 env-gated skips / 0 failures).
21
+
22
+ ### Fixed
23
+ - **Anonymous HMAC unsubscribe tests now actually run.** The self-test boot (`src/cli/commands/test.js`) injects a test-only `UNSUBSCRIBE_HMAC_KEY` into the process env (the emulated functions inherit it — same mechanism as the fixture webhook key), closing the fixture gap that left all 8 anon-HMAC tests in `test/routes/marketing/email-preferences.js` failing as "known env gap". Those tests are the route-level coverage for the v5.6.3 HMAC changes (signature validation, rate limiting, consent mirroring); marketing suites went from 38 passing + 8 failing to 46 passing + 0 failing.
24
+
17
25
  # [5.6.3] - 2026-06-11
18
26
 
19
27
  ### Fixed
@@ -390,7 +390,7 @@ Security-rules tests use the `rules` client (`src/test/utils/firestore-rules-cli
390
390
 
391
391
  ## Test Account Isolation (CRITICAL)
392
392
 
393
- **NEVER use shared accounts (`basic`, `admin`, `premium-active`, …) with the `test` processor or any operation that creates side-effect data** (orders, webhooks, subscriptions). The test processor auto-fires webhooks that upgrade a user's subscription asynchronously — using `basic` for a payment-intent test upgrades `basic` to a paid subscription and breaks every subsequent test that depends on `basic` being a basic user.
393
+ **NEVER use shared accounts (`basic`, `admin`, `premium-active`, …) with the `test` processor or any operation that creates side-effect data** (orders, webhooks, subscriptions, consent revocations). The test processor auto-fires webhooks that upgrade a user's subscription asynchronously — using `basic` for a payment-intent test upgrades `basic` to a paid subscription and breaks every subsequent test that depends on `basic` being a basic user.
394
394
 
395
395
  **Rule: any test that creates persistent side-effect data MUST use a dedicated `journey-*` account.**
396
396
 
@@ -402,7 +402,7 @@ const response = await http.as('basic').post('payments/intent', { processor: 'te
402
402
  const response = await http.as('journey-payments-intent-discount').post('payments/intent', { processor: 'test', ... });
403
403
  ```
404
404
 
405
- **When to create a journey account:** the test uses `processor: 'test'`, creates docs in `payments-orders` / `payments-intents` / `payments-webhooks`, modifies subscription state, or sends webhooks that trigger Firestore onWrite handlers. Add it to `src/test/test-accounts.js` (framework tests) or your project's `test/_init.js` `accounts` array (consumer tests).
405
+ **When to create a journey account:** the test uses `processor: 'test'`, creates docs in `payments-orders` / `payments-intents` / `payments-webhooks`, modifies subscription state, sends webhooks that trigger Firestore onWrite handlers, or **writes `consent.marketing` (grant/revoke)** — e.g. marketing webhook revoke events, or `DELETE /marketing/contact` (which mirrors `revoked` to the user doc). Revoked consent persists for the rest of the run and trips the email library's consent gate (`{ blocked: 'consent' }`) on every later `sync()`/`add()` of that account. Existing examples: `journey-webhook-revoke` (webhook revoke events), `journey-marketing-sync` (extended-mode live-provider sync + cleanup; `_test.allow_*` prefix). Add new ones to `src/test/test-accounts.js` (framework tests) or your project's `test/_init.js` `accounts` array (consumer tests).
406
406
 
407
407
  **Shared accounts are safe for:** validation-only tests (missing fields, invalid input, auth rejection, unknown processor), read-only operations, and tests with no async side effects.
408
408
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.6.3",
3
+ "version": "5.6.4",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -237,6 +237,11 @@ class TestCommand extends BaseCommand {
237
237
  process.env.BACKEND_MANAGER_WEBHOOK_KEY = process.env.BACKEND_MANAGER_WEBHOOK_KEY || cfg.backend_manager?.webhookKey;
238
238
  } catch (_) { /* fixture config unreadable — let the normal key check report it */ }
239
239
 
240
+ // Anonymous HMAC unsubscribe tests sign links with this shared secret; the
241
+ // emulated functions inherit it from this process env (same mechanism as the
242
+ // webhook key above). Test-only value — production sets its own env var.
243
+ process.env.UNSUBSCRIBE_HMAC_KEY = process.env.UNSUBSCRIBE_HMAC_KEY || '_test-unsubscribe-hmac-key';
244
+
240
245
  this.ensureFixtureServiceAccount(fixture);
241
246
  this.linkFixtureDeps(fixture);
242
247
  this.log(chalk.cyan(` Self-test: booting bundled fixture project (${fixture})`));
@@ -530,6 +530,37 @@ const JOURNEY_ACCOUNTS = {
530
530
  subscription: { product: { id: 'premium' }, status: 'cancelled', expires: getPastExpires() },
531
531
  },
532
532
  },
533
+ // Journey: marketing webhook revocation (test/routes/marketing/webhook.js). The
534
+ // SendGrid/Beehiiv revoke-event tests repeatedly write consent.marketing.status='revoked'
535
+ // to the target account — persistent side-effect data, so it must never be the shared
536
+ // `basic` account (revoked consent would persist for the rest of the run and trip the
537
+ // email library's consent gate for every later sync of that account).
538
+ 'journey-webhook-revoke': {
539
+ id: 'journey-webhook-revoke',
540
+ uid: '_test-journey-webhook-revoke',
541
+ email: '_test.journey-webhook-revoke@{domain}',
542
+ properties: {
543
+ roles: {},
544
+ subscription: { product: { id: 'basic' }, status: 'active' },
545
+ personal: { name: { first: 'Webb', last: 'Revoke' } },
546
+ },
547
+ },
548
+ // Journey: live-provider sync round-trip (test/email/marketing-lifecycle.js, extended
549
+ // mode only). The `_test.allow_*` email prefix bypasses the `_test.*` marketing block so
550
+ // sync() reaches real SendGrid/Beehiiv; the suite's cleanup DELETE then removes the
551
+ // contact AND mirrors revoked consent to this account's doc. Dedicated account so that
552
+ // side effect stays isolated — the shared `consent-granted` sentinel is used by the
553
+ // signup and consent-lifecycle suites and must keep its granted state.
554
+ 'journey-marketing-sync': {
555
+ id: 'journey-marketing-sync',
556
+ uid: '_test-journey-marketing-sync',
557
+ email: '_test.allow_journey-marketing-sync@{domain}',
558
+ properties: {
559
+ roles: {},
560
+ subscription: { product: { id: 'basic' }, status: 'active' },
561
+ personal: { name: { first: 'Lifecycle', last: 'Sync' } },
562
+ },
563
+ },
533
564
  };
534
565
 
535
566
  /**
@@ -61,8 +61,10 @@ module.exports = {
61
61
  auth: 'admin',
62
62
 
63
63
  async run({ http, assert, accounts, config, Manager }) {
64
- // Use consent-granted account — it has _test.allow_* prefix that bypasses validation
65
- const grantedUid = accounts['consent-granted'].uid;
64
+ // Dedicated journey account — its _test.allow_* prefix bypasses validation, and the
65
+ // cleanup step's DELETE revokes its doc consent, so it must not be a shared sentinel
66
+ // (consent-granted is used by the signup + consent-lifecycle suites).
67
+ const grantedUid = accounts['journey-marketing-sync'].uid;
66
68
  const admin = Manager.libraries.admin;
67
69
  await admin.firestore().doc(`users/${grantedUid}`).set({
68
70
  consent: { marketing: { status: 'granted' } },
@@ -112,14 +114,17 @@ module.exports = {
112
114
  },
113
115
  },
114
116
 
115
- // Step 4: Clean up the admin test contact that sync added
117
+ // Step 4: Clean up the contact the sync step added to the live providers.
118
+ // DELETE also mirrors revoked consent to the matching user doc — which is why this
119
+ // targets the dedicated journey account (step 2 re-seeds granted before syncing, so
120
+ // the suite is self-healing across runs).
116
121
  {
117
- name: 'cleanup-synced-admin-contact',
122
+ name: 'cleanup-synced-contact',
118
123
  auth: 'admin',
119
124
 
120
125
  async run({ http, accounts }) {
121
126
  await http.delete('backend-manager/marketing/contact', {
122
- email: accounts.admin.email,
127
+ email: accounts['journey-marketing-sync'].email,
123
128
  }).catch(() => {});
124
129
  },
125
130
  },
@@ -18,8 +18,12 @@
18
18
  * - Silent skip when email doesn't map to a user (shared SendGrid account scenario)
19
19
  * - Batched events processed independently
20
20
  * - Unsupported event types ignored
21
+ *
22
+ * All revoke-event tests target the dedicated `journey-webhook-revoke` account: they
23
+ * leave it with consent.marketing.status='revoked' (persistent side-effect data), which
24
+ * must never land on the shared `basic` account — revoked consent persists for the rest
25
+ * of the run and trips the email library's consent gate for later syncs of that account.
21
26
  */
22
- const { TEST_ACCOUNTS } = require('../../../src/test/test-accounts.js');
23
27
 
24
28
  // Helper — generate a unique sg_event_id per test
25
29
  function sgEventId(name) {
@@ -105,8 +109,8 @@ module.exports = {
105
109
  name: 'sendgrid-group-unsubscribe-writes-consent',
106
110
  auth: 'none',
107
111
  async run({ http, firestore, assert, accounts }) {
108
- const uid = accounts.basic.uid;
109
- const email = accounts.basic.email;
112
+ const uid = accounts['journey-webhook-revoke'].uid;
113
+ const email = accounts['journey-webhook-revoke'].email;
110
114
  const eventId = sgEventId('group-unsub');
111
115
  const eventTimestamp = Math.floor(Date.now() / 1000);
112
116
 
@@ -130,8 +134,8 @@ module.exports = {
130
134
  name: 'sendgrid-unsubscribe-event-handled',
131
135
  auth: 'none',
132
136
  async run({ http, firestore, assert, accounts }) {
133
- const uid = accounts.basic.uid;
134
- const email = accounts.basic.email;
137
+ const uid = accounts['journey-webhook-revoke'].uid;
138
+ const email = accounts['journey-webhook-revoke'].email;
135
139
  const eventId = sgEventId('unsub');
136
140
 
137
141
  const response = await http.as('none').post(
@@ -152,8 +156,8 @@ module.exports = {
152
156
  name: 'sendgrid-spamreport-event-handled',
153
157
  auth: 'none',
154
158
  async run({ http, firestore, assert, accounts }) {
155
- const uid = accounts.basic.uid;
156
- const email = accounts.basic.email;
159
+ const uid = accounts['journey-webhook-revoke'].uid;
160
+ const email = accounts['journey-webhook-revoke'].email;
157
161
  const eventId = sgEventId('spamreport');
158
162
 
159
163
  const response = await http.as('none').post(
@@ -173,8 +177,8 @@ module.exports = {
173
177
  name: 'sendgrid-hard-bounce-event-handled',
174
178
  auth: 'none',
175
179
  async run({ http, firestore, assert, accounts }) {
176
- const uid = accounts.basic.uid;
177
- const email = accounts.basic.email;
180
+ const uid = accounts['journey-webhook-revoke'].uid;
181
+ const email = accounts['journey-webhook-revoke'].email;
178
182
  const eventId = sgEventId('hard-bounce');
179
183
 
180
184
  // Only hard bounces (bounce_classification='Invalid Address') revoke consent.
@@ -195,8 +199,8 @@ module.exports = {
195
199
  name: 'sendgrid-dropped-hard-bounce-handled',
196
200
  auth: 'none',
197
201
  async run({ http, firestore, assert, accounts }) {
198
- const uid = accounts.basic.uid;
199
- const email = accounts.basic.email;
202
+ const uid = accounts['journey-webhook-revoke'].uid;
203
+ const email = accounts['journey-webhook-revoke'].email;
200
204
  const eventId = sgEventId('dropped');
201
205
 
202
206
  // 'dropped' follows the same classification filter as 'bounce'.
@@ -219,7 +223,7 @@ module.exports = {
219
223
  name: 'sendgrid-technical-bounce-ignored',
220
224
  auth: 'none',
221
225
  async run({ http, assert, accounts }) {
222
- const email = accounts.basic.email;
226
+ const email = accounts['journey-webhook-revoke'].email;
223
227
  const eventId = sgEventId('technical-bounce');
224
228
 
225
229
  // Technical bounces (DMARC, TLS, DNS) are sender-side issues — the recipient's
@@ -239,7 +243,7 @@ module.exports = {
239
243
  name: 'sendgrid-bounce-without-classification-ignored',
240
244
  auth: 'none',
241
245
  async run({ http, assert, accounts }) {
242
- const email = accounts.basic.email;
246
+ const email = accounts['journey-webhook-revoke'].email;
243
247
  const eventId = sgEventId('unclassified-bounce');
244
248
 
245
249
  // No bounce_classification — can't confirm a hard bounce, so skip.
@@ -258,7 +262,7 @@ module.exports = {
258
262
  name: 'sendgrid-delivered-event-ignored',
259
263
  auth: 'none',
260
264
  async run({ http, firestore, assert, accounts }) {
261
- const email = accounts.basic.email;
265
+ const email = accounts['journey-webhook-revoke'].email;
262
266
  const eventId = sgEventId('delivered');
263
267
 
264
268
  const response = await http.as('none').post(
@@ -276,7 +280,7 @@ module.exports = {
276
280
  name: 'sendgrid-open-event-ignored',
277
281
  auth: 'none',
278
282
  async run({ http, assert, accounts }) {
279
- const email = accounts.basic.email;
283
+ const email = accounts['journey-webhook-revoke'].email;
280
284
  const eventId = sgEventId('open');
281
285
 
282
286
  const response = await http.as('none').post(
@@ -317,8 +321,8 @@ module.exports = {
317
321
  name: 'sendgrid-batched-events-processed-independently',
318
322
  auth: 'none',
319
323
  async run({ http, firestore, assert, accounts }) {
320
- const uid = accounts.basic.uid;
321
- const email = accounts.basic.email;
324
+ const uid = accounts['journey-webhook-revoke'].uid;
325
+ const email = accounts['journey-webhook-revoke'].email;
322
326
  const e1 = sgEventId('batch-1');
323
327
  const e2 = sgEventId('batch-2');
324
328
  const e3 = sgEventId('batch-3');
@@ -348,8 +352,8 @@ module.exports = {
348
352
  name: 'sendgrid-duplicate-event-reprocessed-idempotently',
349
353
  auth: 'none',
350
354
  async run({ http, firestore, assert, accounts }) {
351
- const uid = accounts.basic.uid;
352
- const email = accounts.basic.email;
355
+ const uid = accounts['journey-webhook-revoke'].uid;
356
+ const email = accounts['journey-webhook-revoke'].email;
353
357
  const eventId = sgEventId('duplicate');
354
358
 
355
359
  // First delivery
@@ -380,8 +384,8 @@ module.exports = {
380
384
  name: 'sendgrid-event-without-eventId-processed',
381
385
  auth: 'none',
382
386
  async run({ http, firestore, assert, accounts }) {
383
- const uid = accounts.basic.uid;
384
- const email = accounts.basic.email;
387
+ const uid = accounts['journey-webhook-revoke'].uid;
388
+ const email = accounts['journey-webhook-revoke'].email;
385
389
 
386
390
  const response = await http.as('none').post(
387
391
  `backend-manager/marketing/webhook?provider=sendgrid&key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`,
@@ -404,8 +408,8 @@ module.exports = {
404
408
  name: 'beehiiv-subscription-unsubscribed-writes-consent',
405
409
  auth: 'none',
406
410
  async run({ http, firestore, assert, accounts, config, skip }) {
407
- const uid = accounts.basic.uid;
408
- const email = accounts.basic.email;
411
+ const uid = accounts['journey-webhook-revoke'].uid;
412
+ const email = accounts['journey-webhook-revoke'].email;
409
413
  const eventId = `_test-bh-unsub-${Date.now()}`;
410
414
  const eventISO = new Date().toISOString();
411
415
  const publicationId = config.marketing?.newsletter?.publicationId;
@@ -439,8 +443,8 @@ module.exports = {
439
443
  name: 'beehiiv-subscription-deleted-handled',
440
444
  auth: 'none',
441
445
  async run({ http, firestore, assert, accounts, config }) {
442
- const uid = accounts.basic.uid;
443
- const email = accounts.basic.email;
446
+ const uid = accounts['journey-webhook-revoke'].uid;
447
+ const email = accounts['journey-webhook-revoke'].email;
444
448
  const eventId = `_test-bh-deleted-${Date.now()}`;
445
449
  const publicationId = config.marketing?.newsletter?.publicationId;
446
450
 
@@ -468,8 +472,8 @@ module.exports = {
468
472
  name: 'beehiiv-subscription-paused-handled',
469
473
  auth: 'none',
470
474
  async run({ http, firestore, assert, accounts, config }) {
471
- const uid = accounts.basic.uid;
472
- const email = accounts.basic.email;
475
+ const uid = accounts['journey-webhook-revoke'].uid;
476
+ const email = accounts['journey-webhook-revoke'].email;
473
477
  const eventId = `_test-bh-paused-${Date.now()}`;
474
478
  const publicationId = config.marketing?.newsletter?.publicationId;
475
479
 
@@ -499,13 +503,13 @@ module.exports = {
499
503
  // Send an event with a publication_id that does NOT match this brand's pub.
500
504
  // Simulates the shared-devbeans scenario where the parent forwarder fans
501
505
  // an event to brands that don't share the publication — they silent-skip.
502
- const email = accounts.basic.email;
506
+ const email = accounts['journey-webhook-revoke'].email;
503
507
  const eventId = `_test-bh-pubmismatch-${Date.now()}`;
504
508
 
505
509
  // Snapshot revokedAt BEFORE the request so we can prove the pub-mismatch
506
510
  // handler didn't write anything new. (The basic account may already have
507
511
  // a beehiiv-sourced revoke from a prior test that legitimately fired.)
508
- const beforeDoc = await firestore.get(`users/${accounts.basic.uid}`);
512
+ const beforeDoc = await firestore.get(`users/${accounts['journey-webhook-revoke'].uid}`);
509
513
  const beforeRevokedAt = beforeDoc?.consent?.marketing?.revokedAt || null;
510
514
 
511
515
  const response = await http.as('none').post(
@@ -527,7 +531,7 @@ module.exports = {
527
531
 
528
532
  // Reload the user doc and verify revokedAt is byte-equivalent to before —
529
533
  // pub-mismatch must not write a new revoke entry.
530
- const afterDoc = await firestore.get(`users/${accounts.basic.uid}`);
534
+ const afterDoc = await firestore.get(`users/${accounts['journey-webhook-revoke'].uid}`);
531
535
  const afterRevokedAt = afterDoc?.consent?.marketing?.revokedAt || null;
532
536
 
533
537
  assert.deepEqual(
@@ -568,7 +572,7 @@ module.exports = {
568
572
  auth: 'none',
569
573
  async run({ http, assert, accounts, config }) {
570
574
  // 'subscription.created' (new signup) is NOT a revoke — should be ignored.
571
- const email = accounts.basic.email;
575
+ const email = accounts['journey-webhook-revoke'].email;
572
576
  const publicationId = config.marketing?.newsletter?.publicationId;
573
577
  const eventId = `_test-bh-created-${Date.now()}`;
574
578
 
@@ -593,8 +597,8 @@ module.exports = {
593
597
  name: 'beehiiv-duplicate-event-reprocessed-idempotently',
594
598
  auth: 'none',
595
599
  async run({ http, firestore, assert, accounts, config, skip }) {
596
- const uid = accounts.basic.uid;
597
- const email = accounts.basic.email;
600
+ const uid = accounts['journey-webhook-revoke'].uid;
601
+ const email = accounts['journey-webhook-revoke'].email;
598
602
  const publicationId = config.marketing?.newsletter?.publicationId;
599
603
  const eventId = `_test-bh-dup-${Date.now()}`;
600
604