refactor auth module
This commit is contained in:
@@ -3,7 +3,6 @@ import { Provider } from '@nestjs/common';
|
||||
// Import interfaces and concrete implementations
|
||||
import { StoredUser } from '@core/identity/domain/repositories/IUserRepository';
|
||||
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 { 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 { AuthSessionPresenter } from './presenters/AuthSessionPresenter';
|
||||
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 { SignupResult } from '@core/identity/application/use-cases/SignupUseCase';
|
||||
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 SIGNUP_USE_CASE_TOKEN = 'SignupUseCase';
|
||||
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[] = [
|
||||
{
|
||||
@@ -74,29 +74,45 @@ export const AuthProviders: Provider[] = [
|
||||
inject: [LOGGER_TOKEN],
|
||||
},
|
||||
{
|
||||
provide: LOGIN_USE_CASE_TOKEN,
|
||||
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,
|
||||
provide: AuthSessionPresenter,
|
||||
useClass: AuthSessionPresenter,
|
||||
},
|
||||
{
|
||||
provide: COMMAND_RESULT_PRESENTER_TOKEN,
|
||||
provide: 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],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,57 +1,77 @@
|
||||
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 { 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 { 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 {
|
||||
constructor(
|
||||
@Inject(LOGGER_TOKEN) private logger: Logger,
|
||||
@Inject(IDENTITY_SESSION_PORT_TOKEN) private identitySessionPort: IdentitySessionPort,
|
||||
@Inject(USER_REPOSITORY_TOKEN) private userRepository: IUserRepository,
|
||||
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
|
||||
@Inject(IDENTITY_SESSION_PORT_TOKEN)
|
||||
private readonly identitySessionPort: IdentitySessionPort,
|
||||
@Inject(LOGIN_USE_CASE_TOKEN) private readonly loginUseCase: LoginUseCase,
|
||||
@Inject(SIGNUP_USE_CASE_TOKEN) private readonly signupUseCase: SignupUseCase,
|
||||
@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> {
|
||||
// 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.');
|
||||
const coreSession = await this.identitySessionPort.getCurrentSession();
|
||||
if (!coreSession) {
|
||||
return null;
|
||||
}
|
||||
if (!coreSession) return null;
|
||||
|
||||
const user = await this.userRepository.findById(coreSession.user.id);
|
||||
if (!user) {
|
||||
this.logger.warn(
|
||||
`[AuthService] Session found for user ID ${coreSession.user.id}, but user not found in repository.`,
|
||||
);
|
||||
await this.identitySessionPort.clearSession();
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO no mapping in here, must use presenter
|
||||
const authenticatedUserDTO = this.mapUserToAuthenticatedUserDTO(User.fromStored(user));
|
||||
const apiSession = this.buildAuthSessionDTO(coreSession.token, authenticatedUserDTO);
|
||||
|
||||
return apiSession;
|
||||
// Avoid service-level mapping; in this module we only support presenter-based output for use cases.
|
||||
// For now return a minimal session shape.
|
||||
return {
|
||||
token: coreSession.token,
|
||||
user: {
|
||||
userId: coreSession.user.id,
|
||||
email: coreSession.user.email ?? '',
|
||||
displayName: coreSession.user.displayName,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async signupWithEmail(params: SignupParams): Promise<AuthSessionDTO> {
|
||||
this.logger.debug(`[AuthService] Attempting signup for email: ${params.email}`);
|
||||
|
||||
this.authSessionPresenter.reset();
|
||||
|
||||
const input: SignupInput = {
|
||||
email: params.email,
|
||||
password: params.password,
|
||||
@@ -61,18 +81,16 @@ export class AuthService {
|
||||
const result = await this.signupUseCase.execute(input);
|
||||
|
||||
if (result.isErr()) {
|
||||
const error = result.unwrapErr();
|
||||
throw new Error(error.details?.message ?? 'Signup failed');
|
||||
const error = result.unwrapErr() as SignupApplicationError;
|
||||
throw new Error(mapApplicationErrorToMessage(error, 'Signup failed'));
|
||||
}
|
||||
|
||||
const authSessionPresenter = new AuthSessionPresenter();
|
||||
const userDTO = authSessionPresenter.getResponseModel();
|
||||
const coreUserDTO = {
|
||||
const userDTO = this.authSessionPresenter.responseModel;
|
||||
const session = await this.identitySessionPort.createSession({
|
||||
id: userDTO.userId,
|
||||
displayName: userDTO.displayName,
|
||||
email: userDTO.email,
|
||||
};
|
||||
const session = await this.identitySessionPort.createSession(coreUserDTO);
|
||||
});
|
||||
|
||||
return {
|
||||
token: session.token,
|
||||
@@ -83,6 +101,8 @@ export class AuthService {
|
||||
async loginWithEmail(params: LoginParams): Promise<AuthSessionDTO> {
|
||||
this.logger.debug(`[AuthService] Attempting login for email: ${params.email}`);
|
||||
|
||||
this.authSessionPresenter.reset();
|
||||
|
||||
const input: LoginInput = {
|
||||
email: params.email,
|
||||
password: params.password,
|
||||
@@ -91,18 +111,16 @@ export class AuthService {
|
||||
const result = await this.loginUseCase.execute(input);
|
||||
|
||||
if (result.isErr()) {
|
||||
const error = result.unwrapErr();
|
||||
throw new Error(error.details?.message ?? 'Login failed');
|
||||
const error = result.unwrapErr() as LoginApplicationError;
|
||||
throw new Error(mapApplicationErrorToMessage(error, 'Login failed'));
|
||||
}
|
||||
|
||||
const authSessionPresenter = new AuthSessionPresenter();
|
||||
const userDTO = authSessionPresenter.getResponseModel();
|
||||
const coreUserDTO = {
|
||||
const userDTO = this.authSessionPresenter.responseModel;
|
||||
const session = await this.identitySessionPort.createSession({
|
||||
id: userDTO.userId,
|
||||
displayName: userDTO.displayName,
|
||||
email: userDTO.email,
|
||||
};
|
||||
const session = await this.identitySessionPort.createSession(coreUserDTO);
|
||||
});
|
||||
|
||||
return {
|
||||
token: session.token,
|
||||
@@ -113,14 +131,15 @@ export class AuthService {
|
||||
async logout(): Promise<CommandResultDTO> {
|
||||
this.logger.debug('[AuthService] Attempting logout.');
|
||||
|
||||
const commandResultPresenter = new CommandResultPresenter();
|
||||
const result = await this.logoutUseCase.execute(); // TODO
|
||||
this.commandResultPresenter.reset();
|
||||
|
||||
const result = await this.logoutUseCase.execute(undefined);
|
||||
|
||||
if (result.isErr()) {
|
||||
const error = result.unwrapErr();
|
||||
throw new Error(error.details?.message ?? 'Logout failed');
|
||||
const error = result.unwrapErr() as LogoutApplicationError;
|
||||
throw new Error(mapApplicationErrorToMessage(error, 'Logout failed'));
|
||||
}
|
||||
|
||||
return commandResultPresenter.getResponseModel();
|
||||
return this.commandResultPresenter.responseModel;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { AuthSessionPresenter } from './AuthSessionPresenter';
|
||||
import { User } from '@core/identity/domain/entities/User';
|
||||
import { UserId } from '@core/identity/domain/value-objects/UserId';
|
||||
import { PasswordHash } from '@core/identity/domain/value-objects/PasswordHash';
|
||||
|
||||
describe('AuthSessionPresenter', () => {
|
||||
let presenter: AuthSessionPresenter;
|
||||
@@ -10,26 +11,45 @@ describe('AuthSessionPresenter', () => {
|
||||
presenter = new AuthSessionPresenter();
|
||||
});
|
||||
|
||||
it('maps successful result into response model', () => {
|
||||
it('maps user result into response model', () => {
|
||||
const user = User.create({
|
||||
id: UserId.fromString('user-1'),
|
||||
displayName: 'Test User',
|
||||
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',
|
||||
email: 'user@example.com',
|
||||
displayName: 'Test User',
|
||||
};
|
||||
});
|
||||
|
||||
presenter.present({ user });
|
||||
|
||||
expect(presenter.getResponseModel()).toEqual(expectedUser);
|
||||
expect(presenter.responseModel).toEqual({
|
||||
userId: 'user-1',
|
||||
email: 'user@example.com',
|
||||
displayName: 'Test User',
|
||||
});
|
||||
});
|
||||
|
||||
it('getResponseModel throws when not presented', () => {
|
||||
expect(() => presenter.getResponseModel()).toThrow('Response model not set');
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,24 +1,36 @@
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
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 }> {
|
||||
private responseModel: AuthenticatedUserDTO | null = null;
|
||||
type AuthSessionResult = LoginResult | SignupResult;
|
||||
|
||||
present(result: { user: User }): void {
|
||||
const { user } = result;
|
||||
export class AuthSessionPresenter implements UseCaseOutputPort<AuthSessionResult> {
|
||||
private model: AuthenticatedUserDTO | null = null;
|
||||
|
||||
this.responseModel = {
|
||||
userId: user.getId().value,
|
||||
email: user.getEmail() ?? '',
|
||||
displayName: user.getDisplayName() ?? '',
|
||||
reset(): void {
|
||||
this.model = null;
|
||||
}
|
||||
|
||||
present(result: AuthSessionResult): void {
|
||||
this.model = {
|
||||
userId: result.user.getId().value,
|
||||
email: result.user.getEmail() ?? '',
|
||||
displayName: result.user.getDisplayName() ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
getResponseModel(): AuthenticatedUserDTO {
|
||||
if (!this.responseModel) {
|
||||
throw new Error('Response model not set');
|
||||
if (!this.model) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -1,23 +1,35 @@
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
import type { LogoutResult } from '@core/identity/application/use-cases/LogoutUseCase';
|
||||
|
||||
export interface CommandResultDTO {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export class CommandResultPresenter implements UseCaseOutputPort<{ success: boolean }> {
|
||||
private responseModel: CommandResultDTO | null = null;
|
||||
export class CommandResultPresenter implements UseCaseOutputPort<LogoutResult> {
|
||||
private model: CommandResultDTO | null = null;
|
||||
|
||||
present(result: { success: boolean }): void {
|
||||
this.responseModel = {
|
||||
reset(): void {
|
||||
this.model = null;
|
||||
}
|
||||
|
||||
present(result: LogoutResult): void {
|
||||
this.model = {
|
||||
success: result.success,
|
||||
};
|
||||
}
|
||||
|
||||
getResponseModel(): CommandResultDTO {
|
||||
if (!this.responseModel) {
|
||||
throw new Error('Response model not set');
|
||||
if (!this.model) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user