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:
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
42
backend/src/auth/repositories/rbac.repository.ts
Normal file
42
backend/src/auth/repositories/rbac.repository.ts
Normal 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)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user