ultimate-jekyll-manager 1.3.3 → 1.3.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 +14 -0
- package/TODO-AUTH-TESTING.md +165 -0
- package/TODO-CONSENT-LIVETEST.md +201 -0
- package/dist/assets/js/libs/auth.js +100 -10
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,20 @@ 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
|
+
---
|
|
18
|
+
## [1.3.5] - 2026-05-24
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **Diagnostic logging in `extractBlockingFunctionMessage()` (`src/assets/js/libs/auth.js`).** When BEM's `beforeCreate` rate limit (2 signups/day/IP) fires via Google OAuth redirect, the user just saw "Firebase: Error (auth/error-code:-47)" instead of the helpful "Unable to create account at this time. Please try again later." message. The 1.3.4 extraction handles the standard 400-with-`BLOCKING_FUNCTION_ERROR_RESPONSE` path, but the 503 path (Google's Identity Toolkit returns 503 directly with code -47, no `customData.serverResponse`) flows through to the generic `auth.code` branch. Added a `console.warn` that dumps the full error shape (code, message, customData, serverResponse) so the next failed signup attempt reveals exactly what Firebase delivers — then we can write a matching handler. Diagnostic ships first; fix follows in a subsequent version.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
## [1.3.4] - 2026-05-22
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- **Surface BEM blocking-function error messages to users.** Firebase Auth blocking functions (`before-create`, `before-signin`) that throw `HttpsError('resource-exhausted', 'Too many signups...')` get wrapped by Firebase as the opaque `auth/internal-error` (sometimes `auth/error-code:-47`). The actual BEM-side message is buried in `error.customData.serverResponse` inside a `BLOCKING_FUNCTION_ERROR_RESPONSE : ((error : (message : "...")))` wrapper. New `extractBlockingFunctionMessage(error)` helper in `src/assets/js/libs/auth.js` unwraps it. Wired into all 4 auth error sites (OAuth popup, OAuth redirect, email signup, email signin) so users now see "Too many signups from your IP, please try again later" instead of "Firebase: Error (auth/error-code:-47)."
|
|
30
|
+
|
|
17
31
|
---
|
|
18
32
|
## [1.3.3] - 2026-05-21
|
|
19
33
|
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# TODO: Automated Tests for the Auth System
|
|
2
|
+
|
|
3
|
+
## Why
|
|
4
|
+
|
|
5
|
+
The consent-guard / `sendUserSignupMetadata` ordering bug that shipped in v1.3.1 (and was fixed in v1.3.2) is exactly the kind of regression that should never reach production. The bug was:
|
|
6
|
+
|
|
7
|
+
- Page-load consent guard ran BEFORE `sendUserSignupMetadata`
|
|
8
|
+
- Brand-new signups had `consent.legal.status: 'revoked'` (BEM schema default)
|
|
9
|
+
- Guard saw `'revoked'` → signed user out → metadata POST never fired → user permanently orphaned
|
|
10
|
+
|
|
11
|
+
Discovered only via live testing on Somiibo. Should have been caught by a unit test.
|
|
12
|
+
|
|
13
|
+
This file proposes a layered test strategy so the next refactor + every future change is safe.
|
|
14
|
+
|
|
15
|
+
## Strategy: three tiers, mostly mocked
|
|
16
|
+
|
|
17
|
+
### Tier 1 — Unit (JSDOM + mocked Firebase, fast)
|
|
18
|
+
|
|
19
|
+
Run from UJM. Add to the existing `test/tests/**/*.test.js` harness. No real Firebase, no network. Covers ~80% of bugs including the v1.3.1 regression.
|
|
20
|
+
|
|
21
|
+
Files to write:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
test/tests/auth-consent-capture.test.js
|
|
25
|
+
• captureSignupConsent() reads consent-legal + consent-marketing checkboxes
|
|
26
|
+
• writes the right shape to webManager.storage() under key 'consent'
|
|
27
|
+
• label text is captured verbatim (so BEM gets the exact string the user saw)
|
|
28
|
+
|
|
29
|
+
test/tests/auth-consent-validate.test.js
|
|
30
|
+
• validateConsent() blocks submit when consent-legal is unchecked
|
|
31
|
+
• surfaces the inline error + wrapper outline
|
|
32
|
+
• does NOT block submit when both boxes are checked or only marketing is unchecked
|
|
33
|
+
|
|
34
|
+
test/tests/auth-guard-ordering.test.js ← THIS WOULD HAVE CAUGHT v1.3.1
|
|
35
|
+
• on a fresh user (account < SIGNUP_MAX_AGE) with consent.legal.status: 'revoked'
|
|
36
|
+
→ sendUserSignupMetadata is called
|
|
37
|
+
→ guard does NOT sign out
|
|
38
|
+
• on an old user (account > SIGNUP_MAX_AGE) with consent.legal.status: 'revoked'
|
|
39
|
+
→ guard signs out
|
|
40
|
+
→ notification is shown
|
|
41
|
+
• on any user with consent.legal.status: 'granted'
|
|
42
|
+
→ guard does NOT fire regardless of age
|
|
43
|
+
|
|
44
|
+
test/tests/auth-reverse-signup.test.js
|
|
45
|
+
• reverseAccidentalSignup() calls user.delete() THEN signOut() THEN clears authReturnUrl
|
|
46
|
+
• formManager.showError fires with the right message
|
|
47
|
+
• if user.delete() throws, signOut still runs (best-effort)
|
|
48
|
+
|
|
49
|
+
test/tests/auth-policy-redirect.test.js
|
|
50
|
+
• policy='authenticated' + no user → redirect to unauthenticated URL
|
|
51
|
+
• policy='unauthenticated' + user → redirect to authenticated URL (honoring authReturnUrl)
|
|
52
|
+
• policy='disabled' → no-op
|
|
53
|
+
|
|
54
|
+
test/tests/auth-metadata-payload.test.js
|
|
55
|
+
• sendUserSignupMetadata builds the right payload shape
|
|
56
|
+
• includes consent, attribution, context
|
|
57
|
+
• skips when account is old or signupProcessed flag is set
|
|
58
|
+
• sets signupProcessed flag after successful POST
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Mocking contract:
|
|
62
|
+
|
|
63
|
+
```js
|
|
64
|
+
// Mock webManager
|
|
65
|
+
const fakeWebManager = {
|
|
66
|
+
auth: () => ({ listen, signOut, getIdToken }),
|
|
67
|
+
storage: () => ({ get, set }),
|
|
68
|
+
utilities: () => ({ showNotification }),
|
|
69
|
+
config: { auth: { config: { policy, redirects, roles } } },
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Mock Firebase auth functions
|
|
73
|
+
// - createUserWithEmailAndPassword
|
|
74
|
+
// - signInWithEmailAndPassword
|
|
75
|
+
// - signInWithPopup / signInWithRedirect
|
|
76
|
+
// - getRedirectResult
|
|
77
|
+
// - sendPasswordResetEmail
|
|
78
|
+
// - user.delete()
|
|
79
|
+
// - getAuth().signOut()
|
|
80
|
+
|
|
81
|
+
// Mock FormManager (or use the real one — it's mockable already)
|
|
82
|
+
const fakeFormManager = { showError, showSuccess, ready, ... };
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Approach: load `src/assets/js/core/auth.js` and `src/assets/js/libs/auth.js` into JSDOM
|
|
86
|
+
with all imports stubbed (Jest/Mocha module mocks or rewire). For HTML-aware tests
|
|
87
|
+
(captureSignupConsent), load `src/defaults/dist/_layouts/themes/classy/frontend/pages/auth/signup.html`
|
|
88
|
+
into JSDOM and exercise the real DOM.
|
|
89
|
+
|
|
90
|
+
### Tier 2 — Integration against Firebase Emulator (slower, optional)
|
|
91
|
+
|
|
92
|
+
Run from UJM. Boot `firebase emulators:start --only auth,firestore` before the suite.
|
|
93
|
+
Extend the existing `test/fixtures/consumer-site/` with:
|
|
94
|
+
|
|
95
|
+
- `firebase.json` configured for emulator ports
|
|
96
|
+
- Canned Firebase web config pointing at `localhost:9099` / `localhost:8080`
|
|
97
|
+
- Test user seeding via Admin SDK helpers
|
|
98
|
+
|
|
99
|
+
Covers: full email-signup round-trip, the storage-survives-redirect path, real
|
|
100
|
+
auth-state-listener firing, real Firestore reads in the guard.
|
|
101
|
+
|
|
102
|
+
**Skip OAuth at this tier.** Firebase emulator's auth emulator doesn't speak to
|
|
103
|
+
real Google. OAuth tests stay manual (Tier 3).
|
|
104
|
+
|
|
105
|
+
This is OPTIONAL — only worth building if a bug slips through Tier 1.
|
|
106
|
+
|
|
107
|
+
### Tier 3 — Manual smoke (don't automate)
|
|
108
|
+
|
|
109
|
+
Google OAuth + cross-provider webhooks stay manual. Reasons:
|
|
110
|
+
- Google actively blocks scripted browsers (even Puppeteer with stealth plugins)
|
|
111
|
+
- Real 2FA flows
|
|
112
|
+
- Provider UI rolls and tests break
|
|
113
|
+
- Cost/value is terrible
|
|
114
|
+
|
|
115
|
+
Keep the live-test checklist (see [TODO-CONSENT-LIVETEST.md](TODO-CONSENT-LIVETEST.md))
|
|
116
|
+
as the authoritative manual smoke procedure.
|
|
117
|
+
|
|
118
|
+
## Where the tests live
|
|
119
|
+
|
|
120
|
+
Run from UJM. NOT from a host project. Reasons:
|
|
121
|
+
- `core/auth.js` + `libs/auth.js` are owned by UJM
|
|
122
|
+
- Host project tests would re-test framework internals, wrong layering
|
|
123
|
+
- UJM's existing 60-test Mocha harness is already fast (~6s)
|
|
124
|
+
|
|
125
|
+
Default config (Firebase emulator + a fake brand) baked into
|
|
126
|
+
`test/fixtures/` so contributors don't need their own Firebase project.
|
|
127
|
+
|
|
128
|
+
## Sequencing
|
|
129
|
+
|
|
130
|
+
1. **First**: do the libs/auth.js refactor (split into `auth.js` + `auth-providers.js` + `auth-consent.js`).
|
|
131
|
+
- See the auth-audit findings in this session's notes.
|
|
132
|
+
- Refactor first means we write tests against the FINAL shape, not throw-away.
|
|
133
|
+
- Each new file gets a focused test file.
|
|
134
|
+
|
|
135
|
+
2. **Then**: Tier 1 unit tests for the new shape.
|
|
136
|
+
- Target: every test file from the list above, green.
|
|
137
|
+
- Should take ~half a day.
|
|
138
|
+
|
|
139
|
+
3. **Optional later**: Tier 2 emulator-backed tests, only if Tier 1 misses a class
|
|
140
|
+
of bugs in practice. Probably won't be needed.
|
|
141
|
+
|
|
142
|
+
4. **Never**: Tier 3 automation. Stays manual via [TODO-CONSENT-LIVETEST.md](TODO-CONSENT-LIVETEST.md).
|
|
143
|
+
|
|
144
|
+
## What about Ultimate Jekyll for the test site?
|
|
145
|
+
|
|
146
|
+
Tempting but NO. Reasons:
|
|
147
|
+
- UJ build is heavy (Jekyll + webpack), test boot would be 10+ seconds
|
|
148
|
+
- Circular dependency risk (UJM testing UJM via UJ which uses UJM)
|
|
149
|
+
- UJM's existing Mocha harness is the right tool
|
|
150
|
+
|
|
151
|
+
The test harness should stay vanilla Mocha + JSDOM + fixture HTML files.
|
|
152
|
+
|
|
153
|
+
## What this DOESN'T cover
|
|
154
|
+
|
|
155
|
+
- Google OAuth UI flows (manual only)
|
|
156
|
+
- Cross-provider unsubscribe webhooks (manual + BEM tests already exist)
|
|
157
|
+
- Beehiiv webhook setup (blocked on plan upgrade; manual when ready)
|
|
158
|
+
- Firebase Auth SDK bugs (not our code, not our problem)
|
|
159
|
+
|
|
160
|
+
## Done criteria
|
|
161
|
+
|
|
162
|
+
- All 6 test files above written and green
|
|
163
|
+
- `npm test` in UJM runs them as part of the standard suite
|
|
164
|
+
- The v1.3.1 bug (consent guard ordering) is caught by `auth-guard-ordering.test.js` — verify by reverting the v1.3.2 fix in a branch and watching the test fail
|
|
165
|
+
- Documented in `docs/testing.md` so contributors know to add auth tests when touching auth code
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# TODO: Consent System End-to-End Live Test Checklist
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
|
|
5
|
+
End-to-end manual smoke for the marketing-consent + cross-provider-unsub
|
|
6
|
+
pipeline. Run after each new BEM/WM/UJM deploy that touches auth, consent,
|
|
7
|
+
email-preferences, or webhook code paths.
|
|
8
|
+
|
|
9
|
+
Each step depends on state set up by earlier steps. Run top-to-bottom.
|
|
10
|
+
|
|
11
|
+
Live test target: **Somiibo** (`somiibo.com` + `api.somiibo.com`).
|
|
12
|
+
Parent BEM: **ITW** (`itwcreativeworks.com` + `api.itwcreativeworks.com`).
|
|
13
|
+
|
|
14
|
+
## Pre-flight
|
|
15
|
+
|
|
16
|
+
- [ ] **Use a fresh email** (`consent-test+1@yourdomain.com` or a Gmail alias) so the signup is genuinely new
|
|
17
|
+
- [ ] **Have a separate Google account ready** that has never signed up here (for the reverse-signup test)
|
|
18
|
+
- [ ] **Open multiple browser windows side by side**:
|
|
19
|
+
- Somiibo signup page
|
|
20
|
+
- SendGrid Marketing → Contacts
|
|
21
|
+
- Beehiiv dashboard → Somiibo publication subscribers
|
|
22
|
+
- [ ] **Tail BEM logs in terminal**:
|
|
23
|
+
```bash
|
|
24
|
+
cd /Users/ian/Developer/Repositories/Somiibo/somiibo-backend/functions
|
|
25
|
+
npx mgr logs:tail
|
|
26
|
+
```
|
|
27
|
+
- [ ] **Confirm current package versions live**:
|
|
28
|
+
- BEM: `^5.2.2`
|
|
29
|
+
- web-manager: `^4.2.0`
|
|
30
|
+
- UJM: `^1.3.2`
|
|
31
|
+
- [ ] **Confirm last Somiibo backend + website deploys** were AFTER the BEM/UJM version bumps
|
|
32
|
+
- [ ] **Delete any stale test accounts** from previous attempts:
|
|
33
|
+
```bash
|
|
34
|
+
npx mgr auth:get "<test-email>" # find the uid
|
|
35
|
+
npx mgr auth:delete "<uid>" --force
|
|
36
|
+
npx mgr firestore:delete "users/<uid>" --force
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Tests
|
|
40
|
+
|
|
41
|
+
### ☐ Test 1 — Signup with marketing consent granted (golden path)
|
|
42
|
+
|
|
43
|
+
**Action:** Sign up at `somiibo.com/signup` with both checkboxes checked.
|
|
44
|
+
|
|
45
|
+
**Verify:**
|
|
46
|
+
- [ ] Account created, redirected to logged-in dashboard
|
|
47
|
+
- [ ] `npx mgr firestore:get users/<uid>` shows:
|
|
48
|
+
- `consent.legal.status: 'granted'`
|
|
49
|
+
- `consent.legal.grantedAt.source: 'signup'`
|
|
50
|
+
- `consent.legal.grantedAt.ip` populated (not null)
|
|
51
|
+
- `consent.legal.grantedAt.text` is the actual label string
|
|
52
|
+
- `consent.marketing.status: 'granted'`
|
|
53
|
+
- `consent.marketing.grantedAt.source: 'signup'`
|
|
54
|
+
- `consent.marketing.revokedAt.timestamp: null`
|
|
55
|
+
- [ ] SendGrid Contacts: new contact exists with this email
|
|
56
|
+
- [ ] Beehiiv Subscribers: new subscriber exists
|
|
57
|
+
- [ ] BEM logs: search for `buildConsentRecord: legal=granted, marketing=granted`
|
|
58
|
+
|
|
59
|
+
**Capture the uid** for use in later tests.
|
|
60
|
+
|
|
61
|
+
### ☐ Test 2 — Signup with marketing UNCHECKED
|
|
62
|
+
|
|
63
|
+
**Action:** Sign out. Sign up with a different fresh email. Legal checked, marketing UNCHECKED.
|
|
64
|
+
|
|
65
|
+
**Verify:**
|
|
66
|
+
- [ ] `consent.marketing.status: 'revoked'`, `revokedAt.source: 'signup'`, `grantedAt.timestamp: null`
|
|
67
|
+
- [ ] SendGrid: contact NOT created
|
|
68
|
+
- [ ] Beehiiv: subscriber NOT created
|
|
69
|
+
- [ ] BEM logs: `buildConsentRecord: legal=granted, marketing=revoked` — `mailer.sync()` should NOT have been called
|
|
70
|
+
|
|
71
|
+
### ☐ Test 3 — Signup with legal UNCHECKED (validation blocks)
|
|
72
|
+
|
|
73
|
+
**Action:** Try to submit signup with legal box UNCHECKED.
|
|
74
|
+
|
|
75
|
+
**Verify:**
|
|
76
|
+
- [ ] Submit button is disabled OR clicking submit shows the inline error + red outline around the consent group
|
|
77
|
+
- [ ] No Firebase auth user created
|
|
78
|
+
- [ ] No request fired to `/user/signup` (browser DevTools network tab)
|
|
79
|
+
|
|
80
|
+
### ☐ Test 4 — Google sign-in with a NEW (nonexistent) Google account on /signin
|
|
81
|
+
|
|
82
|
+
**Action:** On `somiibo.com/signin` (NOT `/signup`), click "Continue with Google" and authenticate with a Google account that has never been used here.
|
|
83
|
+
|
|
84
|
+
**Verify:**
|
|
85
|
+
- [ ] Inline error appears: "This account doesn't exist. Try signing up first or use a different account."
|
|
86
|
+
- [ ] User stays on `/signin` (no redirect away)
|
|
87
|
+
- [ ] Firebase Auth dashboard: the user does NOT persist (created then deleted)
|
|
88
|
+
- [ ] Firestore: no orphan user doc remains (`users` collection)
|
|
89
|
+
- [ ] BEM logs: look for `Reversing accidental signup from /signin` warning
|
|
90
|
+
- [ ] `sendUserSignupMetadata` should NOT have run (no `/user/signup` POST in logs)
|
|
91
|
+
|
|
92
|
+
### ☐ Test 5 — Account-page opt-out
|
|
93
|
+
|
|
94
|
+
**Action:** Sign in as the Test 1 user. Go to `/account` → notifications section. Toggle marketing OFF.
|
|
95
|
+
|
|
96
|
+
**Verify:**
|
|
97
|
+
- [ ] Toast/UI confirms the change
|
|
98
|
+
- [ ] `consent.marketing.status: 'revoked'`, `revokedAt.source: 'account'`, `revokedAt.timestamp` is recent, `revokedAt.ip` populated
|
|
99
|
+
- [ ] `consent.marketing.grantedAt` is UNTOUCHED (original signup grant info preserved)
|
|
100
|
+
- [ ] SendGrid: contact removed from list
|
|
101
|
+
- [ ] Beehiiv: subscriber removed
|
|
102
|
+
|
|
103
|
+
### ☐ Test 6 — Account-page opt-in (re-grant)
|
|
104
|
+
|
|
105
|
+
**Action:** Same user. Toggle marketing back ON.
|
|
106
|
+
|
|
107
|
+
**Verify:**
|
|
108
|
+
- [ ] `status: 'granted'`, `grantedAt.source: 'account'`, `grantedAt.timestamp` newer than original
|
|
109
|
+
- [ ] `revokedAt` UNTOUCHED (informational, still shows the Test 5 revoke)
|
|
110
|
+
- [ ] SendGrid: contact re-added
|
|
111
|
+
- [ ] Beehiiv: subscriber re-added
|
|
112
|
+
|
|
113
|
+
### ☐ Test 7 — SendGrid webhook → cross-provider sync via parent forwarder
|
|
114
|
+
|
|
115
|
+
**Action:** In SendGrid dashboard → Marketing → Contacts, find Test 1 user's contact and manually unsubscribe (or add their email to the Global Unsubscribe list via Suppressions).
|
|
116
|
+
|
|
117
|
+
**Verify (allow up to ~30s for delivery):**
|
|
118
|
+
- [ ] ITW BEM logs: `POST /backend-manager/marketing/webhook/forward?provider=sendgrid` with 200
|
|
119
|
+
- [ ] Somiibo BEM logs: `POST /backend-manager/marketing/webhook?provider=sendgrid` from parent forwarder
|
|
120
|
+
- [ ] Somiibo Firestore: `consent.marketing.status: 'revoked'`, `revokedAt.source: 'sendgrid'`
|
|
121
|
+
- [ ] Beehiiv: subscriber removed (cross-provider sync)
|
|
122
|
+
- [ ] ITW Firestore `marketing-webhooks/{eventId}` doc exists (idempotency record)
|
|
123
|
+
- [ ] Repost the same event manually with curl → second receipt short-circuits on `eventId`
|
|
124
|
+
|
|
125
|
+
### ☐ Test 8 — Beehiiv webhook (DEFERRED until Beehiiv plan upgraded)
|
|
126
|
+
|
|
127
|
+
**⏸️ Blocked**: Beehiiv webhook API requires a paid Beehiiv tier (currently 403 `ACCESS_FORBIDDEN`).
|
|
128
|
+
|
|
129
|
+
When unblocked:
|
|
130
|
+
- Run `npm start -- --service beehiiv` in OMEGA to auto-configure all publications
|
|
131
|
+
- OR manually add the webhook in each publication's settings:
|
|
132
|
+
- URL: `https://api.itwcreativeworks.com/backend-manager/marketing/webhook/forward?provider=beehiiv&key=<BACKEND_MANAGER_WEBHOOK_KEY>`
|
|
133
|
+
- Events: `subscription.unsubscribed`, `subscription.deleted`, `subscription.paused`
|
|
134
|
+
- Publications: Somiibo (`pub_60fa806e...`), StudyMonkey (`pub_0716e341...`), shared devbeans (`pub_69c961a7...`)
|
|
135
|
+
|
|
136
|
+
Then test: unsub via Beehiiv's "Manage subscription" link → verify symmetric flip on Firestore + SendGrid.
|
|
137
|
+
|
|
138
|
+
### ☐ Test 9 — Page-load consent guard (ENFORCE_CONSENT_GUARD = true)
|
|
139
|
+
|
|
140
|
+
**Action:** Manually edit Test 1 user's Firestore doc via `npx mgr firestore:set users/<uid>` to flip `consent.legal.status` to `'revoked'`. Refresh the Somiibo page.
|
|
141
|
+
|
|
142
|
+
**Verify:**
|
|
143
|
+
- [ ] User is signed out (with notification toast: "This account hasn't completed setup. Please sign up first.")
|
|
144
|
+
- [ ] Lands on public site
|
|
145
|
+
- [ ] Browser console warning: `[Auth] Signing out user with no legal consent on record`
|
|
146
|
+
- [ ] **Restore** `consent.legal.status` to `'granted'` so the user can use Somiibo again
|
|
147
|
+
|
|
148
|
+
**Important caveat from v1.3.2 fix:** the guard only fires for accounts older than `SIGNUP_MAX_AGE` (5min). For this test to work, the Test 1 user must have signed up more than 5 minutes ago. If you just signed up and immediately ran this, the guard won't fire — wait 5 minutes or use an older test account.
|
|
149
|
+
|
|
150
|
+
### ☐ Test 10 — HMAC anonymous unsubscribe link (from a marketing email)
|
|
151
|
+
|
|
152
|
+
**Action:** Trigger a marketing email send (or grab an existing unsub link from a previous email). Click it.
|
|
153
|
+
|
|
154
|
+
**Verify:**
|
|
155
|
+
- [ ] Unsub confirmation page loads
|
|
156
|
+
- [ ] User's `consent.marketing.status: 'revoked'`, `revokedAt.source: 'sendgrid'` (or `'beehiiv'` depending on link origin)
|
|
157
|
+
- [ ] SendGrid: removed
|
|
158
|
+
- [ ] Beehiiv: removed
|
|
159
|
+
|
|
160
|
+
## Commands cheat-sheet
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
# All from Somiibo backend dir
|
|
164
|
+
cd /Users/ian/Developer/Repositories/Somiibo/somiibo-backend/functions
|
|
165
|
+
|
|
166
|
+
# Logs (live tail)
|
|
167
|
+
npx mgr logs:tail
|
|
168
|
+
|
|
169
|
+
# Logs (one-shot)
|
|
170
|
+
npx mgr logs:read --limit 100
|
|
171
|
+
npx mgr logs:read --filter "consent" --limit 50
|
|
172
|
+
npx mgr logs:read --filter "marketing/webhook" --limit 50
|
|
173
|
+
npx mgr logs:read --filter "<uid>" --limit 100
|
|
174
|
+
|
|
175
|
+
# Firestore
|
|
176
|
+
npx mgr firestore:get "users/<uid>"
|
|
177
|
+
npx mgr firestore:query "marketing-webhooks" --limit 10 # idempotency records (run from ITW backend, the parent)
|
|
178
|
+
npx mgr firestore:set "users/<uid>" '{...partial...}' --merge
|
|
179
|
+
npx mgr firestore:delete "users/<uid>" --force
|
|
180
|
+
|
|
181
|
+
# Auth
|
|
182
|
+
npx mgr auth:get "<email-or-uid>"
|
|
183
|
+
npx mgr auth:delete "<uid>" --force
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## Progress log
|
|
187
|
+
|
|
188
|
+
Track which tests have been run + when. Mark with date once you've verified end-to-end.
|
|
189
|
+
|
|
190
|
+
- [ ] Test 1 — Signup, marketing GRANTED
|
|
191
|
+
- [ ] Test 2 — Signup, marketing UNCHECKED
|
|
192
|
+
- [ ] Test 3 — Signup, legal UNCHECKED (validation blocks)
|
|
193
|
+
- [ ] Test 4 — Google new account on /signin (reverse-signup)
|
|
194
|
+
- [ ] Test 5 — Account opt-out
|
|
195
|
+
- [ ] Test 6 — Account opt-in (re-grant)
|
|
196
|
+
- [ ] Test 7 — SendGrid webhook → cross-provider
|
|
197
|
+
- [ ] Test 8 — Beehiiv webhook (deferred)
|
|
198
|
+
- [ ] Test 9 — Page-load guard
|
|
199
|
+
- [ ] Test 10 — HMAC unsub link
|
|
200
|
+
|
|
201
|
+
Last attempted: 2026-05-22, got blocked on Test 1 by the v1.3.1 consent-guard ordering bug. Fixed in v1.3.2. Ready to restart from Test 1 after Somiibo redeploys against `ultimate-jekyll-manager@^1.3.2`.
|
|
@@ -314,8 +314,13 @@ export default function () {
|
|
|
314
314
|
webManager.sentry().captureException(new Error('Error handling redirect result', { cause: error }));
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
-
// Handle specific OAuth errors
|
|
318
|
-
|
|
317
|
+
// Handle specific OAuth errors. Check blocking-function rejections FIRST —
|
|
318
|
+
// those carry a custom message from BEM (rate limit, disposable email, etc.)
|
|
319
|
+
// that the user actually needs to see, hidden behind a generic Firebase code.
|
|
320
|
+
const blockingMessage = extractBlockingFunctionMessage(error);
|
|
321
|
+
if (blockingMessage) {
|
|
322
|
+
formManager.showError(blockingMessage);
|
|
323
|
+
} else if (error.code === 'auth/account-exists-with-different-credential') {
|
|
319
324
|
formManager.showError('An account already exists with the same email address but different sign-in credentials. Try signing in with a different provider.');
|
|
320
325
|
} else if (error.code === 'auth/popup-blocked') {
|
|
321
326
|
formManager.showError('Popup was blocked. Please allow popups for this site and try again.');
|
|
@@ -508,6 +513,13 @@ export default function () {
|
|
|
508
513
|
}
|
|
509
514
|
}
|
|
510
515
|
|
|
516
|
+
// Blocking-function rejections from BEM (rate limit, disposable email, etc.)
|
|
517
|
+
// — surface the BEM-side message instead of the opaque auth/internal-error.
|
|
518
|
+
const blockingMessage = extractBlockingFunctionMessage(error);
|
|
519
|
+
if (blockingMessage) {
|
|
520
|
+
throw new Error(blockingMessage);
|
|
521
|
+
}
|
|
522
|
+
|
|
511
523
|
// Re-throw the error to be handled by the form handler
|
|
512
524
|
throw error;
|
|
513
525
|
}
|
|
@@ -523,14 +535,24 @@ export default function () {
|
|
|
523
535
|
// Log
|
|
524
536
|
console.log('[Auth] Attempting email sign-in for:', email);
|
|
525
537
|
|
|
526
|
-
|
|
527
|
-
|
|
538
|
+
try {
|
|
539
|
+
// Sign in with email and password
|
|
540
|
+
const userCredential = await attemptEmailSignIn(email, password);
|
|
528
541
|
|
|
529
|
-
|
|
530
|
-
|
|
542
|
+
// Track login
|
|
543
|
+
trackLogin('email', userCredential.user);
|
|
531
544
|
|
|
532
|
-
|
|
533
|
-
|
|
545
|
+
// Show success message
|
|
546
|
+
formManager.showSuccess('Successfully signed in!');
|
|
547
|
+
} catch (error) {
|
|
548
|
+
// Blocking-function rejections from BEM's before-signin (rate limit, etc.)
|
|
549
|
+
// — surface the BEM-side message instead of the opaque auth/internal-error.
|
|
550
|
+
const blockingMessage = extractBlockingFunctionMessage(error);
|
|
551
|
+
if (blockingMessage) {
|
|
552
|
+
throw new Error(blockingMessage);
|
|
553
|
+
}
|
|
554
|
+
throw error;
|
|
555
|
+
}
|
|
534
556
|
}
|
|
535
557
|
|
|
536
558
|
async function handlePasswordReset(formData) {
|
|
@@ -688,8 +710,13 @@ export default function () {
|
|
|
688
710
|
webManager.sentry().captureException(new Error('OAuth provider sign-in error', { cause: error }));
|
|
689
711
|
}
|
|
690
712
|
|
|
691
|
-
// Handle specific errors
|
|
692
|
-
|
|
713
|
+
// Handle specific errors. Blocking-function rejections from BEM carry a
|
|
714
|
+
// custom message (rate limit, disposable email, etc.) that the user needs
|
|
715
|
+
// to see — check those FIRST before generic Firebase codes.
|
|
716
|
+
const blockingMessage = extractBlockingFunctionMessage(error);
|
|
717
|
+
if (blockingMessage) {
|
|
718
|
+
throw new Error(blockingMessage);
|
|
719
|
+
} else if (error.code === 'auth/account-exists-with-different-credential') {
|
|
693
720
|
throw new Error('An account already exists with the same email address but different sign-in credentials. Try signing in with a different provider.');
|
|
694
721
|
} else if (error.code === 'auth/invalid-credential') {
|
|
695
722
|
throw new Error('Invalid credentials. Please try again.');
|
|
@@ -757,6 +784,69 @@ export default function () {
|
|
|
757
784
|
return userErrors.includes(errorCode);
|
|
758
785
|
}
|
|
759
786
|
|
|
787
|
+
// Extract the readable message from a Firebase Auth blocking-function error.
|
|
788
|
+
//
|
|
789
|
+
// When a BEM blocking function (before-create / before-signin) throws
|
|
790
|
+
// HttpsError('resource-exhausted', 'Too many signups...'), Firebase surfaces
|
|
791
|
+
// it as `auth/internal-error` (sometimes also `auth/error-code:-47`) and
|
|
792
|
+
// stashes the actual server response on `error.customData.serverResponse`.
|
|
793
|
+
// The blob looks like:
|
|
794
|
+
//
|
|
795
|
+
// {
|
|
796
|
+
// "error": {
|
|
797
|
+
// "code": 400,
|
|
798
|
+
// "message": "BLOCKING_FUNCTION_ERROR_RESPONSE : ((error : (message : \"Too many signups...\")))",
|
|
799
|
+
// ...
|
|
800
|
+
// }
|
|
801
|
+
// }
|
|
802
|
+
//
|
|
803
|
+
// Returns just the inner message string, or null if nothing useful was found.
|
|
804
|
+
function extractBlockingFunctionMessage(error) {
|
|
805
|
+
// Diagnostic: dump the full shape of every error that lands here so we can
|
|
806
|
+
// see exactly what Firebase delivers when BEM's beforeCreate throws. The
|
|
807
|
+
// 503 path (Identity Toolkit returns 503 with code -47, no BLOCKING_FUNCTION
|
|
808
|
+
// wrapper) needs different handling than the 400 path.
|
|
809
|
+
console.warn('[Auth] extractBlockingFunctionMessage: error shape', {
|
|
810
|
+
code: error?.code,
|
|
811
|
+
message: error?.message,
|
|
812
|
+
name: error?.name,
|
|
813
|
+
hasCustomData: !!error?.customData,
|
|
814
|
+
hasServerResponse: !!error?.customData?.serverResponse,
|
|
815
|
+
serverResponse: error?.customData?.serverResponse,
|
|
816
|
+
fullError: error,
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
const raw = error?.customData?.serverResponse;
|
|
820
|
+
if (!raw) {
|
|
821
|
+
return null;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
let parsed;
|
|
825
|
+
try {
|
|
826
|
+
parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
827
|
+
} catch (e) {
|
|
828
|
+
return null;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const message = parsed?.error?.message || '';
|
|
832
|
+
if (!message) {
|
|
833
|
+
return null;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// Unwrap "BLOCKING_FUNCTION_ERROR_RESPONSE : ((error : (message : \"...\")))"
|
|
837
|
+
const match = message.match(/message\s*:\s*"([^"]+)"/);
|
|
838
|
+
if (match) {
|
|
839
|
+
return match[1];
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// Fall back to the raw message if it's not the BLOCKING_FUNCTION wrapper format
|
|
843
|
+
if (!message.startsWith('BLOCKING_FUNCTION')) {
|
|
844
|
+
return message;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
return null;
|
|
848
|
+
}
|
|
849
|
+
|
|
760
850
|
function trackLogin(method, user) {
|
|
761
851
|
const userId = user.uid;
|
|
762
852
|
const methodName = method.charAt(0).toUpperCase() + method.slice(1);
|