From 8edfc3814e34bfb10080cae131dceaea8ea1bd3d Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Sun, 16 Aug 2026 10:50:38 +0200 Subject: [PATCH 1/3] feat(auth): signup attribution + OAuth user_signed_up event (#4003) - accept an optional strict attribution object (referrer, landingPath, utm*) on the signup body, validated by Zod and persisted on the user only when the analytics client is actually configured (enabled + key) - flatten attribution as snake_case properties on user_signed_up - fire identify + user_signed_up on the OAuth create path (parity with local signup); resolve branches never emit - attribution is excluded from the profile-update write surface Claude-Session: https://claude.ai/code/session_015AXhHayqcLntuU3AbX7No8 --- .../tests/analytics.identify.unit.tests.js | 3 + modules/auth/controllers/auth.controller.js | 75 +++++ .../auth.oauth.signup.analytics.unit.tests.js | 241 ++++++++++++++ .../auth.signup.attribution.unit.tests.js | 293 ++++++++++++++++++ .../auth.signup.inviteHonored.unit.tests.js | 9 +- .../tests/auth.silent.catch.unit.tests.js | 10 +- modules/users/models/users.model.mongoose.js | 12 + modules/users/models/users.schema.js | 34 +- modules/users/tests/user.unit.tests.js | 141 +++++++++ 9 files changed, 811 insertions(+), 7 deletions(-) create mode 100644 modules/auth/tests/auth.oauth.signup.analytics.unit.tests.js create mode 100644 modules/auth/tests/auth.signup.attribution.unit.tests.js diff --git a/lib/services/tests/analytics.identify.unit.tests.js b/lib/services/tests/analytics.identify.unit.tests.js index 6488b871e..d28d491cd 100644 --- a/lib/services/tests/analytics.identify.unit.tests.js +++ b/lib/services/tests/analytics.identify.unit.tests.js @@ -27,6 +27,9 @@ describe('Analytics identify on auth events:', () => { track: jest.fn(), init: jest.fn(), shutdown: jest.fn(), + // #4002/#4003: local signup() gates attribution persistence on this — + // false here (feature not under test in this file, no attribution submitted). + isConfigured: jest.fn().mockReturnValue(false), }, })); }); diff --git a/modules/auth/controllers/auth.controller.js b/modules/auth/controllers/auth.controller.js index e1ceb13ef..fe174f94e 100644 --- a/modules/auth/controllers/auth.controller.js +++ b/modules/auth/controllers/auth.controller.js @@ -59,6 +59,32 @@ const sendVerificationEmail = async (user, verificationToken) => { return mail; }; +/** + * @desc Flatten a persisted `attribution` subdocument into PostHog-style + * snake_case event properties. Only present keys are included (absent + * attribution, or an absent individual field, contributes nothing) — mirrors + * the "only keys that are present" contract for the `user_signed_up` event. + * @param {Object|undefined} attribution - persisted attribution subdocument + * @returns {Object} flattened snake_case properties, possibly empty + */ +const attributionEventProperties = (attribution) => { + if (!attribution || typeof attribution !== 'object') return {}; + const map = { + referrer: 'referrer', + landingPath: 'landing_path', + utmSource: 'utm_source', + utmMedium: 'utm_medium', + utmCampaign: 'utm_campaign', + utmTerm: 'utm_term', + utmContent: 'utm_content', + }; + const properties = {}; + for (const [camelKey, snakeKey] of Object.entries(map)) { + if (attribution[camelKey] !== undefined) properties[snakeKey] = attribution[camelKey]; + } + return properties; +}; + /** * @desc Endpoint to ask the service to create a user * @param {Object} req - Express request object @@ -156,6 +182,19 @@ const signup = async (req, res) => { 'currentOrganization', 'referredBy', ]) delete safeBody[serverOwned]; + // First-touch attribution (#4002/#4003) is a legitimate client-provided field + // (unlike the server-owned list above), but the feature is inert unless the + // PostHog client actually initialized — nothing would ever read it back, so + // strip it before create rather than persist dead data. Gate on + // AnalyticsService.isConfigured() (client !== null) rather than + // config.analytics.posthog.enabled directly: `enabled:true` with no `key` set + // never initializes the client (see lib/services/analytics.js#init), so the + // config flag alone would silently persist attribution nobody ever reads. + // When configured, attribution flows into UserService.create untouched + // (already validated + trimmed + length-capped by SignupUser's `.strict()` + // Attribution shape) and is flattened onto the `user_signed_up` capture event + // below. + if (!AnalyticsService.isConfigured()) delete safeBody.attribution; // Invite-gated signup: canonicalize the account email to the invite's pinned // (lowercased) email. Enforces the pin exactly AND makes the case-insensitive // unique-email index (email_ci_unique, collation strength-2) a reliable single-use backstop — concurrent case-variant @@ -248,6 +287,14 @@ const signup = async (req, res) => { invited: Boolean(invite), invitationId: invite ? String(invite.id) : null, invitedBy: invite?.invitedBy ? String(invite.invitedBy) : null, + // #4002/#4003: first-touch attribution, flattened PostHog-style. Read + // from `safeBody` (the object actually handed to UserService.create), + // NOT the sanitized `user` response — `attribution` is deliberately + // absent from `config.whitelists.users.default`, so `UserService.create`'s + // `removeSensitive()` return would always strip it regardless of whether + // it was actually persisted. Empty when analytics was disabled at create + // time (stripped from safeBody above) or when none was submitted. + ...attributionEventProperties(safeBody.attribution), }, }); } catch (_) { /* analytics must not break auth */ } @@ -635,6 +682,34 @@ const checkOAuthUserProfile = async (profil, key, provider) => { // else return req.body with the data after Zod validation if (oauthInvite) result.value.email = oauthInvite.email; const createdUser = await UserService.create(result.value); + + // Analytics — fire-and-forget, never break auth. Mirrors the local signup + // event (#4002/#4003); OAuth carries no attribution (the redirect has no + // body to carry it in), so no attribution properties here. This MUST only + // fire on THIS branch (new account) — never on branches 1-3 above, which + // resolve to an existing/linked user and are not a signup. + try { + AnalyticsService.identify(String(createdUser.id), { + email: createdUser.email, + firstName: createdUser.firstName, + lastName: createdUser.lastName, + provider: createdUser.provider, + }); + AnalyticsService.capture({ + distinctId: String(createdUser.id), + event: 'user_signed_up', + properties: { + email: createdUser.email, + plan: createdUser.plan, + createdAt: createdUser.createdAt, + provider: createdUser.provider, + invited: Boolean(oauthInvite), + invitationId: oauthInvite ? String(oauthInvite.id) : null, + invitedBy: oauthInvite?.invitedBy ? String(oauthInvite.invitedBy) : null, + }, + }); + } catch (_) { /* analytics must not break auth */ } + // E2: FINALIZE through the returned closure (invitations owns it; auth stays // import-free). OAuth resolves the invite by the provider-verified email and // never CLAIMS it (no token on the redirect), so there is no consumingAt to diff --git a/modules/auth/tests/auth.oauth.signup.analytics.unit.tests.js b/modules/auth/tests/auth.oauth.signup.analytics.unit.tests.js new file mode 100644 index 000000000..de5b5e41b --- /dev/null +++ b/modules/auth/tests/auth.oauth.signup.analytics.unit.tests.js @@ -0,0 +1,241 @@ +/** + * Module dependencies. + */ +import { jest, describe, test, expect, beforeEach } from '@jest/globals'; + +/** + * Unit tests — OAuth signup analytics (epic #4002 / #4003). + * `checkOAuthUserProfile` resolves a user via 4 branches (primary identity, + * linked identity, link-on-verified-email, create). The `user_signed_up` + * analytics event must fire ONLY on branch 4 (a brand-new account) — never + * on branches 1-3, which resolve to an existing/linked user, not a signup. + * Mirrors the mocking pattern in auth.silent.catch.unit.tests.js. + */ + +/** + * Wire up every module `auth.controller.js` imports at module scope for an + * OAuth-focused unit test. `searchResults` is an array of successive return + * values for `UserService.search` (branch 1 then branch 2 lookups). + * @param {Array} searchResults - successive UserService.search() results + * @param {Object} [options] - optional overrides + * @param {Object|null} [options.linkProviderByEmailResult] - branch-3 linkProviderByEmail() resolution + * @returns {Promise<{AuthController: Object, mockCreate: Function, mockIdentify: Function, mockCapture: Function, mockSearch: Function}>} + */ +const loadController = async (searchResults, options = {}) => { + jest.resetModules(); + + jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ + default: { warn: jest.fn(), error: jest.fn(), info: jest.fn() }, + })); + + const mockSearch = jest.fn(); + searchResults.forEach((result) => mockSearch.mockResolvedValueOnce(result)); + + const mockCreate = jest.fn().mockResolvedValue({ + id: 'u9', email: 'newoauth@test.com', firstName: 'New', lastName: 'OAuth', provider: 'google', createdAt: new Date('2026-01-01'), + }); + + jest.unstable_mockModule('../../../modules/users/services/users.service.js', () => ({ + default: { + create: mockCreate, + search: mockSearch, + linkProviderByEmail: jest.fn().mockResolvedValue(options.linkProviderByEmailResult ?? null), + findByEmail: jest.fn().mockResolvedValue(null), + count: jest.fn().mockResolvedValue(0), + }, + })); + + jest.unstable_mockModule('../../../modules/auth/services/auth.eligibility.js', () => ({ + default: { + registerSignupEligibility: jest.fn(), + assertSignupEligible: jest.fn().mockResolvedValue(undefined), + _reset: jest.fn(), + }, + })); + + jest.unstable_mockModule('../../../modules/auth/services/auth.signupCapacity.js', () => ({ + computeSignupCapacity: jest.fn().mockResolvedValue({ cap: null, remaining: null }), + })); + + jest.unstable_mockModule('../../../modules/organizations/services/organizations.service.js', () => ({ + default: { handleSignupOrganization: jest.fn() }, + })); + + jest.unstable_mockModule('../../../modules/organizations/services/organizations.crud.service.js', () => ({ + default: { autoSetCurrentOrganization: jest.fn() }, + })); + + jest.unstable_mockModule('../../../modules/organizations/services/organizations.membership.service.js', () => ({ + default: { findByUserAndOrganization: jest.fn(), listPendingByUser: jest.fn().mockResolvedValue([]) }, + })); + + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { + sign: { up: true, in: true }, // open signup — the invite hook is skipped entirely + jwt: { secret: 'test-secret', expiresIn: 3600 }, + cookie: { secure: false, sameSite: 'lax' }, + organizations: { enabled: false }, + app: { title: 'Test', contact: 'test@test.com' }, + }, + })); + + jest.unstable_mockModule('../../../lib/middlewares/model.js', () => ({ + default: { + // Pass the candidate straight through as "validated" — the real Zod + // schema is not under test here, only the analytics wiring. + getResultFromZod: jest.fn((body) => ({ value: { ...body } })), + checkError: jest.fn(() => false), + }, + })); + + jest.unstable_mockModule('../../../lib/helpers/mailer/index.js', () => ({ + default: { isConfigured: jest.fn().mockReturnValue(false), sendMail: jest.fn() }, + })); + + jest.unstable_mockModule('../../../lib/helpers/responses.js', () => ({ + default: { + success: jest.fn().mockReturnValue(jest.fn()), + error: jest.fn().mockReturnValue(jest.fn()), + }, + })); + + jest.unstable_mockModule('../../../lib/helpers/errors.js', () => ({ + default: { getMessage: jest.fn().mockReturnValue('error') }, + })); + + jest.unstable_mockModule('../../../lib/helpers/AppError.js', () => ({ + default: class AppError extends Error { + constructor(msg, opts) { + super(msg); + this.status = opts?.status; + this.code = opts?.code; + this.details = opts?.details; + } + }, + })); + + jest.unstable_mockModule('../../../modules/users/models/users.schema.js', () => ({ + default: { User: {}, SignupUser: {} }, + })); + + jest.unstable_mockModule('../../../lib/middlewares/policy.js', () => ({ + default: { defineAbilityFor: jest.fn().mockResolvedValue({}) }, + })); + + jest.unstable_mockModule('../../../lib/helpers/abilities.js', () => ({ + default: jest.fn().mockReturnValue([]), + })); + + jest.unstable_mockModule('../../../lib/helpers/getBaseUrl.js', () => ({ + default: jest.fn().mockReturnValue('http://localhost:3000'), + })); + + const mockIdentify = jest.fn(); + const mockCapture = jest.fn(); + jest.unstable_mockModule('../../../lib/services/analytics.js', () => ({ + default: { identify: mockIdentify, groupIdentify: jest.fn(), capture: mockCapture }, + })); + + const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js'); + + return { AuthController, mockCreate, mockIdentify, mockCapture, mockSearch }; +}; + +describe('auth.controller checkOAuthUserProfile analytics (#4002/#4003):', () => { + beforeEach(() => { + jest.resetModules(); + }); + + test('branch 4 (create): fires identify + user_signed_up with email/plan/createdAt/provider/invited/invitationId/invitedBy', async () => { + // No match on primary identity, no match on linked identity, no verified + // email to link on (emailVerifiedByProvider absent) -> falls to branch 4. + const { AuthController, mockCreate, mockIdentify, mockCapture } = await loadController([[], []]); + + const profil = { + firstName: 'New', lastName: 'OAuth', email: 'newoauth@test.com', avatar: '', + providerData: { id: 'google-id-123' }, + }; + + const result = await AuthController.checkOAuthUserProfile(profil, 'id', 'google'); + + expect(mockCreate).toHaveBeenCalledTimes(1); + expect(result.id).toBe('u9'); + + expect(mockIdentify).toHaveBeenCalledWith('u9', expect.objectContaining({ + email: 'newoauth@test.com', provider: 'google', + })); + expect(mockCapture).toHaveBeenCalledWith(expect.objectContaining({ + distinctId: 'u9', + event: 'user_signed_up', + properties: expect.objectContaining({ + email: 'newoauth@test.com', + createdAt: expect.any(Date), + provider: 'google', + invited: false, + invitationId: null, + invitedBy: null, + }), + })); + // plan is present as a key (even though undefined on this stack — no billing module) + expect(Object.prototype.hasOwnProperty.call(mockCapture.mock.calls[0][0].properties, 'plan')).toBe(true); + }); + + test('branch 1 (existing primary identity match): does NOT fire analytics', async () => { + const existingUser = { id: 'existing1', email: 'existing@test.com', provider: 'google' }; + const { AuthController, mockCreate, mockIdentify, mockCapture } = await loadController([[existingUser]]); + + const profil = { + firstName: 'Existing', lastName: 'User', email: 'existing@test.com', avatar: '', + providerData: { id: 'google-id-999' }, + }; + + const result = await AuthController.checkOAuthUserProfile(profil, 'id', 'google'); + + expect(result).toBe(existingUser); + expect(mockCreate).not.toHaveBeenCalled(); + expect(mockIdentify).not.toHaveBeenCalled(); + expect(mockCapture).not.toHaveBeenCalled(); + }); + + test('branch 2 (linked identity match): does NOT fire analytics', async () => { + const linkedUser = { id: 'linked1', email: 'linked@test.com', provider: 'local' }; + // First search (primary identity) misses, second search (linked identity) hits. + const { AuthController, mockCreate, mockIdentify, mockCapture } = await loadController([[], [linkedUser]]); + + const profil = { + firstName: 'Linked', lastName: 'User', email: 'linked@test.com', avatar: '', + providerData: { id: 'google-id-777' }, + }; + + const result = await AuthController.checkOAuthUserProfile(profil, 'id', 'google'); + + expect(result).toBe(linkedUser); + expect(mockCreate).not.toHaveBeenCalled(); + expect(mockIdentify).not.toHaveBeenCalled(); + expect(mockCapture).not.toHaveBeenCalled(); + }); + + test('branch 3 (link on verified email to an existing local account): does NOT fire analytics', async () => { + const linkedAccount = { id: 'localacct1', email: 'verified@test.com', provider: 'local' }; + // Both search-based lookups (primary, linked) miss, so resolution falls through + // to the link-on-verified-email branch, which returns a non-null user and + // returns early — branch 4 (create) must never be reached. + const { AuthController, mockCreate, mockIdentify, mockCapture } = await loadController( + [[], []], + { linkProviderByEmailResult: linkedAccount }, + ); + + const profil = { + firstName: 'Verified', lastName: 'User', email: 'verified@test.com', avatar: '', + providerData: { id: 'google-id-555' }, + emailVerifiedByProvider: true, + }; + + const result = await AuthController.checkOAuthUserProfile(profil, 'id', 'google'); + + expect(result).toBe(linkedAccount); + expect(mockCreate).not.toHaveBeenCalled(); + expect(mockIdentify).not.toHaveBeenCalled(); + expect(mockCapture).not.toHaveBeenCalled(); + }); +}); diff --git a/modules/auth/tests/auth.signup.attribution.unit.tests.js b/modules/auth/tests/auth.signup.attribution.unit.tests.js new file mode 100644 index 000000000..638f369f1 --- /dev/null +++ b/modules/auth/tests/auth.signup.attribution.unit.tests.js @@ -0,0 +1,293 @@ +/** + * Module dependencies. + */ +import { jest, describe, test, expect, beforeEach } from '@jest/globals'; + +/** + * Unit tests — signup attribution (epic #4002 / #4003). + * Verifies the local signup handler persists the validated `attribution` + * object on the created user ONLY when the analytics client is actually + * configured (`AnalyticsService.isConfigured()` — i.e. the PostHog client + * initialized, `enabled && key`), strips it otherwise, and flattens it + * PostHog-style onto the `user_signed_up` capture event. Contract amendment: + * the gate is `isConfigured()`, NOT `config.analytics?.posthog?.enabled` + * alone — `enabled:true` with no `key` never initializes the client (see + * lib/services/analytics.js#init), which would otherwise persist attribution + * nobody ever reads. Mirrors the mocking pattern in + * auth.silent.catch.unit.tests.js. + */ + +const baseAttribution = { + referrer: 'https://google.com', + landingPath: '/pricing', + utmSource: 'google', + utmMedium: 'cpc', + utmCampaign: 'launch', + utmTerm: 'saas', + utmContent: 'ad1', +}; + +/** + * Wire up every module `auth.controller.js` imports at module scope. `isConfigured` + * drives the `AnalyticsService.isConfigured()` mock — the actual gate the controller + * reads. `analyticsConfig` optionally overrides the mocked `config.analytics` shape + * (independent of `isConfigured`), so a test can prove `enabled:true` alone is no + * longer sufficient — the client must have actually initialized. + * @param {boolean} isConfigured - value AnalyticsService.isConfigured() resolves to + * @param {Object} [analyticsConfig] - override for the mocked config.analytics.posthog shape + * @returns {Promise<{AuthController: Object, mockCreate: Function, mockCapture: Function, mockIsConfigured: Function}>} + */ +const loadController = async (isConfigured, analyticsConfig) => { + jest.resetModules(); + + jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ + default: { warn: jest.fn(), error: jest.fn(), info: jest.fn() }, + })); + + const mockCreate = jest.fn().mockResolvedValue({ + id: 'u1', email: 'x@y.com', firstName: 'A', lastName: 'B', provider: 'local', createdAt: new Date('2026-01-01'), + }); + + jest.unstable_mockModule('../../../modules/users/services/users.service.js', () => ({ + default: { + create: mockCreate, + getBrut: jest.fn().mockResolvedValue({ id: 'u1' }), + update: jest.fn().mockResolvedValue({}), + remove: jest.fn(), + count: jest.fn().mockResolvedValue(0), + }, + })); + + jest.unstable_mockModule('../../../modules/auth/services/auth.eligibility.js', () => ({ + default: { + registerSignupEligibility: jest.fn(), + assertSignupEligible: jest.fn().mockResolvedValue(undefined), + _reset: jest.fn(), + }, + })); + + jest.unstable_mockModule('../../../modules/organizations/services/organizations.service.js', () => ({ + default: { + handleSignupOrganization: jest.fn().mockResolvedValue({ + organization: null, joined: false, pendingJoin: false, + abilities: [], organizationSetupRequired: false, + emailVerificationRequired: false, suggestedOrganization: null, + }), + }, + })); + + jest.unstable_mockModule('../../../modules/organizations/services/organizations.crud.service.js', () => ({ + default: { autoSetCurrentOrganization: jest.fn() }, + })); + + jest.unstable_mockModule('../../../modules/organizations/services/organizations.membership.service.js', () => ({ + default: { findByUserAndOrganization: jest.fn(), listPendingByUser: jest.fn().mockResolvedValue([]) }, + })); + + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { + sign: { up: true, in: true }, + jwt: { secret: 'test-secret', expiresIn: 3600 }, + cookie: { secure: false, sameSite: 'lax' }, + organizations: { enabled: false }, + app: { title: 'Test', contact: 'test@test.com' }, + // Kept in sync with `isConfigured` by default so the config mock stays + // realistic; tests proving the isConfigured() gate pass an explicit override. + analytics: analyticsConfig ?? { posthog: { enabled: isConfigured, key: isConfigured ? 'phc_test_key' : undefined } }, + }, + })); + + jest.unstable_mockModule('../../../lib/middlewares/model.js', () => ({ + default: { getResultFromZod: jest.fn(), checkError: jest.fn() }, + })); + + jest.unstable_mockModule('../../../lib/helpers/mailer/index.js', () => ({ + default: { + isConfigured: jest.fn().mockReturnValue(false), + sendMail: jest.fn().mockResolvedValue({ accepted: ['x@y.com'] }), + }, + })); + + jest.unstable_mockModule('../../../lib/helpers/responses.js', () => ({ + default: { + success: jest.fn().mockReturnValue(jest.fn()), + error: jest.fn().mockReturnValue(jest.fn()), + }, + })); + + jest.unstable_mockModule('../../../lib/helpers/errors.js', () => ({ + default: { getMessage: jest.fn().mockReturnValue('error') }, + })); + + jest.unstable_mockModule('../../../lib/helpers/AppError.js', () => ({ + default: class AppError extends Error { + constructor(msg, opts) { + super(msg); + this.code = opts?.code; + this.details = opts?.details; + } + }, + })); + + jest.unstable_mockModule('../../../modules/users/models/users.schema.js', () => ({ + default: { User: {}, SignupUser: {} }, + })); + + jest.unstable_mockModule('../../../lib/middlewares/policy.js', () => ({ + default: { defineAbilityFor: jest.fn().mockResolvedValue({}) }, + })); + + jest.unstable_mockModule('../../../lib/helpers/abilities.js', () => ({ + default: jest.fn().mockReturnValue([]), + })); + + jest.unstable_mockModule('../../../lib/helpers/getBaseUrl.js', () => ({ + default: jest.fn().mockReturnValue('http://localhost:3000'), + })); + + const mockCapture = jest.fn(); + const mockIsConfigured = jest.fn().mockReturnValue(isConfigured); + jest.unstable_mockModule('../../../lib/services/analytics.js', () => ({ + default: { identify: jest.fn(), groupIdentify: jest.fn(), capture: mockCapture, isConfigured: mockIsConfigured }, + })); + + const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js'); + + return { AuthController, mockCreate, mockCapture, mockIsConfigured }; +}; + +describe('auth.controller signup attribution (#4002/#4003):', () => { + beforeEach(() => { + jest.resetModules(); + }); + + test('persists attribution on the created user when the analytics client is configured', async () => { + const { AuthController, mockCreate, mockCapture } = await loadController(true); + + const req = { + body: { + email: 'x@y.com', firstName: 'A', lastName: 'B', password: 'P@ss1234!', + attribution: baseAttribution, + }, + query: {}, + }; + const res = { status: jest.fn().mockReturnThis(), cookie: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() }; + + await AuthController.signup(req, res); + + expect(mockCreate).toHaveBeenCalledTimes(1); + const createdWith = mockCreate.mock.calls[0][0]; + expect(createdWith.attribution).toEqual(baseAttribution); + + expect(mockCapture).toHaveBeenCalledWith(expect.objectContaining({ + event: 'user_signed_up', + properties: expect.objectContaining({ + referrer: baseAttribution.referrer, + landing_path: baseAttribution.landingPath, + utm_source: baseAttribution.utmSource, + utm_medium: baseAttribution.utmMedium, + utm_campaign: baseAttribution.utmCampaign, + utm_term: baseAttribution.utmTerm, + utm_content: baseAttribution.utmContent, + }), + })); + }); + + test('strips attribution before create when the analytics client is not configured (feature inert)', async () => { + const { AuthController, mockCreate, mockCapture } = await loadController(false); + + const req = { + body: { + email: 'x@y.com', firstName: 'A', lastName: 'B', password: 'P@ss1234!', + attribution: baseAttribution, + }, + query: {}, + }; + const res = { status: jest.fn().mockReturnThis(), cookie: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() }; + + await AuthController.signup(req, res); + + expect(mockCreate).toHaveBeenCalledTimes(1); + const createdWith = mockCreate.mock.calls[0][0]; + expect(Object.prototype.hasOwnProperty.call(createdWith, 'attribution')).toBe(false); + + // capture() is still called (invite/referral tracking is independent of + // attribution), but carries none of the flattened attribution keys. + expect(mockCapture).toHaveBeenCalledTimes(1); + const capturedProperties = mockCapture.mock.calls[0][0].properties; + for (const key of ['referrer', 'landing_path', 'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']) { + expect(Object.prototype.hasOwnProperty.call(capturedProperties, key)).toBe(false); + } + }); + + test('strips attribution when config.analytics.posthog.enabled=true but key is missing (client never initialized)', async () => { + // Contract amendment regression guard: `enabled:true` with no `key` never sets + // the PostHog client (lib/services/analytics.js#init), so isConfigured() is + // false even though the raw config flag reads true. The gate must follow + // isConfigured(), not the config flag — passed explicitly here since the + // real init() logic is not under test, only the controller's gate. + const { AuthController, mockCreate, mockCapture, mockIsConfigured } = await loadController( + false, + { posthog: { enabled: true, key: undefined } }, + ); + + const req = { + body: { + email: 'x@y.com', firstName: 'A', lastName: 'B', password: 'P@ss1234!', + attribution: baseAttribution, + }, + query: {}, + }; + const res = { status: jest.fn().mockReturnThis(), cookie: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() }; + + await AuthController.signup(req, res); + + expect(mockIsConfigured).toHaveBeenCalled(); + const createdWith = mockCreate.mock.calls[0][0]; + expect(Object.prototype.hasOwnProperty.call(createdWith, 'attribution')).toBe(false); + + const capturedProperties = mockCapture.mock.calls[0][0].properties; + expect(Object.prototype.hasOwnProperty.call(capturedProperties, 'utm_source')).toBe(false); + }); + + test('a signup with no attribution submitted creates no attribution field and no flattened keys', async () => { + const { AuthController, mockCreate, mockCapture } = await loadController(true); + + const req = { + body: { email: 'x@y.com', firstName: 'A', lastName: 'B', password: 'P@ss1234!' }, + query: {}, + }; + const res = { status: jest.fn().mockReturnThis(), cookie: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() }; + + await AuthController.signup(req, res); + + const createdWith = mockCreate.mock.calls[0][0]; + expect(Object.prototype.hasOwnProperty.call(createdWith, 'attribution')).toBe(false); + + const capturedProperties = mockCapture.mock.calls[0][0].properties; + expect(Object.prototype.hasOwnProperty.call(capturedProperties, 'utm_source')).toBe(false); + }); + + test('only present attribution keys are flattened onto the capture event', async () => { + const { AuthController, mockCreate, mockCapture } = await loadController(true); + + const req = { + body: { + email: 'x@y.com', firstName: 'A', lastName: 'B', password: 'P@ss1234!', + attribution: { utmSource: 'newsletter' }, + }, + query: {}, + }; + const res = { status: jest.fn().mockReturnThis(), cookie: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() }; + + await AuthController.signup(req, res); + + expect(mockCreate.mock.calls[0][0].attribution).toEqual({ utmSource: 'newsletter' }); + + const capturedProperties = mockCapture.mock.calls[0][0].properties; + expect(capturedProperties.utm_source).toBe('newsletter'); + for (const key of ['referrer', 'landing_path', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']) { + expect(Object.prototype.hasOwnProperty.call(capturedProperties, key)).toBe(false); + } + }); +}); diff --git a/modules/auth/tests/auth.signup.inviteHonored.unit.tests.js b/modules/auth/tests/auth.signup.inviteHonored.unit.tests.js index c0ef20057..73a3d579a 100644 --- a/modules/auth/tests/auth.signup.inviteHonored.unit.tests.js +++ b/modules/auth/tests/auth.signup.inviteHonored.unit.tests.js @@ -126,7 +126,14 @@ function mockCommonDeps({ config, eligibility, create }) { })); jest.unstable_mockModule('../../../lib/services/analytics.js', () => ({ - default: { identify: jest.fn(), groupIdentify: jest.fn(), capture: jest.fn() }, + default: { + identify: jest.fn(), + groupIdentify: jest.fn(), + capture: jest.fn(), + // #4002/#4003: local signup() gates attribution persistence on this — + // false here (feature not under test in this file, no attribution submitted). + isConfigured: jest.fn().mockReturnValue(false), + }, })); } diff --git a/modules/auth/tests/auth.silent.catch.unit.tests.js b/modules/auth/tests/auth.silent.catch.unit.tests.js index f06b565de..40f5442d2 100644 --- a/modules/auth/tests/auth.silent.catch.unit.tests.js +++ b/modules/auth/tests/auth.silent.catch.unit.tests.js @@ -127,7 +127,7 @@ describe('auth.controller silent-catch error logging:', () => { })); jest.unstable_mockModule('../../../lib/services/analytics.js', () => ({ - default: { identify: jest.fn(), groupIdentify: jest.fn() }, + default: { identify: jest.fn(), groupIdentify: jest.fn(), isConfigured: jest.fn().mockReturnValue(false) }, })); const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js'); @@ -267,7 +267,7 @@ describe('auth.controller signup mass-assignment strip:', () => { })); jest.unstable_mockModule('../../../lib/services/analytics.js', () => ({ - default: { identify: jest.fn(), groupIdentify: jest.fn(), capture: jest.fn() }, + default: { identify: jest.fn(), groupIdentify: jest.fn(), capture: jest.fn(), isConfigured: jest.fn().mockReturnValue(false) }, })); const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js'); @@ -446,7 +446,7 @@ describe('auth.controller signup analytics: invite/referral attribution (#3945): const mockCapture = jest.fn(); jest.unstable_mockModule('../../../lib/services/analytics.js', () => ({ - default: { identify: jest.fn(), groupIdentify: jest.fn(), capture: mockCapture }, + default: { identify: jest.fn(), groupIdentify: jest.fn(), capture: mockCapture, isConfigured: jest.fn().mockReturnValue(false) }, })); const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js'); @@ -573,7 +573,7 @@ describe('auth.controller signup analytics: invite/referral attribution (#3945): const mockCapture = jest.fn(); jest.unstable_mockModule('../../../lib/services/analytics.js', () => ({ - default: { identify: jest.fn(), groupIdentify: jest.fn(), capture: mockCapture }, + default: { identify: jest.fn(), groupIdentify: jest.fn(), capture: mockCapture, isConfigured: jest.fn().mockReturnValue(false) }, })); const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js'); @@ -796,7 +796,7 @@ describe('auth.controller resendVerification mail-transport failure hardening (# })); jest.unstable_mockModule('../../../lib/services/analytics.js', () => ({ - default: { identify: jest.fn(), groupIdentify: jest.fn(), capture: jest.fn() }, + default: { identify: jest.fn(), groupIdentify: jest.fn(), capture: jest.fn(), isConfigured: jest.fn().mockReturnValue(false) }, })); const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js'); diff --git a/modules/users/models/users.model.mongoose.js b/modules/users/models/users.model.mongoose.js index 5aaee807b..53d898fcb 100644 --- a/modules/users/models/users.model.mongoose.js +++ b/modules/users/models/users.model.mongoose.js @@ -66,6 +66,18 @@ const UserMongoose = new Schema( default: null, }, complementary: {}, // put your specific project private data here + // First-touch signup attribution (#4002/#4003) — server-set-once at signup, + // persisted only when analytics is enabled (see auth.controller.js). No + // index: not queried, only read back for the analytics event / display. + attribution: { + referrer: String, + landingPath: String, + utmSource: String, + utmMedium: String, + utmCampaign: String, + utmTerm: String, + utmContent: String, + }, }, { timestamps: true, diff --git a/modules/users/models/users.schema.js b/modules/users/models/users.schema.js index 78054ead6..74d337bdf 100644 --- a/modules/users/models/users.schema.js +++ b/modules/users/models/users.schema.js @@ -8,6 +8,23 @@ import zodHelpers from '../../../lib/helpers/zod.js'; const names = /^[a-zA-ZàáâäãåąčćęèéêëėįìíîïłńòóôöõøùúûüųūÿýżźñçčšžÀÁÂÄÃÅĄĆČĖĘÈÉÊËÌÍÎÏĮŁŃÒÓÔÖÕØÙÚÛÜŲŪŸÝŻŹÑßÇŒÆČŠŽ∂ð ,.'-]+$/u; +/** + * First-touch signup attribution (epic #4002 / #4003). All fields optional, + * trimmed, length-capped. `.strict()` so an unknown key is REJECTED (422) + * instead of silently accepted — the object's shape is exhaustive by design. + * Server-set-once at signup: see `UserUpdate` below for why it is explicitly + * excluded from the profile-update write surface. + */ +const Attribution = z.object({ + referrer: z.string().max(2048).trim().optional(), + landingPath: z.string().max(2048).trim().optional(), + utmSource: z.string().max(256).trim().optional(), + utmMedium: z.string().max(256).trim().optional(), + utmCampaign: z.string().max(256).trim().optional(), + utmTerm: z.string().max(256).trim().optional(), + utmContent: z.string().max(256).trim().optional(), +}).strict(); + /** * User Data Schema */ @@ -68,9 +85,19 @@ const User = z.object({ // projection / the read whitelist — NEVER by adding it to this schema. // others complementary: z.record(z.string(), z.unknown()).nullable().optional(), + // First-touch signup attribution (#4002/#4003) — server-set-once at signup, + // persisted only when analytics is enabled (auth.controller strips it from + // the create body otherwise). Deliberately NOT on `UserUpdate` (below): a + // client must never be able to overwrite its own first-touch attribution + // after the fact via the profile-update surface. + attribution: Attribution.optional(), }); -const UserUpdate = User.partial(); +// `attribution` is EXCLUDED here (not just left off the `update`/`updateAdmin` +// whitelists in config) — defense-in-depth so the PUT /users route schema +// itself rejects/strips it before the whitelist layer is ever reached, the +// same belt-and-braces pattern `referredBy` uses above. +const UserUpdate = User.partial().omit({ attribution: true }); /** * Public signup write surface. @@ -115,10 +142,15 @@ const SignupUser = z.object({ terms: User.shape.terms, complementary: User.shape.complementary, referredBy: z.string().optional(), + // First-touch attribution (#4002/#4003) — optional, client-provided at signup + // time only. The controller decides whether to persist it (analytics enabled) + // or strip it before create (analytics disabled); see auth.controller.js. + attribution: User.shape.attribution, }).strict(); export default { User, UserUpdate, SignupUser, + Attribution, }; diff --git a/modules/users/tests/user.unit.tests.js b/modules/users/tests/user.unit.tests.js index f17e58f62..45242a1b1 100644 --- a/modules/users/tests/user.unit.tests.js +++ b/modules/users/tests/user.unit.tests.js @@ -324,3 +324,144 @@ describe('User unit tests:', () => { }); }); }); + +/** + * Signup attribution unit tests (epic #4002 / #4003) + */ +describe('Attribution (signup) unit tests:', () => { + describe('Attribution schema', () => { + test('should accept an attribution object with all fields set', (done) => { + const attribution = { + referrer: 'https://google.com', + landingPath: '/pricing', + utmSource: 'google', + utmMedium: 'cpc', + utmCampaign: 'launch', + utmTerm: 'saas', + utmContent: 'ad1', + }; + + const result = schema.Attribution.safeParse(attribution); + expect(typeof result).toBe('object'); + expect(result.error).toBeFalsy(); + expect(result.data).toEqual(attribution); + done(); + }); + + test('should accept an empty attribution object (all fields optional)', (done) => { + const result = schema.Attribution.safeParse({}); + expect(typeof result).toBe('object'); + expect(result.error).toBeFalsy(); + done(); + }); + + test('should trim whitespace on attribution string fields', (done) => { + const result = schema.Attribution.safeParse({ referrer: ' https://google.com ' }); + expect(result.error).toBeFalsy(); + expect(result.data.referrer).toBe('https://google.com'); + done(); + }); + + test('should reject an unknown key via .strict()', (done) => { + const result = schema.Attribution.safeParse({ referrer: 'https://google.com', evilKey: 'nope' }); + expect(result.error).toBeDefined(); + done(); + }); + + test('should accept referrer at exactly the 2048-character cap', (done) => { + const result = schema.Attribution.safeParse({ referrer: 'a'.repeat(2048) }); + expect(result.error).toBeFalsy(); + done(); + }); + + test('should reject referrer longer than the 2048-character cap', (done) => { + const result = schema.Attribution.safeParse({ referrer: 'a'.repeat(2049) }); + expect(result.error).toBeDefined(); + done(); + }); + + test('should reject landingPath longer than the 2048-character cap', (done) => { + const result = schema.Attribution.safeParse({ landingPath: 'a'.repeat(2049) }); + expect(result.error).toBeDefined(); + done(); + }); + + test('should accept a utm field at exactly the 256-character cap', (done) => { + const result = schema.Attribution.safeParse({ utmSource: 'a'.repeat(256) }); + expect(result.error).toBeFalsy(); + done(); + }); + + test('should reject a utm field longer than the 256-character cap', (done) => { + const result = schema.Attribution.safeParse({ utmSource: 'a'.repeat(257) }); + expect(result.error).toBeDefined(); + done(); + }); + }); + + describe('User schema carries an optional attribution subdocument', () => { + test('should accept a full user document with attribution set', (done) => { + const result = schema.User.safeParse({ + firstName: 'Full', + lastName: 'Name', + email: 'test@test.com', + password: 'M3@n.jsI$Aw3$0m3', + provider: 'local', + attribution: { utmSource: 'google', referrer: 'https://google.com' }, + }); + expect(result.error).toBeFalsy(); + expect(result.data.attribution).toEqual({ utmSource: 'google', referrer: 'https://google.com' }); + done(); + }); + + test('should accept a full user document with attribution absent (backward compatible)', (done) => { + const result = schema.User.safeParse({ + firstName: 'Full', + lastName: 'Name', + email: 'test@test.com', + password: 'M3@n.jsI$Aw3$0m3', + provider: 'local', + }); + expect(result.error).toBeFalsy(); + expect(result.data.attribution).toBeUndefined(); + done(); + }); + }); + + describe('SignupUser + attribution', () => { + test('should accept a signup with a valid attribution object', (done) => { + const result = schema.SignupUser.safeParse({ email: 'a@b.com', attribution: { utmSource: 'google' } }); + expect(result.error).toBeFalsy(); + expect(result.data.attribution).toEqual({ utmSource: 'google' }); + done(); + }); + + test('should accept a signup with no attribution at all (backward compatible)', (done) => { + const result = schema.SignupUser.safeParse({ email: 'a@b.com' }); + expect(result.error).toBeFalsy(); + expect(result.data.attribution).toBeUndefined(); + done(); + }); + + test('should reject a signup whose attribution has an over-length field', (done) => { + const result = schema.SignupUser.safeParse({ email: 'a@b.com', attribution: { utmSource: 'a'.repeat(257) } }); + expect(result.error).toBeDefined(); + done(); + }); + + test('should reject a signup whose attribution carries an unknown key', (done) => { + const result = schema.SignupUser.safeParse({ email: 'a@b.com', attribution: { evilKey: 'nope' } }); + expect(result.error).toBeDefined(); + done(); + }); + }); + + describe('UserUpdate excludes attribution (server-set-once at signup)', () => { + test('should silently strip an attribution field sent on a profile update, without erroring', (done) => { + const result = schema.UserUpdate.safeParse({ firstName: 'A', attribution: { utmSource: 'hijack' } }); + expect(result.error).toBeFalsy(); + expect(result.data).not.toHaveProperty('attribution'); + done(); + }); + }); +}); From 587edffdc0773dd9af69ef67415980db5e9bc89b Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Sun, 16 Aug 2026 11:33:11 +0200 Subject: [PATCH 2/3] test(auth): integration coverage for signup attribution persistence Covers the full route -> Zod -> controller -> Mongo path: persisted subdoc + flattened user_signed_up props when analytics is configured, no persistence when not, 422 on unknown attribution key. Claude-Session: https://claude.ai/code/session_015AXhHayqcLntuU3AbX7No8 --- ...th.signup.attribution.integration.tests.js | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 modules/auth/tests/auth.signup.attribution.integration.tests.js diff --git a/modules/auth/tests/auth.signup.attribution.integration.tests.js b/modules/auth/tests/auth.signup.attribution.integration.tests.js new file mode 100644 index 000000000..7645dfe7a --- /dev/null +++ b/modules/auth/tests/auth.signup.attribution.integration.tests.js @@ -0,0 +1,175 @@ +/** + * Module dependencies. + */ +import request from 'supertest'; +import path from 'path'; +import { jest } from '@jest/globals'; + +import { bootstrap } from '../../../lib/app.js'; +import AnalyticsService from '../../../lib/services/analytics.js'; + +/** + * Integration tests — signup attribution (epic #4002/#4003), end-to-end through + * the real Express app + Mongo. Mirrors the structure of auth.integration.tests.js. + * + * Unlike auth.signup.attribution.unit.tests.js (fully module-mocked, no real + * app/DB), this suite proves the wiring through the ACTUAL route → Zod schema + * validation → controller → UserService → Mongo persistence path, plus the real + * shape of the `user_signed_up` capture event. + * + * AnalyticsService.isConfigured/capture/identify are SPIED (jest.spyOn on the + * real singleton, not jest.unstable_mockModule) — the module is a plain object + * of functions (see lib/services/analytics.js), so spying its properties is + * visible to auth.controller.js's own `AnalyticsService.(...)` calls. + * The real PostHog client never initializes in the test env (no + * DEVKIT_NODE_analytics_posthog_key), so `isConfigured()` would naturally read + * false; spying it forces both branches of the gate on demand, while stubbing + * capture/identify guarantees no attempt is ever made to reach a real client. + */ +describe('Auth signup attribution integration tests:', () => { + let UserService = null; + let app; + let agent; + + const password = 'W@os.jsI$Aw3$0m3'; + + const baseAttribution = { + referrer: 'https://google.com', + landingPath: '/pricing', + utmSource: 'google', + utmMedium: 'cpc', + utmCampaign: 'launch', + }; + + const emails = { + configured: 'attribution-configured@test.com', + unconfigured: 'attribution-unconfigured@test.com', + bogus: 'attribution-bogus@test.com', + }; + + const cleanupUsers = async () => { + for (const email of Object.values(emails)) { + try { + const existing = await UserService.getBrut({ email }); + if (existing) await UserService.remove(existing); + } catch (_) { /* cleanup – ignore errors */ } + } + }; + + beforeAll(async () => { + try { + const init = await bootstrap(); + UserService = (await import(path.resolve('./modules/users/services/users.service.js'))).default; + app = init.app; + agent = request.agent(app); + } catch (err) { + console.log(err); + expect(err).toBeFalsy(); + } + // clean up stale users from previous runs on shared databases + await cleanupUsers(); + }); + + afterEach(async () => { + // restores isConfigured/capture/identify spies to the real implementation + // between tests — jest.config.js only sets clearMocks (resets call history, + // not mock implementations), so a stale mockReturnValue would otherwise leak + // into the next test (mirrors auth.integration.tests.js's OAuth block). + jest.restoreAllMocks(); + await cleanupUsers(); + }); + + test('persists the exact attribution subdoc and flattens it onto the user_signed_up capture event when analytics is configured', async () => { + jest.spyOn(AnalyticsService, 'isConfigured').mockReturnValue(true); + const captureSpy = jest.spyOn(AnalyticsService, 'capture').mockImplementation(() => {}); + jest.spyOn(AnalyticsService, 'identify').mockImplementation(() => {}); + + let result; + try { + result = await agent.post('/api/auth/signup').send({ + firstName: 'Attr', + lastName: 'Bution', + email: emails.configured, + password, + provider: 'local', + attribution: baseAttribution, + }).expect(200); + } catch (err) { + console.log(err); + expect(err).toBeFalsy(); + } + + expect(result.body.user.email).toBe(emails.configured); + + const brut = await UserService.getBrut({ email: emails.configured }); + expect(brut.toObject().attribution).toEqual(baseAttribution); + + expect(captureSpy).toHaveBeenCalledWith(expect.objectContaining({ + event: 'user_signed_up', + properties: expect.objectContaining({ + referrer: baseAttribution.referrer, + landing_path: baseAttribution.landingPath, + utm_source: baseAttribution.utmSource, + utm_medium: baseAttribution.utmMedium, + utm_campaign: baseAttribution.utmCampaign, + }), + })); + }); + + test('strips attribution before persistence when analytics is not configured (feature inert)', async () => { + jest.spyOn(AnalyticsService, 'isConfigured').mockReturnValue(false); + const captureSpy = jest.spyOn(AnalyticsService, 'capture').mockImplementation(() => {}); + jest.spyOn(AnalyticsService, 'identify').mockImplementation(() => {}); + + let result; + try { + result = await agent.post('/api/auth/signup').send({ + firstName: 'Attr', + lastName: 'Bution', + email: emails.unconfigured, + password, + provider: 'local', + attribution: baseAttribution, + }).expect(200); + } catch (err) { + console.log(err); + expect(err).toBeFalsy(); + } + + expect(result.body.user.email).toBe(emails.unconfigured); + + const brut = await UserService.getBrut({ email: emails.unconfigured }); + expect(Object.prototype.hasOwnProperty.call(brut.toObject(), 'attribution')).toBe(false); + + // capture() still fires (invite/referral tracking is independent of + // attribution) but carries none of the flattened attribution keys. + expect(captureSpy).toHaveBeenCalledTimes(1); + const capturedProperties = captureSpy.mock.calls[0][0].properties; + for (const key of ['referrer', 'landing_path', 'utm_source', 'utm_medium', 'utm_campaign']) { + expect(Object.prototype.hasOwnProperty.call(capturedProperties, key)).toBe(false); + } + }); + + test('rejects signup carrying an unknown attribution key with 422 (strict Attribution schema)', async () => { + let result; + try { + result = await agent.post('/api/auth/signup').send({ + firstName: 'Attr', + lastName: 'Bution', + email: emails.bogus, + password, + provider: 'local', + attribution: { bogusKey: 'x' }, + }).expect(422); + } catch (err) { + console.log(err); + expect(err).toBeFalsy(); + } + + expect(result.body.type).toBe('error'); + expect(result.body.message).toBe('Schema validation error'); + + const persisted = await UserService.getBrut({ email: emails.bogus }); + expect(persisted == null).toBe(true); + }); +}); From 51c50a292802d4c3eb2b71f9179b5379782d92d5 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Sun, 16 Aug 2026 12:00:35 +0200 Subject: [PATCH 3/3] test(auth): add JSDoc to cleanupUsers integration test helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses CodeRabbit review comment on PR #4024 — matches the existing convention in sibling attribution/organization integration suites, which already JSDoc their cleanup helpers. Claude-Session: https://claude.ai/code/session_015AXhHayqcLntuU3AbX7No8 --- .../auth/tests/auth.signup.attribution.integration.tests.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/modules/auth/tests/auth.signup.attribution.integration.tests.js b/modules/auth/tests/auth.signup.attribution.integration.tests.js index 7645dfe7a..e18640983 100644 --- a/modules/auth/tests/auth.signup.attribution.integration.tests.js +++ b/modules/auth/tests/auth.signup.attribution.integration.tests.js @@ -47,6 +47,10 @@ describe('Auth signup attribution integration tests:', () => { bogus: 'attribution-bogus@test.com', }; + /** + * Remove any leftover test users (by email) from a previous run. + * @returns {Promise} + */ const cleanupUsers = async () => { for (const email of Object.values(emails)) { try {