This commit is contained in:
2025-12-31 19:55:43 +01:00
parent 8260bf7baf
commit 167e82a52b
66 changed files with 5124 additions and 228 deletions

View File

@@ -0,0 +1,140 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { InMemoryMagicLinkRepository } from './InMemoryMagicLinkRepository';
const mockLogger = {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
};
describe('InMemoryMagicLinkRepository', () => {
let repository: InMemoryMagicLinkRepository;
beforeEach(() => {
repository = new InMemoryMagicLinkRepository(mockLogger as any);
});
describe('createPasswordResetRequest', () => {
it('should create a password reset request', async () => {
const request = {
email: 'test@example.com',
token: 'abc123',
expiresAt: new Date(Date.now() + 15 * 60 * 1000),
userId: 'user-123',
};
await repository.createPasswordResetRequest(request);
const found = await repository.findByToken('abc123');
expect(found).toEqual(request);
});
it('should enforce rate limiting', async () => {
const request = {
email: 'test@example.com',
token: 'token1',
expiresAt: new Date(Date.now() + 15 * 60 * 1000),
userId: 'user-123',
};
// Create 3 requests for same email
await repository.createPasswordResetRequest({ ...request, token: 'token1' });
await repository.createPasswordResetRequest({ ...request, token: 'token2' });
await repository.createPasswordResetRequest({ ...request, token: 'token3' });
// 4th should fail
const result = await repository.checkRateLimit('test@example.com');
expect(result.isErr()).toBe(true);
});
it('should allow requests after time window expires', async () => {
const now = Date.now();
const request = {
email: 'test@example.com',
token: 'token1',
expiresAt: new Date(now + 15 * 60 * 1000),
userId: 'user-123',
};
// Mock Date.now to return time after rate limit window
const originalNow = Date.now;
Date.now = () => now + (16 * 60 * 1000); // 16 minutes later
try {
await repository.createPasswordResetRequest(request);
const found = await repository.findByToken('token1');
expect(found).toBeDefined();
} finally {
Date.now = originalNow;
}
});
});
describe('findByToken', () => {
it('should find existing token', async () => {
const request = {
email: 'test@example.com',
token: 'abc123',
expiresAt: new Date(Date.now() + 15 * 60 * 1000),
userId: 'user-123',
};
await repository.createPasswordResetRequest(request);
const found = await repository.findByToken('abc123');
expect(found).toEqual(request);
});
it('should return null for non-existent token', async () => {
const found = await repository.findByToken('nonexistent');
expect(found).toBeNull();
});
});
describe('markAsUsed', () => {
it('should mark token as used', async () => {
const request = {
email: 'test@example.com',
token: 'abc123',
expiresAt: new Date(Date.now() + 15 * 60 * 1000),
userId: 'user-123',
};
await repository.createPasswordResetRequest(request);
await repository.markAsUsed('abc123');
const found = await repository.findByToken('abc123');
expect(found).toBeNull();
});
it('should handle non-existent token gracefully', async () => {
await expect(repository.markAsUsed('nonexistent')).resolves.not.toThrow();
});
});
describe('checkRateLimit', () => {
it('should allow requests under limit', async () => {
const result = await repository.checkRateLimit('test@example.com');
expect(result.isOk()).toBe(true);
});
it('should reject requests over limit', async () => {
const email = 'test@example.com';
const request = {
email,
token: 'token',
expiresAt: new Date(Date.now() + 15 * 60 * 1000),
userId: 'user-123',
};
// Create 3 requests
await repository.createPasswordResetRequest({ ...request, token: 'token1' });
await repository.createPasswordResetRequest({ ...request, token: 'token2' });
await repository.createPasswordResetRequest({ ...request, token: 'token3' });
const result = await repository.checkRateLimit(email);
expect(result.isErr()).toBe(true);
});
});
});

View File

