Fix/1449 signup email verification#1575
Conversation
There was a problem hiding this comment.
Issues (Must Fix)
-
[sql/9_03_2026_05_08_add_caver_pending_mail.sql:1] The migration file is missing the required
\c grottoce;prefix (needed for Docker init compatibility) and lacksIF NOT EXISTSfor idempotency. Additionally, the PR body describes an index (CREATE INDEX t_caver_pending_mail_idx ON public.t_caver (pending_mail)) that is absent from the actual migration file. Since thechange-emailcontroller queriespendingMailfor conflict detection, this index is important for performance.\c grottoce; ALTER TABLE t_caver ADD COLUMN IF NOT EXISTS pending_mail VARCHAR(50) DEFAULT NULL; CREATE INDEX IF NOT EXISTS t_caver_pending_mail_idx ON t_caver (pending_mail); -
[test/customSQL.js] Per project conventions, any index added in
sql/must also be added to theINDEX_OPTIMIZATION_MIGRATIONblock intest/customSQL.jsso the test database has the same indexes as production. Thet_caver_pending_mail_idxindex is missing here.CREATE INDEX IF NOT EXISTS t_caver_pending_mail_idx ON t_caver (pending_mail); -
[api/controllers/v1/auth/verify-email.js:22-25] The conflict check at verification time only looks at
mailof other users but doesn't exclude the current user and doesn't check other users'pendingMail. This is inconsistent with the more thorough check inchange-email.js. If another user has the same email as theirpendingMail, the verification would succeed and create a state where two users have the same email (one asmail, one aspendingMail). Consider aligning with the pattern inchange-email.js:const alreadyInUse = await TCaver.findOne({ id: { '!=': caver.id }, or: [{ mail: caver.pendingMail }, { pendingMail: caver.pendingMail }], });
Suggestions (Should Consider)
-
[api/controllers/v1/auth/verify-email.js:44-48] There's a TOCTOU (time-of-check-time-of-use) race condition between the conflict check and the update. If two users verify at the exact same time with the same target email, both could pass the check and one update would violate the DB unique constraint on
mail. Consider wrapping the check + update in error handling that catches the unique constraint violation from PostgreSQL (error code23505) and returnsres.conflict()gracefully, rather than letting it bubble up as a 500. -
[api/controllers/v1/account/change-email.js:31-34] Same TOCTOU concern here — between the
findOneconflict check and theupdateOne, another user could claim the same email. The DB unique constraint onmailprotects the final commit inverify-email, but there's no unique constraint onpending_mail. Two users could end up with the samependingMailvalue if requests arrive concurrently. Consider adding a unique constraint onpending_mail(with a partial index excluding NULLs) or accepting this as a low-risk edge case since only one can ultimately verify. -
[api/controllers/v1/account/change-email.js:3-5] The controller uses
req.param('email')which reads from both query string and body. The Swagger spec documents it asin: query, but the tests send it in the request body. Consider documenting it as arequestBodyin the Swagger spec to match actual usage, or at minimum noting that both are accepted. -
[test/integration/4_routes/Account/change-email.test.js:155] The test assumes the fixture password is
'testtest'. If this changes, the test will break silently. Consider importing the password from a shared constant or adding a comment referencing where this value is defined in fixtures.
Nitpicks (Optional)
-
[api/controllers/v1/account/change-email.js:67] The final success path returns
res.ok()(which yields 204 since no data is passed). Consider returning a message likeres.ok({ message: 'Verification email sent.' })for consistency with the cancellation path that returns a message body. This would also make the Swagger 200 response more accurate. -
[assets/swaggerV1.yaml:3148-3149] There are two consecutive blank lines after the
'409'response. Minor formatting issue.
…g address validation
…w resending verification emails
e1e7dca to
ec75868
Compare
ClemRz
left a comment
There was a problem hiding this comment.
Issues (Must Fix)
-
[sql/9_03_2026_05_08_add_caver_pending_mail.sql:1] The migration file is missing the required
\c grottoce;prefix (needed for Docker init compatibility) and lacksIF NOT EXISTSfor idempotency. The PR body also describes an index (CREATE INDEX t_caver_pending_mail_idx) that is absent from the actual migration file. Since theupdate.jscontroller queriespendingMailfor conflict detection, this index matters for performance.\c grottoce; ALTER TABLE t_caver ADD COLUMN IF NOT EXISTS pending_mail VARCHAR(50) DEFAULT NULL; CREATE INDEX IF NOT EXISTS t_caver_pending_mail_idx ON t_caver (pending_mail); -
[test/customSQL.js] Per project conventions, any index added in
sql/must also be added to theINDEX_OPTIMIZATION_MIGRATIONblock intest/customSQL.jsso the test database has the same indexes. Thet_caver_pending_mail_idxindex is missing here. -
[api/controllers/v1/auth/verify-email.js:23-26] The conflict check at verification time only looks at
{ mail: caver.pendingMail }without excluding the current user and without checking other users'pendingMail. This is inconsistent with the more thorough check inupdate.js. Two specific problems:- If the user's own
mailhappens to equal theirpendingMail(shouldn't happen normally, but defensive coding), the query would find the user themselves and incorrectly return 409. - If another user has the same value as their
pendingMail, the verification would succeed, creating a state where two users have the same email (one asmail, one aspendingMail).
Consider aligning with the pattern in
update.js:const alreadyInUse = await TCaver.findOne({ id: { '!=': caver.id }, or: [{ mail: caver.pendingMail }, { pendingMail: caver.pendingMail }], }); - If the user's own
-
[api/controllers/v1/account/update.js:87-88] The controller fetches
cavera second time at line 87 (const caver = await TCaver.findOne({ id: req.token.id })) even thoughcurrentCaverwas already fetched at line 43 for the email flow. When the email field is present, this results in two identical DB queries. Consider reusingcurrentCaveror restructuring so the fetch only happens once. More importantly, if the email field is NOT present in the request body,currentCaveris never fetched, butcaveris — so the two fetches serve different code paths. This is fine functionally but the naming (currentCavervscaver) is confusing since they hold the same data.
Suggestions (Should Consider)
-
[api/controllers/v1/auth/verify-email.js:53-58] The
notifyEmailChangedcall is fire-and-forget but has no.catch()handler. IfnotifyEmailChangedrejects (despite the service's documentation saying it never throws), the unhandled rejection would crash the process in Node.js. Consider adding.catch()for safety:if (caver.pendingMail) { AccountNotificationService.notifyEmailChanged({ oldEmail: caver.mail, nickname: caver.nickname, languageId: caver.language, }).catch((err) => { sails.log.error('Failed to send email change notification:', err); }); return res.ok({ message: 'Email successfully changed.' }); } -
[test/integration/4_routes/Account/update-notifications.test.js:33-39] The
afterEachrestoresmailandpasswordbut doesn't restorependingMail,activationCode, ormailIsValid. Since the tests now set these fields (via the PATCH + verify-email flow), stale state could leak between tests if a test fails mid-way. Consider:afterEach(async () => { notifyEmailChangedStub.restore(); notifyPasswordChangedStub.restore(); await TCaver.updateOne({ id: 3 }).set({ mail: originalEmail, password: originalPasswordHash, pendingMail: null, activationCode: null, mailIsValid: true, }); }); -
[assets/swaggerV1.yaml] The
PATCH /accountendpoint now returns200with a message body when cancelling a pending email change, but the Swagger spec only documents204as the success response. Consider adding a200response to document this new behavior, or changing the cancellation response to204for consistency. -
[api/controllers/v1/auth/verify-email.js:44-48] There's a TOCTOU (time-of-check-time-of-use) race condition between the conflict check and the update. If two users verify at the exact same time targeting the same email, both could pass the check. The DB unique constraint on
mailprotects the final commit, but the resulting error would bubble up as a 500 rather than a clean 409. Consider wrapping the update in error handling that catches PostgreSQL unique constraint violations (error code23505) and returnsres.conflict()gracefully.
Nitpicks (Optional)
-
[assets/swaggerV1.yaml:3244-3245] There are two consecutive blank lines after the
'409'response definition. Minor formatting issue. -
[test/integration/4_routes/Account/change-email.test.js:155] The test hardcodes
'testtest'as the fixture password. Consider usingAuthTokenService.TEST_PASSWORD(which is already imported and used inupdate-notifications.test.js) for consistency and resilience to fixture changes.
…s checks and conflict handling
… test cleanup reliability
🤔 What
Implemented a secure email verification workflow for both account registration and email changes.
pendingMailattribute to theTCavermodel to store unverified email addresses.verify-emailcontroller to support both initial account activation and email change confirmation.change-emailcontroller to initiate a verification flow instead of immediate updates.🤷♂️ Why
Previously, changing an email address was immediate and unverified, which posed several risks:
This implementation ensures that the user's login identifier remains unchanged until the new email is verified, and ownership is proven through a secure token.
🔍 How
api/models/TCaver.jsto include apendingMailfield.pendingMail.mailIsValidis set tofalse, and anactivationCodeis generated and sent via email.mail.verify-emailcontroller promotespendingMailtomailand clears the pending state.mailandpendingMailfields across all users, ensuring that an email address cannot be claimed even as a pending change by another user.🧪 Testing
Comprehensive integration tests have been added in
test/integration/4_routes/Account/change-email.test.jscovering: