backend-manager 5.2.3 → 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 +18 -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/providers/beehiiv.js +62 -25
- package/src/manager/libraries/email/providers/sendgrid.js +32 -6
- package/src/test/runner.js +0 -31
- package/src/test/test-accounts.js +8 -63
- package/test/marketing/consent-lifecycle.js +255 -0
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,24 @@ 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.2.5] - 2026-05-22
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **`utilities.trim(input)`** in `src/manager/helpers/utilities.js`. Walks objects/arrays recursively and trims whitespace on every string. Does NOT strip HTML. Middleware uses it to clean up incoming request data without mangling URLs, Markdown, or any payload with `<`/`>`/`&`.
|
|
22
|
+
- **`findContact(email)`** on the SendGrid + Beehiiv providers, and **`findSubscriber(email, publicationId)`** on Beehiiv. Both extracted from the existing `removeContact`/`removeSubscriber` search-by-email logic so tests (and other call sites) can verify provider state without forcing a delete.
|
|
23
|
+
- **`test/marketing/consent-lifecycle.js`** — 5-phase live integration test (gated on `TEST_EXTENDED_MODE=true`) that walks two long-lived sentinel accounts through pre-check → sync → declined-stays-out → unsubscribe → validation-gate, asserting actual SendGrid + Beehiiv state at every step. Uses `pollProvider()` to handle SendGrid's async background jobs (upsert/delete can take 10-60s+ to surface).
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- **Middleware no longer strips HTML by default.** The auto-`sanitize-html` pass on incoming request data was mangling legitimate input — URL query strings (`?a=1&b=2` → `?a=1&b=2`), Markdown, and any payload containing `<`/`>`/`&`. The middleware now only trims whitespace by default. HTML sanitization is opt-in per route via `Manager.Middleware(req, res).run('route', { sanitize: true })`. Sanitize at the HTML-insertion site (`utilities.sanitize()` in your template/email render) rather than at the request boundary. The existing schema-level `sanitize: false` opt-out still works when route-level sanitize is on.
|
|
28
|
+
- **Test sentinel accounts `consent-granted` and `consent-declined` now use the `_test.allow_*` prefix** (`_test.allow_consent-granted@...`, `_test.allow_consent-declined@...`). The `_test.*` validation block from v5.2.3 also blocks the previous `_test.consent-*` names from reaching providers — `_test.allow_*` is the carved-out exception specifically for live-provider integration tests.
|
|
29
|
+
- **Beehiiv API timeouts bumped to 60s** on the two remaining stragglers (`findSubscriber`, `resolveSegmentIds`) to match the SendGrid+Beehiiv 60s convention from v5.2.3.
|
|
30
|
+
|
|
31
|
+
### Removed
|
|
32
|
+
|
|
33
|
+
- **`cleanupMarketingProviders()` function and its pre-run/post-run hooks** in `src/test/test-accounts.js` + `src/test/runner.js`. No longer needed — the validation gate added in v5.2.3 blocks `_test.*` emails from reaching SendGrid + Beehiiv upstream, so there's nothing to clean up. The new `consent-lifecycle.js` test (which intentionally round-trips real contacts via `_test.allow_*`) manages its own pre-check force-clean inline.
|
|
34
|
+
|
|
17
35
|
# [5.2.3] - 2026-05-22
|
|
18
36
|
|
|
19
37
|
### Added
|
package/CLAUDE.md
CHANGED
|
@@ -76,7 +76,7 @@ See [docs/cli-firestore-auth.md](docs/cli-firestore-auth.md) and [docs/cli-logs.
|
|
|
76
76
|
- **Firestore shorthand**: `admin.firestore().doc('users/abc123')` (path string) rather than `.collection('users').doc('abc123')`.
|
|
77
77
|
- **Template strings for requires**: `` require(`${functionsDir}/node_modules/backend-manager`) `` rather than string concat.
|
|
78
78
|
- **No backwards compatibility** unless explicitly requested.
|
|
79
|
-
- **Routes receive
|
|
79
|
+
- **Routes receive whitespace-trimmed data; HTML is preserved.** Sanitize at the HTML-insertion site via `utilities.sanitize()`. Opt into middleware-level HTML strip per-route with `{ sanitize: true }`. See [docs/sanitization.md](docs/sanitization.md).
|
|
80
80
|
- **Match schema names to route names** — if route is `myEndpoint`, schema is `myEndpoint`.
|
|
81
81
|
- **Always use `assistant.respond()` for responses** — do NOT use `res.send()` directly.
|
|
82
82
|
- **Add Firestore composite indexes** for any compound query (`where` + `orderBy`, or multiple `where`s) to `src/cli/commands/setup-tests/helpers/required-indexes.js` (the SSOT). Without the index, queries crash with `FAILED_PRECONDITION` in production.
|
|
@@ -113,7 +113,7 @@ Deep references live in `docs/`. **Whenever you make a behavioral change, update
|
|
|
113
113
|
|
|
114
114
|
- [docs/routes.md](docs/routes.md) — recipes for new API commands, routes, event handlers, cron jobs (with code templates)
|
|
115
115
|
- [docs/schemas.md](docs/schemas.md) — schema definition format, defaults vs premium overrides
|
|
116
|
-
- [docs/sanitization.md](docs/sanitization.md) —
|
|
116
|
+
- [docs/sanitization.md](docs/sanitization.md) — middleware trim-only default; opt-in HTML strip (`{ sanitize: true }`) with per-field opt-out (`sanitize: false`); manual `utilities.sanitize()` for HTML-insertion sites
|
|
117
117
|
- [docs/auth-hooks.md](docs/auth-hooks.md) — consumer hooks for `before-create`/`before-signin`/`on-create`/`on-delete` (blocking + non-blocking examples)
|
|
118
118
|
- [docs/common-operations.md](docs/common-operations.md) — inside-the-handler patterns: authenticate, read/write Firestore, error handling, send response, `bm_api` hook
|
|
119
119
|
|
|
@@ -134,6 +134,6 @@ Deep references live in `docs/`. **Whenever you make a behavioral change, update
|
|
|
134
134
|
|
|
135
135
|
### Testing & CLI
|
|
136
136
|
|
|
137
|
-
- [docs/testing.md](docs/testing.md) — running, filtering, log files, test types (standalone/suite/group), context object, assertions, auth levels. **
|
|
137
|
+
- [docs/testing.md](docs/testing.md) — running, filtering, log files, test types (standalone/suite/group), context object, assertions, auth levels. **All cleanup runs at the START of every run, never at the end.** If you add a test that writes Firestore data, register the collection/namespace in the runner's pre-test wipe list, don't add a trailing cleanup step. Marketing providers (SendGrid/Beehiiv) don't need a special exception — `_test.*` emails are blocked at the validation layer so test signups never reach providers. The `_test.allow_*` carve-out exists only for the live-provider lifecycle test (`test/marketing/consent-lifecycle.js`), which manages its own teardown.
|
|
138
138
|
- [docs/cli-firestore-auth.md](docs/cli-firestore-auth.md) — `npx mgr firestore:*` and `auth:*` commands, shared flags, examples
|
|
139
139
|
- [docs/cli-logs.md](docs/cli-logs.md) — `npx mgr logs:read` / `logs:tail` with full flag reference and built-in Cloud Function names
|
package/docs/consent.md
CHANGED
|
@@ -138,7 +138,7 @@ Each processor's `handleEvent` does the same shape of work:
|
|
|
138
138
|
| SendGrid | `unsubscribe`, `group_unsubscribe`, `spamreport`, `bounce`, `dropped` |
|
|
139
139
|
| Beehiiv | `subscription.unsubscribed`, `subscription.deleted`, `subscription.paused` |
|
|
140
140
|
|
|
141
|
-
**Beehiiv publication filter.** Each Beehiiv event includes a `publication_id`. The processor compares this against
|
|
141
|
+
**Beehiiv publication filter.** Each Beehiiv event includes a `publication_id`. The processor compares this against `beehiivProvider.getPublicationId()`, which reads `Manager.config.marketing.beehiiv.publicationId` (populated at brand-onboarding time by OMEGA's `beehiiv/ensure/publication.js`). Mismatch → silent skip. This is how shared-publication events (e.g. devbeans shared by 6 brands) get routed correctly — each brand processes only events matching its own publication. Brands without `publicationId` in config silently skip all Beehiiv webhook events. The same convention applies to SendGrid: `marketing.sendgrid.listId` is populated by OMEGA's `sendgrid/ensure/list.js`.
|
|
142
142
|
|
|
143
143
|
## Parent forwarder (Phase E)
|
|
144
144
|
|
|
@@ -304,18 +304,13 @@ After the migration: optionally run a re-opt-in drip campaign to legally recover
|
|
|
304
304
|
|
|
305
305
|
Run with `npx mgr test` (full suite) or `npx mgr test routes/marketing/webhook` (just the webhook tests).
|
|
306
306
|
|
|
307
|
-
###
|
|
307
|
+
### Live-provider tests (extended mode only)
|
|
308
308
|
|
|
309
|
-
|
|
309
|
+
Most BEM tests are self-contained against the local emulator. The marketing-consent system has one test that's an exception — [test/marketing/consent-lifecycle.js](../test/marketing/consent-lifecycle.js) — which makes real API calls to SendGrid + Beehiiv to verify the full round-trip works end-to-end.
|
|
310
310
|
|
|
311
|
-
|
|
311
|
+
The validation pipeline (`src/manager/libraries/email/validation.js`) blocks all `_test.*` emails from reaching providers via the `/^_test\.(?!allow_)/` pattern in `blocked-local-patterns.js`. The two `_test.allow_*` sentinels (`_test.allow_consent-granted` and `_test.allow_consent-declined`) used by the lifecycle test bypass that gate intentionally, and the test cleans up after itself (phase-3 removes the granted contact via `Manager.Email().remove()`).
|
|
312
312
|
|
|
313
|
-
|
|
314
|
-
2. **Post-run** — scrubs everything this run created. Best-effort (wrapped in try/catch — cleanup failures don't change the test result).
|
|
315
|
-
|
|
316
|
-
Both fire ONLY when `TEST_EXTENDED_MODE=true`. In normal mode (the default), no provider calls happen and no cleanup is needed.
|
|
317
|
-
|
|
318
|
-
This is the one exception to the "Firestore cleanup runs at start only" rule documented in [docs/testing.md](testing.md) — we can't wipe SendGrid/Beehiiv state in our own Firestore wipe, so we have to ask the providers to do it for us, twice for defense in depth.
|
|
313
|
+
The "all cleanup runs at start, never at the end" rule documented in [docs/testing.md](testing.md) applies to all test data, including third-party providers.
|
|
319
314
|
|
|
320
315
|
## Frontend pieces (cross-references)
|
|
321
316
|
|
package/docs/sanitization.md
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
# Sanitization (XSS Prevention)
|
|
2
2
|
|
|
3
|
-
BEM
|
|
3
|
+
BEM middleware always **trims** whitespace on incoming string fields (via `utilities.trim()`). HTML sanitization is **opt-in** — it's not run by default because it mangles legitimate input like URL query strings (`&` → `&`) and Markdown.
|
|
4
|
+
|
|
5
|
+
The expectation is that you sanitize at the **HTML-insertion site** (in the template, in the email body, etc.) — not at the request boundary.
|
|
4
6
|
|
|
5
7
|
## How It Works
|
|
6
8
|
|
|
7
|
-
1. **
|
|
8
|
-
2. **
|
|
9
|
-
3.
|
|
9
|
+
1. **Trimming**: Every string in `settings` is whitespace-trimmed by the middleware (objects + arrays walked recursively). Always on.
|
|
10
|
+
2. **HTML sanitization**: Off by default. Opt in per-route with `{ sanitize: true }` on `Manager.Middleware(...).run(...)`.
|
|
11
|
+
3. **Schemas** can mark individual fields with `sanitize: false` to skip the HTML strip for that field when route-level sanitize is enabled (used for fields that legitimately need raw HTML — rich-text editors, email templates).
|
|
12
|
+
|
|
13
|
+
## Route-Level Opt-In
|
|
14
|
+
|
|
15
|
+
```javascript
|
|
16
|
+
// In functions/index.js — enable HTML strip for a specific route
|
|
17
|
+
Manager.Middleware(req, res).run('my-route', { sanitize: true });
|
|
18
|
+
```
|
|
10
19
|
|
|
11
|
-
|
|
20
|
+
When enabled, every string in `settings` is run through `sanitize-html` (strip all tags) unless the schema marks the field with `sanitize: false`.
|
|
12
21
|
|
|
13
|
-
|
|
22
|
+
## Schema Field Opt-Out (when route-level sanitize is on)
|
|
14
23
|
|
|
15
24
|
```javascript
|
|
16
25
|
// This field will NOT be sanitized — raw HTML is preserved
|
|
@@ -19,43 +28,42 @@ htmlContent: {
|
|
|
19
28
|
default: '',
|
|
20
29
|
sanitize: false,
|
|
21
30
|
},
|
|
22
|
-
// This field IS sanitized
|
|
31
|
+
// This field IS sanitized when route-level sanitize is enabled
|
|
23
32
|
name: {
|
|
24
33
|
types: ['string'],
|
|
25
34
|
default: '',
|
|
26
35
|
},
|
|
27
36
|
```
|
|
28
37
|
|
|
29
|
-
##
|
|
38
|
+
## Manual Sanitization (Recommended)
|
|
30
39
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
```javascript
|
|
34
|
-
// In functions/index.js
|
|
35
|
-
Manager.Middleware(req, res).run('my-route', { sanitize: false });
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
## Manual Sanitization (Outside Middleware)
|
|
39
|
-
|
|
40
|
-
For cron jobs, event handlers, or anywhere outside the request pipeline, use `utilities.sanitize()` directly:
|
|
40
|
+
For most use cases — particularly anywhere you're inserting user-supplied content into HTML — call `utilities.sanitize()` directly at the insertion site:
|
|
41
41
|
|
|
42
42
|
```javascript
|
|
43
43
|
// Available in route context
|
|
44
|
-
const
|
|
44
|
+
const safeHtml = utilities.sanitize(untrustedData);
|
|
45
45
|
|
|
46
46
|
// Or via Manager
|
|
47
|
-
const
|
|
47
|
+
const safeHtml = Manager.Utilities().sanitize(untrustedData);
|
|
48
48
|
```
|
|
49
49
|
|
|
50
50
|
Accepts any data type — strings, objects, arrays, primitives. Walks objects/arrays recursively, strips HTML from strings, passes everything else through unchanged.
|
|
51
51
|
|
|
52
|
-
##
|
|
52
|
+
## Why HTML Sanitization Is Not the Middleware Default
|
|
53
|
+
|
|
54
|
+
Stripping HTML from every incoming string at the request boundary is too aggressive — it corrupts legitimate input:
|
|
53
55
|
|
|
54
|
-
|
|
56
|
+
- URL query strings: `https://example.com/?a=1&b=2` becomes `https://example.com/?a=1&b=2`
|
|
57
|
+
- Markdown / code snippets with `<`, `>`, `&` characters get mangled
|
|
58
|
+
- API payloads round-tripped through the system get silently rewritten
|
|
59
|
+
|
|
60
|
+
Stored XSS comes from rendering, not from receiving. Sanitize at the render site (where you know the output context — HTML body, attribute, URL, JSON) instead of at the front door.
|
|
61
|
+
|
|
62
|
+
## Route Handler Context
|
|
55
63
|
|
|
56
64
|
```javascript
|
|
57
65
|
module.exports = async ({ Manager, assistant, analytics, usage, user, settings, libraries, utilities }) => {
|
|
58
|
-
// settings —
|
|
59
|
-
// utilities — Manager.Utilities() instance for manual
|
|
66
|
+
// settings — whitespace-trimmed by middleware; HTML preserved unless route opts in via { sanitize: true }
|
|
67
|
+
// utilities — Manager.Utilities() instance for manual sanitize()/trim()
|
|
60
68
|
};
|
|
61
69
|
```
|
package/docs/schemas.md
CHANGED
|
@@ -36,4 +36,4 @@ module.exports = function (assistant, settings, options) {
|
|
|
36
36
|
|
|
37
37
|
## Field Sanitization
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
Middleware always trims whitespace on string fields. HTML sanitization is **opt-in per route** — `Manager.Middleware(req, res).run('my-route', { sanitize: true })`. When opted in, fields can individually opt back out with `sanitize: false`. See [docs/sanitization.md](sanitization.md).
|
package/docs/testing.md
CHANGED
|
@@ -19,18 +19,19 @@ What the runner wipes pre-test (in [src/test/test-accounts.js](../src/test/test-
|
|
|
19
19
|
|
|
20
20
|
1. **`meta/stats`** doc ensured (required for on-create batch writes).
|
|
21
21
|
2. **`users/_test-*`** Firebase Auth users + Firestore docs (delete).
|
|
22
|
-
3. **
|
|
23
|
-
4. **Mixed Firestore collections** — `payments-orders`, `payments-webhooks`, `payments-intents`, `payments-disputes`, `marketing-webhooks`. Two-pass cleanup per collection:
|
|
22
|
+
3. **Mixed Firestore collections** — `payments-orders`, `payments-webhooks`, `payments-intents`, `payments-disputes`, `marketing-webhooks`. Two-pass cleanup per collection:
|
|
24
23
|
- Pass 1 — owner-keyed: `where('owner', 'in', [...testUids])` (batched at 30 uids per `in` query).
|
|
25
24
|
- Pass 2 — id-keyed: any doc whose ID starts with `_test-` (catches ownerless test docs like dispute alerts and raw test webhooks).
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
4. **Test-only Firestore collections** — `_test`, `_test_query` — wiped in full.
|
|
26
|
+
5. **Realtime Database** — the `_test` namespace removed in full (`admin.database().ref('_test').remove()`).
|
|
28
27
|
|
|
29
|
-
###
|
|
28
|
+
### Marketing-provider cleanup
|
|
30
29
|
|
|
31
|
-
|
|
30
|
+
Test signups never reach SendGrid + Beehiiv. The validation pipeline (`src/manager/libraries/email/validation.js`) blocks all `_test.*` emails at the marketing-library layer via the `/^_test\.(?!allow_)/` pattern in `blocked-local-patterns.js`.
|
|
32
31
|
|
|
33
|
-
|
|
32
|
+
The single exception is the `_test.allow_*` prefix. Two long-lived test accounts (`_test.allow_consent-granted@...` and `_test.allow_consent-declined@...`) intentionally round-trip through SendGrid + Beehiiv as the live-provider integration sentinels. They are exercised by `test/marketing/consent-lifecycle.js`, which manages its own setup, assertions, and teardown.
|
|
33
|
+
|
|
34
|
+
All cleanup follows the start-only rule. No trailing-cleanup exception.
|
|
34
35
|
|
|
35
36
|
### When adding a new test that writes data
|
|
36
37
|
|
package/package.json
CHANGED
|
@@ -35,7 +35,7 @@ Middleware.prototype.run = function (libPath, options) {
|
|
|
35
35
|
options.setupAnalytics = typeof options.setupAnalytics === 'boolean' ? options.setupAnalytics : true;
|
|
36
36
|
options.setupUsage = typeof options.setupUsage === 'boolean' ? options.setupUsage : true;
|
|
37
37
|
options.setupSettings = typeof options.setupSettings === 'undefined' ? true : options.setupSettings;
|
|
38
|
-
options.sanitize = typeof options.sanitize === 'undefined' ?
|
|
38
|
+
options.sanitize = typeof options.sanitize === 'undefined' ? false : options.sanitize;
|
|
39
39
|
options.includeNonSchemaSettings = typeof options.includeNonSchemaSettings === 'undefined' ? false : options.includeNonSchemaSettings;
|
|
40
40
|
options.schema = typeof options.schema === 'undefined' ? libPath : options.schema;
|
|
41
41
|
options.parseMultipartFormData = typeof options.parseMultipartFormData === 'undefined' ? true : options.parseMultipartFormData;
|
|
@@ -177,13 +177,16 @@ Middleware.prototype.run = function (libPath, options) {
|
|
|
177
177
|
assistant.settings = data;
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
-
//
|
|
181
|
-
|
|
180
|
+
// Trim whitespace on all string settings (always on — harmless and useful).
|
|
181
|
+
assistant.settings = Manager.Utilities().trim(assistant.settings);
|
|
182
|
+
|
|
183
|
+
// Optional HTML strip (off by default — opt in with `{ sanitize: true }`).
|
|
184
|
+
// Sanitize at the HTML-insertion site instead unless you need a belt-and-suspenders pass here.
|
|
185
|
+
// Respects sanitize: false on individual schema fields.
|
|
182
186
|
if (options.sanitize) {
|
|
183
187
|
const schema = options.setupSettings ? Manager.Settings().schema : null;
|
|
184
188
|
const utilities = Manager.Utilities();
|
|
185
189
|
|
|
186
|
-
// Walk settings, skipping fields the schema marks as sanitize: false
|
|
187
190
|
assistant.settings = sanitizeWithSchema(utilities, assistant.settings, schema);
|
|
188
191
|
}
|
|
189
192
|
|
|
@@ -528,4 +528,35 @@ Utilities.prototype.sanitize = function (input) {
|
|
|
528
528
|
return input;
|
|
529
529
|
};
|
|
530
530
|
|
|
531
|
+
/**
|
|
532
|
+
* Trim whitespace from all strings in input. Walks objects/arrays recursively.
|
|
533
|
+
* Does NOT strip HTML — use sanitize() for that.
|
|
534
|
+
*
|
|
535
|
+
* @param {*} input - The data to trim (string, object, array, or primitive)
|
|
536
|
+
* @returns {*} Trimmed copy (objects/arrays) or trimmed value (strings)
|
|
537
|
+
*/
|
|
538
|
+
Utilities.prototype.trim = function (input) {
|
|
539
|
+
if (input == null) {
|
|
540
|
+
return input;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (typeof input === 'string') {
|
|
544
|
+
return input.trim();
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (Array.isArray(input)) {
|
|
548
|
+
return input.map(item => this.trim(item));
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (typeof input === 'object') {
|
|
552
|
+
const result = {};
|
|
553
|
+
for (const [key, value] of Object.entries(input)) {
|
|
554
|
+
result[key] = this.trim(value);
|
|
555
|
+
}
|
|
556
|
+
return result;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return input;
|
|
560
|
+
};
|
|
561
|
+
|
|
531
562
|
module.exports = Utilities;
|
|
@@ -79,39 +79,55 @@ async function addSubscriber({ email, firstName, lastName, source, publicationId
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
/**
|
|
82
|
-
*
|
|
82
|
+
* Look up a Beehiiv subscriber by email. Returns the subscription object
|
|
83
|
+
* (id, email, status, custom_fields, ...) or null if not found.
|
|
84
|
+
*
|
|
85
|
+
* Useful for tests that need to verify whether a subscriber landed in the
|
|
86
|
+
* publication after a marketing sync.
|
|
83
87
|
*
|
|
84
88
|
* @param {string} email
|
|
85
89
|
* @param {string} publicationId
|
|
86
|
-
* @returns {
|
|
90
|
+
* @returns {Promise<object|null>}
|
|
87
91
|
*/
|
|
88
|
-
async function
|
|
92
|
+
async function findSubscriber(email, publicationId) {
|
|
89
93
|
try {
|
|
90
94
|
const encodedEmail = encodeURIComponent(email);
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
{
|
|
98
|
-
response: 'json',
|
|
99
|
-
headers: headers(),
|
|
100
|
-
timeout: 10000,
|
|
101
|
-
}
|
|
102
|
-
);
|
|
103
|
-
} catch (e) {
|
|
104
|
-
if (e.status === 404) {
|
|
105
|
-
return { success: true, skipped: true, reason: 'Subscriber not found' };
|
|
95
|
+
const searchData = await fetch(
|
|
96
|
+
`${BASE_URL}/publications/${publicationId}/subscriptions/by_email/${encodedEmail}`,
|
|
97
|
+
{
|
|
98
|
+
response: 'json',
|
|
99
|
+
headers: headers(),
|
|
100
|
+
timeout: 60000,
|
|
106
101
|
}
|
|
107
|
-
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
return searchData.data || null;
|
|
105
|
+
} catch (e) {
|
|
106
|
+
if (e.status === 404) {
|
|
107
|
+
return null;
|
|
108
108
|
}
|
|
109
|
+
console.error('Beehiiv findSubscriber error:', e);
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
109
113
|
|
|
110
|
-
|
|
111
|
-
|
|
114
|
+
/**
|
|
115
|
+
* Remove a subscriber from a Beehiiv publication by email.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} email
|
|
118
|
+
* @param {string} publicationId
|
|
119
|
+
* @returns {{ success: boolean, deleted?: boolean, skipped?: boolean, error?: string }}
|
|
120
|
+
*/
|
|
121
|
+
async function removeSubscriber(email, publicationId) {
|
|
122
|
+
try {
|
|
123
|
+
// Step 1: Look up the subscription
|
|
124
|
+
const subscription = await findSubscriber(email, publicationId);
|
|
125
|
+
|
|
126
|
+
if (!subscription?.id) {
|
|
127
|
+
return { success: true, skipped: true, reason: 'Subscriber not found' };
|
|
112
128
|
}
|
|
113
129
|
|
|
114
|
-
const subscriptionId =
|
|
130
|
+
const subscriptionId = subscription.id;
|
|
115
131
|
|
|
116
132
|
// Step 2: Permanently delete the subscription
|
|
117
133
|
await fetch(
|
|
@@ -119,7 +135,7 @@ async function removeSubscriber(email, publicationId) {
|
|
|
119
135
|
{
|
|
120
136
|
method: 'delete',
|
|
121
137
|
headers: headers(),
|
|
122
|
-
timeout:
|
|
138
|
+
timeout: 60000,
|
|
123
139
|
}
|
|
124
140
|
);
|
|
125
141
|
|
|
@@ -186,7 +202,7 @@ function getPublicationId() {
|
|
|
186
202
|
// const data = await fetch(`${BASE_URL}/publications?limit=${limit}&page=${page}`, {
|
|
187
203
|
// response: 'json',
|
|
188
204
|
// headers: headers(),
|
|
189
|
-
// timeout:
|
|
205
|
+
// timeout: 60000,
|
|
190
206
|
// });
|
|
191
207
|
//
|
|
192
208
|
// if (!data.data || data.data.length === 0) {
|
|
@@ -270,6 +286,25 @@ async function removeContact(email) {
|
|
|
270
286
|
return removeSubscriber(email, publicationId);
|
|
271
287
|
}
|
|
272
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Look up a contact in this brand's Beehiiv publication. Resolves the
|
|
291
|
+
* publicationId from config and calls findSubscriber. Mirrors SendGrid's
|
|
292
|
+
* findContact() surface so tests can use the same pattern across both
|
|
293
|
+
* providers.
|
|
294
|
+
*
|
|
295
|
+
* @param {string} email
|
|
296
|
+
* @returns {Promise<object|null>}
|
|
297
|
+
*/
|
|
298
|
+
async function findContact(email) {
|
|
299
|
+
const publicationId = getPublicationId();
|
|
300
|
+
|
|
301
|
+
if (!publicationId) {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return findSubscriber(email, publicationId);
|
|
306
|
+
}
|
|
307
|
+
|
|
273
308
|
/**
|
|
274
309
|
* Build Beehiiv custom_fields array from a user doc.
|
|
275
310
|
* Resolves all field values, then maps to display names for Beehiiv.
|
|
@@ -316,7 +351,7 @@ async function resolveSegmentIds() {
|
|
|
316
351
|
const data = await fetch(`${BASE_URL}/publications/${publicationId}/segments?limit=100`, {
|
|
317
352
|
response: 'json',
|
|
318
353
|
headers: headers(),
|
|
319
|
-
timeout:
|
|
354
|
+
timeout: 60000,
|
|
320
355
|
});
|
|
321
356
|
|
|
322
357
|
_segmentIdCache = {};
|
|
@@ -435,6 +470,8 @@ module.exports = {
|
|
|
435
470
|
|
|
436
471
|
// Contacts
|
|
437
472
|
addContact,
|
|
473
|
+
findContact,
|
|
474
|
+
findSubscriber,
|
|
438
475
|
removeContact,
|
|
439
476
|
buildFields,
|
|
440
477
|
|
|
@@ -131,14 +131,17 @@ async function upsertContacts({ contacts, listIds }) {
|
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
/**
|
|
134
|
-
*
|
|
134
|
+
* Look up a SendGrid contact by email. Returns the contact object (id, email,
|
|
135
|
+
* list_ids, custom_fields, ...) or null if not found.
|
|
136
|
+
*
|
|
137
|
+
* Useful for tests that need to verify whether a contact landed in the list
|
|
138
|
+
* after a marketing sync.
|
|
135
139
|
*
|
|
136
140
|
* @param {string} email
|
|
137
|
-
* @returns {
|
|
141
|
+
* @returns {Promise<object|null>}
|
|
138
142
|
*/
|
|
139
|
-
async function
|
|
143
|
+
async function findContact(email) {
|
|
140
144
|
try {
|
|
141
|
-
// Step 1: Get contact ID by email
|
|
142
145
|
const searchData = await fetch(`${BASE_URL}/marketing/contacts/search/emails`, {
|
|
143
146
|
method: 'post',
|
|
144
147
|
response: 'json',
|
|
@@ -147,11 +150,33 @@ async function removeContact(email) {
|
|
|
147
150
|
body: { emails: [email] },
|
|
148
151
|
});
|
|
149
152
|
|
|
150
|
-
|
|
153
|
+
return searchData.result?.[email]?.contact || null;
|
|
154
|
+
} catch (e) {
|
|
155
|
+
// 404 is the normal "not in contacts" response — return null silently.
|
|
156
|
+
if (e.status === 404) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
console.error('SendGrid findContact error:', e);
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Remove a contact from SendGrid by email address.
|
|
166
|
+
*
|
|
167
|
+
* @param {string} email
|
|
168
|
+
* @returns {{ success: boolean, jobId?: string, skipped?: boolean, error?: string }}
|
|
169
|
+
*/
|
|
170
|
+
async function removeContact(email) {
|
|
171
|
+
try {
|
|
172
|
+
// Step 1: Get contact ID by email
|
|
173
|
+
const contact = await findContact(email);
|
|
174
|
+
|
|
175
|
+
if (!contact?.id) {
|
|
151
176
|
return { success: true, skipped: true, reason: 'Contact not found' };
|
|
152
177
|
}
|
|
153
178
|
|
|
154
|
-
const contactId =
|
|
179
|
+
const contactId = contact.id;
|
|
155
180
|
|
|
156
181
|
// Step 2: Delete contact by ID
|
|
157
182
|
const deleteData = await fetch(`${BASE_URL}/marketing/contacts?ids=${contactId}`, {
|
|
@@ -635,6 +660,7 @@ module.exports = {
|
|
|
635
660
|
|
|
636
661
|
// Contacts
|
|
637
662
|
addContact,
|
|
663
|
+
findContact,
|
|
638
664
|
removeContact,
|
|
639
665
|
getSegmentContacts,
|
|
640
666
|
bulkDeleteContacts,
|
package/src/test/runner.js
CHANGED
|
@@ -119,25 +119,6 @@ class TestRunner {
|
|
|
119
119
|
await this.runTestsInDir(projectTestsDir, 'project');
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
// Post-run cleanup: scrub test accounts from third-party marketing providers
|
|
123
|
-
// (SendGrid/Beehiiv) so each test run leaves the contact list in the same
|
|
124
|
-
// state it found it. Pairs with the pre-run cleanup as defense in depth —
|
|
125
|
-
// pre-run handles crashed previous runs, post-run handles the current run.
|
|
126
|
-
// Only fires in extended mode (normal mode never touches real providers).
|
|
127
|
-
if (process.env.TEST_EXTENDED_MODE) {
|
|
128
|
-
process.stdout.write(chalk.gray('\n Cleaning up test accounts from marketing providers... '));
|
|
129
|
-
try {
|
|
130
|
-
const cleanupResult = await testAccounts.cleanupMarketingProviders(this.options.domain, {
|
|
131
|
-
apiUrl: this.options.apiUrl,
|
|
132
|
-
backendManagerKey: this.options.backendManagerKey,
|
|
133
|
-
});
|
|
134
|
-
console.log(chalk.green(`✓ (${cleanupResult.cleaned} cleaned)`));
|
|
135
|
-
} catch (e) {
|
|
136
|
-
// Post-run cleanup is best-effort — failures shouldn't change the test result
|
|
137
|
-
console.log(chalk.yellow(`⚠ cleanup error: ${e.message}`));
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
122
|
// Cleanup rules context
|
|
142
123
|
if (this.rulesContext) {
|
|
143
124
|
await this.rulesContext.cleanup();
|
|
@@ -229,18 +210,6 @@ class TestRunner {
|
|
|
229
210
|
const deleteResult = await testAccounts.deleteTestUsers(this.options.admin);
|
|
230
211
|
console.log(chalk.green(`✓ (${deleteResult.deleted} deleted, ${deleteResult.skipped} skipped)`));
|
|
231
212
|
|
|
232
|
-
// Clean any leftover test accounts from third-party marketing providers
|
|
233
|
-
// (SendGrid/Beehiiv). Runs BEFORE we create fresh users so a previously
|
|
234
|
-
// killed run doesn't leave the contact list polluted.
|
|
235
|
-
if (process.env.TEST_EXTENDED_MODE) {
|
|
236
|
-
process.stdout.write(chalk.gray(' Cleaning test accounts from marketing providers... '));
|
|
237
|
-
const cleanupResult = await testAccounts.cleanupMarketingProviders(this.options.domain, {
|
|
238
|
-
apiUrl: this.options.apiUrl,
|
|
239
|
-
backendManagerKey: this.options.backendManagerKey,
|
|
240
|
-
});
|
|
241
|
-
console.log(chalk.green(`✓ (${cleanupResult.cleaned} cleaned)`));
|
|
242
|
-
}
|
|
243
|
-
|
|
244
213
|
process.stdout.write(chalk.gray(' Creating test accounts... '));
|
|
245
214
|
|
|
246
215
|
// Create fresh test accounts
|
|
@@ -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
|
};
|
|
@@ -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
|
+
};
|