From d4a849b7c9995a90730bb570fbb966cbea86f480 Mon Sep 17 00:00:00 2001 From: VIDYANKSHINI Date: Mon, 8 Jun 2026 19:59:55 +0530 Subject: [PATCH 1/3] fix(auth): persist provider connection state for Google OAuth --- apps/backend/src/__tests__/auth.test.ts | 96 +++++++++++++++++++++++++ apps/backend/src/routes/auth.ts | 11 +++ 2 files changed, 107 insertions(+) create mode 100644 apps/backend/src/__tests__/auth.test.ts diff --git a/apps/backend/src/__tests__/auth.test.ts b/apps/backend/src/__tests__/auth.test.ts new file mode 100644 index 00000000..7819810d --- /dev/null +++ b/apps/backend/src/__tests__/auth.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Fastify from 'fastify'; +import jwt from '@fastify/jwt'; +import cookie from '@fastify/cookie'; +import { authRoutes } from '../routes/auth.js'; +import type { PrismaClient } from '@prisma/client'; + +process.env.NODE_ENV = 'test'; +process.env.PUBLIC_APP_URL = 'http://localhost:3000'; +process.env.BACKEND_URL = 'http://localhost:3001'; +process.env.MOBILE_REDIRECT_URI = 'devcard://auth'; +process.env.GOOGLE_CLIENT_ID = 'test-google-id'; +process.env.GOOGLE_CLIENT_SECRET = 'test-google-secret'; +process.env.ENCRYPTION_KEY = '12345678901234567890123456789012'; + +const mockPrisma = { + user: { + upsert: vi.fn(), + findUnique: vi.fn(), + }, + oAuthToken: { + upsert: vi.fn(), + }, +}; + +global.fetch = vi.fn(); + +async function buildApp() { + const app = Fastify(); + await app.register(jwt, { secret: 'test-secret' }); + await app.register(cookie); + app.decorate('prisma', mockPrisma as unknown as PrismaClient); + + app.register(authRoutes, { prefix: '/auth' }); + await app.ready(); + return app; +} + +describe('GET /auth/google/callback', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('persists Google OAuthToken upon successful login', async () => { + const mockUser = { id: 'user-123', username: 'testuser' }; + mockPrisma.user.upsert.mockResolvedValue(mockUser); + mockPrisma.oAuthToken.upsert.mockResolvedValue({}); + + (global.fetch as any) + .mockResolvedValueOnce({ + json: vi.fn().mockResolvedValue({ + access_token: 'fake-google-token', + scope: 'openid email profile', + }), + }) // tokenRes + .mockResolvedValueOnce({ + json: vi.fn().mockResolvedValue({ + id: 'google-id-123', + email: 'test@gmail.com', + name: 'Test User', + picture: 'https://avatar.com/test', + }), + }); // userRes + + const app = await buildApp(); + + const res = await app.inject({ + method: 'GET', + url: '/auth/google/callback?code=fake-code&state=fake-state', + cookies: { + oauth_state: 'fake-state', + }, + }); + + expect(res.statusCode).toBe(302); + expect(res.headers.location).toBe('http://localhost:3000/dashboard'); + + // Verify user upsert + expect(mockPrisma.user.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { provider_providerId: { provider: 'google', providerId: 'google-id-123' } }, + }) + ); + + // Verify oAuthToken upsert + expect(mockPrisma.oAuthToken.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId_platform: { userId: 'user-123', platform: 'google' } }, + create: expect.objectContaining({ + platform: 'google', + scopes: 'openid email profile', + }), + }) + ); + }); +}); diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index c14949e1..40f5bc94 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -230,6 +230,17 @@ export async function authRoutes(app: FastifyInstance) { }, }); + try { + const encryptedToken = encrypt(tokenData.access_token); + await app.prisma.oAuthToken.upsert({ + where: { userId_platform: { userId: user.id, platform: 'google' } }, + update: { accessToken: encryptedToken, scopes: tokenData.scope || 'openid email profile' }, + create: { userId: user.id, platform: 'google', accessToken: encryptedToken, scopes: tokenData.scope || 'openid email profile' }, + }); + } catch (err) { + app.log.error({ err, userId: user.id }, 'Failed to persist Google OAuth token — authentication proceeds'); + } + const token = app.jwt.sign({ id: user.id, username: user.username }, { expiresIn: '30d' }); if (request.query.state?.startsWith('mobile_')) { From 3f6612ff24e9d36fe8b410fcbf3bc86eb95d2ad8 Mon Sep 17 00:00:00 2001 From: VIDYANKSHINI Date: Mon, 8 Jun 2026 20:14:00 +0530 Subject: [PATCH 2/3] chore(auth): address PR review feedback and fix lint --- apps/backend/src/__tests__/auth.test.ts | 79 +++++++++++++++++++++---- apps/backend/src/routes/auth.ts | 12 ++-- 2 files changed, 74 insertions(+), 17 deletions(-) diff --git a/apps/backend/src/__tests__/auth.test.ts b/apps/backend/src/__tests__/auth.test.ts index 7819810d..e6f8a561 100644 --- a/apps/backend/src/__tests__/auth.test.ts +++ b/apps/backend/src/__tests__/auth.test.ts @@ -1,17 +1,11 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import Fastify from 'fastify'; -import jwt from '@fastify/jwt'; import cookie from '@fastify/cookie'; +import jwt from '@fastify/jwt'; +import Fastify from 'fastify'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + import { authRoutes } from '../routes/auth.js'; -import type { PrismaClient } from '@prisma/client'; -process.env.NODE_ENV = 'test'; -process.env.PUBLIC_APP_URL = 'http://localhost:3000'; -process.env.BACKEND_URL = 'http://localhost:3001'; -process.env.MOBILE_REDIRECT_URI = 'devcard://auth'; -process.env.GOOGLE_CLIENT_ID = 'test-google-id'; -process.env.GOOGLE_CLIENT_SECRET = 'test-google-secret'; -process.env.ENCRYPTION_KEY = '12345678901234567890123456789012'; +import type { PrismaClient } from '@prisma/client'; const mockPrisma = { user: { @@ -23,7 +17,8 @@ const mockPrisma = { }, }; -global.fetch = vi.fn(); +const originalEnv = process.env; +const originalFetch = global.fetch; async function buildApp() { const app = Fastify(); @@ -39,6 +34,21 @@ async function buildApp() { describe('GET /auth/google/callback', () => { beforeEach(() => { vi.clearAllMocks(); + process.env = { ...originalEnv }; + process.env.NODE_ENV = 'test'; + process.env.PUBLIC_APP_URL = 'http://localhost:3000'; + process.env.BACKEND_URL = 'http://localhost:3001'; + process.env.MOBILE_REDIRECT_URI = 'devcard://auth'; + process.env.GOOGLE_CLIENT_ID = 'test-google-id'; + process.env.GOOGLE_CLIENT_SECRET = 'test-google-secret'; + process.env.ENCRYPTION_KEY = '12345678901234567890123456789012'; + + global.fetch = vi.fn(); + }); + + afterEach(() => { + process.env = originalEnv; + global.fetch = originalFetch; }); it('persists Google OAuthToken upon successful login', async () => { @@ -93,4 +103,49 @@ describe('GET /auth/google/callback', () => { }) ); }); + + it('allows authentication to succeed even if token persistence fails', async () => { + const mockUser = { id: 'user-123', username: 'testuser' }; + mockPrisma.user.upsert.mockResolvedValue(mockUser); + + // Simulate upsert failure + mockPrisma.oAuthToken.upsert.mockRejectedValue(new Error('DB Error')); + + (global.fetch as any) + .mockResolvedValueOnce({ + json: vi.fn().mockResolvedValue({ + access_token: 'fake-google-token', + }), + }) + .mockResolvedValueOnce({ + json: vi.fn().mockResolvedValue({ + id: 'google-id-123', + email: 'test@gmail.com', + name: 'Test User', + }), + }); + + const app = await buildApp(); + + // Spy on app.log.error to ensure it gets logged + const logSpy = vi.spyOn(app.log, 'error'); + + const res = await app.inject({ + method: 'GET', + url: '/auth/google/callback?code=fake-code&state=fake-state', + cookies: { + oauth_state: 'fake-state', + }, + }); + + // Should still succeed and redirect to dashboard + expect(res.statusCode).toBe(302); + expect(res.headers.location).toBe('http://localhost:3000/dashboard'); + + // Verify the error was logged + expect(logSpy).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-123' }), + 'Failed to persist Google OAuth token — authentication proceeds' + ); + }); }); diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index 40f5bc94..89aceffb 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -1,6 +1,7 @@ -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import { encrypt } from '../utils/encryption.js'; import { buildOAuthState, getMobileRedirectUri } from '../services/authService.js'; +import { encrypt } from '../utils/encryption.js'; + +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize'; const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'; @@ -232,10 +233,11 @@ export async function authRoutes(app: FastifyInstance) { try { const encryptedToken = encrypt(tokenData.access_token); + const scopes = tokenData.scope || 'openid email profile'; await app.prisma.oAuthToken.upsert({ where: { userId_platform: { userId: user.id, platform: 'google' } }, - update: { accessToken: encryptedToken, scopes: tokenData.scope || 'openid email profile' }, - create: { userId: user.id, platform: 'google', accessToken: encryptedToken, scopes: tokenData.scope || 'openid email profile' }, + update: { accessToken: encryptedToken, scopes }, + create: { userId: user.id, platform: 'google', accessToken: encryptedToken, scopes }, }); } catch (err) { app.log.error({ err, userId: user.id }, 'Failed to persist Google OAuth token — authentication proceeds'); @@ -268,7 +270,7 @@ export async function authRoutes(app: FastifyInstance) { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } + try { await request.jwtVerify() } catch (_e) { reply.status(401).send({ error: 'Unauthorized' }) } }] }, async (request: FastifyRequest, reply: FastifyReply) => { const userId = (request.user as any).id; const user = await app.prisma.user.findUnique({ From 36a9efd79a8c2b8b89b974d0577ebf10edb632b7 Mon Sep 17 00:00:00 2001 From: VIDYANKSHINI Date: Tue, 9 Jun 2026 12:50:36 +0530 Subject: [PATCH 3/3] chore(auth): address further Copilot review feedback --- apps/backend/src/__tests__/auth.test.ts | 12 ++++++++---- apps/backend/src/routes/auth.ts | 5 +++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/backend/src/__tests__/auth.test.ts b/apps/backend/src/__tests__/auth.test.ts index f93380a8..81006eea 100644 --- a/apps/backend/src/__tests__/auth.test.ts +++ b/apps/backend/src/__tests__/auth.test.ts @@ -43,7 +43,7 @@ describe('GET /auth/google/callback', () => { process.env.GOOGLE_CLIENT_ID = 'test-google-id'; process.env.GOOGLE_CLIENT_SECRET = 'test-google-secret'; process.env.ENCRYPTION_KEY = '12345678901234567890123456789012'; - + global.fetch = vi.fn(); }); @@ -103,12 +103,14 @@ describe('GET /auth/google/callback', () => { }), }) ); + + await app.close(); }); it('allows authentication to succeed even if token persistence fails', async () => { const mockUser = { id: 'user-123', username: 'testuser' }; mockPrisma.user.upsert.mockResolvedValue(mockUser); - + // Simulate upsert failure mockPrisma.oAuthToken.upsert.mockRejectedValue(new Error('DB Error')); @@ -127,7 +129,7 @@ describe('GET /auth/google/callback', () => { }); const app = await buildApp(); - + // Spy on app.log.error to ensure it gets logged const logSpy = vi.spyOn(app.log, 'error'); @@ -142,11 +144,13 @@ describe('GET /auth/google/callback', () => { // Should still succeed and redirect to dashboard expect(res.statusCode).toBe(302); expect(res.headers.location).toBe('http://localhost:3000/dashboard'); - + // Verify the error was logged expect(logSpy).toHaveBeenCalledWith( expect.objectContaining({ userId: 'user-123' }), 'Failed to persist Google OAuth token — authentication proceeds' ); + + await app.close(); }); }); diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index f79592d2..89387ea5 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -235,10 +235,11 @@ export async function authRoutes(app: FastifyInstance): Promise { try { const encryptedToken = encrypt(tokenData.access_token); const scopes = tokenData.scope || 'openid email profile'; + const platform = 'google' as const; await app.prisma.oAuthToken.upsert({ - where: { userId_platform: { userId: user.id, platform: 'google' } }, + where: { userId_platform: { userId: user.id, platform } }, update: { accessToken: encryptedToken, scopes }, - create: { userId: user.id, platform: 'google', accessToken: encryptedToken, scopes }, + create: { userId: user.id, platform, accessToken: encryptedToken, scopes }, }); } catch (err) { app.log.error({ err, userId: user.id }, 'Failed to persist Google OAuth token — authentication proceeds');