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
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,21 @@ UPLOAD_DIR=./uploads
# For local dev this should match your server's host + port
PUBLIC_BASE_URL=http://localhost:3000

# ─── Mailer ───────────────────────────────────────────
# Driver: "console" (dev, logs the email) or "smtp" (real delivery)
MAILER_DRIVER=console

# Only used when MAILER_DRIVER=smtp
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=
SMTP_PASS=
SMTP_FROM=no-reply@myfans.local

# Milliseconds before a send attempt is treated as failed
MAILER_TIMEOUT_MS=10000

# ─── Tips ─────────────────────────────────────────────
# Platform fee in basis points (1/100th of a percent). 500 = 5%.
TIP_PLATFORM_FEE_BPS=500
Expand Down
2 changes: 2 additions & 0 deletions src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ import { UsersModule } from '../users/users.module';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { LoggerModule } from '../logger/logger.module';
import { AuditModule } from '../audit/audit.module';
import { MailerModule } from '../mailer/mailer.module';

@Module({
imports: [
UsersModule,
PassportModule,
LoggerModule,
AuditModule,
MailerModule,
TypeOrmModule.forFeature([RefreshToken, PasswordResetToken]),
JwtModule.registerAsync({
imports: [ConfigModule],
Expand Down
20 changes: 18 additions & 2 deletions src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { UsersService } from '../users/users.service';
import { SignupDto } from './dto/signup.dto';
import { TokenService, TokenPair } from './token.service';
Expand All @@ -15,6 +16,7 @@ import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import * as crypto from 'crypto';
import { UserRole } from './enums/role.enum';
import { MailerService } from '../mailer/mailer.service';

export interface AuthResponse extends TokenPair {
user: { id: number; name: string; email: string; role: UserRole };
Expand All @@ -35,6 +37,8 @@ export class AuthService {
private readonly tokenService: TokenService,
@InjectRepository(PasswordResetToken)
private readonly passwordResetTokenRepository: Repository<PasswordResetToken>,
private readonly mailerService: MailerService,
private readonly configService: ConfigService,
) {}

async forgotPassword(email: string): Promise<void> {
Expand All @@ -53,8 +57,20 @@ export class AuthService {
expiresAt,
});

// Log token in dev mode (stub email delivery)
console.log(`Password reset token for ${email}: ${token}`);
const baseUrl =
this.configService.get<string>('PUBLIC_BASE_URL') ??
'http://localhost:3000';
const resetUrl = `${baseUrl}/reset-password?token=${token}`;

// Mail failures must never fail the (always-200) forgot-password request.
try {
await this.mailerService.sendPasswordReset(email, {
name: user.name,
resetUrl,
});
} catch {
// MailerService already logs/records failures internally.
}
}

async resetPassword(token: string, newPassword: string): Promise<void> {
Expand Down
48 changes: 43 additions & 5 deletions src/auth/password-reset.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ConfigService } from '@nestjs/config';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UsersService } from '../users/users.service';
import { TokenService } from './token.service';
import { PasswordResetToken } from './entities/password-reset-token.entity';
import { AuthController } from './auth.controller';
import { AuditService } from '../audit/audit.service';
import { MailerService } from '../mailer/mailer.service';
import * as bcrypt from 'bcrypt';
import * as crypto from 'crypto';

Expand All @@ -30,12 +32,21 @@ const mockAuditService = () => ({
log: jest.fn(),
});

const mockMailerService = () => ({
sendPasswordReset: jest.fn().mockResolvedValue({ accepted: true }),
});

const mockConfigService = () => ({
get: jest.fn().mockReturnValue('http://localhost:3000'),
});

describe('Password Reset Flow (Unit)', () => {
let authService: AuthService;
let authController: AuthController;
let resetTokenRepo: any;
let usersService: any;
let tokenService: any;
let mailerService: any;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
Expand All @@ -58,6 +69,14 @@ describe('Password Reset Flow (Unit)', () => {
provide: AuditService,
useFactory: mockAuditService,
},
{
provide: MailerService,
useFactory: mockMailerService,
},
{
provide: ConfigService,
useFactory: mockConfigService,
},
],
}).compile();

Expand All @@ -66,15 +85,15 @@ describe('Password Reset Flow (Unit)', () => {
resetTokenRepo = module.get(getRepositoryToken(PasswordResetToken));
usersService = module.get<UsersService>(UsersService);
tokenService = module.get<TokenService>(TokenService);
mailerService = module.get<MailerService>(MailerService);
});

describe('forgotPassword', () => {
it('should generate a token, save its SHA-256 hash, and log it to the console when user exists', async () => {
it('should generate a token, save its SHA-256 hash, and send exactly one reset email when user exists', async () => {
const email = 'existing@example.com';
const mockUser = { id: 42, email, name: 'Test User' };
usersService.findByEmail.mockResolvedValue(mockUser);
resetTokenRepo.save.mockResolvedValue({});
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();

await authService.forgotPassword(email);

Expand All @@ -86,18 +105,37 @@ describe('Password Reset Flow (Unit)', () => {
expiresAt: expect.any(Date),
}),
);
expect(consoleLogSpy).toHaveBeenCalled();
consoleLogSpy.mockRestore();
expect(mailerService.sendPasswordReset).toHaveBeenCalledTimes(1);
expect(mailerService.sendPasswordReset).toHaveBeenCalledWith(
email,
expect.objectContaining({
name: mockUser.name,
resetUrl: expect.stringContaining('/reset-password?token='),
}),
);
});

it('should return early without generating or saving a token if the user does not exist (no user enumeration)', async () => {
it('should return early without generating a token or sending mail if the user does not exist (no user enumeration)', async () => {
const email = 'nonexistent@example.com';
usersService.findByEmail.mockResolvedValue(null);

await authService.forgotPassword(email);

expect(usersService.findByEmail).toHaveBeenCalledWith(email);
expect(resetTokenRepo.save).not.toHaveBeenCalled();
expect(mailerService.sendPasswordReset).not.toHaveBeenCalled();
});

it('still resolves (HTTP 200 path) even if the mailer throws', async () => {
const email = 'existing@example.com';
const mockUser = { id: 42, email, name: 'Test User' };
usersService.findByEmail.mockResolvedValue(mockUser);
resetTokenRepo.save.mockResolvedValue({});
mailerService.sendPasswordReset.mockRejectedValue(
new Error('SMTP down'),
);

await expect(authService.forgotPassword(email)).resolves.toBeUndefined();
});
});

Expand Down
8 changes: 8 additions & 0 deletions src/config/env.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ const envSchema = Joi.object({
TIP_PLATFORM_FEE_BPS: Joi.number().integer().min(0).max(10000).default(500),
TIP_MIN_AMOUNT_CENTS: Joi.number().integer().min(1).default(100),
TIP_MAX_AMOUNT_CENTS: Joi.number().integer().min(1).default(100000),
MAILER_DRIVER: Joi.string().valid('console', 'smtp').default('console'),
SMTP_HOST: Joi.string().optional(),
SMTP_PORT: Joi.number().port().optional(),
SMTP_SECURE: Joi.string().valid('true', 'false').optional(),
SMTP_USER: Joi.string().allow('').optional(),
SMTP_PASS: Joi.string().allow('').optional(),
SMTP_FROM: Joi.string().optional(),
MAILER_TIMEOUT_MS: Joi.number().integer().min(1).default(10000),
});

function assertJwtConfig(value: Record<string, unknown>): void {
Expand Down
51 changes: 51 additions & 0 deletions src/mailer/adapters/console-mailer.adapter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { ConsoleMailerAdapter } from './console-mailer.adapter';
import { AppLogger } from '../../logger/app-logger.service';

describe('ConsoleMailerAdapter', () => {
const originalNodeEnv = process.env.NODE_ENV;
let logger: jest.Mocked<Pick<AppLogger, 'log'>>;

beforeEach(() => {
logger = { log: jest.fn() };
});

afterEach(() => {
process.env.NODE_ENV = originalNodeEnv;
});

it('logs the full email content in non-production environments', async () => {
process.env.NODE_ENV = 'development';
const adapter = new ConsoleMailerAdapter(logger as unknown as AppLogger);
const token = 'a'.repeat(64);

const result = await adapter.send({
to: 'jeremiah@example.com',
subject: 'Reset your password',
html: '<p>hi</p>',
text: `Use this token: ${token}`,
});

expect(result.accepted).toBe(true);
const loggedMessage = logger.log.mock.calls[0][0] as string;
expect(loggedMessage).toContain('jeremiah@example.com');
expect(loggedMessage).toContain(token);
});

it('redacts the recipient email and long tokens in production', async () => {
process.env.NODE_ENV = 'production';
const adapter = new ConsoleMailerAdapter(logger as unknown as AppLogger);
const token = 'a'.repeat(64);

await adapter.send({
to: 'jeremiah@example.com',
subject: 'Reset your password',
html: '<p>hi</p>',
text: `Use this token: ${token}`,
});

const loggedMessage = logger.log.mock.calls[0][0] as string;
expect(loggedMessage).not.toContain('jeremiah@example.com');
expect(loggedMessage).not.toContain(token);
expect(loggedMessage).toContain('[REDACTED]');
});
});
26 changes: 26 additions & 0 deletions src/mailer/adapters/console-mailer.adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { randomUUID } from 'node:crypto';
import { AppLogger } from '../../logger/app-logger.service';
import { MailerPort, SendMailInput, SendMailResult } from '../mailer.port';
import { isProductionLike, maskEmail, redactSensitive } from '../mailer.util';

/** Dev-mode mailer — logs the email instead of sending it. */
export class ConsoleMailerAdapter implements MailerPort {
constructor(private readonly logger: AppLogger) {}

send(input: SendMailInput): Promise<SendMailResult> {
const redact = isProductionLike();
const to = redact ? maskEmail(input.to) : input.to;
const subject = redact ? redactSensitive(input.subject) : input.subject;
const body = redact ? redactSensitive(input.text) : input.text;

this.logger.log(
`[ConsoleMailer] to=${to} subject="${subject}" tags=${JSON.stringify(input.tags ?? {})}\n${body}`,
ConsoleMailerAdapter.name,
);

return Promise.resolve({
accepted: true,
messageId: `console-${randomUUID()}`,
});
}
}
82 changes: 82 additions & 0 deletions src/mailer/adapters/smtp-mailer.adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { randomUUID } from 'node:crypto';
import { AppLogger } from '../../logger/app-logger.service';
import { MailerPort, SendMailInput, SendMailResult } from '../mailer.port';

/** Shape mirrors nodemailer's createTransport(SMTPTransport.Options) config. */
export interface SmtpMailerConfig {
host: string;
port: number;
secure: boolean;
auth?: { user: string; pass: string };
from: string;
}

interface SmtpTransportLike {
sendMail(message: {
from: string;
to: string;
subject: string;
html: string;
text: string;
}): Promise<{ messageId: string }>;
}

/**
* SMTP adapter stub. Lazily requires `nodemailer` so the dependency stays
* optional until a real deploy sets MAILER_DRIVER=smtp; in test/no-package
* environments it falls back to an in-memory no-op transport so `connect`
* never touches the network.
*/
export class SmtpMailerAdapter implements MailerPort {
private transport: SmtpTransportLike | null = null;

constructor(
private readonly config: SmtpMailerConfig,
private readonly logger: AppLogger,
) {}

private getTransport(): SmtpTransportLike {
if (this.transport) return this.transport;

if (process.env.NODE_ENV === 'test') {
this.transport = this.buildNoopTransport();
return this.transport;
}

try {
// Optional peer dependency — only required for real SMTP delivery.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const nodemailer = require('nodemailer') as {
createTransport: (opts: SmtpMailerConfig) => SmtpTransportLike;
};
this.transport = nodemailer.createTransport(this.config);
} catch {
this.logger.warn(
'[SmtpMailer] nodemailer package not installed — falling back to no-op transport',
SmtpMailerAdapter.name,
);
this.transport = this.buildNoopTransport();
}

return this.transport;
}

private buildNoopTransport(): SmtpTransportLike {
return {
sendMail: () =>
Promise.resolve({ messageId: `smtp-noop-${randomUUID()}` }),
};
}

async send(input: SendMailInput): Promise<SendMailResult> {
const transport = this.getTransport();
const result = await transport.sendMail({
from: this.config.from,
to: input.to,
subject: input.subject,
html: input.html,
text: input.text,
});
return { accepted: true, messageId: result.messageId };
}
}
21 changes: 21 additions & 0 deletions src/mailer/email-outbox.port.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export interface EmailOutboxRecord {
id: string;
to: string;
status: 'sent' | 'failed';
tags?: Record<string, unknown>;
error?: string;
createdAt: Date;
}

/**
* Outbox row writer. In-process/sync today (InMemoryEmailOutbox); swap for a
* TypeORM-backed implementation to enable real async/retry delivery later
* without touching MailerService or any call site.
*/
export interface EmailOutbox {
record(
entry: Omit<EmailOutboxRecord, 'id' | 'createdAt'>,
): Promise<EmailOutboxRecord>;
}

export const EMAIL_OUTBOX = Symbol('EMAIL_OUTBOX');
Loading
Loading