feat(auth): implement Auth module with register, login, JWT, guards, and CQRS
- Add RefreshToken and OAuthAccount models to Prisma schema - Implement clean architecture: domain (entities, VOs, events, repo interfaces), infrastructure (Prisma repos, Passport strategies, token service), application (CQRS command/query handlers), presentation (controller, guards, DTOs) - Endpoints: POST /auth/register, /auth/login, /auth/refresh, GET /auth/profile, GET /auth/profile/agent, PATCH /auth/kyc - JWT access + refresh token rotation with family-based revocation - Role-based guards (BUYER, SELLER, AGENT, ADMIN) - 16 unit tests (value objects, entity) + integration test suite - All 80 tests passing, clean TypeScript build Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
197
apps/api/src/modules/auth/__tests__/auth.integration.spec.ts
Normal file
197
apps/api/src/modules/auth/__tests__/auth.integration.spec.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { type INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import request from 'supertest';
|
||||
import { AuthModule } from '../auth.module';
|
||||
import { PrismaService } from '@modules/shared/infrastructure/prisma.service';
|
||||
import { SharedModule } from '@modules/shared/shared.module';
|
||||
|
||||
describe('Auth Controller (Integration)', () => {
|
||||
let app: INestApplication;
|
||||
let prisma: PrismaService;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [CqrsModule.forRoot(), SharedModule, AuthModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
transformOptions: { enableImplicitConversion: true },
|
||||
}),
|
||||
);
|
||||
await app.init();
|
||||
|
||||
prisma = app.get(PrismaService);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up test data
|
||||
await prisma.refreshToken.deleteMany({});
|
||||
await prisma.user.deleteMany({
|
||||
where: { phone: { in: ['+84912345678', '+84987654321'] } },
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe('POST /auth/register', () => {
|
||||
it('should register a new user and return tokens', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/auth/register')
|
||||
.send({
|
||||
phone: '0912345678',
|
||||
password: 'StrongPass123',
|
||||
fullName: 'Test User',
|
||||
email: 'test@example.com',
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
expect(res.body.accessToken).toBeDefined();
|
||||
expect(res.body.refreshToken).toBeDefined();
|
||||
expect(res.body.expiresIn).toBe(900);
|
||||
});
|
||||
|
||||
it('should reject duplicate phone', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/auth/register')
|
||||
.send({
|
||||
phone: '0912345678',
|
||||
password: 'StrongPass123',
|
||||
fullName: 'Duplicate User',
|
||||
})
|
||||
.expect(409);
|
||||
});
|
||||
|
||||
it('should reject invalid phone', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/auth/register')
|
||||
.send({
|
||||
phone: '12345',
|
||||
password: 'StrongPass123',
|
||||
fullName: 'Invalid Phone',
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('should reject short password', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/auth/register')
|
||||
.send({
|
||||
phone: '0987654321',
|
||||
password: 'short',
|
||||
fullName: 'Short Pass',
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/login', () => {
|
||||
it('should login with valid credentials', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({
|
||||
phone: '0912345678',
|
||||
password: 'StrongPass123',
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
expect(res.body.accessToken).toBeDefined();
|
||||
expect(res.body.refreshToken).toBeDefined();
|
||||
});
|
||||
|
||||
it('should reject invalid password', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({
|
||||
phone: '0912345678',
|
||||
password: 'WrongPassword',
|
||||
})
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('should reject non-existent phone', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({
|
||||
phone: '0999999999',
|
||||
password: 'StrongPass123',
|
||||
})
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/refresh', () => {
|
||||
let refreshToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({
|
||||
phone: '0912345678',
|
||||
password: 'StrongPass123',
|
||||
});
|
||||
refreshToken = res.body.refreshToken;
|
||||
});
|
||||
|
||||
it('should rotate refresh token', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/auth/refresh')
|
||||
.send({ refreshToken })
|
||||
.expect(201);
|
||||
|
||||
expect(res.body.accessToken).toBeDefined();
|
||||
expect(res.body.refreshToken).toBeDefined();
|
||||
expect(res.body.refreshToken).not.toBe(refreshToken);
|
||||
});
|
||||
|
||||
it('should reject reused refresh token', async () => {
|
||||
// The old token was already rotated — reuse should fail
|
||||
await request(app.getHttpServer())
|
||||
.post('/auth/refresh')
|
||||
.send({ refreshToken })
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /auth/profile', () => {
|
||||
let accessToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({
|
||||
phone: '0912345678',
|
||||
password: 'StrongPass123',
|
||||
});
|
||||
accessToken = res.body.accessToken;
|
||||
});
|
||||
|
||||
it('should return user profile', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/auth/profile')
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.phone).toBe('+84912345678');
|
||||
expect(res.body.fullName).toBe('Test User');
|
||||
expect(res.body.role).toBe('BUYER');
|
||||
expect(res.body.kycStatus).toBe('NONE');
|
||||
});
|
||||
|
||||
it('should reject unauthenticated request', async () => {
|
||||
await request(app.getHttpServer()).get('/auth/profile').expect(401);
|
||||
});
|
||||
|
||||
it('should reject invalid token', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get('/auth/profile')
|
||||
.set('Authorization', 'Bearer invalid-token')
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user