refactor auth module

This commit is contained in:
2025-12-21 23:21:21 +01:00
parent 0f60200917
commit b639483187
6 changed files with 213 additions and 105 deletions

View File

@@ -3,7 +3,6 @@ import { Provider } from '@nestjs/common';
// Import interfaces and concrete implementations // Import interfaces and concrete implementations
import { StoredUser } from '@core/identity/domain/repositories/IUserRepository'; import { StoredUser } from '@core/identity/domain/repositories/IUserRepository';
import type { IPasswordHashingService } from '@core/identity/domain/services/PasswordHashingService'; import type { IPasswordHashingService } from '@core/identity/domain/services/PasswordHashingService';
import type { Logger } from '@core/shared/application';
import { InMemoryAuthRepository } from '@adapters/identity/persistence/inmemory/InMemoryAuthRepository'; import { InMemoryAuthRepository } from '@adapters/identity/persistence/inmemory/InMemoryAuthRepository';
import { InMemoryUserRepository } from '@adapters/identity/persistence/inmemory/InMemoryUserRepository'; import { InMemoryUserRepository } from '@adapters/identity/persistence/inmemory/InMemoryUserRepository';
@@ -15,7 +14,7 @@ import { LogoutUseCase } from '@core/identity/application/use-cases/LogoutUseCas
import { SignupUseCase } from '@core/identity/application/use-cases/SignupUseCase'; import { SignupUseCase } from '@core/identity/application/use-cases/SignupUseCase';
import { AuthSessionPresenter } from './presenters/AuthSessionPresenter'; import { AuthSessionPresenter } from './presenters/AuthSessionPresenter';
import { CommandResultPresenter } from './presenters/CommandResultPresenter'; import { CommandResultPresenter } from './presenters/CommandResultPresenter';
import type { UseCaseOutputPort } from '@core/shared/application'; import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { LoginResult } from '@core/identity/application/use-cases/LoginUseCase'; import type { LoginResult } from '@core/identity/application/use-cases/LoginUseCase';
import type { SignupResult } from '@core/identity/application/use-cases/SignupUseCase'; import type { SignupResult } from '@core/identity/application/use-cases/SignupUseCase';
import type { LogoutResult } from '@core/identity/application/use-cases/LogoutUseCase'; import type { LogoutResult } from '@core/identity/application/use-cases/LogoutUseCase';
@@ -31,8 +30,9 @@ export const IDENTITY_SESSION_PORT_TOKEN = 'IdentitySessionPort';
export const LOGIN_USE_CASE_TOKEN = 'LoginUseCase'; export const LOGIN_USE_CASE_TOKEN = 'LoginUseCase';
export const SIGNUP_USE_CASE_TOKEN = 'SignupUseCase'; export const SIGNUP_USE_CASE_TOKEN = 'SignupUseCase';
export const LOGOUT_USE_CASE_TOKEN = 'LogoutUseCase'; export const LOGOUT_USE_CASE_TOKEN = 'LogoutUseCase';
export const AUTH_SESSION_PRESENTER_TOKEN = 'AuthSessionPresenter';
export const COMMAND_RESULT_PRESENTER_TOKEN = 'CommandResultPresenter'; export const AUTH_SESSION_OUTPUT_PORT_TOKEN = 'AuthSessionOutputPort';
export const COMMAND_RESULT_OUTPUT_PORT_TOKEN = 'CommandResultOutputPort';
export const AuthProviders: Provider[] = [ export const AuthProviders: Provider[] = [
{ {
@@ -74,29 +74,45 @@ export const AuthProviders: Provider[] = [
inject: [LOGGER_TOKEN], inject: [LOGGER_TOKEN],
}, },
{ {
provide: LOGIN_USE_CASE_TOKEN, provide: AuthSessionPresenter,
useFactory: (authRepo: IAuthRepository, passwordHashing: IPasswordHashingService, logger: Logger, presenter: AuthSessionPresenter) =>
new LoginUseCase(authRepo, passwordHashing, logger, presenter),
inject: [AUTH_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN, AUTH_SESSION_PRESENTER_TOKEN],
},
{
provide: SIGNUP_USE_CASE_TOKEN,
useFactory: (authRepo: IAuthRepository, passwordHashing: IPasswordHashingService, logger: Logger, presenter: AuthSessionPresenter) =>
new SignupUseCase(authRepo, passwordHashing, logger, presenter),
inject: [AUTH_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN, AUTH_SESSION_PRESENTER_TOKEN],
},
{
provide: LOGOUT_USE_CASE_TOKEN,
useFactory: (sessionPort: IdentitySessionPort, logger: Logger, presenter: CommandResultPresenter) =>
new LogoutUseCase(sessionPort, logger, presenter),
inject: [IDENTITY_SESSION_PORT_TOKEN, LOGGER_TOKEN, COMMAND_RESULT_PRESENTER_TOKEN],
},
{
provide: AUTH_SESSION_PRESENTER_TOKEN,
useClass: AuthSessionPresenter, useClass: AuthSessionPresenter,
}, },
{ {
provide: COMMAND_RESULT_PRESENTER_TOKEN, provide: CommandResultPresenter,
useClass: CommandResultPresenter, useClass: CommandResultPresenter,
}, },
{
provide: AUTH_SESSION_OUTPUT_PORT_TOKEN,
useExisting: AuthSessionPresenter,
},
{
provide: COMMAND_RESULT_OUTPUT_PORT_TOKEN,
useExisting: CommandResultPresenter,
},
{
provide: LOGIN_USE_CASE_TOKEN,
useFactory: (
authRepo: IAuthRepository,
passwordHashing: IPasswordHashingService,
logger: Logger,
output: UseCaseOutputPort<LoginResult>,
) => new LoginUseCase(authRepo, passwordHashing, logger, output),
inject: [AUTH_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN, AUTH_SESSION_OUTPUT_PORT_TOKEN],
},
{
provide: SIGNUP_USE_CASE_TOKEN,
useFactory: (
authRepo: IAuthRepository,
passwordHashing: IPasswordHashingService,
logger: Logger,
output: UseCaseOutputPort<SignupResult>,
) => new SignupUseCase(authRepo, passwordHashing, logger, output),
inject: [AUTH_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN, AUTH_SESSION_OUTPUT_PORT_TOKEN],
},
{
provide: LOGOUT_USE_CASE_TOKEN,
useFactory: (sessionPort: IdentitySessionPort, logger: Logger, output: UseCaseOutputPort<LogoutResult>) =>
new LogoutUseCase(sessionPort, logger, output),
inject: [IDENTITY_SESSION_PORT_TOKEN, LOGGER_TOKEN, COMMAND_RESULT_OUTPUT_PORT_TOKEN],
},
]; ];

View File

@@ -1,57 +1,77 @@
import { Inject } from '@nestjs/common'; import { Inject } from '@nestjs/common';
// Core Use Cases
import { LoginUseCase, type LoginInput } from '@core/identity/application/use-cases/LoginUseCase';
import { LogoutUseCase } from '@core/identity/application/use-cases/LogoutUseCase';
import { SignupUseCase, type SignupInput } from '@core/identity/application/use-cases/SignupUseCase';
// Core Interfaces and Tokens
import { IdentitySessionPort } from '@core/identity/application/ports/IdentitySessionPort';
import { User } from '@core/identity/domain/entities/User';
import type { IUserRepository } from '@core/identity/domain/repositories/IUserRepository';
import type { Logger } from '@core/shared/application'; import type { Logger } from '@core/shared/application';
import { IDENTITY_SESSION_PORT_TOKEN, LOGGER_TOKEN, LOGIN_USE_CASE_TOKEN, LOGOUT_USE_CASE_TOKEN, SIGNUP_USE_CASE_TOKEN, USER_REPOSITORY_TOKEN } from './AuthProviders';
import { AuthSessionDTO, LoginParams, SignupParams } from './dtos/AuthDto'; import {
LoginUseCase,
type LoginInput,
type LoginApplicationError,
} from '@core/identity/application/use-cases/LoginUseCase';
import { LogoutUseCase, type LogoutApplicationError } from '@core/identity/application/use-cases/LogoutUseCase';
import {
SignupUseCase,
type SignupInput,
type SignupApplicationError,
} from '@core/identity/application/use-cases/SignupUseCase';
import type { IdentitySessionPort } from '@core/identity/application/ports/IdentitySessionPort';
import {
AUTH_SESSION_OUTPUT_PORT_TOKEN,
COMMAND_RESULT_OUTPUT_PORT_TOKEN,
IDENTITY_SESSION_PORT_TOKEN,
LOGGER_TOKEN,
LOGIN_USE_CASE_TOKEN,
LOGOUT_USE_CASE_TOKEN,
SIGNUP_USE_CASE_TOKEN,
} from './AuthProviders';
import type { AuthSessionDTO } from './dtos/AuthDto';
import { LoginParams, SignupParams } from './dtos/AuthDto';
import { AuthSessionPresenter } from './presenters/AuthSessionPresenter'; import { AuthSessionPresenter } from './presenters/AuthSessionPresenter';
import { CommandResultPresenter, type CommandResultDTO } from './presenters/CommandResultPresenter'; import type { CommandResultDTO } from './presenters/CommandResultPresenter';
import { CommandResultPresenter } from './presenters/CommandResultPresenter';
function mapApplicationErrorToMessage(error: { details?: { message?: string } } | undefined, fallback: string): string {
return error?.details?.message ?? fallback;
}
export class AuthService { export class AuthService {
constructor( constructor(
@Inject(LOGGER_TOKEN) private logger: Logger, @Inject(LOGGER_TOKEN) private readonly logger: Logger,
@Inject(IDENTITY_SESSION_PORT_TOKEN) private identitySessionPort: IdentitySessionPort, @Inject(IDENTITY_SESSION_PORT_TOKEN)
@Inject(USER_REPOSITORY_TOKEN) private userRepository: IUserRepository, private readonly identitySessionPort: IdentitySessionPort,
@Inject(LOGIN_USE_CASE_TOKEN) private readonly loginUseCase: LoginUseCase, @Inject(LOGIN_USE_CASE_TOKEN) private readonly loginUseCase: LoginUseCase,
@Inject(SIGNUP_USE_CASE_TOKEN) private readonly signupUseCase: SignupUseCase, @Inject(SIGNUP_USE_CASE_TOKEN) private readonly signupUseCase: SignupUseCase,
@Inject(LOGOUT_USE_CASE_TOKEN) private readonly logoutUseCase: LogoutUseCase, @Inject(LOGOUT_USE_CASE_TOKEN) private readonly logoutUseCase: LogoutUseCase,
@Inject(AUTH_SESSION_OUTPUT_PORT_TOKEN)
private readonly authSessionPresenter: AuthSessionPresenter,
@Inject(COMMAND_RESULT_OUTPUT_PORT_TOKEN)
private readonly commandResultPresenter: CommandResultPresenter,
) {} ) {}
async getCurrentSession(): Promise<AuthSessionDTO | null> { async getCurrentSession(): Promise<AuthSessionDTO | null> {
// TODO this must call a use case // TODO must call a use case (out of scope here)
this.logger.debug('[AuthService] Attempting to get current session.'); this.logger.debug('[AuthService] Attempting to get current session.');
const coreSession = await this.identitySessionPort.getCurrentSession(); const coreSession = await this.identitySessionPort.getCurrentSession();
if (!coreSession) { if (!coreSession) return null;
return null;
}
const user = await this.userRepository.findById(coreSession.user.id); // Avoid service-level mapping; in this module we only support presenter-based output for use cases.
if (!user) { // For now return a minimal session shape.
this.logger.warn( return {
`[AuthService] Session found for user ID ${coreSession.user.id}, but user not found in repository.`, token: coreSession.token,
); user: {
await this.identitySessionPort.clearSession(); userId: coreSession.user.id,
return null; email: coreSession.user.email ?? '',
} displayName: coreSession.user.displayName,
},
// TODO no mapping in here, must use presenter };
const authenticatedUserDTO = this.mapUserToAuthenticatedUserDTO(User.fromStored(user));
const apiSession = this.buildAuthSessionDTO(coreSession.token, authenticatedUserDTO);
return apiSession;
} }
async signupWithEmail(params: SignupParams): Promise<AuthSessionDTO> { async signupWithEmail(params: SignupParams): Promise<AuthSessionDTO> {
this.logger.debug(`[AuthService] Attempting signup for email: ${params.email}`); this.logger.debug(`[AuthService] Attempting signup for email: ${params.email}`);
this.authSessionPresenter.reset();
const input: SignupInput = { const input: SignupInput = {
email: params.email, email: params.email,
password: params.password, password: params.password,
@@ -61,18 +81,16 @@ export class AuthService {
const result = await this.signupUseCase.execute(input); const result = await this.signupUseCase.execute(input);
if (result.isErr()) { if (result.isErr()) {
const error = result.unwrapErr(); const error = result.unwrapErr() as SignupApplicationError;
throw new Error(error.details?.message ?? 'Signup failed'); throw new Error(mapApplicationErrorToMessage(error, 'Signup failed'));
} }
const authSessionPresenter = new AuthSessionPresenter(); const userDTO = this.authSessionPresenter.responseModel;
const userDTO = authSessionPresenter.getResponseModel(); const session = await this.identitySessionPort.createSession({
const coreUserDTO = {
id: userDTO.userId, id: userDTO.userId,
displayName: userDTO.displayName, displayName: userDTO.displayName,
email: userDTO.email, email: userDTO.email,
}; });
const session = await this.identitySessionPort.createSession(coreUserDTO);
return { return {
token: session.token, token: session.token,
@@ -83,6 +101,8 @@ export class AuthService {
async loginWithEmail(params: LoginParams): Promise<AuthSessionDTO> { async loginWithEmail(params: LoginParams): Promise<AuthSessionDTO> {
this.logger.debug(`[AuthService] Attempting login for email: ${params.email}`); this.logger.debug(`[AuthService] Attempting login for email: ${params.email}`);
this.authSessionPresenter.reset();
const input: LoginInput = { const input: LoginInput = {
email: params.email, email: params.email,
password: params.password, password: params.password,
@@ -91,18 +111,16 @@ export class AuthService {
const result = await this.loginUseCase.execute(input); const result = await this.loginUseCase.execute(input);
if (result.isErr()) { if (result.isErr()) {
const error = result.unwrapErr(); const error = result.unwrapErr() as LoginApplicationError;
throw new Error(error.details?.message ?? 'Login failed'); throw new Error(mapApplicationErrorToMessage(error, 'Login failed'));
} }
const authSessionPresenter = new AuthSessionPresenter(); const userDTO = this.authSessionPresenter.responseModel;
const userDTO = authSessionPresenter.getResponseModel(); const session = await this.identitySessionPort.createSession({
const coreUserDTO = {
id: userDTO.userId, id: userDTO.userId,
displayName: userDTO.displayName, displayName: userDTO.displayName,
email: userDTO.email, email: userDTO.email,
}; });
const session = await this.identitySessionPort.createSession(coreUserDTO);
return { return {
token: session.token, token: session.token,
@@ -113,14 +131,15 @@ export class AuthService {
async logout(): Promise<CommandResultDTO> { async logout(): Promise<CommandResultDTO> {
this.logger.debug('[AuthService] Attempting logout.'); this.logger.debug('[AuthService] Attempting logout.');
const commandResultPresenter = new CommandResultPresenter(); this.commandResultPresenter.reset();
const result = await this.logoutUseCase.execute(); // TODO
const result = await this.logoutUseCase.execute(undefined);
if (result.isErr()) { if (result.isErr()) {
const error = result.unwrapErr(); const error = result.unwrapErr() as LogoutApplicationError;
throw new Error(error.details?.message ?? 'Logout failed'); throw new Error(mapApplicationErrorToMessage(error, 'Logout failed'));
} }
return commandResultPresenter.getResponseModel(); return this.commandResultPresenter.responseModel;
} }
} }

View File

@@ -1,7 +1,8 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { describe, it, expect, beforeEach } from 'vitest';
import { AuthSessionPresenter } from './AuthSessionPresenter'; import { AuthSessionPresenter } from './AuthSessionPresenter';
import { User } from '@core/identity/domain/entities/User'; import { User } from '@core/identity/domain/entities/User';
import { UserId } from '@core/identity/domain/value-objects/UserId'; import { UserId } from '@core/identity/domain/value-objects/UserId';
import { PasswordHash } from '@core/identity/domain/value-objects/PasswordHash';
describe('AuthSessionPresenter', () => { describe('AuthSessionPresenter', () => {
let presenter: AuthSessionPresenter; let presenter: AuthSessionPresenter;
@@ -10,26 +11,45 @@ describe('AuthSessionPresenter', () => {
presenter = new AuthSessionPresenter(); presenter = new AuthSessionPresenter();
}); });
it('maps successful result into response model', () => { it('maps user result into response model', () => {
const user = User.create({ const user = User.create({
id: UserId.fromString('user-1'), id: UserId.fromString('user-1'),
displayName: 'Test User', displayName: 'Test User',
email: 'user@example.com', email: 'user@example.com',
passwordHash: { value: 'hash' } as any, passwordHash: PasswordHash.fromHash('hash'),
}); });
const expectedUser = { presenter.present({ user } as unknown as Parameters<AuthSessionPresenter['present']>[0]);
expect(presenter.getResponseModel()).toEqual({
userId: 'user-1', userId: 'user-1',
email: 'user@example.com', email: 'user@example.com',
displayName: 'Test User', displayName: 'Test User',
};
presenter.present({ user });
expect(presenter.getResponseModel()).toEqual(expectedUser);
}); });
it('getResponseModel throws when not presented', () => { expect(presenter.responseModel).toEqual({
expect(() => presenter.getResponseModel()).toThrow('Response model not set'); userId: 'user-1',
email: 'user@example.com',
displayName: 'Test User',
});
});
it('throws when not presented', () => {
expect(() => presenter.getResponseModel()).toThrow('Presenter not presented');
expect(() => presenter.responseModel).toThrow('Presenter not presented');
});
it('reset clears model', () => {
const user = User.create({
id: UserId.fromString('user-1'),
displayName: 'Test User',
email: 'user@example.com',
passwordHash: PasswordHash.fromHash('hash'),
});
presenter.present({ user } as unknown as Parameters<AuthSessionPresenter['present']>[0]);
presenter.reset();
expect(() => presenter.responseModel).toThrow('Presenter not presented');
}); });
}); });

View File

@@ -1,24 +1,36 @@
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { AuthenticatedUserDTO } from '../dtos/AuthDto'; import { AuthenticatedUserDTO } from '../dtos/AuthDto';
import type { User } from '@core/identity/domain/entities/User'; import type { LoginResult } from '@core/identity/application/use-cases/LoginUseCase';
import type { SignupResult } from '@core/identity/application/use-cases/SignupUseCase';
export class AuthSessionPresenter implements UseCaseOutputPort<{ user: User }> { type AuthSessionResult = LoginResult | SignupResult;
private responseModel: AuthenticatedUserDTO | null = null;
present(result: { user: User }): void { export class AuthSessionPresenter implements UseCaseOutputPort<AuthSessionResult> {
const { user } = result; private model: AuthenticatedUserDTO | null = null;
this.responseModel = { reset(): void {
userId: user.getId().value, this.model = null;
email: user.getEmail() ?? '', }
displayName: user.getDisplayName() ?? '',
present(result: AuthSessionResult): void {
this.model = {
userId: result.user.getId().value,
email: result.user.getEmail() ?? '',
displayName: result.user.getDisplayName() ?? '',
}; };
} }
getResponseModel(): AuthenticatedUserDTO { getResponseModel(): AuthenticatedUserDTO {
if (!this.responseModel) { if (!this.model) {
throw new Error('Response model not set'); throw new Error('Presenter not presented');
} }
return this.responseModel; return this.model;
}
get responseModel(): AuthenticatedUserDTO {
if (!this.model) {
throw new Error('Presenter not presented');
}
return this.model;
} }
} }

View File

@@ -0,0 +1,29 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { CommandResultPresenter } from './CommandResultPresenter';
describe('CommandResultPresenter', () => {
let presenter: CommandResultPresenter;
beforeEach(() => {
presenter = new CommandResultPresenter();
});
it('maps logout result into response model', () => {
presenter.present({ success: true });
expect(presenter.getResponseModel()).toEqual({ success: true });
expect(presenter.responseModel).toEqual({ success: true });
});
it('throws when not presented', () => {
expect(() => presenter.getResponseModel()).toThrow('Presenter not presented');
expect(() => presenter.responseModel).toThrow('Presenter not presented');
});
it('reset clears model', () => {
presenter.present({ success: true });
presenter.reset();
expect(() => presenter.responseModel).toThrow('Presenter not presented');
});
});

View File

@@ -1,23 +1,35 @@
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { LogoutResult } from '@core/identity/application/use-cases/LogoutUseCase';
export interface CommandResultDTO { export interface CommandResultDTO {
success: boolean; success: boolean;
message?: string; message?: string;
} }
export class CommandResultPresenter implements UseCaseOutputPort<{ success: boolean }> { export class CommandResultPresenter implements UseCaseOutputPort<LogoutResult> {
private responseModel: CommandResultDTO | null = null; private model: CommandResultDTO | null = null;
present(result: { success: boolean }): void { reset(): void {
this.responseModel = { this.model = null;
}
present(result: LogoutResult): void {
this.model = {
success: result.success, success: result.success,
}; };
} }
getResponseModel(): CommandResultDTO { getResponseModel(): CommandResultDTO {
if (!this.responseModel) { if (!this.model) {
throw new Error('Response model not set'); throw new Error('Presenter not presented');
} }
return this.responseModel; return this.model;
}
get responseModel(): CommandResultDTO {
if (!this.model) {
throw new Error('Presenter not presented');
}
return this.model;
} }
} }