diff --git a/apps/public-api/src/__tests__/userAuth.social.test.js b/apps/public-api/src/__tests__/userAuth.social.test.js index 5b78db78..308c5559 100644 --- a/apps/public-api/src/__tests__/userAuth.social.test.js +++ b/apps/public-api/src/__tests__/userAuth.social.test.js @@ -79,6 +79,15 @@ jest.mock('@urbackend/common', () => { if (encrypted.encrypted === 'google') return 'google_secret'; return null; }), + AppError: class AppError extends Error { + constructor(statusCode, message, error = null) { + super(message); + this.statusCode = statusCode; + this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error'; + this.error = error || (statusCode >= 500 ? "Internal Server Error" : "Error"); + this.isOperational = true; + } + }, }; }); @@ -690,4 +699,251 @@ describe('public userAuth social auth', () => { expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('error=')); expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('deletion')); }); + + test('handleSocialAuthCallback handles ValidationError on create by saving with validateBeforeSave: false', async () => { + redis.get.mockResolvedValueOnce(JSON.stringify({ + projectId: 'project_1', + provider: 'github', + callbackUrl: 'http://localhost:5173/auth/callback', + })); + mockProjectFindByIdChain.lean.mockResolvedValueOnce(makeProject()); + + mockUsersModel.findOne + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); + + const mockSave = jest.fn().mockResolvedValue(undefined); + class MockModel { + constructor(payload) { + Object.assign(this, payload); + this._id = 'mocked_user_id'; + } + } + MockModel.prototype.save = mockSave; + MockModel.findOne = jest.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); + MockModel.create = jest.fn().mockImplementation(() => { + const error = new Error('users validation failed: customField: Path `customField` is required.'); + error.name = 'ValidationError'; + throw error; + }); + + const { getCompiledModel } = require('@urbackend/common'); + getCompiledModel.mockReturnValueOnce(MockModel); + + global.fetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ access_token: 'github_access_token' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 123, login: 'alice', avatar_url: '' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ([{ email: 'alice@example.com', verified: true, primary: true }]), + }); + + const req = makeReq({ params: { provider: 'github' }, query: { code: 'code_1', state: 'state_1' } }); + const res = makeRes(); + + await controller.handleSocialAuthCallback(req, res); + + expect(MockModel.create).toHaveBeenCalled(); + expect(mockSave).toHaveBeenCalledWith({ validateBeforeSave: false }); + expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('/auth/callback?')); + expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('userId=mocked_user_id')); + }); + + test('handleSocialAuthCallback handles ValidationError on create where save fails with 11000 duplicate key error', async () => { + redis.get.mockResolvedValueOnce(JSON.stringify({ + projectId: 'project_1', + provider: 'github', + callbackUrl: 'http://localhost:5173/auth/callback', + })); + mockProjectFindByIdChain.lean.mockResolvedValueOnce(makeProject()); + + mockUsersModel.findOne + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); + + const mockSave = jest.fn().mockRejectedValue({ code: 11000 }); + class MockModel { + constructor(payload) { + Object.assign(this, payload); + } + } + MockModel.prototype.save = mockSave; + MockModel.findOne = jest.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); + MockModel.create = jest.fn().mockImplementation(() => { + const error = new Error('users validation failed: customField: Path `customField` is required.'); + error.name = 'ValidationError'; + throw error; + }); + + const { getCompiledModel } = require('@urbackend/common'); + getCompiledModel.mockReturnValueOnce(MockModel); + + global.fetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ access_token: 'github_access_token' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 123, login: 'alice', avatar_url: '' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ([{ email: 'alice@example.com', verified: true, primary: true }]), + }); + + const req = makeReq({ params: { provider: 'github' }, query: { code: 'code_1', state: 'state_1' } }); + const res = makeRes(); + + await controller.handleSocialAuthCallback(req, res); + + expect(MockModel.create).toHaveBeenCalled(); + expect(mockSave).toHaveBeenCalledWith({ validateBeforeSave: false }); + expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('error=User+already+exists.')); + }); + + test('handleSocialAuthCallback handles ValidationError on create where save fails with generic database error', async () => { + redis.get.mockResolvedValueOnce(JSON.stringify({ + projectId: 'project_1', + provider: 'github', + callbackUrl: 'http://localhost:5173/auth/callback', + })); + mockProjectFindByIdChain.lean.mockResolvedValueOnce(makeProject()); + + mockUsersModel.findOne + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); + + const mockSave = jest.fn().mockRejectedValue(new Error('Connection timed out')); + class MockModel { + constructor(payload) { + Object.assign(this, payload); + } + } + MockModel.prototype.save = mockSave; + MockModel.findOne = jest.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); + MockModel.create = jest.fn().mockImplementation(() => { + const error = new Error('users validation failed: customField: Path `customField` is required.'); + error.name = 'ValidationError'; + throw error; + }); + + const { getCompiledModel } = require('@urbackend/common'); + getCompiledModel.mockReturnValueOnce(MockModel); + + global.fetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ access_token: 'github_access_token' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 123, login: 'alice', avatar_url: '' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ([{ email: 'alice@example.com', verified: true, primary: true }]), + }); + + const req = makeReq({ params: { provider: 'github' }, query: { code: 'code_1', state: 'state_1' } }); + const res = makeRes(); + + await controller.handleSocialAuthCallback(req, res); + + expect(MockModel.create).toHaveBeenCalled(); + expect(mockSave).toHaveBeenCalledWith({ validateBeforeSave: false }); + expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('error=Failed+to+complete+social+signup.')); + }); + + test('handleSocialAuthCallback handles direct duplicate key error on Model.create', async () => { + redis.get.mockResolvedValueOnce(JSON.stringify({ + projectId: 'project_1', + provider: 'github', + callbackUrl: 'http://localhost:5173/auth/callback', + })); + mockProjectFindByIdChain.lean.mockResolvedValueOnce(makeProject()); + + mockUsersModel.findOne + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); + + mockUsersModel.create.mockImplementationOnce(() => { + const error = new Error('E11000 duplicate key error collection'); + error.code = 11000; + throw error; + }); + + global.fetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ access_token: 'github_access_token' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 123, login: 'alice', avatar_url: '' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ([{ email: 'alice@example.com', verified: true, primary: true }]), + }); + + const req = makeReq({ params: { provider: 'github' }, query: { code: 'code_1', state: 'state_1' } }); + const res = makeRes(); + + await controller.handleSocialAuthCallback(req, res); + + expect(mockUsersModel.create).toHaveBeenCalled(); + expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('error=User+already+exists.')); + }); + + test('handleSocialAuthCallback handles direct generic database error on Model.create', async () => { + redis.get.mockResolvedValueOnce(JSON.stringify({ + projectId: 'project_1', + provider: 'github', + callbackUrl: 'http://localhost:5173/auth/callback', + })); + mockProjectFindByIdChain.lean.mockResolvedValueOnce(makeProject()); + + mockUsersModel.findOne + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); + + mockUsersModel.create.mockImplementationOnce(() => { + throw new Error('Database connection lost'); + }); + + global.fetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ access_token: 'github_access_token' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 123, login: 'alice', avatar_url: '' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ([{ email: 'alice@example.com', verified: true, primary: true }]), + }); + + const req = makeReq({ params: { provider: 'github' }, query: { code: 'code_1', state: 'state_1' } }); + const res = makeRes(); + + await controller.handleSocialAuthCallback(req, res); + + expect(mockUsersModel.create).toHaveBeenCalled(); + expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('error=Failed+to+complete+social+signup.')); + }); }); diff --git a/apps/public-api/src/controllers/userAuth.controller.js b/apps/public-api/src/controllers/userAuth.controller.js index da94a698..b735d4e3 100644 --- a/apps/public-api/src/controllers/userAuth.controller.js +++ b/apps/public-api/src/controllers/userAuth.controller.js @@ -580,7 +580,31 @@ const findOrCreateSocialUser = async ({ project, usersColConfig, Model, provider newUserPayload.avatarUrl = profile.avatarUrl; } - user = await Model.create(newUserPayload); + try { + user = await Model.create(newUserPayload); + } catch (err) { + if (err.name === "ValidationError") { + try { + if (typeof Model === "function") { + const doc = new Model(newUserPayload); + await doc.save({ validateBeforeSave: false }); + user = doc; + } else { + user = await Model.create(newUserPayload); + } + } catch (saveErr) { + if (saveErr.code === 11000) { + throw new AppError(409, "User already exists."); + } + throw new AppError(500, "Failed to complete social signup."); + } + } else { + if (err.code === 11000) { + throw new AppError(409, "User already exists."); + } + throw new AppError(500, "Failed to complete social signup."); + } + } return { user, isNewUser: true, linkedByEmail: false }; }; @@ -1836,3 +1860,10 @@ module.exports.updateAdminUser = async (req, res) => { res.status(500).json({ error: err.message }); } }; + + + + + + + diff --git a/package-lock.json b/package-lock.json index 76c104f7..8df804eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2909,7 +2909,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2943,7 +2942,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2977,7 +2975,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3043,7 +3040,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6876,7 +6872,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6893,7 +6888,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6910,7 +6904,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6927,7 +6920,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6944,7 +6936,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6961,7 +6952,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6978,7 +6968,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6995,7 +6984,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7012,7 +7000,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7029,7 +7016,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7046,7 +7032,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7063,7 +7048,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7080,7 +7064,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7097,7 +7080,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7114,7 +7096,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7131,7 +7112,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7148,7 +7128,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7165,7 +7144,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7182,7 +7160,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7199,7 +7176,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7216,7 +7192,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7233,7 +7208,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [