From e1380ac968d03feb7f94a28257cf40875bd1c749 Mon Sep 17 00:00:00 2001 From: Namraa Patel Date: Tue, 16 Jun 2026 04:48:05 +0530 Subject: [PATCH 1/2] fix(auth): prevent concurrent signup race conditions and lost updates --- package-lock.json | 33 ++++++++++ package.json | 1 + server.test.js | 105 ++++++++++++++++++++++++++++++ src/controllers/authController.js | 56 +++++++++++++--- src/data/users.json | 80 +++++++++++++++++++++++ 5 files changed, 267 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2d540966..b726d34c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", "multer": "^2.0.2", + "proper-lockfile": "^4.1.2", "rate-limit-redis": "^4.2.3", "react-hot-toast": "^2.6.0", "redis": "^4.7.1", @@ -1329,6 +1330,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -2057,6 +2064,17 @@ "node": ">= 0.8.0" } }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2239,6 +2257,15 @@ "node": ">=4" } }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -2490,6 +2517,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", diff --git a/package.json b/package.json index 49fce363..59c2b494 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", "multer": "^2.0.2", + "proper-lockfile": "^4.1.2", "rate-limit-redis": "^4.2.3", "react-hot-toast": "^2.6.0", "redis": "^4.7.1", diff --git a/server.test.js b/server.test.js index 2454893f..a6889df8 100644 --- a/server.test.js +++ b/server.test.js @@ -943,6 +943,111 @@ describe("route error responses", () => { ); }); + test("POST /api/auth/signup - single user registration succeeds", async () => { + const testEmail = `test-single-${Date.now()}@example.com`; + const res = await fetch(`${baseUrl}/api/auth/signup`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: testEmail, + password: "Test@1234", + }), + }); + assert.equal(res.status, 201); + const data = await res.json(); + assert.ok(data.token); + assert.equal(data.message, "Signup successful"); + }); + + test("POST /api/auth/signup - duplicate email is rejected", async () => { + const testEmail = `test-dup-${Date.now()}@example.com`; + + const firstRes = await fetch(`${baseUrl}/api/auth/signup`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: testEmail, + password: "Test@1234", + }), + }); + assert.equal(firstRes.status, 201); + + const secondRes = await fetch(`${baseUrl}/api/auth/signup`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: testEmail, + password: "Different@1234", + }), + }); + assert.equal(secondRes.status, 400); + const data = await secondRes.json(); + assert.equal(data.message, "User already exists"); + }); + + test("POST /api/auth/signup - concurrent registrations prevent lost updates", async () => { + const timestamp = Date.now(); + const emails = [ + `concurrent-1-${timestamp}@example.com`, + `concurrent-2-${timestamp}@example.com`, + `concurrent-3-${timestamp}@example.com`, + ]; + + const promises = emails.map((email) => + fetch(`${baseUrl}/api/auth/signup`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email, + password: "Test@1234", + }), + }) + ); + + const results = await Promise.all(promises); + + const successCount = results.filter((r) => r.status === 201).length; + assert.equal(successCount, 3, "All concurrent registrations should succeed"); + + const data = await results[0].json(); + assert.ok(data.token); + }); + + test("POST /api/auth/signup - validates password strength", async () => { + const testEmail = `test-weak-${Date.now()}@example.com`; + const res = await fetch(`${baseUrl}/api/auth/signup`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: testEmail, + password: "weak", + }), + }); + assert.equal(res.status, 400); + const data = await res.json(); + assert.match(data.message, /Password must contain/); + }); + + test("POST /api/auth/signup - rejects missing email or password", async () => { + const res1 = await fetch(`${baseUrl}/api/auth/signup`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + password: "Test@1234", + }), + }); + assert.equal(res1.status, 400); + + const res2 = await fetch(`${baseUrl}/api/auth/signup`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: "test@example.com", + }), + }); + assert.equal(res2.status, 400); + }); + test("successful upload response does not include a url field", async () => { const originalPostForm = axios.postForm; const originalPost = axios.post; diff --git a/src/controllers/authController.js b/src/controllers/authController.js index feaea014..6e256d35 100644 --- a/src/controllers/authController.js +++ b/src/controllers/authController.js @@ -1,7 +1,9 @@ const fs = require("fs"); +const fsPromises = require("fs/promises"); const path = require("path"); const bcrypt = require("bcryptjs"); const jwt = require("jsonwebtoken"); +const lockfile = require("proper-lockfile"); const { validatePassword } = require("../utils/passwordValidator"); @@ -21,11 +23,36 @@ const saveUsers = (users) => { fs.writeFileSync(usersFile, JSON.stringify(users, null, 2)); }; +const getUsersLocked = async () => { + const release = await lockfile.lock(usersFile, { retries: 3, stale: 5000 }); + try { + const data = fs.readFileSync(usersFile, "utf8"); + return { users: JSON.parse(data), release }; + } catch (error) { + await release(); + throw error; + } +}; + +const saveUsersLocked = async (users) => { + const tempFile = `${usersFile}.tmp`; + try { + fs.writeFileSync(tempFile, JSON.stringify(users, null, 2), "utf8"); + fs.renameSync(tempFile, usersFile); + } catch (error) { + try { + fs.unlinkSync(tempFile); + } catch {} + throw error; + } +}; + const normalizeEmail = (email) => { return email ? String(email).trim().toLowerCase() : email; }; exports.signup = async (req, res) => { + let release = null; try { let { email, password } = req.body; @@ -34,7 +61,7 @@ exports.signup = async (req, res) => { message: "Email and password are required", }); } - + email = normalizeEmail(email); const validation = validatePassword(password); @@ -45,29 +72,34 @@ exports.signup = async (req, res) => { }); } - const users = getUsers(); + const hashedPassword = await bcrypt.hash( + password, + 10 + ); + + const { users, release: lockRelease } = await getUsersLocked(); + release = lockRelease; const existingUser = users.find( (u) => u.email === email ); if (existingUser) { + await release(); + release = null; return res.status(400).json({ message: "User already exists", }); } - const hashedPassword = await bcrypt.hash( - password, - 10 - ); - users.push({ email, password: hashedPassword, }); - saveUsers(users); + await saveUsersLocked(users); + await release(); + release = null; const token = jwt.sign({ email }, SECRET, { expiresIn: "7d" }); @@ -76,6 +108,14 @@ exports.signup = async (req, res) => { message: "Signup successful", }); } catch (error) { + console.error("[signup] error:", error); + if (release) { + try { + await release(); + } catch (releaseError) { + console.error("[signup] lock release error:", releaseError); + } + } res.status(500).json({ message: "Server error", }); diff --git a/src/data/users.json b/src/data/users.json index adb2d281..8f909f65 100644 --- a/src/data/users.json +++ b/src/data/users.json @@ -62,5 +62,85 @@ { "email": "testuser2-1780859135237@example.com", "password": "$2b$10$URdT8Tm/Xg7Kwi/UnSiinOWzjsqFc8QCUgpY/TeURTj2T3R1wYW8S" + }, + { + "email": "test-single-1781563615023@example.com", + "password": "$2b$10$42pJVS8U5Ah.CvS964pVnOE62kHoi2.w3/FjO37utfCz/WfT1vgEC" + }, + { + "email": "test-dup-1781563615094@example.com", + "password": "$2b$10$z3GrGd1Q2IA2kKZiqzLu0ep/s9NQsj/8bKD8azwBHTlOFSJWCLA1e" + }, + { + "email": "test-single-1781565261948@example.com", + "password": "$2b$10$W282bOlfyfmnidvmfKKJ6.1zpeHK0OykUc9tVcGhrPgd8/pEx2qpC" + }, + { + "email": "test-dup-1781565262034@example.com", + "password": "$2b$10$jnnGjTMe25buqv6RI71E/uYHdlTLaOOFt7CeY/QO4HQNzs5ggb9vO" + }, + { + "email": "testuser2-1781565364215@example.com", + "password": "$2b$10$4DQy8/2f//RWrgmz6DPvwuBvb5jw41fVgMwQIOJ6oOmSRN71/5EJm" + }, + { + "email": "concurrent-2-1781565262116@example.com", + "password": "$2b$10$C7vu58Tv/NoKhPhxcM/B3O8zw99qzgc.IgxIZ2veFJDxr7uLJRbi." + }, + { + "email": "test-single-1781565393547@example.com", + "password": "$2b$10$NuadoenVQS0rDdFYzAkj3e/Hj9qI4nSjrCOoaanaI.e.oeVmlYiyq" + }, + { + "email": "test-dup-1781565393632@example.com", + "password": "$2b$10$UA06pmO8OBCf23BeWLd5yOn9zINJf7ZmSrFxIRDdw5EQUm6mjRjy6" + }, + { + "email": "concurrent-1-1781565393785@example.com", + "password": "$2b$10$H8nu4NppokEvA1b7VyQjEezcO8ho/lPr2gR0YGAvzD3ugEUYqEJeO" + }, + { + "email": "concurrent-2-1781565393785@example.com", + "password": "$2b$10$Ko9lzehPFEW4hm0qXYC4YeQU2C/UQeB8gIhcdxXE0xj5MlTXmSMLa" + }, + { + "email": "concurrent-3-1781565393785@example.com", + "password": "$2b$10$u1dX1TN151IKflsVe2.Lg.Q/rVjWbCHnLkmJDdVCpw292wxDX3koG" + }, + { + "email": "testuser-1781565397038@example.com", + "password": "$2b$10$HSNm0Fe5b0ewQ8xQ3n2Cm.7FuaFlcZchKVHturxsJBJZiwThYozoe" + }, + { + "email": "testuser2-1781565397165@example.com", + "password": "$2b$10$pRLwQ07u0EbEhsdGbltib.g//sUwza8Tnei6qyJOsmyvhowvOH98K" + }, + { + "email": "test-single-1781565423709@example.com", + "password": "$2b$10$JxHBML.Xc14DC5.kt52dCuoSVdIcn23zjNuwAGtiIs/TyhFsw6sfO" + }, + { + "email": "test-dup-1781565423853@example.com", + "password": "$2b$10$xA7ER4.dnsNmCJYEos8IgeqzB23sphzN.nLZ6fK16eoSvE9qHmWUi" + }, + { + "email": "concurrent-2-1781565424073@example.com", + "password": "$2b$10$x9wJgEHyTIkTmqpjZ.rdse7Q.MUFprzjMnFNymCrI1YBczgQyoOcG" + }, + { + "email": "concurrent-3-1781565424073@example.com", + "password": "$2b$10$GgcQsZda.P1jVsXx24lwQO.yb7RwnphvG9cwJwWof46FU1hTWy5wm" + }, + { + "email": "concurrent-1-1781565424073@example.com", + "password": "$2b$10$2337WO3.d2atXLEmnFc6HuE1BIZB.uMfGftrOC8TOL5/iZaU3WgoK" + }, + { + "email": "testuser-1781565427394@example.com", + "password": "$2b$10$7qJsFGA9AtRFxhKA5U7ySOI0h3V74FpHLJ7Y4qStf7qiaWPT77OcW" + }, + { + "email": "testuser2-1781565427513@example.com", + "password": "$2b$10$.QQzez4lYAASqV0TqxBH4.VaeEm9dIigNdKh2ZiSgmkOK15drMUFy" } ] \ No newline at end of file From 73de0dd8d1c9a09e452b071291018397af879d65 Mon Sep 17 00:00:00 2001 From: Namraa Patel Date: Tue, 16 Jun 2026 04:56:03 +0530 Subject: [PATCH 2/2] Fixing Backend test cases --- server.js | 1 - src/controllers/authController.js | 9 +++------ src/data/users.json | 28 ++++++++++++++++++++++++++++ 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/server.js b/server.js index cddb5b77..fc235f0f 100644 --- a/server.js +++ b/server.js @@ -19,7 +19,6 @@ const { summarizeSchema, summarizeCredentialSchema, sessionsLookupSchema, - knowledgeGapsSchema, MAX_QUESTION_LENGTH, } = require("./validators/schemas"); const { clientIpFromRequest } = require("./security/ip"); diff --git a/src/controllers/authController.js b/src/controllers/authController.js index 6e256d35..cc7a9a29 100644 --- a/src/controllers/authController.js +++ b/src/controllers/authController.js @@ -1,5 +1,4 @@ const fs = require("fs"); -const fsPromises = require("fs/promises"); const path = require("path"); const bcrypt = require("bcryptjs"); const jwt = require("jsonwebtoken"); @@ -19,10 +18,6 @@ const getUsers = () => { return JSON.parse(fs.readFileSync(usersFile)); }; -const saveUsers = (users) => { - fs.writeFileSync(usersFile, JSON.stringify(users, null, 2)); -}; - const getUsersLocked = async () => { const release = await lockfile.lock(usersFile, { retries: 3, stale: 5000 }); try { @@ -42,7 +37,9 @@ const saveUsersLocked = async (users) => { } catch (error) { try { fs.unlinkSync(tempFile); - } catch {} + } catch { + // Ignore cleanup errors - temp file might not exist + } throw error; } }; diff --git a/src/data/users.json b/src/data/users.json index 8f909f65..78df8ec6 100644 --- a/src/data/users.json +++ b/src/data/users.json @@ -142,5 +142,33 @@ { "email": "testuser2-1781565427513@example.com", "password": "$2b$10$.QQzez4lYAASqV0TqxBH4.VaeEm9dIigNdKh2ZiSgmkOK15drMUFy" + }, + { + "email": "test-single-1781565909297@example.com", + "password": "$2b$10$wa9CkFV6aRlk.PyBUgof4eV/N9tc0HkJ3lAz5kUV0q0wW37fz.cmy" + }, + { + "email": "test-dup-1781565909389@example.com", + "password": "$2b$10$iPlqobg3xD2wLmuQvbaibuakxHTC1iGIjpZOikKmTw4qedmE8lsba" + }, + { + "email": "concurrent-2-1781565909544@example.com", + "password": "$2b$10$WHB/j3OjDOOIs.P61sMCDORWu/iRzDWXV8dPX00wyQeLyvoFhUjzu" + }, + { + "email": "concurrent-1-1781565909544@example.com", + "password": "$2b$10$iKbPkTelic69o3VvosaB/.PK8o2v8.AuHTm5DoVvWW7CUJRAYlbvS" + }, + { + "email": "concurrent-3-1781565909544@example.com", + "password": "$2b$10$HczORwVfZuP5dBNfSBf.t.M/7LupAxG/aqGGd4RkOGjKdpm1Jz9N2" + }, + { + "email": "testuser-1781565912824@example.com", + "password": "$2b$10$ZLG4Qzhu6uK2Q5OdX6pWiu14U6AohVgbqB9P3omCQc/GSOMcSJ9tu" + }, + { + "email": "testuser2-1781565912945@example.com", + "password": "$2b$10$yxSGW7A43bEecmCxW7RqBuIjwuLt.jQZ.Xb0dYzLHbZX6yUU.Fno." } ] \ No newline at end of file