Skip to content

Commit 5604011

Browse files
feat(auth): trigger handleSignupOrganization on verifyEmail success (best-effort) (#3765)
Wire AuthOrganizationService.handleSignupOrganization into verifyEmail after the emailVerified flag is persisted. Required for mailer-deferred org/grant provisioning (N2 signup path): when email verification is deferred, org setup must run at verify-time, not only at signup time. The call is idempotent (convergence guard: re-verify edge cases do not double-credit the grant). Wrapped in try/catch — org-setup failure must NEVER fail email verification (regression risk if wrapper is removed). Closes #3762 Promotes trawl_node trawl#1317 up to devkit. Part of infra plan 2026-06-01-trawl-promote-up-followups.md Task 4.
1 parent 11ab4b7 commit 5604011

2 files changed

Lines changed: 161 additions & 0 deletions

File tree

modules/auth/controllers/auth.controller.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,21 @@ const verifyEmail = async (req, res) => {
666666
emailVerificationToken: null,
667667
emailVerificationExpires: null,
668668
}, 'recover');
669+
// Mark verified on the local object so handleSignupOrganization sees emailVerified=true
670+
user.emailVerified = true;
671+
672+
// Post-verification org setup — provision org/grant if not yet done (best-effort).
673+
// handleSignupOrganization is idempotent: if the org already exists it converges
674+
// without double-crediting. Failure must never block email verification success.
675+
try {
676+
await AuthOrganizationService.handleSignupOrganization(user);
677+
} catch (orgErr) {
678+
logger.warn('[auth.verifyEmail] org/grant provisioning failed (non-fatal)', {
679+
userId: user.id,
680+
error: orgErr?.message,
681+
});
682+
}
683+
669684
return responses.success(res, 'Email verified successfully')({ emailVerified: true });
670685
} catch (err) {
671686
responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import { jest, describe, test, expect, beforeEach } from '@jest/globals';
5+
6+
/**
7+
* Unit tests for auth.controller verifyEmail() — handleSignupOrganization wiring.
8+
*
9+
* Verifies that:
10+
* 1. verifyEmail calls handleSignupOrganization after the user's emailVerified flag is set.
11+
* 2. A failure in handleSignupOrganization does NOT cause verifyEmail to fail (best-effort).
12+
*/
13+
describe('auth.controller verifyEmail — handleSignupOrganization wiring:', () => {
14+
let handleSignupOrganizationMock;
15+
let mockUserService;
16+
let mockResponses;
17+
18+
beforeEach(() => {
19+
jest.resetModules();
20+
21+
handleSignupOrganizationMock = jest.fn().mockResolvedValue({ _id: 'org_001' });
22+
23+
mockUserService = {
24+
create: jest.fn(),
25+
getBrut: jest.fn().mockResolvedValue({
26+
_id: 'user_001',
27+
id: 'user_001',
28+
email: 'user@example.com',
29+
emailVerificationToken: 'tok',
30+
emailVerificationExpires: Date.now() + 3600000,
31+
}),
32+
update: jest.fn().mockResolvedValue({}),
33+
remove: jest.fn(),
34+
search: jest.fn(),
35+
count: jest.fn().mockResolvedValue(0),
36+
};
37+
38+
mockResponses = {
39+
successCb: jest.fn(),
40+
errorCb: jest.fn(),
41+
};
42+
43+
jest.unstable_mockModule('../../../lib/services/logger.js', () => ({
44+
default: { warn: jest.fn(), error: jest.fn(), info: jest.fn() },
45+
}));
46+
jest.unstable_mockModule('../../../config/index.js', () => ({
47+
default: {
48+
sign: { up: true, in: true },
49+
jwt: { secret: 's', expiresIn: 3600 },
50+
cookie: { secure: false, sameSite: 'lax' },
51+
organizations: { enabled: true },
52+
app: { title: 'Test', contact: 'a@b.com' },
53+
},
54+
}));
55+
jest.unstable_mockModule('../../../modules/users/services/users.service.js', () => ({
56+
default: mockUserService,
57+
}));
58+
jest.unstable_mockModule('../../../modules/auth/services/auth.invitation.service.js', () => ({
59+
default: { findValid: jest.fn().mockResolvedValue(null), consume: jest.fn().mockResolvedValue(null) },
60+
}));
61+
jest.unstable_mockModule('../../../modules/auth/services/auth.signupCapacity.js', () => ({
62+
computeSignupCapacity: jest.fn().mockResolvedValue({ cap: null, remaining: null }),
63+
}));
64+
jest.unstable_mockModule('../../../modules/users/repositories/users.repository.js', () => ({
65+
default: { update: jest.fn() },
66+
}));
67+
jest.unstable_mockModule('../../../modules/organizations/services/organizations.service.js', () => ({
68+
default: { handleSignupOrganization: handleSignupOrganizationMock },
69+
}));
70+
jest.unstable_mockModule('../../../modules/organizations/services/organizations.crud.service.js', () => ({
71+
default: { autoSetCurrentOrganization: jest.fn() },
72+
}));
73+
jest.unstable_mockModule('../../../modules/organizations/services/organizations.membership.service.js', () => ({
74+
default: { findByUserAndOrganization: jest.fn(), listPendingByUser: jest.fn().mockResolvedValue([]) },
75+
}));
76+
jest.unstable_mockModule('../../../modules/users/models/users.schema.js', () => ({
77+
default: { User: {} },
78+
}));
79+
jest.unstable_mockModule('../../../lib/middlewares/model.js', () => ({
80+
default: { getResultFromZod: jest.fn(), checkError: jest.fn() },
81+
}));
82+
jest.unstable_mockModule('../../../lib/middlewares/policy.js', () => ({
83+
default: { defineAbilityFor: jest.fn().mockResolvedValue({}) },
84+
}));
85+
jest.unstable_mockModule('../../../lib/helpers/mailer/index.js', () => ({
86+
default: { isConfigured: jest.fn().mockReturnValue(false), sendMail: jest.fn() },
87+
}));
88+
jest.unstable_mockModule('../../../lib/helpers/responses.js', () => ({
89+
default: {
90+
success: jest.fn().mockReturnValue(mockResponses.successCb),
91+
error: jest.fn().mockReturnValue(mockResponses.errorCb),
92+
},
93+
}));
94+
jest.unstable_mockModule('../../../lib/helpers/errors.js', () => ({
95+
default: { getMessage: jest.fn().mockReturnValue('error') },
96+
}));
97+
jest.unstable_mockModule('../../../lib/helpers/AppError.js', () => ({
98+
default: class AppError extends Error {
99+
constructor(msg, opts) {
100+
super(msg);
101+
this.code = opts?.code;
102+
this.details = opts?.details;
103+
}
104+
},
105+
}));
106+
jest.unstable_mockModule('../../../lib/helpers/abilities.js', () => ({
107+
default: jest.fn().mockReturnValue([]),
108+
}));
109+
jest.unstable_mockModule('../../../lib/helpers/getBaseUrl.js', () => ({
110+
default: jest.fn().mockReturnValue('http://localhost:3000'),
111+
}));
112+
jest.unstable_mockModule('../../../lib/services/analytics.js', () => ({
113+
default: { identify: jest.fn(), capture: jest.fn(), groupIdentify: jest.fn() },
114+
}));
115+
});
116+
117+
test('calls handleSignupOrganization when email verification succeeds', async () => {
118+
const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js');
119+
120+
const req = { params: { token: 'tok' } };
121+
const res = {};
122+
123+
await AuthController.verifyEmail(req, res);
124+
125+
expect(handleSignupOrganizationMock).toHaveBeenCalledTimes(1);
126+
// Must be called with a user that has emailVerified=true (marked before the call)
127+
expect(handleSignupOrganizationMock).toHaveBeenCalledWith(
128+
expect.objectContaining({ emailVerified: true }),
129+
);
130+
});
131+
132+
test('does not crash if handleSignupOrganization throws (best-effort)', async () => {
133+
handleSignupOrganizationMock.mockRejectedValue(new Error('org boom'));
134+
135+
const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js');
136+
137+
const req = { params: { token: 'tok' } };
138+
const res = {};
139+
140+
// Email verification must NOT fail because of an org-setup error
141+
await AuthController.verifyEmail(req, res);
142+
143+
// The success response must still be sent
144+
expect(mockResponses.successCb).toHaveBeenCalledWith({ emailVerified: true });
145+
});
146+
});

0 commit comments

Comments
 (0)