feat: add modular services and repositories for improved code organization

Introduce repository pattern across multiple services, including `favorites`, `tags`, `sessions`, `reports`, `auth`, and more. Decouple crypto functionalities into modular services like `HashingService`, `JwtService`, and `EncryptionService`. Improve testability and maintainability by simplifying dependencies and consolidating utility logic.
This commit is contained in:
Mathis HERRIOT
2026-01-14 12:11:39 +01:00
parent 9c45bf11e4
commit 514bd354bf
64 changed files with 1801 additions and 1295 deletions

View File

@@ -6,11 +6,12 @@ import { UsersModule } from "../users/users.module";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { RbacService } from "./rbac.service";
import { RbacRepository } from "./repositories/rbac.repository";
@Module({
imports: [UsersModule, CryptoModule, SessionsModule, DatabaseModule],
controllers: [AuthController],
providers: [AuthService, RbacService],
providers: [AuthService, RbacService, RbacRepository],
exports: [AuthService, RbacService],
})
export class AuthModule {}

View File

@@ -17,14 +17,14 @@ import { BadRequestException, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { authenticator } from "otplib";
import * as qrcode from "qrcode";
import { CryptoService } from "../crypto/crypto.service";
import { HashingService } from "../crypto/services/hashing.service";
import { JwtService } from "../crypto/services/jwt.service";
import { SessionsService } from "../sessions/sessions.service";
import { UsersService } from "../users/users.service";
import { AuthService } from "./auth.service";
jest.mock("otplib");
jest.mock("qrcode");
jest.mock("../crypto/crypto.service");
jest.mock("../users/users.service");
jest.mock("../sessions/sessions.service");
@@ -41,10 +41,13 @@ describe("AuthService", () => {
findOneWithPrivateData: jest.fn(),
};
const mockCryptoService = {
const mockHashingService = {
hashPassword: jest.fn(),
hashEmail: jest.fn(),
verifyPassword: jest.fn(),
};
const mockJwtService = {
generateJwt: jest.fn(),
};
@@ -62,7 +65,8 @@ describe("AuthService", () => {
providers: [
AuthService,
{ provide: UsersService, useValue: mockUsersService },
{ provide: CryptoService, useValue: mockCryptoService },
{ provide: HashingService, useValue: mockHashingService },
{ provide: JwtService, useValue: mockJwtService },
{ provide: SessionsService, useValue: mockSessionsService },
{ provide: ConfigService, useValue: mockConfigService },
],
@@ -142,8 +146,8 @@ describe("AuthService", () => {
email: "test@example.com",
password: "password",
};
mockCryptoService.hashPassword.mockResolvedValue("hashed-password");
mockCryptoService.hashEmail.mockResolvedValue("hashed-email");
mockHashingService.hashPassword.mockResolvedValue("hashed-password");
mockHashingService.hashEmail.mockResolvedValue("hashed-email");
mockUsersService.create.mockResolvedValue({ uuid: "new-user-id" });
const result = await service.register(dto);
@@ -164,10 +168,10 @@ describe("AuthService", () => {
passwordHash: "hash",
isTwoFactorEnabled: false,
};
mockCryptoService.hashEmail.mockResolvedValue("hashed-email");
mockHashingService.hashEmail.mockResolvedValue("hashed-email");
mockUsersService.findByEmailHash.mockResolvedValue(user);
mockCryptoService.verifyPassword.mockResolvedValue(true);
mockCryptoService.generateJwt.mockResolvedValue("access-token");
mockHashingService.verifyPassword.mockResolvedValue(true);
mockJwtService.generateJwt.mockResolvedValue("access-token");
mockSessionsService.createSession.mockResolvedValue({
refreshToken: "refresh-token",
});
@@ -189,9 +193,9 @@ describe("AuthService", () => {
passwordHash: "hash",
isTwoFactorEnabled: true,
};
mockCryptoService.hashEmail.mockResolvedValue("hashed-email");
mockHashingService.hashEmail.mockResolvedValue("hashed-email");
mockUsersService.findByEmailHash.mockResolvedValue(user);
mockCryptoService.verifyPassword.mockResolvedValue(true);
mockHashingService.verifyPassword.mockResolvedValue(true);
const result = await service.login(dto);
@@ -218,7 +222,7 @@ describe("AuthService", () => {
mockUsersService.findOneWithPrivateData.mockResolvedValue(user);
mockUsersService.getTwoFactorSecret.mockResolvedValue("secret");
(authenticator.verify as jest.Mock).mockReturnValue(true);
mockCryptoService.generateJwt.mockResolvedValue("access-token");
mockJwtService.generateJwt.mockResolvedValue("access-token");
mockSessionsService.createSession.mockResolvedValue({
refreshToken: "refresh-token",
});
@@ -240,7 +244,7 @@ describe("AuthService", () => {
const user = { uuid: "user-id", username: "test" };
mockSessionsService.refreshSession.mockResolvedValue(session);
mockUsersService.findOne.mockResolvedValue(user);
mockCryptoService.generateJwt.mockResolvedValue("new-access");
mockJwtService.generateJwt.mockResolvedValue("new-access");
const result = await service.refresh(refreshToken);

View File

@@ -7,7 +7,8 @@ import {
import { ConfigService } from "@nestjs/config";
import { authenticator } from "otplib";
import { toDataURL } from "qrcode";
import { CryptoService } from "../crypto/crypto.service";
import { HashingService } from "../crypto/services/hashing.service";
import { JwtService } from "../crypto/services/jwt.service";
import { SessionsService } from "../sessions/sessions.service";
import { UsersService } from "../users/users.service";
import { LoginDto } from "./dto/login.dto";
@@ -19,7 +20,8 @@ export class AuthService {
constructor(
private readonly usersService: UsersService,
private readonly cryptoService: CryptoService,
private readonly hashingService: HashingService,
private readonly jwtService: JwtService,
private readonly sessionsService: SessionsService,
private readonly configService: ConfigService,
) {}
@@ -81,8 +83,8 @@ export class AuthService {
this.logger.log(`Registering new user: ${dto.username}`);
const { username, email, password } = dto;
const passwordHash = await this.cryptoService.hashPassword(password);
const emailHash = await this.cryptoService.hashEmail(email);
const passwordHash = await this.hashingService.hashPassword(password);
const emailHash = await this.hashingService.hashEmail(email);
const user = await this.usersService.create({
username,
@@ -101,14 +103,14 @@ export class AuthService {
this.logger.log(`Login attempt for email: ${dto.email}`);
const { email, password } = dto;
const emailHash = await this.cryptoService.hashEmail(email);
const emailHash = await this.hashingService.hashEmail(email);
const user = await this.usersService.findByEmailHash(emailHash);
if (!user) {
throw new UnauthorizedException("Invalid credentials");
}
const isPasswordValid = await this.cryptoService.verifyPassword(
const isPasswordValid = await this.hashingService.verifyPassword(
password,
user.passwordHash,
);
@@ -125,7 +127,7 @@ export class AuthService {
};
}
const accessToken = await this.cryptoService.generateJwt({
const accessToken = await this.jwtService.generateJwt({
sub: user.uuid,
username: user.username,
});
@@ -163,7 +165,7 @@ export class AuthService {
throw new UnauthorizedException("Invalid 2FA token");
}
const accessToken = await this.cryptoService.generateJwt({
const accessToken = await this.jwtService.generateJwt({
sub: user.uuid,
username: user.username,
});
@@ -189,7 +191,7 @@ export class AuthService {
throw new UnauthorizedException("User not found");
}
const accessToken = await this.cryptoService.generateJwt({
const accessToken = await this.jwtService.generateJwt({
sub: user.uuid,
username: user.username,
});

View File

@@ -6,13 +6,13 @@ import {
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { getIronSession } from "iron-session";
import { CryptoService } from "../../crypto/crypto.service";
import { JwtService } from "../../crypto/services/jwt.service";
import { getSessionOptions, SessionData } from "../session.config";
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
private readonly cryptoService: CryptoService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
@@ -33,7 +33,7 @@ export class AuthGuard implements CanActivate {
}
try {
const payload = await this.cryptoService.verifyJwt(token);
const payload = await this.jwtService.verifyJwt(token);
request.user = payload;
} catch {
throw new UnauthorizedException();

View File

@@ -1,15 +1,14 @@
import { Test, TestingModule } from "@nestjs/testing";
import { DatabaseService } from "../database/database.service";
import { RbacService } from "./rbac.service";
import { RbacRepository } from "./repositories/rbac.repository";
describe("RbacService", () => {
let service: RbacService;
let repository: RbacRepository;
const mockDb = {
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
innerJoin: jest.fn().mockReturnThis(),
where: jest.fn(),
const mockRbacRepository = {
findRolesByUserId: jest.fn(),
findPermissionsByUserId: jest.fn(),
};
beforeEach(async () => {
@@ -18,15 +17,14 @@ describe("RbacService", () => {
providers: [
RbacService,
{
provide: DatabaseService,
useValue: {
db: mockDb,
},
provide: RbacRepository,
useValue: mockRbacRepository,
},
],
}).compile();
service = module.get<RbacService>(RbacService);
repository = module.get<RbacRepository>(RbacRepository);
});
it("should be defined", () => {
@@ -36,34 +34,26 @@ describe("RbacService", () => {
describe("getUserRoles", () => {
it("should return user roles", async () => {
const userId = "user-id";
const mockRoles = [{ slug: "admin" }, { slug: "user" }];
mockDb.where.mockResolvedValue(mockRoles);
const mockRoles = ["admin", "user"];
mockRbacRepository.findRolesByUserId.mockResolvedValue(mockRoles);
const result = await service.getUserRoles(userId);
expect(result).toEqual(["admin", "user"]);
expect(mockDb.select).toHaveBeenCalled();
expect(mockDb.from).toHaveBeenCalled();
expect(mockDb.innerJoin).toHaveBeenCalled();
expect(result).toEqual(mockRoles);
expect(repository.findRolesByUserId).toHaveBeenCalledWith(userId);
});
});
describe("getUserPermissions", () => {
it("should return unique user permissions", async () => {
it("should return user permissions", async () => {
const userId = "user-id";
const mockPermissions = [
{ slug: "read" },
{ slug: "write" },
{ slug: "read" }, // Duplicate
];
mockDb.where.mockResolvedValue(mockPermissions);
const mockPermissions = ["read", "write"];
mockRbacRepository.findPermissionsByUserId.mockResolvedValue(mockPermissions);
const result = await service.getUserPermissions(userId);
expect(result).toEqual(["read", "write"]);
expect(mockDb.select).toHaveBeenCalled();
expect(mockDb.from).toHaveBeenCalled();
expect(mockDb.innerJoin).toHaveBeenCalledTimes(2);
expect(result).toEqual(mockPermissions);
expect(repository.findPermissionsByUserId).toHaveBeenCalledWith(userId);
});
});
});

View File

@@ -1,42 +1,15 @@
import { Injectable } from "@nestjs/common";
import { eq } from "drizzle-orm";
import { DatabaseService } from "../database/database.service";
import {
permissions,
roles,
rolesToPermissions,
usersToRoles,
} from "../database/schemas";
import { RbacRepository } from "./repositories/rbac.repository";
@Injectable()
export class RbacService {
constructor(private readonly databaseService: DatabaseService) {}
constructor(private readonly rbacRepository: RbacRepository) {}
async getUserRoles(userId: string) {
const result = await this.databaseService.db
.select({
slug: roles.slug,
})
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(eq(usersToRoles.userId, userId));
return result.map((r) => r.slug);
return this.rbacRepository.findRolesByUserId(userId);
}
async getUserPermissions(userId: string) {
const result = await this.databaseService.db
.select({
slug: permissions.slug,
})
.from(usersToRoles)
.innerJoin(
rolesToPermissions,
eq(usersToRoles.roleId, rolesToPermissions.roleId),
)
.innerJoin(permissions, eq(rolesToPermissions.permissionId, permissions.id))
.where(eq(usersToRoles.userId, userId));
return Array.from(new Set(result.map((p) => p.slug)));
return this.rbacRepository.findPermissionsByUserId(userId);
}
}

View File

@@ -0,0 +1,42 @@
import { Injectable } from "@nestjs/common";
import { eq } from "drizzle-orm";
import { DatabaseService } from "../../database/database.service";
import {
permissions,
roles,
rolesToPermissions,
usersToRoles,
} from "../../database/schemas";
@Injectable()
export class RbacRepository {
constructor(private readonly databaseService: DatabaseService) {}
async findRolesByUserId(userId: string) {
const result = await this.databaseService.db
.select({
slug: roles.slug,
})
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(eq(usersToRoles.userId, userId));
return result.map((r) => r.slug);
}
async findPermissionsByUserId(userId: string) {
const result = await this.databaseService.db
.select({
slug: permissions.slug,
})
.from(usersToRoles)
.innerJoin(
rolesToPermissions,
eq(usersToRoles.roleId, rolesToPermissions.roleId),
)
.innerJoin(permissions, eq(rolesToPermissions.permissionId, permissions.id))
.where(eq(usersToRoles.userId, userId));
return Array.from(new Set(result.map((p) => p.slug)));
}
}