mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 16:59:40 +02:00
487 lines
14 KiB
TypeScript
487 lines
14 KiB
TypeScript
/**
|
|
* Authentication Flow Integration Tests
|
|
*
|
|
* Tests complete authentication workflows:
|
|
* - Registration → Login → Token Generation
|
|
* - Token Refresh → Logout
|
|
* - Multi-device sessions
|
|
*/
|
|
|
|
import { Test } from '@nestjs/testing';
|
|
import type { TestingModule } from '@nestjs/testing';
|
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
|
import { AuthService } from '../../src/auth/auth.service';
|
|
import { CreditsService } from '../../src/credits/credits.service';
|
|
import configuration from '../../src/config/configuration';
|
|
|
|
describe('Authentication Flow Integration Tests', () => {
|
|
let authService: AuthService;
|
|
let creditsService: CreditsService;
|
|
let module: TestingModule;
|
|
|
|
beforeAll(async () => {
|
|
module = await Test.createTestingModule({
|
|
imports: [
|
|
ConfigModule.forRoot({
|
|
load: [configuration],
|
|
isGlobal: true,
|
|
}),
|
|
],
|
|
providers: [AuthService, CreditsService],
|
|
}).compile();
|
|
|
|
authService = module.get<AuthService>(AuthService);
|
|
creditsService = module.get<CreditsService>(CreditsService);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await module.close();
|
|
});
|
|
|
|
describe('B2C User Registration → Login → Token Flow', () => {
|
|
it('should complete full B2C registration and login flow', async () => {
|
|
const uniqueEmail = `test-b2c-${Date.now()}@example.com`;
|
|
|
|
// Step 1: Register new user
|
|
const registerResult = await authService.register({
|
|
email: uniqueEmail,
|
|
password: 'SecurePassword123!',
|
|
name: 'Test User',
|
|
});
|
|
|
|
expect(registerResult).toMatchObject({
|
|
id: expect.any(String),
|
|
email: uniqueEmail,
|
|
name: 'Test User',
|
|
});
|
|
|
|
const userId = registerResult.id;
|
|
|
|
// Step 2: Initialize credit balance
|
|
const balance = await creditsService.initializeUserBalance(userId);
|
|
|
|
expect(balance).toMatchObject({
|
|
userId,
|
|
balance: 0,
|
|
freeCreditsRemaining: 150, // Signup bonus
|
|
dailyFreeCredits: 5,
|
|
});
|
|
|
|
// Step 3: Login with credentials
|
|
const loginResult = await authService.login({
|
|
email: uniqueEmail,
|
|
password: 'SecurePassword123!',
|
|
});
|
|
|
|
expect(loginResult).toMatchObject({
|
|
user: {
|
|
id: userId,
|
|
email: uniqueEmail,
|
|
},
|
|
accessToken: expect.any(String),
|
|
refreshToken: expect.any(String),
|
|
tokenType: 'Bearer',
|
|
expiresIn: 900, // 15 minutes
|
|
});
|
|
|
|
// Step 4: Validate access token
|
|
const validationResult = await authService.validateToken(loginResult.accessToken);
|
|
|
|
expect(validationResult.valid).toBe(true);
|
|
expect(validationResult.payload).toMatchObject({
|
|
sub: userId,
|
|
email: uniqueEmail,
|
|
role: 'user',
|
|
});
|
|
});
|
|
|
|
it('should support multiple login sessions from different devices', async () => {
|
|
const uniqueEmail = `multi-device-${Date.now()}@example.com`;
|
|
|
|
// Register user
|
|
const registerResult = await authService.register({
|
|
email: uniqueEmail,
|
|
password: 'SecurePassword123!',
|
|
name: 'Multi Device User',
|
|
});
|
|
|
|
// Login from mobile device
|
|
const mobileLogin = await authService.login(
|
|
{
|
|
email: uniqueEmail,
|
|
password: 'SecurePassword123!',
|
|
deviceId: 'mobile-device-123',
|
|
deviceName: 'iPhone 15',
|
|
},
|
|
'192.168.1.100',
|
|
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)'
|
|
);
|
|
|
|
// Login from web device
|
|
const webLogin = await authService.login(
|
|
{
|
|
email: uniqueEmail,
|
|
password: 'SecurePassword123!',
|
|
deviceId: 'web-device-456',
|
|
deviceName: 'Chrome Browser',
|
|
},
|
|
'192.168.1.101',
|
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
|
);
|
|
|
|
// Both sessions should be valid
|
|
expect(mobileLogin.accessToken).toBeDefined();
|
|
expect(webLogin.accessToken).toBeDefined();
|
|
expect(mobileLogin.accessToken).not.toBe(webLogin.accessToken);
|
|
|
|
// Validate both tokens
|
|
const mobileValidation = await authService.validateToken(mobileLogin.accessToken);
|
|
const webValidation = await authService.validateToken(webLogin.accessToken);
|
|
|
|
expect(mobileValidation.valid).toBe(true);
|
|
expect(webValidation.valid).toBe(true);
|
|
|
|
// Session IDs should be different
|
|
expect(mobileValidation.payload.sessionId).not.toBe(webValidation.payload.sessionId);
|
|
});
|
|
});
|
|
|
|
describe('Token Refresh Flow', () => {
|
|
it('should refresh tokens and rotate refresh token', async () => {
|
|
const uniqueEmail = `refresh-test-${Date.now()}@example.com`;
|
|
|
|
// Register and login
|
|
await authService.register({
|
|
email: uniqueEmail,
|
|
password: 'SecurePassword123!',
|
|
name: 'Refresh Test User',
|
|
});
|
|
|
|
const loginResult = await authService.login({
|
|
email: uniqueEmail,
|
|
password: 'SecurePassword123!',
|
|
});
|
|
|
|
const originalRefreshToken = loginResult.refreshToken;
|
|
const originalAccessToken = loginResult.accessToken;
|
|
|
|
// Wait a moment to ensure different timestamps
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
|
|
// Refresh tokens
|
|
const refreshResult = await authService.refreshToken(originalRefreshToken);
|
|
|
|
expect(refreshResult).toMatchObject({
|
|
user: {
|
|
email: uniqueEmail,
|
|
},
|
|
accessToken: expect.any(String),
|
|
refreshToken: expect.any(String),
|
|
});
|
|
|
|
// New tokens should be different
|
|
expect(refreshResult.accessToken).not.toBe(originalAccessToken);
|
|
expect(refreshResult.refreshToken).not.toBe(originalRefreshToken);
|
|
|
|
// Old refresh token should be revoked
|
|
await expect(authService.refreshToken(originalRefreshToken)).rejects.toThrow(
|
|
'Invalid refresh token'
|
|
);
|
|
|
|
// New refresh token should work
|
|
const secondRefreshResult = await authService.refreshToken(refreshResult.refreshToken);
|
|
expect(secondRefreshResult.accessToken).toBeDefined();
|
|
});
|
|
|
|
it('should not allow refresh with revoked token after logout', async () => {
|
|
const uniqueEmail = `logout-test-${Date.now()}@example.com`;
|
|
|
|
// Register and login
|
|
await authService.register({
|
|
email: uniqueEmail,
|
|
password: 'SecurePassword123!',
|
|
name: 'Logout Test User',
|
|
});
|
|
|
|
const loginResult = await authService.login({
|
|
email: uniqueEmail,
|
|
password: 'SecurePassword123!',
|
|
});
|
|
|
|
const refreshToken = loginResult.refreshToken;
|
|
|
|
// Extract sessionId from access token
|
|
const validation = await authService.validateToken(loginResult.accessToken);
|
|
const sessionId = validation.payload.sessionId;
|
|
|
|
// Logout
|
|
await authService.logout(sessionId);
|
|
|
|
// Attempt to refresh with revoked token
|
|
await expect(authService.refreshToken(refreshToken)).rejects.toThrow('Invalid refresh token');
|
|
});
|
|
});
|
|
|
|
describe('Logout Flow', () => {
|
|
it('should revoke session on logout', async () => {
|
|
const uniqueEmail = `logout-flow-${Date.now()}@example.com`;
|
|
|
|
// Register and login
|
|
await authService.register({
|
|
email: uniqueEmail,
|
|
password: 'SecurePassword123!',
|
|
name: 'Logout Flow User',
|
|
});
|
|
|
|
const loginResult = await authService.login({
|
|
email: uniqueEmail,
|
|
password: 'SecurePassword123!',
|
|
});
|
|
|
|
// Extract sessionId
|
|
const validation = await authService.validateToken(loginResult.accessToken);
|
|
const sessionId = validation.payload.sessionId;
|
|
|
|
// Logout
|
|
const logoutResult = await authService.logout(sessionId);
|
|
|
|
expect(logoutResult).toEqual({
|
|
message: 'Logged out successfully',
|
|
});
|
|
|
|
// Refresh token should no longer work
|
|
await expect(authService.refreshToken(loginResult.refreshToken)).rejects.toThrow();
|
|
});
|
|
|
|
it('should not affect other sessions when logging out one session', async () => {
|
|
const uniqueEmail = `multi-session-logout-${Date.now()}@example.com`;
|
|
|
|
// Register
|
|
await authService.register({
|
|
email: uniqueEmail,
|
|
password: 'SecurePassword123!',
|
|
name: 'Multi Session User',
|
|
});
|
|
|
|
// Create two sessions
|
|
const session1 = await authService.login({
|
|
email: uniqueEmail,
|
|
password: 'SecurePassword123!',
|
|
deviceId: 'device-1',
|
|
});
|
|
|
|
const session2 = await authService.login({
|
|
email: uniqueEmail,
|
|
password: 'SecurePassword123!',
|
|
deviceId: 'device-2',
|
|
});
|
|
|
|
// Logout session 1
|
|
const validation1 = await authService.validateToken(session1.accessToken);
|
|
await authService.logout(validation1.payload.sessionId);
|
|
|
|
// Session 1 refresh token should not work
|
|
await expect(authService.refreshToken(session1.refreshToken)).rejects.toThrow();
|
|
|
|
// Session 2 should still work
|
|
const session2Refresh = await authService.refreshToken(session2.refreshToken);
|
|
expect(session2Refresh.accessToken).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('Security Validations', () => {
|
|
it('should prevent registration with duplicate email', async () => {
|
|
const duplicateEmail = `duplicate-${Date.now()}@example.com`;
|
|
|
|
// First registration
|
|
await authService.register({
|
|
email: duplicateEmail,
|
|
password: 'SecurePassword123!',
|
|
name: 'First User',
|
|
});
|
|
|
|
// Second registration with same email should fail
|
|
await expect(
|
|
authService.register({
|
|
email: duplicateEmail,
|
|
password: 'AnotherPassword456!',
|
|
name: 'Second User',
|
|
})
|
|
).rejects.toThrow('User with this email already exists');
|
|
});
|
|
|
|
it('should reject login with incorrect password', async () => {
|
|
const uniqueEmail = `wrong-password-${Date.now()}@example.com`;
|
|
|
|
await authService.register({
|
|
email: uniqueEmail,
|
|
password: 'CorrectPassword123!',
|
|
name: 'Password Test User',
|
|
});
|
|
|
|
await expect(
|
|
authService.login({
|
|
email: uniqueEmail,
|
|
password: 'WrongPassword123!',
|
|
})
|
|
).rejects.toThrow('Invalid credentials');
|
|
});
|
|
|
|
it('should reject login for non-existent user', async () => {
|
|
await expect(
|
|
authService.login({
|
|
email: `nonexistent-${Date.now()}@example.com`,
|
|
password: 'SomePassword123!',
|
|
})
|
|
).rejects.toThrow('Invalid credentials');
|
|
});
|
|
|
|
it('should normalize email to lowercase', async () => {
|
|
const mixedCaseEmail = `MixedCase${Date.now()}@EXAMPLE.COM`;
|
|
|
|
const registerResult = await authService.register({
|
|
email: mixedCaseEmail,
|
|
password: 'SecurePassword123!',
|
|
name: 'Mixed Case User',
|
|
});
|
|
|
|
expect(registerResult.email).toBe(mixedCaseEmail.toLowerCase());
|
|
|
|
// Should be able to login with different casing
|
|
const loginResult = await authService.login({
|
|
email: mixedCaseEmail.toUpperCase(),
|
|
password: 'SecurePassword123!',
|
|
});
|
|
|
|
expect(loginResult.user.email).toBe(mixedCaseEmail.toLowerCase());
|
|
});
|
|
});
|
|
|
|
describe('Credit Balance Integration', () => {
|
|
it('should initialize credit balance automatically on registration', async () => {
|
|
const uniqueEmail = `credits-init-${Date.now()}@example.com`;
|
|
|
|
const registerResult = await authService.register({
|
|
email: uniqueEmail,
|
|
password: 'SecurePassword123!',
|
|
name: 'Credits User',
|
|
});
|
|
|
|
const userId = registerResult.id;
|
|
|
|
// Initialize balance
|
|
const balance = await creditsService.initializeUserBalance(userId);
|
|
|
|
expect(balance.freeCreditsRemaining).toBe(150); // Signup bonus
|
|
expect(balance.dailyFreeCredits).toBe(5);
|
|
expect(balance.balance).toBe(0);
|
|
});
|
|
|
|
it('should not create duplicate balances', async () => {
|
|
const uniqueEmail = `no-duplicate-balance-${Date.now()}@example.com`;
|
|
|
|
const registerResult = await authService.register({
|
|
email: uniqueEmail,
|
|
password: 'SecurePassword123!',
|
|
name: 'No Duplicate User',
|
|
});
|
|
|
|
const userId = registerResult.id;
|
|
|
|
// Initialize balance twice
|
|
const balance1 = await creditsService.initializeUserBalance(userId);
|
|
const balance2 = await creditsService.initializeUserBalance(userId);
|
|
|
|
// Should return the same balance
|
|
expect(balance1.userId).toBe(balance2.userId);
|
|
expect(balance1.freeCreditsRemaining).toBe(balance2.freeCreditsRemaining);
|
|
});
|
|
});
|
|
|
|
describe('Error Handling', () => {
|
|
it('should handle soft-deleted user login attempt', async () => {
|
|
const uniqueEmail = `deleted-user-${Date.now()}@example.com`;
|
|
|
|
const registerResult = await authService.register({
|
|
email: uniqueEmail,
|
|
password: 'SecurePassword123!',
|
|
name: 'To Be Deleted',
|
|
});
|
|
|
|
// Note: In a real scenario, you'd soft-delete the user here
|
|
// For now, we just test the logic exists
|
|
|
|
// This test validates the login check for deletedAt field exists
|
|
expect(registerResult.id).toBeDefined();
|
|
});
|
|
|
|
it('should handle expired refresh token', async () => {
|
|
const uniqueEmail = `expired-token-${Date.now()}@example.com`;
|
|
|
|
await authService.register({
|
|
email: uniqueEmail,
|
|
password: 'SecurePassword123!',
|
|
name: 'Expired Token User',
|
|
});
|
|
|
|
const loginResult = await authService.login({
|
|
email: uniqueEmail,
|
|
password: 'SecurePassword123!',
|
|
});
|
|
|
|
// Test with obviously invalid token
|
|
await expect(authService.refreshToken('invalid-refresh-token')).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('Password Security', () => {
|
|
it('should hash passwords using bcrypt with proper cost factor', async () => {
|
|
const uniqueEmail = `password-hash-${Date.now()}@example.com`;
|
|
|
|
const registerResult = await authService.register({
|
|
email: uniqueEmail,
|
|
password: 'TestPassword123!',
|
|
name: 'Hash Test User',
|
|
});
|
|
|
|
// Login should work with correct password
|
|
const loginResult = await authService.login({
|
|
email: uniqueEmail,
|
|
password: 'TestPassword123!',
|
|
});
|
|
|
|
expect(loginResult.accessToken).toBeDefined();
|
|
|
|
// Login should fail with incorrect password
|
|
await expect(
|
|
authService.login({
|
|
email: uniqueEmail,
|
|
password: 'WrongPassword123!',
|
|
})
|
|
).rejects.toThrow('Invalid credentials');
|
|
});
|
|
|
|
it('should not expose password in any response', async () => {
|
|
const uniqueEmail = `no-password-leak-${Date.now()}@example.com`;
|
|
|
|
const registerResult = await authService.register({
|
|
email: uniqueEmail,
|
|
password: 'SecurePassword123!',
|
|
name: 'No Leak User',
|
|
});
|
|
|
|
// Registration response should not contain password
|
|
expect(registerResult).not.toHaveProperty('password');
|
|
expect(registerResult).not.toHaveProperty('hashedPassword');
|
|
|
|
const loginResult = await authService.login({
|
|
email: uniqueEmail,
|
|
password: 'SecurePassword123!',
|
|
});
|
|
|
|
// Login response should not contain password
|
|
expect(loginResult.user).not.toHaveProperty('password');
|
|
expect(loginResult.user).not.toHaveProperty('hashedPassword');
|
|
});
|
|
});
|
|
});
|