backend-manager 5.2.3 → 5.2.6

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/CLAUDE.md +3 -3
  3. package/TODO-CANCEL-EMAIL-MISSING-ORDER-ID.md +159 -0
  4. package/TODO-WEBHOOK-KEY-LEGACY-REMOVAL.md +15 -0
  5. package/TODO-WEBHOOK-KEY-UPGRADE.md +138 -0
  6. package/docs/consent.md +5 -10
  7. package/docs/sanitization.md +32 -24
  8. package/docs/schemas.md +1 -1
  9. package/docs/stripe-webhook-forwarding.md +2 -2
  10. package/docs/testing.md +8 -7
  11. package/package.json +1 -1
  12. package/scripts/test-helper-providers.js +162 -0
  13. package/src/cli/commands/base-command.js +5 -5
  14. package/src/cli/commands/emulator.js +201 -54
  15. package/src/cli/commands/test.js +80 -9
  16. package/src/manager/events/cron/daily/ghostii-auto-publisher.js +2 -2
  17. package/src/manager/events/firestore/payments-webhooks/analytics.js +2 -2
  18. package/src/manager/functions/core/actions/api/user/delete.js +1 -1
  19. package/src/manager/helpers/analytics.js +1 -1
  20. package/src/manager/helpers/middleware.js +7 -4
  21. package/src/manager/helpers/utilities.js +31 -0
  22. package/src/manager/libraries/email/generators/newsletter.js +2 -2
  23. package/src/manager/libraries/email/providers/beehiiv.js +69 -27
  24. package/src/manager/libraries/email/providers/sendgrid.js +38 -12
  25. package/src/manager/libraries/email/validation.js +1 -1
  26. package/src/manager/libraries/infer-contact.js +1 -1
  27. package/src/manager/routes/general/email/post.js +4 -2
  28. package/src/manager/routes/marketing/email-preferences/post.js +2 -2
  29. package/src/manager/routes/payments/dispute-alert/post.js +3 -3
  30. package/src/manager/routes/payments/intent/processors/test.js +2 -2
  31. package/src/manager/routes/payments/webhook/post.js +2 -2
  32. package/src/manager/routes/user/delete.js +1 -1
  33. package/src/manager/routes/user/oauth2/providers/discord.js +1 -1
  34. package/src/manager/routes/user/oauth2/providers/google.js +1 -1
  35. package/src/test/runner.js +7 -31
  36. package/src/test/test-accounts.js +8 -63
  37. package/src/test/utils/http-client.js +1 -0
  38. package/test/events/payments/journey-payments-cancel.js +4 -4
  39. package/test/events/payments/journey-payments-failure.js +2 -2
  40. package/test/events/payments/journey-payments-legacy-product.js +1 -1
  41. package/test/events/payments/journey-payments-one-time-failure.js +1 -1
  42. package/test/events/payments/journey-payments-plan-change.js +1 -1
  43. package/test/events/payments/journey-payments-refund-webhook.js +4 -4
  44. package/test/events/payments/journey-payments-suspend.js +4 -4
  45. package/test/events/payments/journey-payments-trial.js +2 -2
  46. package/test/events/payments/journey-payments-uid-resolution.js +1 -1
  47. package/test/marketing/consent-lifecycle.js +255 -0
  48. package/test/routes/payments/dispute-alert.js +13 -13
  49. package/test/routes/payments/webhook.js +3 -3
  50. /package/src/manager/routes/general/email/templates/{download-app-link.js → general/download-app-link.js} +0 -0
@@ -162,10 +162,14 @@ const STATIC_ACCOUNTS = {
162
162
  subscription: { product: { id: 'basic' }, status: 'active' },
163
163
  },
164
164
  },
