Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ name: coverit
services:
api:
image: ghcr.io/coveritlabs/coverit-api:${API_TAG:-dev}
pull_policy: always
container_name: coverit-api
ports:
- "3000:3000"
Expand All @@ -20,6 +21,13 @@ services:
- JWT_REFRESH_EXPIRY_SECONDS=${JWT_REFRESH_EXPIRY_SECONDS:-604800}
- RESET_TOKEN_TTL_SECONDS=${RESET_TOKEN_TTL_SECONDS:-900}
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:5173}
- OAUTH_FRONTEND_URL=${OAUTH_FRONTEND_URL:-http://localhost:5173}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
- GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL:-http://localhost:3000/api/v1/auth/oauth/google/callback}
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-}
- GITHUB_CALLBACK_URL=${GITHUB_CALLBACK_URL:-http://localhost:3000/api/v1/auth/oauth/github/callback}
restart: unless-stopped
depends_on:
db:
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"http-status-codes": "^2.3.0",
"helmet": "^7.1.0",
"ioredis": "^5.10.0",
"jsonwebtoken": "^9.0.3",
Expand Down
3 changes: 2 additions & 1 deletion prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ datasource db {
model User {
id String @id @default(uuid()) @db.Uuid
email String @unique
password String
password String?
name String
provider String @default("local")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

Expand Down
127 changes: 127 additions & 0 deletions src/__tests__/controllers/auth.controller.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// Copyright (c) 2026 CoverIt Labs. All Rights Reserved.
// Proprietary and confidential. Unauthorized use is strictly prohibited.
// See LICENSE file in the project root for full license information.

import express from 'express';
import request from 'supertest';

jest.mock('@services/auth.service');
jest.mock('@services/oauth.service');
jest.mock('@config/env', () => ({ env: { OAUTH_FRONTEND_URL: 'https://app.example.com' } }));

import * as authService from '@services/auth.service';
import * as oauthService from '@services/oauth.service';
import { env } from '@config/env';
import {
signup,
login,
refresh,
logout,
forgotPassword,
resetPassword,
oauthRedirect,
oauthCallback,
} from '@api/controllers/auth.controller';
import { AUTH_MESSAGES } from '@constants/messages';

function makeApp() {
const a = express();
a.use(express.json());
a.post('/signup', signup);
a.post('/login', login);
a.post('/refresh', refresh);
a.post('/logout', logout);
a.post('/forgot', forgotPassword);
a.post('/reset', resetPassword);
a.get('/oauth/:provider/redirect', oauthRedirect);
a.get('/oauth/:provider/callback', oauthCallback);
return a;
}

describe('auth.controller', () => {
let app: express.Application;

beforeEach(() => {
jest.resetAllMocks();
app = makeApp();
});

test('signup returns 201 on success', async () => {
(authService.signup as jest.Mock).mockResolvedValue({ message: 'ok' });
const res = await request(app).post('/signup').send({ email: 'a@b.com', password: 'p', name: 'n' });
expect(res.status).toBe(201);
expect(res.body).toEqual({ message: 'ok' });
});

test('login returns 200 on success', async () => {
(authService.login as jest.Mock).mockResolvedValue({ tokens: {}, user: {} });
const res = await request(app).post('/login').send({ email: 'a@b.com', password: 'p' });
expect(res.status).toBe(200);
});

test('refresh returns 200 on success', async () => {
(authService.refresh as jest.Mock).mockResolvedValue({ tokens: {} });
const res = await request(app).post('/refresh').send({ refreshToken: 'rt' });
expect(res.status).toBe(200);
});

test('logout with refreshToken calls service', async () => {
(authService.logout as jest.Mock).mockResolvedValue({ message: 'logged out' });
const res = await request(app).post('/logout').send({ refreshToken: 'x' });
expect(res.status).toBe(200);
expect(authService.logout).toHaveBeenCalledWith('x');
});

test('logout without refreshToken returns success message', async () => {
const res = await request(app).post('/logout').send({});
expect(res.status).toBe(200);
expect(res.body.message).toBe(AUTH_MESSAGES.LOGOUT_SUCCESS);
});

test('forgotPassword triggers service and returns 200', async () => {
(authService.forgotPassword as jest.Mock).mockResolvedValue(undefined);
const res = await request(app).post('/forgot').send({ email: 'a@b.com' });
expect(res.status).toBe(200);
expect(authService.forgotPassword).toHaveBeenCalledWith({ email: 'a@b.com' });
});

test('resetPassword returns 200 on success', async () => {
(authService.resetPassword as jest.Mock).mockResolvedValue({ message: 'ok' });
const res = await request(app).post('/reset').send({ token: 't', newPassword: 'np' });
expect(res.status).toBe(200);
});

test('oauthRedirect returns 400 for unsupported provider', async () => {
const res = await request(app).get('/oauth/unknown/redirect');
expect(res.status).toBe(400);
expect(res.body.message).toBe(AUTH_MESSAGES.UNSUPPORTED_OAUTH_PROVIDER);
});

test('oauthRedirect redirects to provider authorization url', async () => {
(oauthService.getAuthorizationUrl as jest.Mock).mockReturnValue('https://auth.example');
const res = await request(app).get('/oauth/google/redirect');
expect(res.status).toBe(302);
expect(res.header.location).toBe('https://auth.example');
});

test('oauthCallback redirects to login when code missing', async () => {
const res = await request(app).get('/oauth/google/callback');
expect(res.status).toBe(302);
expect(res.header.location).toBe(`${env.OAUTH_FRONTEND_URL}/login?error=${encodeURIComponent(AUTH_MESSAGES.OAUTH_CODE_MISSING)}`);
});

test('oauthCallback redirects back to frontend on success', async () => {
(oauthService.exchangeCodeForProfile as jest.Mock).mockResolvedValue({ email: 'a@b.com', name: 'A' });
(authService.oauthLogin as jest.Mock).mockResolvedValue({ tokens: { accessToken: 'a', refreshToken: 'r' }, user: { id: 'u', email: 'a@b.com', name: 'A' } });
const res = await request(app).get('/oauth/google/callback').query({ code: 'c' });
expect(res.status).toBe(302);
expect(res.header.location).toContain(`${env.OAUTH_FRONTEND_URL}/oauth/callback?`);
});

test('oauthCallback redirects to login with error when exchange fails', async () => {
(oauthService.exchangeCodeForProfile as jest.Mock).mockRejectedValue(new Error('fail'));
const res = await request(app).get('/oauth/google/callback').query({ code: 'c' });
expect(res.status).toBe(302);
expect(res.header.location).toContain('/login?error=');
});
});
11 changes: 11 additions & 0 deletions src/__tests__/middlewares/requireAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,16 @@ describe('requireAuth middleware', () => {
});

describe('errorHandler middleware', () => {
let consoleErrorSpy: jest.SpyInstance;

beforeEach(() => {
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
});

afterEach(() => {
consoleErrorSpy.mockRestore();
});

it('should return 500 for unknown errors', async () => {
const errApp = express();
errApp.use(express.json());
Expand All @@ -114,5 +124,6 @@ describe('errorHandler middleware', () => {

expect(res.status).toBe(500);
expect(res.body.message).toBe('Internal server error');
expect(consoleErrorSpy).toHaveBeenCalledWith('Unhandled error:', expect.any(Error));
});
});
40 changes: 40 additions & 0 deletions src/__tests__/services/auth.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,3 +270,43 @@ describe('verifyAccessToken', () => {
expect(() => verifyAccessToken(token)).toThrow('Malformed token');
});
});

describe('authService.oauthLogin', () => {
it('should create a new user when none exists and return tokens', async () => {
mockPrisma.user.findUnique.mockResolvedValue(null);
mockPrisma.user.create.mockResolvedValue({
id: 'uuid-2',
email: 'oauth@user.com',
name: 'OAuth User',
provider: 'google',
createdAt: new Date(),
updatedAt: new Date(),
});
mockRedis.set.mockResolvedValue('OK');

const res = await authService.oauthLogin('google', { email: 'oauth@user.com', name: 'OAuth User' });

expect(mockPrisma.user.create).toHaveBeenCalled();
expect(res.user).toEqual({ id: 'uuid-2', email: 'oauth@user.com', name: 'OAuth User' });
expect(res.tokens?.accessToken).toBeDefined();
expect(res.tokens?.refreshToken).toBeDefined();
expect(mockRedis.set).toHaveBeenCalledWith(expect.stringContaining('refresh:uuid-2:'), '1', 'EX', 604800);
});

it('should use existing user when found and return tokens', async () => {
mockPrisma.user.findUnique.mockResolvedValue({
id: 'uuid-3',
email: 'exist@user.com',
name: 'Existing',
});
mockRedis.set.mockResolvedValue('OK');

const res = await authService.oauthLogin('google', { email: 'exist@user.com', name: 'Existing' });

expect(mockPrisma.user.create).not.toHaveBeenCalled();
expect(res.user).toEqual({ id: 'uuid-3', email: 'exist@user.com', name: 'Existing' });
expect(res.tokens?.accessToken).toBeDefined();
expect(res.tokens?.refreshToken).toBeDefined();
expect(mockRedis.set).toHaveBeenCalledWith(expect.stringContaining('refresh:uuid-3:'), '1', 'EX', 604800);
});
});
113 changes: 113 additions & 0 deletions src/__tests__/services/oauth.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Copyright (c) 2026 CoverIt Labs. All Rights Reserved.
// Proprietary and confidential. Unauthorized use is strictly prohibited.
// See LICENSE file in the project root for full license information.

import { BadRequestError } from '@utils/errors';
import { AUTH_MESSAGES } from '@constants/messages';

jest.mock('@config/env', () => ({
env: {
NODE_ENV: 'test',
GOOGLE_CLIENT_ID: 'google-id',
GOOGLE_CLIENT_SECRET: 'google-secret',
GOOGLE_CALLBACK_URL: 'https://app.example.com/oauth/google/callback',
GITHUB_CLIENT_ID: 'github-id',
GITHUB_CLIENT_SECRET: 'github-secret',
GITHUB_CALLBACK_URL: 'https://app.example.com/oauth/github/callback',
},
}));

import { getAuthorizationUrl, exchangeCodeForProfile } from '@services/oauth.service';

describe('oauth.service', () => {
let fetchSpy: jest.SpyInstance;

afterEach(() => {
if (fetchSpy) fetchSpy.mockRestore();
});

test('getAuthorizationUrl builds google URL and includes access_type', () => {
const url = getAuthorizationUrl('google', 'state123');
expect(url).toContain('accounts.google.com');
expect(url).toContain('client_id=google-id');
expect(url).toContain('access_type=offline');
expect(url).toContain('prompt=consent');
expect(url).toContain('state=state123');
});

test('getAuthorizationUrl throws when provider not configured', () => {
jest.resetModules();
jest.doMock('@config/env', () => ({ env: { GOOGLE_CLIENT_ID: '', GOOGLE_CLIENT_SECRET: '' } }));
const svc = require('@services/oauth.service');
expect(() => svc.getAuthorizationUrl('google', 's')).toThrow(AUTH_MESSAGES.OAUTH_PROVIDER_NOT_CONFIGURED);
jest.resetModules();
});

test('exchangeCodeForProfile throws when token endpoint returns non-ok', async () => {
fetchSpy = jest.spyOn(global as any, 'fetch').mockImplementation(async () => ({ ok: false }));
await expect(exchangeCodeForProfile('google', 'code')).rejects.toThrow(BadRequestError);
});

test('exchangeCodeForProfile throws when token response contains error', async () => {
fetchSpy = jest.spyOn(global as any, 'fetch').mockImplementationOnce(async () => ({
ok: true,
json: async () => ({ error: 'bad' }),
}));

await expect(exchangeCodeForProfile('google', 'code')).rejects.toThrow(BadRequestError);
});

test('exchangeCodeForProfile returns google profile on success', async () => {
fetchSpy = jest.spyOn(global as any, 'fetch')
.mockImplementationOnce(async () => ({ ok: true, json: async () => ({ access_token: 'tok' }) }))
.mockImplementationOnce(async () => ({ ok: true, json: async () => ({ email: 'a@example.com', name: 'Alice' }) }));

const profile = await exchangeCodeForProfile('google', 'code');
expect(profile.email).toBe('a@example.com');
expect(profile.name).toBe('Alice');
});

test('exchangeCodeForProfile returns github profile using emails endpoint when necessary', async () => {
fetchSpy = jest.spyOn(global as any, 'fetch')
.mockImplementationOnce(async () => ({ ok: true, json: async () => ({ access_token: 'gh-token' }) }))
.mockImplementationOnce(async () => ({ ok: true, json: async () => ({ email: null, login: 'octocat', name: null }) }))
.mockImplementationOnce(async () => ({ ok: true, json: async () => ([{ email: 'gh@example.com', primary: true, verified: true }]) }));

const profile = await exchangeCodeForProfile('github', 'code');
expect(profile.email).toBe('gh@example.com');
expect(profile.name).toBe('octocat');
});

test('exchangeCodeForProfile throws when google userinfo endpoint fails', async () => {
fetchSpy = jest.spyOn(global as any, 'fetch')
.mockImplementationOnce(async () => ({ ok: true, json: async () => ({ access_token: 'tok' }) }))
.mockImplementationOnce(async () => ({ ok: false }));

await expect(exchangeCodeForProfile('google', 'code')).rejects.toThrow(BadRequestError);
});

test('exchangeCodeForProfile throws when google userinfo missing email', async () => {
fetchSpy = jest.spyOn(global as any, 'fetch')
.mockImplementationOnce(async () => ({ ok: true, json: async () => ({ access_token: 'tok' }) }))
.mockImplementationOnce(async () => ({ ok: true, json: async () => ({ name: 'NoEmail' }) }));

await expect(exchangeCodeForProfile('google', 'code')).rejects.toThrow(BadRequestError);
});

test('exchangeCodeForProfile throws when github user endpoint fails', async () => {
fetchSpy = jest.spyOn(global as any, 'fetch')
.mockImplementationOnce(async () => ({ ok: true, json: async () => ({ access_token: 'gh-token' }) }))
.mockImplementationOnce(async () => ({ ok: false }));

await expect(exchangeCodeForProfile('github', 'code')).rejects.toThrow(BadRequestError);
});

test('exchangeCodeForProfile throws when github emails endpoint not ok and no email found', async () => {
fetchSpy = jest.spyOn(global as any, 'fetch')
.mockImplementationOnce(async () => ({ ok: true, json: async () => ({ access_token: 'gh-token' }) }))
.mockImplementationOnce(async () => ({ ok: true, json: async () => ({ email: null, login: 'octocat', name: null }) }))
.mockImplementationOnce(async () => ({ ok: false }));

await expect(exchangeCodeForProfile('github', 'code')).rejects.toThrow(BadRequestError);
});
});
Loading
Loading