backend-manager 5.2.2 → 5.2.5
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 +33 -0
- package/CLAUDE.md +3 -3
- package/docs/consent.md +5 -10
- package/docs/sanitization.md +32 -24
- package/docs/schemas.md +1 -1
- package/docs/testing.md +8 -7
- package/package.json +1 -1
- package/src/manager/helpers/middleware.js +7 -4
- package/src/manager/helpers/utilities.js +31 -0
- package/src/manager/libraries/email/data/blocked-local-patterns.js +10 -5
- package/src/manager/libraries/email/marketing/index.js +15 -7
- package/src/manager/libraries/email/providers/beehiiv.js +147 -94
- package/src/manager/libraries/email/providers/sendgrid.js +131 -72
- package/src/test/runner.js +0 -31
- package/src/test/test-accounts.js +8 -63
- package/templates/backend-manager-config.json +2 -1
- package/test/marketing/consent-lifecycle.js +255 -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.
|
|
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.
|
|
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
|
};
|
|
@@ -138,10 +138,11 @@
|
|
|
138
138
|
marketing: {
|
|
139
139
|
sendgrid: {
|
|
140
140
|
enabled: true,
|
|
141
|
+
listId: '', // SendGrid Marketing list UUID. Populated by OMEGA's sendgrid/ensure/list.js. Skips runtime list-discovery API call.
|
|
141
142
|
},
|
|
142
143
|
beehiiv: {
|
|
143
144
|
enabled: false,
|
|
144
|
-
|
|
145
|
+
publicationId: '', // Beehiiv publication ID (e.g., 'pub_xxxxx'). Populated by OMEGA's beehiiv/ensure/publication.js. Required for marketing sync to Beehiiv.
|
|
145
146
|
// Content pipeline. Lives under the provider that publishes the result —
|
|
146
147
|
// Beehiiv for newsletters, eventually SendGrid for promo blasts. The
|
|
147
148
|
// shape is the same regardless of provider: sources, tone, template,
|
|
@@ -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
|
+
};
|