@@ -0,0 +1,118 @@
import { IMagicLinkRepository, PasswordResetRequest } from '@core/identity/domain/repositories/IMagicLinkRepository';
import { Result } from '@core/shared/application/Result';
import { Logger } from '@core/shared/application';
export class InMemoryMagicLinkRepository implements IMagicLinkRepository {
private resetRequests: Map<string, PasswordResetRequest> = new Map();
private rateLimitStore: Map<string, { count: number; lastRequest: Date }> = new Map();
// Rate limit: max 3 requests per 15 minutes
private readonly RATE_LIMIT_MAX = 3;
private readonly RATE_LIMIT_WINDOW = 15 * 60 * 1000; // 15 minutes
constructor(private readonly logger: Logger) {}
async createPasswordResetRequest(request: PasswordResetRequest): Promise<void> {
this.logger.debug('[InMemoryMagicLinkRepository] Creating password reset request', {
email: request.email,
token: request.token.substring(0, 10) + '...',
expiresAt: request.expiresAt,
});
this.resetRequests.set(request.token, request);
// Update rate limit tracking
const now = new Date();
const existing = this.rateLimitStore.get(request.email);
if (existing) {
// Reset count if window has passed
if (now.getTime() - existing.lastRequest.getTime() > this.RATE_LIMIT_WINDOW) {
this.rateLimitStore.set(request.email, { count: 1, lastRequest: now });
} else {
this.rateLimitStore.set(request.email, {
count: existing.count + 1,
lastRequest: now
});
}
} else {
this.rateLimitStore.set(request.email, { count: 1, lastRequest: now });
}
}
async findByToken(token: string): Promise<PasswordResetRequest | null> {
const request = this.resetRequests.get(token);
if (!request) {
return null;
}
// Check if expired
if (request.expiresAt < new Date()) {
this.resetRequests.delete(token);
return null;
}
// Check if already used
if (request.used) {
return null;
}
return request;
}
async markAsUsed(token: string): Promise<void> {
const request = this.resetRequests.get(token);
if (request) {
request.used = true;
this.logger.debug('[InMemoryMagicLinkRepository] Marked token as used', {
token: token.substring(0, 10) + '...',
});
}
}
async checkRateLimit(email: string): Promise<Result<void, { message: string }>> {
const now = new Date();
const tracking = this.rateLimitStore.get(email);
if (!tracking) {
return Result.ok(undefined);
}
// Check if window has passed
if (now.getTime() - tracking.lastRequest.getTime() > this.RATE_LIMIT_WINDOW) {
return Result.ok(undefined);
}
// Check if exceeded limit
if (tracking.count >= this.RATE_LIMIT_MAX) {
const timeRemaining = Math.ceil(
(this.RATE_LIMIT_WINDOW - (now.getTime() - tracking.lastRequest.getTime())) / 60000
);
return Result.err({
message: `Too many reset attempts. Please try again in ${timeRemaining} minutes.`,
});
}
return Result.ok(undefined);
}
async cleanupExpired(): Promise<void> {
const now = new Date();
const toDelete: string[] = [];
for (const [token, request] of this.resetRequests.entries()) {
if (request.expiresAt < now || request.used) {
toDelete.push(token);
}
}
toDelete.forEach(token => this.resetRequests.delete(token));
if (toDelete.length > 0) {
this.logger.debug('[InMemoryMagicLinkRepository] Cleaned up expired tokens', {
count: toDelete.length,
});
}
}
}

View File

@@ -0,0 +1,31 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
@Entity('password_reset_requests')
export class PasswordResetRequestOrmEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
email!: string;
@Column({ unique: true })
token!: string;
@Column()
expiresAt!: Date;
@Column()
userId!: string;
@Column({ default: false })
used!: boolean;
@Column({ default: 0 })
attemptCount!: number;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
}

View File