165
+ // The two `consent-*` accounts use the `_test.allow_*` prefix so they bypass
166
+ // the `_test.*` marketing-block in blocked-local-patterns.js. They're the
167
+ // live-provider integration sentinels — they intentionally round-trip through
168
+ // SendGrid + Beehiiv to verify the consent gate works end-to-end.
165
169
  'consent-granted': {
166
170
  id: 'consent-granted',
167
- uid: '_test-consent-granted',
168
- email: '_test.consent-granted@{domain}',
171
+ uid: '_test-allow-consent-granted',
172
+ email: '_test.allow_consent-granted@{domain}',
169
173
  properties: {
170
174
  roles: {},
171
175
  subscription: { product: { id: 'basic' }, status: 'active' },
@@ -173,8 +177,8 @@ const STATIC_ACCOUNTS = {
173
177
  },
174
178
  'consent-declined': {
175
179
  id: 'consent-declined',
176
- uid: '_test-consent-declined',
177
- email: '_test.consent-declined@{domain}',
180
+ uid: '_test-allow-consent-declined',
181
+ email: '_test.allow_consent-declined@{domain}',
178
182
  properties: {
179
183
  roles: {},
180
184
  subscription: { product: { id: 'basic' }, status: 'active' },
@@ -805,64 +809,6 @@ const TEST_DATA = {
805
809
  defaultProjectId: 'demo-test',
806
810
  };
807
811
 
808
- /**
809
- * Clean up test accounts from marketing providers (SendGrid + Beehiiv)
810
- * Called after account setup when TEST_EXTENDED_MODE is set to remove
811
- * contacts added by auth:on-create
812
- * @param {string} domain - Domain for email addresses
813
- * @param {object} options - Options with apiUrl and backendManagerKey
814
- * @returns {Promise<object>} Result with cleaned count
815
- */
816
- async function cleanupMarketingProviders(domain, options = {}) {
817
- const fetch = require('wonderful-fetch');
818
- const results = { cleaned: 0, errors: [] };
819
-
820
- const { apiUrl, backendManagerKey } = options;
821
- if (!apiUrl || !backendManagerKey) {
822
- console.error('cleanupMarketingProviders: Missing apiUrl or backendManagerKey');
823
- return results;
824
- }
825
-
826
- // Get all test account emails (test contacts like rachel.greene+bem cleaned up by their own tests)
827
- const definitions = getAccountDefinitions(domain);
828
- const emails = Object.values(definitions).map(acc => acc.email);
829
-
830
- // Clean up each email via the API endpoint (uses hosting port 5002)
831
- await Promise.all(
832
- emails.map(async (email) => {
833
- try {
834
- const response = await fetch(`${apiUrl}/backend-manager/marketing/contact`, {
835
- method: 'DELETE',
836
- response: 'json',
837
- timeout: 30000,
838
- body: {
839
- backendManagerKey,
840
- email,
841
- },
842
- });
843
-
844
- // Log the result for debugging
845
- if (response.providers?.beehiiv?.deleted) {
846
- results.cleaned++;
847
- } else if (response.providers?.beehiiv?.skipped) {
848
- // Skipped means not found - that's fine
849
- results.cleaned++;
850
- } else if (response.providers?.beehiiv?.error) {
851
- console.error(`Failed to delete ${email} from Beehiiv:`, response.providers.beehiiv.error);
852
- results.errors.push({ email, error: response.providers.beehiiv.error });
853
- } else {
854
- results.cleaned++;
855
- }
856
- } catch (error) {
857
- console.error(`Failed to cleanup ${email}:`, error.message);
858
- results.errors.push({ email, error: error.message });
859
- }
860
- })
861
- );
862
-
863
- return results;
864
- }
865
-
866
812
  module.exports = {
867
813
  STATIC_ACCOUNTS,
868
814
  JOURNEY_ACCOUNTS,
@@ -873,5 +819,4 @@ module.exports = {
873
819
  fetchPrivateKeys,
874
820
  deleteTestUsers,
875
821
  createTestAccounts,
876
- cleanupMarketingProviders,
877
822
  };
@@ -22,6 +22,7 @@ class HttpClient {
22
22
  // Store accounts reference for as() method
23
23
  this.accounts = options.accounts || null;
24
24
  this.backendManagerKey = options.backendManagerKey || '';
25
+ this.backendManagerWebhookKey = options.backendManagerWebhookKey || '';
25
26
  }
26
27
 
27
28
  /**
@@ -56,7 +56,7 @@ module.exports = {
56
56
 
57
57
  state.eventId1 = `_test-evt-journey-cancel-pending-${Date.now()}`;
58
58
 
59
- const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
59
+ const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerWebhookKey}`, {
60
60
  id: state.eventId1,
61
61
  type: 'customer.subscription.updated',
62
62
  data: {
@@ -64,7 +64,7 @@ module.exports = {
64
64
  id: state.subscriptionId,
65
65
  object: 'subscription',
66
66
  status: 'active',
67
- metadata: { uid: state.uid },
67
+ metadata: { uid: state.uid, orderId: state.orderId },
68
68
  cancel_at_period_end: true,
69
69
  cancel_at: Math.floor(futureDate.getTime() / 1000),
70
70
  canceled_at: null,
@@ -105,7 +105,7 @@ module.exports = {
105
105
  async run({ http, assert, state, config, payments }) {
106
106
  state.eventId2 = `_test-evt-journey-cancel-final-${Date.now()}`;
107
107
 
108
- const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
108
+ const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerWebhookKey}`, {
109
109
  id: state.eventId2,
110
110
  type: 'customer.subscription.deleted',
111
111
  data: {
@@ -113,7 +113,7 @@ module.exports = {
113
113
  id: state.subscriptionId,
114
114
  object: 'subscription',
115
115
  status: 'canceled',
116
- metadata: { uid: state.uid },
116
+ metadata: { uid: state.uid, orderId: state.orderId },
117
117
  cancel_at_period_end: false,
118
118
  canceled_at: Math.floor(Date.now() / 1000),
119
119
  current_period_end: Math.floor(Date.now() / 1000),
@@ -58,7 +58,7 @@ module.exports = {
58
58
 
59
59
  // Send invoice.payment_failed with subscription billing reason
60
60
  // This tests the new parseWebhook routing: billing_reason=subscription_cycle → subscription category
61
- const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
61
+ const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerWebhookKey}`, {
62
62
  id: state.eventId,
63
63
  type: 'invoice.payment_failed',
64
64
  data: {
@@ -72,7 +72,7 @@ module.exports = {
72
72
  parent: {
73
73
  subscription_details: {
74
74
  subscription: state.subscriptionId,
75
- metadata: { uid: state.uid },
75
+ metadata: { uid: state.uid, orderId: state.orderId },
76
76
  },
77
77
  type: 'subscription_details',
78
78
  },
@@ -66,7 +66,7 @@ module.exports = {
66
66
  // Send a subscription created webhook with the LEGACY product ID
67
67
  // This simulates an existing subscriber whose Stripe subscription still
68
68
  // references the old product ID from before migration
69
- const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
69
+ const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerWebhookKey}`, {
70
70
  id: state.legacyEventId,
71
71
  type: 'customer.subscription.created',
72
72
  data: {
@@ -39,7 +39,7 @@ module.exports = {
39
39
 
40
40
  // Send invoice.payment_failed with a non-subscription billing reason
41
41
  // This routes to category: 'one-time' in the webhook parser
42
- const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
42
+ const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerWebhookKey}`, {
43
43
  id: state.eventId,
44
44
  type: 'invoice.payment_failed',
45
45
  data: {
@@ -59,7 +59,7 @@ module.exports = {
59
59
  state.eventId = `_test-evt-journey-plan-change-${Date.now()}`;
60
60
 
61
61
  // Send subscription.updated with product B's Stripe product ID (or test sentinel)
62
- const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
62
+ const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerWebhookKey}`, {
63
63
  id: state.eventId,
64
64
  type: 'customer.subscription.updated',
65
65
  data: {
@@ -59,7 +59,7 @@ module.exports = {
59
59
 
60
60
  state.cancelEventId = `_test-evt-journey-refund-cancel-${Date.now()}`;
61
61
 
62
- const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
62
+ const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerWebhookKey}`, {
63
63
  id: state.cancelEventId,
64
64
  type: 'customer.subscription.updated',
65
65
  data: {
@@ -67,7 +67,7 @@ module.exports = {
67
67
  id: state.subscriptionId,
68
68
  object: 'subscription',
69
69
  status: 'active',
70
- metadata: { uid: state.uid },
70
+ metadata: { uid: state.uid, orderId: state.orderId },
71
71
  cancel_at_period_end: true,
72
72
  cancel_at: Math.floor(futureDate.getTime() / 1000),
73
73
  canceled_at: null,
@@ -110,7 +110,7 @@ module.exports = {
110
110
  state.refundEventId = `_test-evt-journey-refund-charge-${Date.now()}`;
111
111
  state.refundAmountCents = 2800; // $28.00
112
112
 
113
- const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
113
+ const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerWebhookKey}`, {
114
114
  id: state.refundEventId,
115
115
  type: 'charge.refunded',
116
116
  data: {
@@ -121,7 +121,7 @@ module.exports = {
121
121
  amount_refunded: state.refundAmountCents,
122
122
  currency: 'usd',
123
123
  subscription: state.subscriptionId,
124
- metadata: { uid: state.uid },
124
+ metadata: { uid: state.uid, orderId: state.orderId },
125
125
  refunds: {
126
126
  data: [
127
127
  {
@@ -53,7 +53,7 @@ module.exports = {
53
53
  async run({ http, assert, state, config, payments }) {
54
54
  state.eventId1 = `_test-evt-journey-suspend-fail-${Date.now()}`;
55
55
 
56
- const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
56
+ const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerWebhookKey}`, {
57
57
  id: state.eventId1,
58
58
  type: 'customer.subscription.updated',
59
59
  data: {
@@ -61,7 +61,7 @@ module.exports = {
61
61
  id: state.subscriptionId,
62
62
  object: 'subscription',
63
63
  status: 'past_due',
64
- metadata: { uid: state.uid },
64
+ metadata: { uid: state.uid, orderId: state.orderId },
65
65
  cancel_at_period_end: false,
66
66
  canceled_at: null,
67
67
  current_period_end: Math.floor(Date.now() / 1000) + 86400,
@@ -104,7 +104,7 @@ module.exports = {
104
104
 
105
105
  state.eventId2 = `_test-evt-journey-suspend-recover-${Date.now()}`;
106
106
 
107
- const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
107
+ const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerWebhookKey}`, {
108
108
  id: state.eventId2,
109
109
  type: 'customer.subscription.updated',
110
110
  data: {
@@ -112,7 +112,7 @@ module.exports = {
112
112
  id: state.subscriptionId,
113
113
  object: 'subscription',
114
114
  status: 'active',
115
- metadata: { uid: state.uid },
115
+ metadata: { uid: state.uid, orderId: state.orderId },
116
116
  cancel_at_period_end: false,
117
117
  canceled_at: null,
118
118
  current_period_end: Math.floor(futureDate.getTime() / 1000),
@@ -115,7 +115,7 @@ module.exports = {
115
115
 
116
116
  state.eventId2 = `_test-evt-journey-trial-active-${Date.now()}`;
117
117
 
118
- const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
118
+ const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerWebhookKey}`, {
119
119
  id: state.eventId2,
120
120
  type: 'customer.subscription.updated',
121
121
  data: {
@@ -123,7 +123,7 @@ module.exports = {
123
123
  id: state.subscriptionId,
124
124
  object: 'subscription',
125
125
  status: 'active',
126
- metadata: { uid: state.uid },
126
+ metadata: { uid: state.uid, orderId: state.orderId },
127
127
  cancel_at_period_end: false,
128
128
  canceled_at: null,
129
129
  current_period_end: Math.floor(futureDate.getTime() / 1000),
@@ -65,7 +65,7 @@ module.exports = {
65
65
 
66
66
  state.noUidEventId = `_test-evt-journey-uid-resolve-${Date.now()}`;
67
67
 
68
- const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
68
+ const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerWebhookKey}`, {
69
69
  id: state.noUidEventId,
70
70
  type: 'customer.subscription.updated',
71
71
  data: {
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Test: Marketing Provider Lifecycle (end-to-end against live SendGrid + Beehiiv)
3
+ *
4
+ * Walks two long-lived test accounts through their full lifecycle and verifies
5
+ * provider state at every transition:
6
+ *
7
+ * 1. Pre-check — both accounts should be absent from SendGrid + Beehiiv
8
+ * 2. Sync — flip consent.marketing.status and call Marketing.sync()
9
+ * 3. Verify — granted account present in both, declined account absent from both
10
+ * 4. Unsubscribe — hit the email-preferences endpoint for the granted account
11
+ * 5. Verify — granted account now absent from both
12
+ *
13
+ * The two accounts use the `_test.allow_*` prefix so they bypass the
14
+ * blocked-local-patterns gate (which blocks plain `_test.*` from reaching
15
+ * providers). They are the only accounts intentionally allowed to round-trip
16
+ * through SendGrid + Beehiiv.
17
+ *
18
+ * Run with TEST_EXTENDED_MODE=true (no-op otherwise). Requires SENDGRID_API_KEY
19
+ * and BEEHIIV_API_KEY in env. Total runtime is ~60-90s — most of it spent waiting
20
+ * for SendGrid's async upsert/delete background jobs to surface.
21
+ */
22
+ const sendgridProvider = require('../../src/manager/libraries/email/providers/sendgrid.js');
23
+ const beehiivProvider = require('../../src/manager/libraries/email/providers/beehiiv.js');
24
+
25
+ const SETTLE_MS = 5000; // Beehiiv settles in 1-2s; SendGrid's background-job upsert can take 10-20s+
26
+ const POLL_INTERVAL_MS = 2000;
27
+ const POLL_MAX_MS = 90000; // SendGrid's delete-contact job can take 30-60s+ to surface
28
+
29
+ function sleep(ms) {
30
+ return new Promise((resolve) => setTimeout(resolve, ms));
31
+ }
32
+
33
+ /**
34
+ * Poll a provider lookup until it matches the expected state (present/absent).
35
+ * Returns the resolved value (contact object or null) once condition is met, or
36
+ * the last value seen after timing out.
37
+ *
38
+ * @param {Function} fetchFn - async function that returns contact or null
39
+ * @param {boolean} expectPresent - true = wait until present, false = wait until absent
40
+ */
41
+ async function pollProvider(fetchFn, expectPresent) {
42
+ const start = Date.now();
43
+ let lastValue = null;
44
+
45
+ while (Date.now() - start < POLL_MAX_MS) {
46
+ lastValue = await fetchFn();
47
+ const present = !!lastValue;
48
+ if (present === expectPresent) {
49
+ return lastValue;
50
+ }
51
+ await sleep(POLL_INTERVAL_MS);
52
+ }
53
+
54
+ return lastValue;
55
+ }
56
+
57
+ module.exports = {
58
+ description: 'Marketing provider lifecycle (live SendGrid + Beehiiv round-trip)',
59
+ type: 'group',
60
+ skip: !process.env.TEST_EXTENDED_MODE
61
+ ? 'TEST_EXTENDED_MODE not set (this test hits live SendGrid + Beehiiv APIs)'
62
+ : false,
63
+ tests: [
64
+ // ─────────────────────────────────────────────────────────────────────
65
+ // Phase 1 — Pre-check: both accounts should be absent from providers
66
+ // ─────────────────────────────────────────────────────────────────────
67
+ {
68
+ name: 'phase-1-pre-check-both-accounts-absent',
69
+ auth: 'admin',
70
+ timeout: 180000,
71
+
72
+ async run({ accounts, assert }) {
73
+ const granted = accounts['consent-granted'];
74
+ const declined = accounts['consent-declined'];
75
+
76
+ // Force-clean any leftovers from a prior run (e.g. the previous phase-2a
77
+ // left a contact in SendGrid that THIS run's phase-1 needs to start
78
+ // without). SendGrid's delete is an async job, so wait for absence.
79
+ await sendgridProvider.removeContact(granted.email);
80
+ await sendgridProvider.removeContact(declined.email);
81
+ await beehiivProvider.removeContact(granted.email);
82
+ await beehiivProvider.removeContact(declined.email);
83
+
84
+ const grantedSg = await pollProvider(() => sendgridProvider.findContact(granted.email), false);
85
+ const declinedSg = await pollProvider(() => sendgridProvider.findContact(declined.email), false);
86
+ const grantedBh = await pollProvider(() => beehiivProvider.findContact(granted.email), false);
87
+ const declinedBh = await pollProvider(() => beehiivProvider.findContact(declined.email), false);
88
+
89
+ assert.equal(grantedSg, null, 'granted account should be absent from SendGrid');
90
+ assert.equal(declinedSg, null, 'declined account should be absent from SendGrid');
91
+ assert.equal(grantedBh, null, 'granted account should be absent from Beehiiv');
92
+ assert.equal(declinedBh, null, 'declined account should be absent from Beehiiv');
93
+ },
94
+ },
95
+
96
+ // ─────────────────────────────────────────────────────────────────────
97
+ // Phase 2 — Sync granted account → both providers
98
+ // ─────────────────────────────────────────────────────────────────────
99
+ {
100
+ name: 'phase-2a-granted-account-syncs-to-both-providers',
101
+ auth: 'admin',
102
+ timeout: 180000,
103
+
104
+ async run({ accounts, assert, Manager, assistant }) {
105
+ const granted = accounts['consent-granted'];
106
+ const admin = Manager.libraries.admin;
107
+
108
+ // Set consent.marketing.status = 'granted' on the user doc
109
+ await admin.firestore().doc(`users/${granted.uid}`).set({
110
+ consent: {
111
+ marketing: {
112
+ status: 'granted',
113
+ grantedAt: {
114
+ timestamp: new Date().toISOString(),
115
+ timestampUNIX: Math.floor(Date.now() / 1000),
116
+ source: 'test',
117
+ ip: null,
118
+ text: 'consent-lifecycle test',
119
+ },
120
+ },
121
+ legal: {
122
+ status: 'granted',
123
+ grantedAt: {
124
+ timestamp: new Date().toISOString(),
125
+ timestampUNIX: Math.floor(Date.now() / 1000),
126
+ source: 'test',
127
+ ip: null,
128
+ text: 'consent-lifecycle test',
129
+ },
130
+ },
131
+ },
132
+ }, { merge: true });
133
+
134
+ // Trigger marketing sync via the Email() surface
135
+ const result = await Manager.Email(assistant).sync(granted.uid);
136
+ assert.ok(result, 'sync should return a result');
137
+ assert.notEqual(result.blocked, 'validation', 'sync should not be blocked by validation (uses _test.allow_*)');
138
+
139
+ // Poll providers until they reflect the upsert. SendGrid's upsert is
140
+ // an async background job (returns a job_id, not the inserted contact)
141
+ // so a single check 5s later isn't enough — it can take 10-20s to
142
+ // surface. Beehiiv is usually instant but uses the same poll for symmetry.
143
+ const sgContact = await pollProvider(() => sendgridProvider.findContact(granted.email), true);
144
+ const bhContact = await pollProvider(() => beehiivProvider.findContact(granted.email), true);
145
+
146
+ assert.ok(sgContact, 'granted account should now exist in SendGrid');
147
+ assert.ok(bhContact, 'granted account should now exist in Beehiiv');
148
+ },
149
+ },
150
+
151
+ // ─────────────────────────────────────────────────────────────────────
152
+ // Phase 2b — Declined account: verify it stays out of providers when we
153
+ // DON'T call sync (which is what the signup route does for declined
154
+ // marketing consent).
155
+ // ─────────────────────────────────────────────────────────────────────
156
+ {
157
+ name: 'phase-2b-declined-account-stays-out-of-providers',
158
+ auth: 'admin',
159
+ timeout: 180000,
160
+
161
+ async run({ accounts, assert, Manager, assistant }) {
162
+ const declined = accounts['consent-declined'];
163
+ const admin = Manager.libraries.admin;
164
+
165
+ // Set consent.marketing.status = 'revoked' on the user doc.
166
+ // We deliberately do NOT call sync — the production signup route gates
167
+ // on consent.marketing.status === 'granted' before calling sync, so a
168
+ // declined user simply never gets a sync call. We verify that contract
169
+ // by NOT calling sync and asserting the user stays absent.
170
+ await admin.firestore().doc(`users/${declined.uid}`).set({
171
+ consent: {
172
+ marketing: {
173
+ status: 'revoked',
174
+ grantedAt: { timestamp: null, timestampUNIX: null, source: null, ip: null, text: null },
175
+ revokedAt: {
176
+ timestamp: new Date().toISOString(),
177
+ timestampUNIX: Math.floor(Date.now() / 1000),
178
+ source: 'test',
179
+ ip: null,
180
+ text: null,
181
+ },
182
+ },
183
+ legal: {
184
+ status: 'granted',
185
+ grantedAt: {
186
+ timestamp: new Date().toISOString(),
187
+ timestampUNIX: Math.floor(Date.now() / 1000),
188
+ source: 'test',
189
+ ip: null,
190
+ text: 'consent-lifecycle test',
191
+ },
192
+ },
193
+ },
194
+ }, { merge: true });
195
+
196
+ await sleep(SETTLE_MS);
197
+
198
+ const sgContact = await sendgridProvider.findContact(declined.email);
199
+ const bhContact = await beehiivProvider.findContact(declined.email);
200
+
201
+ assert.equal(sgContact, null, 'declined account should remain absent from SendGrid');
202
+ assert.equal(bhContact, null, 'declined account should remain absent from Beehiiv');
203
+ },
204
+ },
205
+
206
+ // ─────────────────────────────────────────────────────────────────────
207
+ // Phase 3 — Unsubscribe: granted account is removed via Manager.Email().remove
208
+ // ─────────────────────────────────────────────────────────────────────
209
+ {
210
+ name: 'phase-3-granted-account-unsubscribe-removes-from-both',
211
+ auth: 'admin',
212
+ timeout: 180000,
213
+
214
+ async run({ accounts, assert, Manager, assistant }) {
215
+ const granted = accounts['consent-granted'];
216
+
217
+ // Trigger removal — simulates the email-preferences opt-out flow
218
+ const result = await Manager.Email(assistant).remove(granted.email);
219
+ assert.ok(result, 'remove should return a result');
220
+
221
+ // Poll for absence — SendGrid's contact delete is also an async job.
222
+ const sgContact = await pollProvider(() => sendgridProvider.findContact(granted.email), false);
223
+ const bhContact = await pollProvider(() => beehiivProvider.findContact(granted.email), false);
224
+
225
+ assert.equal(sgContact, null, 'granted account should now be absent from SendGrid');
226
+ assert.equal(bhContact, null, 'granted account should now be absent from Beehiiv');
227
+ },
228
+ },
229
+
230
+ // ─────────────────────────────────────────────────────────────────────
231
+ // Phase 4 — Validation gate: _test.* (non-allow) is blocked by validate()
232
+ // ─────────────────────────────────────────────────────────────────────
233
+ {
234
+ name: 'phase-4-non-allow-test-email-blocked-by-validation',
235
+ auth: 'admin',
236
+ timeout: 30000,
237
+
238
+ async run({ assert, Manager, assistant }) {
239
+ // _test.never-reaches-providers@... is NOT _test.allow_* → should be blocked
240
+ const blockedEmail = '_test.never-reaches-providers@somiibo.com';
241
+
242
+ const result = await Manager.Email(assistant).add({ email: blockedEmail });
243
+
244
+ assert.equal(result.blocked, 'validation', 'non-allow _test.* email should be blocked by validation');
245
+
246
+ // And the provider lookup should show nothing
247
+ const sgContact = await sendgridProvider.findContact(blockedEmail);
248
+ const bhContact = await beehiivProvider.findContact(blockedEmail);
249
+
250
+ assert.equal(sgContact, null, 'blocked email should never appear in SendGrid');
251
+ assert.equal(bhContact, null, 'blocked email should never appear in Beehiiv');
252
+ },
253
+ },
254
+ ],
255
+ };
@@ -32,7 +32,7 @@ module.exports = {
32
32
  name: 'rejects-unknown-provider',
33
33
  auth: 'none',
34
34
  async run({ http, assert }) {
35
- const response = await http.as('none').post(`payments/dispute-alert?provider=unknown&key=${process.env.BACKEND_MANAGER_KEY}`, {
35
+ const response = await http.as('none').post(`payments/dispute-alert?provider=unknown&key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`, {
36
36
  id: '_test-dispute-unknown-provider',
37
37
  card: '4242',
38
38
  amount: 9.99,
@@ -47,7 +47,7 @@ module.exports = {
47
47
  name: 'rejects-missing-id',
48
48
  auth: 'none',
49
49
  async run({ http, assert }) {
50
- const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
50
+ const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`, {
51
51
  card: '4242',
52
52
  amount: 9.99,
53
53
  transactionDate: '2026-01-15',
@@ -61,7 +61,7 @@ module.exports = {
61
61
  name: 'rejects-missing-card',
62
62
  auth: 'none',
63
63
  async run({ http, assert }) {
64
- const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
64
+ const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`, {
65
65
  id: '_test-dispute-no-card',
66
66
  amount: 9.99,
67
67
  transactionDate: '2026-01-15',
@@ -75,7 +75,7 @@ module.exports = {
75
75
  name: 'rejects-missing-amount',
76
76
  auth: 'none',
77
77
  async run({ http, assert }) {
78
- const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
78
+ const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`, {
79
79
  id: '_test-dispute-no-amount',
80
80
  card: '4242',
81
81
  transactionDate: '2026-01-15',
@@ -89,7 +89,7 @@ module.exports = {
89
89
  name: 'rejects-missing-transaction-date',
90
90
  auth: 'none',
91
91
  async run({ http, assert }) {
92
- const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
92
+ const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`, {
93
93
  id: '_test-dispute-no-date',
94
94
  card: '4242',
95
95
  amount: 9.99,
@@ -105,7 +105,7 @@ module.exports = {
105
105
  async run({ http, assert, firestore }) {
106
106
  const alertId = '_test-dispute-valid';
107
107
 
108
- const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
108
+ const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`, {
109
109
  id: alertId,
110
110
  card: '4242424242424242',
111
111
  cardBrand: 'Visa',
@@ -164,7 +164,7 @@ module.exports = {
164
164
  const alertId = '_test-dispute-alertid-field';
165
165
 
166
166
  // Chargeblast alert.created events use alertId instead of id
167
- const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
167
+ const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`, {
168
168
  alertId: alertId,
169
169
  card: '546616******5805',
170
170
  cardBrand: 'Mastercard',
@@ -189,7 +189,7 @@ module.exports = {
189
189
  const alertId = '_test-dispute-minimal';
190
190
 
191
191
  // Send minimal alert (alert.created shape — no externalOrder, metadata, etc.)
192
- const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
192
+ const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`, {
193
193
  id: alertId,
194
194
  card: '9124',
195
195
  amount: 10,
@@ -218,7 +218,7 @@ module.exports = {
218
218
  async run({ http, assert, firestore }) {
219
219
  const alertId = '_test-dispute-last4';
220
220
 
221
- const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
221
+ const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`, {
222
222
  id: alertId,
223
223
  card: '1234',
224
224
  amount: 9.99,
@@ -240,7 +240,7 @@ module.exports = {
240
240
  const alertId = '_test-dispute-duplicate';
241
241
 
242
242
  // Send first alert
243
- await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
243
+ await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`, {
244
244
  id: alertId,
245
245
  card: '4242',
246
246
  amount: 29.99,
@@ -248,7 +248,7 @@ module.exports = {
248
248
  });
249
249
 
250
250
  // Send duplicate
251
- const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
251
+ const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`, {
252
252
  id: alertId,
253
253
  card: '4242',
254
254
  amount: 29.99,
@@ -274,7 +274,7 @@ module.exports = {
274
274
  });
275
275
 
276
276
  // Send alert with same ID — should retry since previous status was 'failed'
277
- const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
277
+ const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`, {
278
278
  id: alertId,
279
279
  card: '4242',
280
280
  amount: 29.99,
@@ -300,7 +300,7 @@ module.exports = {
300
300
  const alertId = '_test-dispute-default-provider';
301
301
 
302
302
  // Send without provider query param
303
- const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
303
+ const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`, {
304
304
  id: alertId,
305
305
  card: '4242',
306
306
  amount: 9.99,