@@ -15,7 +15,7 @@ export class UserOrmMapper {
assertNonEmptyString(entityName, 'email', entity.email);
assertNonEmptyString(entityName, 'displayName', entity.displayName);
assertNonEmptyString(entityName, 'passwordHash', entity.passwordHash);
assertNonEmptyString(entityName, 'salt', entity.salt);
assertOptionalStringOrNull(entityName, 'salt', entity.salt);
assertOptionalStringOrNull(entityName, 'primaryDriverId', entity.primaryDriverId);
assertDate(entityName, 'createdAt', entity.createdAt);
} catch (error) {
@@ -48,7 +48,7 @@ export class UserOrmMapper {
entity.email = stored.email;
entity.displayName = stored.displayName;
entity.passwordHash = stored.passwordHash;
entity.salt = stored.salt;
entity.salt = stored.salt ?? '';
entity.primaryDriverId = stored.primaryDriverId ?? null;
entity.createdAt = stored.createdAt;
return entity;
@@ -60,7 +60,7 @@ export class UserOrmMapper {
email: entity.email,
displayName: entity.displayName,
passwordHash: entity.passwordHash,
salt: entity.salt,
...(entity.salt ? { salt: entity.salt } : {}),
primaryDriverId: entity.primaryDriverId ?? undefined,
createdAt: entity.createdAt,
};

View File

@@ -0,0 +1,130 @@
import type { DataSource } from 'typeorm';
import { IMagicLinkRepository, PasswordResetRequest } from '@core/identity/domain/repositories/IMagicLinkRepository';
import { Result } from '@core/shared/application/Result';
import { Logger } from '@core/shared/application';
import { PasswordResetRequestOrmEntity } from '../entities/PasswordResetRequestOrmEntity';
export class TypeOrmMagicLinkRepository implements IMagicLinkRepository {
// Rate limit: max 3 requests per 15 minutes
private readonly RATE_LIMIT_MAX = 3;
private readonly RATE_LIMIT_WINDOW = 15 * 60 * 1000; // 15 minutes
constructor(
private readonly dataSource: DataSource,
private readonly logger: Logger,
) {}
async createPasswordResetRequest(request: PasswordResetRequest): Promise<void> {
this.logger.debug('[TypeOrmMagicLinkRepository] Creating password reset request', {
email: request.email,
token: request.token.substring(0, 10) + '...',
expiresAt: request.expiresAt,
});
const repo = this.dataSource.getRepository(PasswordResetRequestOrmEntity);
const entity = new PasswordResetRequestOrmEntity();
entity.email = request.email;
entity.token = request.token;
entity.expiresAt = request.expiresAt;
entity.userId = request.userId;
entity.used = false;
entity.attemptCount = 0;
await repo.save(entity);
}
async findByToken(token: string): Promise<PasswordResetRequest | null> {
const repo = this.dataSource.getRepository(PasswordResetRequestOrmEntity);
const entity = await repo.findOne({ where: { token } });
if (!entity) {
return null;
}
// Check if expired
if (entity.expiresAt < new Date()) {
await repo.delete(entity.id);
return null;
}
// Check if already used
if (entity.used) {
return null;
}
return {
email: entity.email,
token: entity.token,
expiresAt: entity.expiresAt,
userId: entity.userId,
used: entity.used,
};
}
async markAsUsed(token: string): Promise<void> {
const repo = this.dataSource.getRepository(PasswordResetRequestOrmEntity);
await repo.update(
{ token },
{
used: true,
attemptCount: () => 'attemptCount + 1'
}
);
this.logger.debug('[TypeOrmMagicLinkRepository] Marked token as used', {
token: token.substring(0, 10) + '...',
});
}
async checkRateLimit(email: string): Promise<Result<void, { message: string }>> {
const repo = this.dataSource.getRepository(PasswordResetRequestOrmEntity);
const now = new Date();
const windowStart = new Date(now.getTime() - this.RATE_LIMIT_WINDOW);
// Count requests in the current window
const recentRequests = await repo.count({
where: {
email,
createdAt: windowStart,
used: false,
},
});
if (recentRequests >= this.RATE_LIMIT_MAX) {
// Find the oldest request to calculate remaining time
const oldestRequest = await repo.findOne({
where: { email },
order: { createdAt: 'ASC' },
});
if (oldestRequest) {
const timeRemaining = Math.ceil(
(this.RATE_LIMIT_WINDOW - (now.getTime() - oldestRequest.createdAt.getTime())) / 60000
);
return Result.err({
message: `Too many reset attempts. Please try again in ${timeRemaining} minutes.`,
});
}
}
return Result.ok(undefined);
}
async cleanupExpired(): Promise<void> {
const repo = this.dataSource.getRepository(PasswordResetRequestOrmEntity);
const now = new Date();
const result = await repo.delete({
expiresAt: now,
});
if (result.affected && result.affected > 0) {
this.logger.debug('[TypeOrmMagicLinkRepository] Cleaned up expired tokens', {
count: result.affected,
});
}
}
}

View File

@@ -0,0 +1,31 @@
import { IMagicLinkNotificationPort, MagicLinkNotificationInput } from '@core/identity/domain/ports/IMagicLinkNotificationPort';
import { Logger } from '@core/shared/application';
/**
* Console adapter for magic link notifications
* Logs to console for development/testing purposes
*/
export class ConsoleMagicLinkNotificationAdapter implements IMagicLinkNotificationPort {
constructor(private readonly logger: Logger) {}
async sendMagicLink(input: MagicLinkNotificationInput): Promise<void> {
this.logger.info('[ConsoleMagicLinkNotificationAdapter] Magic link generated', {
email: input.email,
userId: input.userId,
magicLink: input.magicLink,
expiresAt: input.expiresAt,
});
// In development, log to console
if (process.env.NODE_ENV === 'development') {
console.log('\n🔒 PASSWORD RESET MAGIC LINK');
console.log('='.repeat(50));
console.log(`📧 Email: ${input.email}`);
console.log(`👤 User ID: ${input.userId}`);
console.log(`🔗 Link: ${input.magicLink}`);
console.log(`⏰ Expires: ${input.expiresAt.toLocaleString()}`);
console.log('='.repeat(50));
console.log('⚠️ This would be sent via email in production\n');
}
}
}

View File

@@ -0,0 +1,46 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
@Entity({ name: 'driver_stats' })
export class DriverStatsOrmEntity {
@PrimaryColumn({ type: 'uuid' })
driverId!: string;
@Column({ type: 'integer' })
rating!: number;
@Column({ type: 'integer' })
safetyRating!: number;
@Column({ type: 'numeric', precision: 3, scale: 1 })
sportsmanshipRating!: number;
@Column({ type: 'integer' })
totalRaces!: number;
@Column({ type: 'integer' })
wins!: number;
@Column({ type: 'integer' })
podiums!: number;
@Column({ type: 'integer' })
dnfs!: number;
@Column({ type: 'numeric', precision: 5, scale: 2 })
avgFinish!: number;
@Column({ type: 'integer' })
bestFinish!: number;
@Column({ type: 'integer' })
worstFinish!: number;
@Column({ type: 'integer' })
consistency!: number;
@Column({ type: 'text' })
experienceLevel!: string;
@Column({ type: 'integer', nullable: true })
overallRank!: number | null;
}

View File

@@ -0,0 +1,65 @@
import type { DriverStats } from '@core/racing/application/use-cases/IDriverStatsUseCase';
import { DriverStatsOrmEntity } from '../entities/DriverStatsOrmEntity';
import {
assertNonEmptyString,
assertInteger,
assertNumber
} from '../schema/TypeOrmSchemaGuards';
export class DriverStatsOrmMapper {
toOrmEntity(driverId: string, domain: DriverStats): DriverStatsOrmEntity {
const entity = new DriverStatsOrmEntity();
entity.driverId = driverId;
entity.rating = domain.rating;
entity.safetyRating = domain.safetyRating;
entity.sportsmanshipRating = domain.sportsmanshipRating;
entity.totalRaces = domain.totalRaces;
entity.wins = domain.wins;
entity.podiums = domain.podiums;
entity.dnfs = domain.dnfs;
entity.avgFinish = domain.avgFinish;
entity.bestFinish = domain.bestFinish;
entity.worstFinish = domain.worstFinish;
entity.consistency = domain.consistency;
entity.experienceLevel = domain.experienceLevel;
entity.overallRank = domain.overallRank ?? null;
return entity;
}
toDomain(entity: DriverStatsOrmEntity): DriverStats {
const entityName = 'DriverStats';
assertNonEmptyString(entityName, 'driverId', entity.driverId);
assertInteger(entityName, 'rating', entity.rating);
assertInteger(entityName, 'safetyRating', entity.safetyRating);
assertInteger(entityName, 'sportsmanshipRating', entity.sportsmanshipRating);
assertInteger(entityName, 'totalRaces', entity.totalRaces);
assertInteger(entityName, 'wins', entity.wins);
assertInteger(entityName, 'podiums', entity.podiums);
assertInteger(entityName, 'dnfs', entity.dnfs);
assertNumber(entityName, 'avgFinish', entity.avgFinish);
assertInteger(entityName, 'bestFinish', entity.bestFinish);
assertInteger(entityName, 'worstFinish', entity.worstFinish);
assertInteger(entityName, 'consistency', entity.consistency);
assertNonEmptyString(entityName, 'experienceLevel', entity.experienceLevel);
const result: DriverStats = {
rating: entity.rating,
safetyRating: entity.safetyRating,
sportsmanshipRating: entity.sportsmanshipRating,
totalRaces: entity.totalRaces,
wins: entity.wins,
podiums: entity.podiums,
dnfs: entity.dnfs,
avgFinish: entity.avgFinish,
bestFinish: entity.bestFinish,
worstFinish: entity.worstFinish,
consistency: entity.consistency,
experienceLevel: entity.experienceLevel,
overallRank: entity.overallRank ?? null,
};
return result;
}
}

View File

@@ -0,0 +1,45 @@
import type { IDriverStatsRepository } from '@core/racing/domain/repositories/IDriverStatsRepository';
import type { DriverStats } from '@core/racing/application/use-cases/IDriverStatsUseCase';
import type { Repository } from 'typeorm';
import { DriverStatsOrmEntity } from '../entities/DriverStatsOrmEntity';
import { DriverStatsOrmMapper } from '../mappers/DriverStatsOrmMapper';
export class TypeOrmDriverStatsRepository implements IDriverStatsRepository {
constructor(
private readonly repo: Repository<DriverStatsOrmEntity>,
private readonly mapper: DriverStatsOrmMapper,
) {}
async getDriverStats(driverId: string): Promise<DriverStats | null> {
const entity = await this.repo.findOne({ where: { driverId } });
return entity ? this.mapper.toDomain(entity) : null;
}
getDriverStatsSync(_driverId: string): DriverStats | null {
// TypeORM repositories don't support synchronous operations
// This method is provided for interface compatibility but should not be used
// with TypeORM implementations. Return null to indicate it's not supported.
return null;
}
async saveDriverStats(driverId: string, stats: DriverStats): Promise<void> {
const entity = this.mapper.toOrmEntity(driverId, stats);
await this.repo.save(entity);
}
async getAllStats(): Promise<Map<string, DriverStats>> {
const entities = await this.repo.find();
const statsMap = new Map<string, DriverStats>();
for (const entity of entities) {
statsMap.set(entity.driverId, this.mapper.toDomain(entity));
}
return statsMap;
}
async clear(): Promise<void> {
await this.repo.clear();
}
}