From 514bd354bf0849a630e3ef2a63206caf43909065 Mon Sep 17 00:00:00 2001 From: Mathis HERRIOT <197931332+0x485254@users.noreply.github.com> Date: Wed, 14 Jan 2026 12:11:39 +0100 Subject: [PATCH] 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. --- backend/src/api-keys/api-keys.module.ts | 3 +- backend/src/api-keys/api-keys.service.spec.ts | 113 ++---- backend/src/api-keys/api-keys.service.ts | 48 +-- .../repositories/api-keys.repository.ts | 58 +++ backend/src/auth/auth.module.ts | 3 +- backend/src/auth/auth.service.spec.ts | 30 +- backend/src/auth/auth.service.ts | 20 +- backend/src/auth/guards/auth.guard.ts | 6 +- backend/src/auth/rbac.service.spec.ts | 44 +- backend/src/auth/rbac.service.ts | 35 +- .../src/auth/repositories/rbac.repository.ts | 42 ++ backend/src/categories/categories.module.ts | 3 +- .../src/categories/categories.service.spec.ts | 60 ++- backend/src/categories/categories.service.ts | 35 +- .../repositories/categories.repository.ts | 50 +++ .../src/common/interfaces/mail.interface.ts | 4 + .../src/common/interfaces/media.interface.ts | 25 ++ .../common/interfaces/storage.interface.ts | 39 ++ .../src/common/services/purge.service.spec.ts | 52 +-- backend/src/common/services/purge.service.ts | 40 +- backend/src/contents/contents.controller.ts | 20 +- backend/src/contents/contents.module.ts | 3 +- backend/src/contents/contents.service.spec.ts | 78 ++-- backend/src/contents/contents.service.ts | 240 +++-------- .../repositories/contents.repository.ts | 383 ++++++++++++++++++ backend/src/crypto/crypto.module.ts | 20 +- backend/src/crypto/crypto.service.spec.ts | 8 + backend/src/crypto/crypto.service.ts | 132 ++---- .../src/crypto/services/encryption.service.ts | 58 +++ .../src/crypto/services/hashing.service.ts | 32 ++ backend/src/crypto/services/jwt.service.ts | 37 ++ .../crypto/services/post-quantum.service.ts | 20 + backend/src/favorites/favorites.module.ts | 3 +- .../src/favorites/favorites.service.spec.ts | 57 +-- backend/src/favorites/favorites.service.ts | 40 +- .../repositories/favorites.repository.ts | 46 +++ backend/src/mail/mail.service.ts | 3 +- backend/src/media/media.module.ts | 4 +- backend/src/media/media.service.spec.ts | 5 + backend/src/media/media.service.ts | 93 +---- .../strategies/image-processor.strategy.ts | 44 ++ .../strategies/media-processor.strategy.ts | 6 + .../strategies/video-processor.strategy.ts | 71 ++++ backend/src/reports/reports.module.ts | 3 +- backend/src/reports/reports.service.spec.ts | 55 +-- backend/src/reports/reports.service.ts | 37 +- .../repositories/reports.repository.ts | 50 +++ backend/src/s3/s3.service.ts | 3 +- .../repositories/sessions.repository.ts | 64 +++ backend/src/sessions/sessions.module.ts | 3 +- backend/src/sessions/sessions.service.spec.ts | 118 +++--- backend/src/sessions/sessions.service.ts | 71 ++-- .../src/tags/repositories/tags.repository.ts | 48 +++ backend/src/tags/tags.module.ts | 3 +- backend/src/tags/tags.service.spec.ts | 50 +-- backend/src/tags/tags.service.ts | 43 +- .../users/repositories/users.repository.ts | 158 ++++++++ backend/src/users/users.module.ts | 3 +- backend/src/users/users.service.spec.ts | 65 ++- backend/src/users/users.service.ts | 181 ++------- backend/test/__mocks__/cuid2.js | 3 + backend/test/__mocks__/jose.js | 13 + backend/test/__mocks__/ml-kem.js | 7 + backend/test/__mocks__/sha3.js | 5 + 64 files changed, 1801 insertions(+), 1295 deletions(-) create mode 100644 backend/src/api-keys/repositories/api-keys.repository.ts create mode 100644 backend/src/auth/repositories/rbac.repository.ts create mode 100644 backend/src/categories/repositories/categories.repository.ts create mode 100644 backend/src/common/interfaces/mail.interface.ts create mode 100644 backend/src/common/interfaces/media.interface.ts create mode 100644 backend/src/common/interfaces/storage.interface.ts create mode 100644 backend/src/contents/repositories/contents.repository.ts create mode 100644 backend/src/crypto/services/encryption.service.ts create mode 100644 backend/src/crypto/services/hashing.service.ts create mode 100644 backend/src/crypto/services/jwt.service.ts create mode 100644 backend/src/crypto/services/post-quantum.service.ts create mode 100644 backend/src/favorites/repositories/favorites.repository.ts create mode 100644 backend/src/media/strategies/image-processor.strategy.ts create mode 100644 backend/src/media/strategies/media-processor.strategy.ts create mode 100644 backend/src/media/strategies/video-processor.strategy.ts create mode 100644 backend/src/reports/repositories/reports.repository.ts create mode 100644 backend/src/sessions/repositories/sessions.repository.ts create mode 100644 backend/src/tags/repositories/tags.repository.ts create mode 100644 backend/src/users/repositories/users.repository.ts create mode 100644 backend/test/__mocks__/cuid2.js create mode 100644 backend/test/__mocks__/jose.js create mode 100644 backend/test/__mocks__/ml-kem.js create mode 100644 backend/test/__mocks__/sha3.js diff --git a/backend/src/api-keys/api-keys.module.ts b/backend/src/api-keys/api-keys.module.ts index cfacffc..9f49ed9 100644 --- a/backend/src/api-keys/api-keys.module.ts +++ b/backend/src/api-keys/api-keys.module.ts @@ -4,11 +4,12 @@ import { CryptoModule } from "../crypto/crypto.module"; import { DatabaseModule } from "../database/database.module"; import { ApiKeysController } from "./api-keys.controller"; import { ApiKeysService } from "./api-keys.service"; +import { ApiKeysRepository } from "./repositories/api-keys.repository"; @Module({ imports: [DatabaseModule, AuthModule, CryptoModule], controllers: [ApiKeysController], - providers: [ApiKeysService], + providers: [ApiKeysService, ApiKeysRepository], exports: [ApiKeysService], }) export class ApiKeysModule {} diff --git a/backend/src/api-keys/api-keys.service.spec.ts b/backend/src/api-keys/api-keys.service.spec.ts index 66b7ac9..f6c1098 100644 --- a/backend/src/api-keys/api-keys.service.spec.ts +++ b/backend/src/api-keys/api-keys.service.spec.ts @@ -1,58 +1,43 @@ -import { createHash } from "node:crypto"; import { Test, TestingModule } from "@nestjs/testing"; -import { DatabaseService } from "../database/database.service"; -import { apiKeys } from "../database/schemas"; +import { HashingService } from "../crypto/services/hashing.service"; import { ApiKeysService } from "./api-keys.service"; +import { ApiKeysRepository } from "./repositories/api-keys.repository"; describe("ApiKeysService", () => { let service: ApiKeysService; + let repository: ApiKeysRepository; - const mockDb = { - insert: jest.fn(), - values: jest.fn(), - select: jest.fn(), - from: jest.fn(), - where: jest.fn(), - limit: jest.fn(), - update: jest.fn(), - set: jest.fn(), - returning: jest.fn(), + const mockApiKeysRepository = { + create: jest.fn(), + findAll: jest.fn(), + revoke: jest.fn(), + findActiveByKeyHash: jest.fn(), + updateLastUsed: jest.fn(), + }; + + const mockHashingService = { + hashSha256: jest.fn().mockResolvedValue("hashed-key"), }; beforeEach(async () => { jest.clearAllMocks(); - mockDb.insert.mockReturnThis(); - mockDb.values.mockResolvedValue(undefined); - mockDb.select.mockReturnThis(); - mockDb.from.mockReturnThis(); - mockDb.where.mockReturnThis(); - mockDb.limit.mockReturnThis(); - mockDb.update.mockReturnThis(); - mockDb.set.mockReturnThis(); - mockDb.returning.mockResolvedValue([]); - - // Default for findAll which is awaited on where() - mockDb.where.mockImplementation(() => { - const chain = { - returning: jest.fn().mockResolvedValue([]), - }; - return Object.assign(Promise.resolve([]), chain); - }); - const module: TestingModule = await Test.createTestingModule({ providers: [ ApiKeysService, { - provide: DatabaseService, - useValue: { - db: mockDb, - }, + provide: ApiKeysRepository, + useValue: mockApiKeysRepository, + }, + { + provide: HashingService, + useValue: mockHashingService, }, ], }).compile(); service = module.get(ApiKeysService); + repository = module.get(ApiKeysRepository); }); it("should be defined", () => { @@ -67,8 +52,7 @@ describe("ApiKeysService", () => { const result = await service.create(userId, name, expiresAt); - expect(mockDb.insert).toHaveBeenCalledWith(apiKeys); - expect(mockDb.values).toHaveBeenCalledWith( + expect(repository.create).toHaveBeenCalledWith( expect.objectContaining({ userId, name, @@ -87,12 +71,11 @@ describe("ApiKeysService", () => { it("should find all API keys for a user", async () => { const userId = "user-id"; const expectedKeys = [{ id: "1", name: "Key 1" }]; - (mockDb.where as jest.Mock).mockResolvedValue(expectedKeys); + mockApiKeysRepository.findAll.mockResolvedValue(expectedKeys); const result = await service.findAll(userId); - expect(mockDb.select).toHaveBeenCalled(); - expect(mockDb.from).toHaveBeenCalledWith(apiKeys); + expect(repository.findAll).toHaveBeenCalledWith(userId); expect(result).toEqual(expectedKeys); }); }); @@ -102,17 +85,11 @@ describe("ApiKeysService", () => { const userId = "user-id"; const keyId = "key-id"; const expectedResult = [{ id: keyId, isActive: false }]; - - mockDb.where.mockReturnValue({ - returning: jest.fn().mockResolvedValue(expectedResult), - }); + mockApiKeysRepository.revoke.mockResolvedValue(expectedResult); const result = await service.revoke(userId, keyId); - expect(mockDb.update).toHaveBeenCalledWith(apiKeys); - expect(mockDb.set).toHaveBeenCalledWith( - expect.objectContaining({ isActive: false }), - ); + expect(repository.revoke).toHaveBeenCalledWith(userId, keyId); expect(result).toEqual(expectedResult); }); }); @@ -120,42 +97,19 @@ describe("ApiKeysService", () => { describe("validateKey", () => { it("should validate a valid API key", async () => { const key = "mg_live_testkey"; - const keyHash = createHash("sha256").update(key).digest("hex"); - const apiKey = { id: "1", keyHash, isActive: true, expiresAt: null }; - - (mockDb.limit as jest.Mock).mockResolvedValue([apiKey]); - (mockDb.where as jest.Mock).mockResolvedValue([apiKey]); // For the update later if needed, but here it's for select - - // We need to be careful with chaining mockDb.where is used in both select and update - const mockSelect = { - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - limit: jest.fn().mockResolvedValue([apiKey]), - }; - const mockUpdate = { - set: jest.fn().mockReturnThis(), - where: jest.fn().mockResolvedValue(undefined), - }; - - (mockDb.select as jest.Mock).mockReturnValue(mockSelect); - (mockDb.update as jest.Mock).mockReturnValue(mockUpdate); + const apiKey = { id: "1", isActive: true, expiresAt: null }; + mockApiKeysRepository.findActiveByKeyHash.mockResolvedValue(apiKey); const result = await service.validateKey(key); expect(result).toEqual(apiKey); - expect(mockDb.select).toHaveBeenCalled(); - expect(mockDb.update).toHaveBeenCalledWith(apiKeys); + expect(repository.findActiveByKeyHash).toHaveBeenCalled(); + expect(repository.updateLastUsed).toHaveBeenCalledWith(apiKey.id); }); it("should return null for invalid API key", async () => { - (mockDb.select as jest.Mock).mockReturnValue({ - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - limit: jest.fn().mockResolvedValue([]), - }); - + mockApiKeysRepository.findActiveByKeyHash.mockResolvedValue(null); const result = await service.validateKey("invalid-key"); - expect(result).toBeNull(); }); @@ -164,12 +118,7 @@ describe("ApiKeysService", () => { const expiredDate = new Date(); expiredDate.setFullYear(expiredDate.getFullYear() - 1); const apiKey = { id: "1", isActive: true, expiresAt: expiredDate }; - - (mockDb.select as jest.Mock).mockReturnValue({ - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - limit: jest.fn().mockResolvedValue([apiKey]), - }); + mockApiKeysRepository.findActiveByKeyHash.mockResolvedValue(apiKey); const result = await service.validateKey(key); diff --git a/backend/src/api-keys/api-keys.service.ts b/backend/src/api-keys/api-keys.service.ts index 40290f9..a234e55 100644 --- a/backend/src/api-keys/api-keys.service.ts +++ b/backend/src/api-keys/api-keys.service.ts @@ -1,14 +1,16 @@ -import { createHash, randomBytes } from "node:crypto"; +import { randomBytes } from "node:crypto"; import { Injectable, Logger } from "@nestjs/common"; -import { and, eq } from "drizzle-orm"; -import { DatabaseService } from "../database/database.service"; -import { apiKeys } from "../database/schemas"; +import { HashingService } from "../crypto/services/hashing.service"; +import { ApiKeysRepository } from "./repositories/api-keys.repository"; @Injectable() export class ApiKeysService { private readonly logger = new Logger(ApiKeysService.name); - constructor(private readonly databaseService: DatabaseService) {} + constructor( + private readonly apiKeysRepository: ApiKeysRepository, + private readonly hashingService: HashingService, + ) {} async create(userId: string, name: string, expiresAt?: Date) { this.logger.log(`Creating API key for user ${userId}: ${name}`); @@ -16,9 +18,9 @@ export class ApiKeysService { const randomPart = randomBytes(24).toString("hex"); const key = `${prefix}${randomPart}`; - const keyHash = createHash("sha256").update(key).digest("hex"); + const keyHash = await this.hashingService.hashSha256(key); - await this.databaseService.db.insert(apiKeys).values({ + await this.apiKeysRepository.create({ userId, name, prefix: prefix.substring(0, 8), @@ -34,37 +36,18 @@ export class ApiKeysService { } async findAll(userId: string) { - return await this.databaseService.db - .select({ - id: apiKeys.id, - name: apiKeys.name, - prefix: apiKeys.prefix, - isActive: apiKeys.isActive, - lastUsedAt: apiKeys.lastUsedAt, - expiresAt: apiKeys.expiresAt, - createdAt: apiKeys.createdAt, - }) - .from(apiKeys) - .where(eq(apiKeys.userId, userId)); + return await this.apiKeysRepository.findAll(userId); } async revoke(userId: string, keyId: string) { this.logger.log(`Revoking API key ${keyId} for user ${userId}`); - return await this.databaseService.db - .update(apiKeys) - .set({ isActive: false, updatedAt: new Date() }) - .where(and(eq(apiKeys.id, keyId), eq(apiKeys.userId, userId))) - .returning(); + return await this.apiKeysRepository.revoke(userId, keyId); } async validateKey(key: string) { - const keyHash = createHash("sha256").update(key).digest("hex"); + const keyHash = await this.hashingService.hashSha256(key); - const [apiKey] = await this.databaseService.db - .select() - .from(apiKeys) - .where(and(eq(apiKeys.keyHash, keyHash), eq(apiKeys.isActive, true))) - .limit(1); + const apiKey = await this.apiKeysRepository.findActiveByKeyHash(keyHash); if (!apiKey) return null; @@ -73,10 +56,7 @@ export class ApiKeysService { } // Update last used at - await this.databaseService.db - .update(apiKeys) - .set({ lastUsedAt: new Date() }) - .where(eq(apiKeys.id, apiKey.id)); + await this.apiKeysRepository.updateLastUsed(apiKey.id); return apiKey; } diff --git a/backend/src/api-keys/repositories/api-keys.repository.ts b/backend/src/api-keys/repositories/api-keys.repository.ts new file mode 100644 index 0000000..996cc64 --- /dev/null +++ b/backend/src/api-keys/repositories/api-keys.repository.ts @@ -0,0 +1,58 @@ +import { Injectable } from "@nestjs/common"; +import { and, eq } from "drizzle-orm"; +import { DatabaseService } from "../../database/database.service"; +import { apiKeys } from "../../database/schemas"; + +@Injectable() +export class ApiKeysRepository { + constructor(private readonly databaseService: DatabaseService) {} + + async create(data: { + userId: string; + name: string; + prefix: string; + keyHash: string; + expiresAt?: Date; + }) { + return await this.databaseService.db.insert(apiKeys).values(data); + } + + async findAll(userId: string) { + return await this.databaseService.db + .select({ + id: apiKeys.id, + name: apiKeys.name, + prefix: apiKeys.prefix, + isActive: apiKeys.isActive, + lastUsedAt: apiKeys.lastUsedAt, + expiresAt: apiKeys.expiresAt, + createdAt: apiKeys.createdAt, + }) + .from(apiKeys) + .where(eq(apiKeys.userId, userId)); + } + + async revoke(userId: string, keyId: string) { + return await this.databaseService.db + .update(apiKeys) + .set({ isActive: false, updatedAt: new Date() }) + .where(and(eq(apiKeys.id, keyId), eq(apiKeys.userId, userId))) + .returning(); + } + + async findActiveByKeyHash(keyHash: string) { + const result = await this.databaseService.db + .select() + .from(apiKeys) + .where(and(eq(apiKeys.keyHash, keyHash), eq(apiKeys.isActive, true))) + .limit(1); + return result[0] || null; + } + + async updateLastUsed(id: string) { + return await this.databaseService.db + .update(apiKeys) + .set({ lastUsedAt: new Date() }) + .where(eq(apiKeys.id, id)); + } +} diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index 7d6dfba..32b235c 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -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 {} diff --git a/backend/src/auth/auth.service.spec.ts b/backend/src/auth/auth.service.spec.ts index fd7bdb8..4164ad7 100644 --- a/backend/src/auth/auth.service.spec.ts +++ b/backend/src/auth/auth.service.spec.ts @@ -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); diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 12826d6..8e293c4 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -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, }); diff --git a/backend/src/auth/guards/auth.guard.ts b/backend/src/auth/guards/auth.guard.ts index 2073569..38cdcf3 100644 --- a/backend/src/auth/guards/auth.guard.ts +++ b/backend/src/auth/guards/auth.guard.ts @@ -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(); diff --git a/backend/src/auth/rbac.service.spec.ts b/backend/src/auth/rbac.service.spec.ts index dfba4c9..e638acc 100644 --- a/backend/src/auth/rbac.service.spec.ts +++ b/backend/src/auth/rbac.service.spec.ts @@ -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); + repository = module.get(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); }); }); }); diff --git a/backend/src/auth/rbac.service.ts b/backend/src/auth/rbac.service.ts index ca429e3..5afc32a 100644 --- a/backend/src/auth/rbac.service.ts +++ b/backend/src/auth/rbac.service.ts @@ -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); } } diff --git a/backend/src/auth/repositories/rbac.repository.ts b/backend/src/auth/repositories/rbac.repository.ts new file mode 100644 index 0000000..0b1d1a3 --- /dev/null +++ b/backend/src/auth/repositories/rbac.repository.ts @@ -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))); + } +} diff --git a/backend/src/categories/categories.module.ts b/backend/src/categories/categories.module.ts index 8d3333a..adf89be 100644 --- a/backend/src/categories/categories.module.ts +++ b/backend/src/categories/categories.module.ts @@ -2,11 +2,12 @@ import { Module } from "@nestjs/common"; import { DatabaseModule } from "../database/database.module"; import { CategoriesController } from "./categories.controller"; import { CategoriesService } from "./categories.service"; +import { CategoriesRepository } from "./repositories/categories.repository"; @Module({ imports: [DatabaseModule], controllers: [CategoriesController], - providers: [CategoriesService], + providers: [CategoriesService, CategoriesRepository], exports: [CategoriesService], }) export class CategoriesModule {} diff --git a/backend/src/categories/categories.service.spec.ts b/backend/src/categories/categories.service.spec.ts index 8c0508f..f0f43ab 100644 --- a/backend/src/categories/categories.service.spec.ts +++ b/backend/src/categories/categories.service.spec.ts @@ -1,25 +1,24 @@ import { Test, TestingModule } from "@nestjs/testing"; -import { DatabaseService } from "../database/database.service"; -import { categories } from "../database/schemas"; +import { CACHE_MANAGER } from "@nestjs/cache-manager"; import { CategoriesService } from "./categories.service"; +import { CategoriesRepository } from "./repositories/categories.repository"; import { CreateCategoryDto } from "./dto/create-category.dto"; import { UpdateCategoryDto } from "./dto/update-category.dto"; describe("CategoriesService", () => { let service: CategoriesService; + let repository: CategoriesRepository; - const mockDb = { - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - limit: jest.fn().mockResolvedValue([]), - orderBy: jest.fn().mockResolvedValue([]), - insert: jest.fn().mockReturnThis(), - values: jest.fn().mockReturnThis(), - update: jest.fn().mockReturnThis(), - set: jest.fn().mockReturnThis(), - delete: jest.fn().mockReturnThis(), - returning: jest.fn().mockResolvedValue([]), + const mockCategoriesRepository = { + findAll: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + }; + + const mockCacheManager = { + del: jest.fn(), }; beforeEach(async () => { @@ -28,15 +27,15 @@ describe("CategoriesService", () => { providers: [ CategoriesService, { - provide: DatabaseService, - useValue: { - db: mockDb, - }, + provide: CategoriesRepository, + useValue: mockCategoriesRepository, }, + { provide: CACHE_MANAGER, useValue: mockCacheManager }, ], }).compile(); service = module.get(CategoriesService); + repository = module.get(CategoriesRepository); }); it("should be defined", () => { @@ -46,28 +45,28 @@ describe("CategoriesService", () => { describe("findAll", () => { it("should return all categories ordered by name", async () => { const mockCategories = [{ name: "A" }, { name: "B" }]; - mockDb.orderBy.mockResolvedValue(mockCategories); + mockCategoriesRepository.findAll.mockResolvedValue(mockCategories); const result = await service.findAll(); expect(result).toEqual(mockCategories); - expect(mockDb.select).toHaveBeenCalled(); - expect(mockDb.from).toHaveBeenCalledWith(categories); + expect(repository.findAll).toHaveBeenCalled(); }); }); describe("findOne", () => { it("should return a category by id", async () => { const mockCategory = { id: "1", name: "Cat" }; - mockDb.limit.mockResolvedValue([mockCategory]); + mockCategoriesRepository.findOne.mockResolvedValue(mockCategory); const result = await service.findOne("1"); expect(result).toEqual(mockCategory); + expect(repository.findOne).toHaveBeenCalledWith("1"); }); it("should return null if category not found", async () => { - mockDb.limit.mockResolvedValue([]); + mockCategoriesRepository.findOne.mockResolvedValue(null); const result = await service.findOne("999"); expect(result).toBeNull(); }); @@ -76,12 +75,11 @@ describe("CategoriesService", () => { describe("create", () => { it("should create a category and generate slug", async () => { const dto: CreateCategoryDto = { name: "Test Category" }; - mockDb.returning.mockResolvedValue([{ ...dto, slug: "test-category" }]); + mockCategoriesRepository.create.mockResolvedValue([{ ...dto, slug: "test-category" }]); const result = await service.create(dto); - expect(mockDb.insert).toHaveBeenCalledWith(categories); - expect(mockDb.values).toHaveBeenCalledWith({ + expect(repository.create).toHaveBeenCalledWith({ name: "Test Category", slug: "test-category", }); @@ -93,12 +91,12 @@ describe("CategoriesService", () => { it("should update a category and regenerate slug", async () => { const id = "1"; const dto: UpdateCategoryDto = { name: "New Name" }; - mockDb.returning.mockResolvedValue([{ id, ...dto, slug: "new-name" }]); + mockCategoriesRepository.update.mockResolvedValue([{ id, ...dto, slug: "new-name" }]); const result = await service.update(id, dto); - expect(mockDb.update).toHaveBeenCalledWith(categories); - expect(mockDb.set).toHaveBeenCalledWith( + expect(repository.update).toHaveBeenCalledWith( + id, expect.objectContaining({ name: "New Name", slug: "new-name", @@ -111,11 +109,11 @@ describe("CategoriesService", () => { describe("remove", () => { it("should remove a category", async () => { const id = "1"; - mockDb.returning.mockResolvedValue([{ id }]); + mockCategoriesRepository.remove.mockResolvedValue([{ id }]); const result = await service.remove(id); - expect(mockDb.delete).toHaveBeenCalledWith(categories); + expect(repository.remove).toHaveBeenCalledWith(id); expect(result).toEqual([{ id }]); }); }); diff --git a/backend/src/categories/categories.service.ts b/backend/src/categories/categories.service.ts index 0bddd96..5a41bea 100644 --- a/backend/src/categories/categories.service.ts +++ b/backend/src/categories/categories.service.ts @@ -1,9 +1,7 @@ import { Injectable, Logger, Inject } from "@nestjs/common"; import { CACHE_MANAGER } from "@nestjs/cache-manager"; import { Cache } from "cache-manager"; -import { eq } from "drizzle-orm"; -import { DatabaseService } from "../database/database.service"; -import { categories } from "../database/schemas"; +import { CategoriesRepository } from "./repositories/categories.repository"; import { CreateCategoryDto } from "./dto/create-category.dto"; import { UpdateCategoryDto } from "./dto/update-category.dto"; @@ -12,7 +10,7 @@ export class CategoriesService { private readonly logger = new Logger(CategoriesService.name); constructor( - private readonly databaseService: DatabaseService, + private readonly categoriesRepository: CategoriesRepository, @Inject(CACHE_MANAGER) private cacheManager: Cache, ) {} @@ -22,20 +20,11 @@ export class CategoriesService { } async findAll() { - return await this.databaseService.db - .select() - .from(categories) - .orderBy(categories.name); + return await this.categoriesRepository.findAll(); } async findOne(id: string) { - const result = await this.databaseService.db - .select() - .from(categories) - .where(eq(categories.id, id)) - .limit(1); - - return result[0] || null; + return await this.categoriesRepository.findOne(id); } async create(data: CreateCategoryDto) { @@ -44,10 +33,7 @@ export class CategoriesService { .toLowerCase() .replace(/ /g, "-") .replace(/[^\w-]/g, ""); - const result = await this.databaseService.db - .insert(categories) - .values({ ...data, slug }) - .returning(); + const result = await this.categoriesRepository.create({ ...data, slug }); await this.clearCategoriesCache(); return result; @@ -65,11 +51,7 @@ export class CategoriesService { .replace(/[^\w-]/g, "") : undefined, }; - const result = await this.databaseService.db - .update(categories) - .set(updateData) - .where(eq(categories.id, id)) - .returning(); + const result = await this.categoriesRepository.update(id, updateData); await this.clearCategoriesCache(); return result; @@ -77,10 +59,7 @@ export class CategoriesService { async remove(id: string) { this.logger.log(`Removing category: ${id}`); - const result = await this.databaseService.db - .delete(categories) - .where(eq(categories.id, id)) - .returning(); + const result = await this.categoriesRepository.remove(id); await this.clearCategoriesCache(); return result; diff --git a/backend/src/categories/repositories/categories.repository.ts b/backend/src/categories/repositories/categories.repository.ts new file mode 100644 index 0000000..3ef00c6 --- /dev/null +++ b/backend/src/categories/repositories/categories.repository.ts @@ -0,0 +1,50 @@ +import { Injectable } from "@nestjs/common"; +import { eq } from "drizzle-orm"; +import { DatabaseService } from "../../database/database.service"; +import { categories } from "../../database/schemas"; +import type { CreateCategoryDto } from "../dto/create-category.dto"; +import type { UpdateCategoryDto } from "../dto/update-category.dto"; + +@Injectable() +export class CategoriesRepository { + constructor(private readonly databaseService: DatabaseService) {} + + async findAll() { + return await this.databaseService.db + .select() + .from(categories) + .orderBy(categories.name); + } + + async findOne(id: string) { + const result = await this.databaseService.db + .select() + .from(categories) + .where(eq(categories.id, id)) + .limit(1); + + return result[0] || null; + } + + async create(data: CreateCategoryDto & { slug: string }) { + return await this.databaseService.db + .insert(categories) + .values(data) + .returning(); + } + + async update(id: string, data: UpdateCategoryDto & { slug?: string; updatedAt: Date }) { + return await this.databaseService.db + .update(categories) + .set(data) + .where(eq(categories.id, id)) + .returning(); + } + + async remove(id: string) { + return await this.databaseService.db + .delete(categories) + .where(eq(categories.id, id)) + .returning(); + } +} diff --git a/backend/src/common/interfaces/mail.interface.ts b/backend/src/common/interfaces/mail.interface.ts new file mode 100644 index 0000000..f5f7c7a --- /dev/null +++ b/backend/src/common/interfaces/mail.interface.ts @@ -0,0 +1,4 @@ +export interface IMailService { + sendEmailValidation(email: string, token: string): Promise; + sendPasswordReset(email: string, token: string): Promise; +} diff --git a/backend/src/common/interfaces/media.interface.ts b/backend/src/common/interfaces/media.interface.ts new file mode 100644 index 0000000..0f5e548 --- /dev/null +++ b/backend/src/common/interfaces/media.interface.ts @@ -0,0 +1,25 @@ +export interface MediaProcessingResult { + buffer: Buffer; + mimeType: string; + extension: string; + width?: number; + height?: number; + size: number; +} + +export interface ScanResult { + isInfected: boolean; + virusName?: string; +} + +export interface IMediaService { + scanFile(buffer: Buffer, filename: string): Promise; + processImage( + buffer: Buffer, + format?: "webp" | "avif", + ): Promise; + processVideo( + buffer: Buffer, + format?: "webm" | "av1", + ): Promise; +} diff --git a/backend/src/common/interfaces/storage.interface.ts b/backend/src/common/interfaces/storage.interface.ts new file mode 100644 index 0000000..c65062a --- /dev/null +++ b/backend/src/common/interfaces/storage.interface.ts @@ -0,0 +1,39 @@ +import type { Readable } from "node:stream"; + +export interface IStorageService { + uploadFile( + fileName: string, + file: Buffer, + mimeType: string, + metaData?: Record, + bucketName?: string, + ): Promise; + + getFile( + fileName: string, + bucketName?: string, + ): Promise; + + getFileUrl( + fileName: string, + expiry?: number, + bucketName?: string, + ): Promise; + + getUploadUrl( + fileName: string, + expiry?: number, + bucketName?: string, + ): Promise; + + deleteFile(fileName: string, bucketName?: string): Promise; + + getFileInfo(fileName: string, bucketName?: string): Promise; + + moveFile( + sourceFileName: string, + destinationFileName: string, + sourceBucketName?: string, + destinationBucketName?: string, + ): Promise; +} diff --git a/backend/src/common/services/purge.service.spec.ts b/backend/src/common/services/purge.service.spec.ts index 7e6d31e..4768400 100644 --- a/backend/src/common/services/purge.service.spec.ts +++ b/backend/src/common/services/purge.service.spec.ts @@ -1,38 +1,31 @@ import { Logger } from "@nestjs/common"; import { Test, TestingModule } from "@nestjs/testing"; -import { DatabaseService } from "../../database/database.service"; +import { ContentsRepository } from "../../contents/repositories/contents.repository"; +import { ReportsRepository } from "../../reports/repositories/reports.repository"; +import { SessionsRepository } from "../../sessions/repositories/sessions.repository"; +import { UsersRepository } from "../../users/repositories/users.repository"; import { PurgeService } from "./purge.service"; describe("PurgeService", () => { let service: PurgeService; - const mockDb = { - delete: jest.fn(), - where: jest.fn(), - returning: jest.fn(), - }; + const mockSessionsRepository = { purgeExpired: jest.fn().mockResolvedValue([]) }; + const mockReportsRepository = { purgeObsolete: jest.fn().mockResolvedValue([]) }; + const mockUsersRepository = { purgeDeleted: jest.fn().mockResolvedValue([]) }; + const mockContentsRepository = { purgeSoftDeleted: jest.fn().mockResolvedValue([]) }; beforeEach(async () => { jest.clearAllMocks(); jest.spyOn(Logger.prototype, "error").mockImplementation(() => {}); jest.spyOn(Logger.prototype, "log").mockImplementation(() => {}); - const chain = { - delete: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - returning: jest.fn().mockResolvedValue([]), - }; - - const mockImplementation = () => Object.assign(Promise.resolve([]), chain); - for (const mock of Object.values(chain)) { - mock.mockImplementation(mockImplementation); - } - Object.assign(mockDb, chain); - const module: TestingModule = await Test.createTestingModule({ providers: [ PurgeService, - { provide: DatabaseService, useValue: { db: mockDb } }, + { provide: SessionsRepository, useValue: mockSessionsRepository }, + { provide: ReportsRepository, useValue: mockReportsRepository }, + { provide: UsersRepository, useValue: mockUsersRepository }, + { provide: ContentsRepository, useValue: mockContentsRepository }, ], }).compile(); @@ -44,23 +37,22 @@ describe("PurgeService", () => { }); describe("purgeExpiredData", () => { - it("should purge data", async () => { - mockDb.returning - .mockResolvedValueOnce([{ id: "s1" }]) // sessions - .mockResolvedValueOnce([{ id: "r1" }]) // reports - .mockResolvedValueOnce([{ id: "u1" }]) // users - .mockResolvedValueOnce([{ id: "c1" }]); // contents + it("should purge data using repositories", async () => { + mockSessionsRepository.purgeExpired.mockResolvedValue([{ id: "s1" }]); + mockReportsRepository.purgeObsolete.mockResolvedValue([{ id: "r1" }]); + mockUsersRepository.purgeDeleted.mockResolvedValue([{ id: "u1" }]); + mockContentsRepository.purgeSoftDeleted.mockResolvedValue([{ id: "c1" }]); await service.purgeExpiredData(); - expect(mockDb.delete).toHaveBeenCalledTimes(4); - expect(mockDb.returning).toHaveBeenCalledTimes(4); + expect(mockSessionsRepository.purgeExpired).toHaveBeenCalled(); + expect(mockReportsRepository.purgeObsolete).toHaveBeenCalled(); + expect(mockUsersRepository.purgeDeleted).toHaveBeenCalled(); + expect(mockContentsRepository.purgeSoftDeleted).toHaveBeenCalled(); }); it("should handle errors", async () => { - mockDb.delete.mockImplementation(() => { - throw new Error("Db error"); - }); + mockSessionsRepository.purgeExpired.mockRejectedValue(new Error("Db error")); await expect(service.purgeExpiredData()).resolves.not.toThrow(); }); }); diff --git a/backend/src/common/services/purge.service.ts b/backend/src/common/services/purge.service.ts index 504dfe5..106fd3d 100644 --- a/backend/src/common/services/purge.service.ts +++ b/backend/src/common/services/purge.service.ts @@ -1,14 +1,20 @@ import { Injectable, Logger } from "@nestjs/common"; import { Cron, CronExpression } from "@nestjs/schedule"; -import { and, eq, isNotNull, lte } from "drizzle-orm"; -import { DatabaseService } from "../../database/database.service"; -import { contents, reports, sessions, users } from "../../database/schemas"; +import { ContentsRepository } from "../../contents/repositories/contents.repository"; +import { ReportsRepository } from "../../reports/repositories/reports.repository"; +import { SessionsRepository } from "../../sessions/repositories/sessions.repository"; +import { UsersRepository } from "../../users/repositories/users.repository"; @Injectable() export class PurgeService { private readonly logger = new Logger(PurgeService.name); - constructor(private readonly databaseService: DatabaseService) {} + constructor( + private readonly sessionsRepository: SessionsRepository, + private readonly reportsRepository: ReportsRepository, + private readonly usersRepository: UsersRepository, + private readonly contentsRepository: ContentsRepository, + ) {} // Toutes les nuits à minuit @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) @@ -19,40 +25,26 @@ export class PurgeService { const now = new Date(); // 1. Purge des sessions expirées - const deletedSessions = await this.databaseService.db - .delete(sessions) - .where(lte(sessions.expiresAt, now)) - .returning(); + const deletedSessions = await this.sessionsRepository.purgeExpired(now); this.logger.log(`Purged ${deletedSessions.length} expired sessions.`); // 2. Purge des signalements obsolètes - const deletedReports = await this.databaseService.db - .delete(reports) - .where(lte(reports.expiresAt, now)) - .returning(); + const deletedReports = await this.reportsRepository.purgeObsolete(now); this.logger.log(`Purged ${deletedReports.length} obsolete reports.`); // 3. Purge des utilisateurs supprimés (Soft Delete > 30 jours) const thirtyDaysAgo = new Date(); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); - const deletedUsers = await this.databaseService.db - .delete(users) - .where( - and(eq(users.status, "deleted"), lte(users.deletedAt, thirtyDaysAgo)), - ) - .returning(); + const deletedUsers = await this.usersRepository.purgeDeleted(thirtyDaysAgo); this.logger.log( `Purged ${deletedUsers.length} users marked for deletion more than 30 days ago.`, ); // 4. Purge des contenus supprimés (Soft Delete > 30 jours) - const deletedContents = await this.databaseService.db - .delete(contents) - .where( - and(isNotNull(contents.deletedAt), lte(contents.deletedAt, thirtyDaysAgo)), - ) - .returning(); + const deletedContents = await this.contentsRepository.purgeSoftDeleted( + thirtyDaysAgo, + ); this.logger.log( `Purged ${deletedContents.length} contents marked for deletion more than 30 days ago.`, ); diff --git a/backend/src/contents/contents.controller.ts b/backend/src/contents/contents.controller.ts index 608278c..cbdcf7b 100644 --- a/backend/src/contents/contents.controller.ts +++ b/backend/src/contents/contents.controller.ts @@ -136,25 +136,7 @@ export class ContentsController { ); if (isBot) { - const imageUrl = this.contentsService.getFileUrl(content.storageKey); - const html = ` - - - - ${content.title} - - - - - - - - - -

${content.title}

- ${content.title} - -`; + const html = this.contentsService.generateBotHtml(content); return res.send(html); } diff --git a/backend/src/contents/contents.module.ts b/backend/src/contents/contents.module.ts index a782100..971cc58 100644 --- a/backend/src/contents/contents.module.ts +++ b/backend/src/contents/contents.module.ts @@ -6,10 +6,11 @@ import { MediaModule } from "../media/media.module"; import { S3Module } from "../s3/s3.module"; import { ContentsController } from "./contents.controller"; import { ContentsService } from "./contents.service"; +import { ContentsRepository } from "./repositories/contents.repository"; @Module({ imports: [DatabaseModule, S3Module, AuthModule, CryptoModule, MediaModule], controllers: [ContentsController], - providers: [ContentsService], + providers: [ContentsService, ContentsRepository], }) export class ContentsModule {} diff --git a/backend/src/contents/contents.service.spec.ts b/backend/src/contents/contents.service.spec.ts index ebcdff1..2f4fff8 100644 --- a/backend/src/contents/contents.service.spec.ts +++ b/backend/src/contents/contents.service.spec.ts @@ -4,33 +4,28 @@ jest.mock("uuid", () => ({ import { BadRequestException } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; +import { CACHE_MANAGER } from "@nestjs/cache-manager"; import { Test, TestingModule } from "@nestjs/testing"; import { DatabaseService } from "../database/database.service"; import { MediaService } from "../media/media.service"; import { S3Service } from "../s3/s3.service"; import { ContentsService } from "./contents.service"; +import { ContentsRepository } from "./repositories/contents.repository"; describe("ContentsService", () => { let service: ContentsService; let s3Service: S3Service; let mediaService: MediaService; - const mockDb = { - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - offset: jest.fn().mockReturnThis(), - orderBy: jest.fn().mockReturnThis(), - innerJoin: jest.fn().mockReturnThis(), - insert: jest.fn().mockReturnThis(), - values: jest.fn().mockReturnThis(), - update: jest.fn().mockReturnThis(), - set: jest.fn().mockReturnThis(), - returning: jest.fn().mockResolvedValue([]), - onConflictDoNothing: jest.fn().mockReturnThis(), - transaction: jest.fn().mockImplementation((cb) => cb(mockDb)), - execute: jest.fn().mockResolvedValue([]), + const mockContentsRepository = { + findAll: jest.fn(), + count: jest.fn(), + create: jest.fn(), + incrementViews: jest.fn(), + incrementUsage: jest.fn(), + softDelete: jest.fn(), + findOne: jest.fn(), + findBySlug: jest.fn(), }; const mockS3Service = { @@ -48,46 +43,24 @@ describe("ContentsService", () => { get: jest.fn(), }; + const mockCacheManager = { + store: { + keys: jest.fn().mockResolvedValue([]), + }, + del: jest.fn(), + }; + beforeEach(async () => { jest.clearAllMocks(); - const chain = { - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - orderBy: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - offset: jest.fn().mockReturnThis(), - innerJoin: jest.fn().mockReturnThis(), - insert: jest.fn().mockReturnThis(), - values: jest.fn().mockReturnThis(), - update: jest.fn().mockReturnThis(), - set: jest.fn().mockReturnThis(), - returning: jest.fn().mockReturnThis(), - onConflictDoNothing: jest.fn().mockReturnThis(), - }; - - const mockImplementation = () => { - return Object.assign(Promise.resolve([]), chain); - }; - - for (const mock of Object.values(chain)) { - - //TODO Fix : TS2774: This condition will always return true since this function is always defined. Did you mean to call it instead? - if (mock.mockReturnValue) { - mock.mockImplementation(mockImplementation); - } - } - - Object.assign(mockDb, chain); - const module: TestingModule = await Test.createTestingModule({ providers: [ ContentsService, - { provide: DatabaseService, useValue: { db: mockDb } }, + { provide: ContentsRepository, useValue: mockContentsRepository }, { provide: S3Service, useValue: mockS3Service }, { provide: MediaService, useValue: mockMediaService }, { provide: ConfigService, useValue: mockConfigService }, + { provide: CACHE_MANAGER, useValue: mockCacheManager }, ], }).compile(); @@ -127,7 +100,8 @@ describe("ContentsService", () => { mimeType: "image/webp", size: 500, }); - mockDb.returning.mockResolvedValue([{ id: "content-id" }]); + mockContentsRepository.findBySlug.mockResolvedValue(null); + mockContentsRepository.create.mockResolvedValue({ id: "content-id" }); const result = await service.uploadAndProcess("user1", file, { title: "Meme", @@ -155,8 +129,8 @@ describe("ContentsService", () => { describe("findAll", () => { it("should return contents and total count", async () => { - mockDb.where.mockResolvedValueOnce([{ count: 10 }]); // for count - mockDb.offset.mockResolvedValueOnce([{ id: "1" }]); // for data + mockContentsRepository.count.mockResolvedValue(10); + mockContentsRepository.findAll.mockResolvedValue([{ id: "1" }]); const result = await service.findAll({ limit: 10, offset: 0 }); @@ -167,9 +141,9 @@ describe("ContentsService", () => { describe("incrementViews", () => { it("should increment views", async () => { - mockDb.returning.mockResolvedValue([{ id: "1", views: 1 }]); + mockContentsRepository.incrementViews.mockResolvedValue([{ id: "1", views: 1 }]); const result = await service.incrementViews("1"); - expect(mockDb.update).toHaveBeenCalled(); + expect(mockContentsRepository.incrementViews).toHaveBeenCalledWith("1"); expect(result[0].views).toBe(1); }); }); diff --git a/backend/src/contents/contents.service.ts b/backend/src/contents/contents.service.ts index f09cb76..695384a 100644 --- a/backend/src/contents/contents.service.ts +++ b/backend/src/contents/contents.service.ts @@ -3,39 +3,21 @@ import { CACHE_MANAGER } from "@nestjs/cache-manager"; import { Cache } from "cache-manager"; import { Inject } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; -import { - and, - desc, - eq, - exists, - ilike, - isNull, - type SQL, - sql, -} from "drizzle-orm"; import { v4 as uuidv4 } from "uuid"; -import { DatabaseService } from "../database/database.service"; -import { - categories, - contents, - contentsToTags, - favorites, - tags, - users, -} from "../database/schemas"; -import type { MediaProcessingResult } from "../media/interfaces/media.interface"; +import type { IMediaService } from "../common/interfaces/media.interface"; +import type { IStorageService } from "../common/interfaces/storage.interface"; import { MediaService } from "../media/media.service"; import { S3Service } from "../s3/s3.service"; -import { CreateContentDto } from "./dto/create-content.dto"; +import { ContentsRepository } from "./repositories/contents.repository"; @Injectable() export class ContentsService { private readonly logger = new Logger(ContentsService.name); constructor( - private readonly databaseService: DatabaseService, - private readonly s3Service: S3Service, - private readonly mediaService: MediaService, + private readonly contentsRepository: ContentsRepository, + @Inject(S3Service) private readonly s3Service: IStorageService, + @Inject(MediaService) private readonly mediaService: IMediaService, private readonly configService: ConfigService, @Inject(CACHE_MANAGER) private cacheManager: Cache, ) {} @@ -104,7 +86,7 @@ export class ContentsService { } // 2. Transcodage - let processed: MediaProcessingResult; + let processed; if (file.mimetype.startsWith("image/")) { // Image ou GIF -> WebP (format moderne, bien supporté) processed = await this.mediaService.processImage(file.buffer, "webp"); @@ -139,195 +121,71 @@ export class ContentsService { favoritesOnly?: boolean; userId?: string; // Nécessaire si favoritesOnly est vrai }) { - const { - limit, - offset, - sortBy, - tag, - category, - author, - query, - favoritesOnly, - userId, - } = options; - - let whereClause: SQL | undefined = isNull(contents.deletedAt); - - if (tag) { - whereClause = and( - whereClause, - exists( - this.databaseService.db - .select() - .from(contentsToTags) - .innerJoin(tags, eq(contentsToTags.tagId, tags.id)) - .where( - and(eq(contentsToTags.contentId, contents.id), eq(tags.name, tag)), - ), - ), - ); - } - - if (author) { - whereClause = and( - whereClause, - exists( - this.databaseService.db - .select() - .from(users) - .where(and(eq(users.uuid, contents.userId), eq(users.username, author))), - ), - ); - } - - if (category) { - whereClause = and( - whereClause, - exists( - this.databaseService.db - .select() - .from(categories) - .where( - and( - eq(categories.id, contents.categoryId), - sql`(${categories.slug} = ${category} OR ${categories.id}::text = ${category})`, - ), - ), - ), - ); - } - - if (query) { - whereClause = and(whereClause, ilike(contents.title, `%${query}%`)); - } - - if (favoritesOnly && userId) { - whereClause = and( - whereClause, - exists( - this.databaseService.db - .select() - .from(favorites) - .where( - and(eq(favorites.contentId, contents.id), eq(favorites.userId, userId)), - ), - ), - ); - } - - // Pagination Total Count - const totalCountResult = await this.databaseService.db - .select({ count: sql`count(*)` }) - .from(contents) - .where(whereClause); - - const totalCount = Number(totalCountResult[0].count); - - // Sorting - let orderBy: SQL = desc(contents.createdAt); - if (sortBy === "trend") { - orderBy = desc(sql`${contents.views} + ${contents.usageCount}`); - } - - const data = await this.databaseService.db - .select() - .from(contents) - .where(whereClause) - .orderBy(orderBy) - .limit(limit) - .offset(offset); + const [data, totalCount] = await Promise.all([ + this.contentsRepository.findAll(options), + this.contentsRepository.count(options), + ]); return { data, totalCount }; } - async create(userId: string, data: CreateContentDto) { + async create(userId: string, data: any) { this.logger.log(`Creating content for user ${userId}: ${data.title}`); const { tags: tagNames, ...contentData } = data; const slug = await this.ensureUniqueSlug(contentData.title); - return await this.databaseService.db.transaction(async (tx) => { - const [newContent] = await tx - .insert(contents) - .values({ ...contentData, userId, slug }) - .returning(); + const newContent = await this.contentsRepository.create( + { ...contentData, userId, slug }, + tagNames, + ); - if (tagNames && tagNames.length > 0) { - for (const tagName of tagNames) { - const slug = tagName - .toLowerCase() - .replace(/ /g, "-") - .replace(/[^\w-]/g, ""); - - // Trouver ou créer le tag - let [tag] = await tx - .select() - .from(tags) - .where(eq(tags.slug, slug)) - .limit(1); - - if (!tag) { - [tag] = await tx - .insert(tags) - .values({ name: tagName, slug, userId }) - .returning(); - } - - // Lier le tag au contenu - await tx - .insert(contentsToTags) - .values({ contentId: newContent.id, tagId: tag.id }) - .onConflictDoNothing(); - } - } - - await this.clearContentsCache(); - return newContent; - }); + await this.clearContentsCache(); + return newContent; } async incrementViews(id: string) { - return await this.databaseService.db - .update(contents) - .set({ views: sql`${contents.views} + 1` }) - .where(eq(contents.id, id)) - .returning(); + return await this.contentsRepository.incrementViews(id); } async incrementUsage(id: string) { - return await this.databaseService.db - .update(contents) - .set({ usageCount: sql`${contents.usageCount} + 1` }) - .where(eq(contents.id, id)) - .returning(); + return await this.contentsRepository.incrementUsage(id); } async remove(id: string, userId: string) { this.logger.log(`Removing content ${id} for user ${userId}`); - const result = await this.databaseService.db - .update(contents) - .set({ deletedAt: new Date() }) - .where(and(eq(contents.id, id), eq(contents.userId, userId))) - .returning(); + const deleted = await this.contentsRepository.softDelete(id, userId); - if (result.length > 0) { + if (deleted) { await this.clearContentsCache(); } - return result; + return deleted; } async findOne(idOrSlug: string) { - const [content] = await this.databaseService.db - .select() - .from(contents) - .where( - and( - isNull(contents.deletedAt), - sql`(${contents.id}::text = ${idOrSlug} OR ${contents.slug} = ${idOrSlug})`, - ), - ) - .limit(1); - return content; + return this.contentsRepository.findOne(idOrSlug); + } + + generateBotHtml(content: any): string { + const imageUrl = this.getFileUrl(content.storageKey); + return ` + + + + ${content.title} + + + + + + + + + +

${content.title}

+ ${content.title} + +`; } getFileUrl(storageKey: string): string { @@ -359,11 +217,7 @@ export class ContentsService { let counter = 1; while (true) { - const [existing] = await this.databaseService.db - .select() - .from(contents) - .where(eq(contents.slug, slug)) - .limit(1); + const existing = await this.contentsRepository.findBySlug(slug); if (!existing) break; slug = `${baseSlug}-${counter++}`; diff --git a/backend/src/contents/repositories/contents.repository.ts b/backend/src/contents/repositories/contents.repository.ts new file mode 100644 index 0000000..d044c51 --- /dev/null +++ b/backend/src/contents/repositories/contents.repository.ts @@ -0,0 +1,383 @@ +import { Injectable } from "@nestjs/common"; +import { + and, + desc, + eq, + exists, + ilike, + isNull, + sql, + lte, + type SQL, +} from "drizzle-orm"; +import { DatabaseService } from "../../database/database.service"; +import { + categories, + contents, + contentsToTags, + favorites, + tags, + users, +} from "../../database/schemas"; +import type { NewContentInDb } from "../../database/schemas/content"; + +export interface FindAllOptions { + limit: number; + offset: number; + sortBy?: "trend" | "recent"; + tag?: string; + category?: string; + author?: string; + query?: string; + favoritesOnly?: boolean; + userId?: string; +} + +@Injectable() +export class ContentsRepository { + constructor(private readonly databaseService: DatabaseService) {} + + async findAll(options: FindAllOptions) { + const { + limit, + offset, + sortBy, + tag, + category, + author, + query, + favoritesOnly, + userId, + } = options; + + let whereClause: SQL | undefined = isNull(contents.deletedAt); + + if (tag) { + whereClause = and( + whereClause, + exists( + this.databaseService.db + .select() + .from(contentsToTags) + .innerJoin(tags, eq(contentsToTags.tagId, tags.id)) + .where( + and(eq(contentsToTags.contentId, contents.id), eq(tags.name, tag)), + ), + ), + ); + } + + if (category) { + whereClause = and( + whereClause, + exists( + this.databaseService.db + .select() + .from(categories) + .where( + and( + eq(contents.categoryId, categories.id), + sql`(${categories.id}::text = ${category} OR ${categories.slug} = ${category})`, + ), + ), + ), + ); + } + + if (author) { + whereClause = and( + whereClause, + exists( + this.databaseService.db + .select() + .from(users) + .where( + and( + eq(contents.userId, users.uuid), + sql`(${users.uuid}::text = ${author} OR ${users.username} = ${author})`, + ), + ), + ), + ); + } + + if (query) { + whereClause = and(whereClause, ilike(contents.title, `%${query}%`)); + } + + if (favoritesOnly && userId) { + whereClause = and( + whereClause, + exists( + this.databaseService.db + .select() + .from(favorites) + .where( + and( + eq(favorites.contentId, contents.id), + eq(favorites.userId, userId), + ), + ), + ), + ); + } + + let orderBy = desc(contents.createdAt); + if (sortBy === "trend") { + orderBy = desc(sql`${contents.views} + ${contents.usageCount} * 2`); + } + + const results = await this.databaseService.db + .select({ + id: contents.id, + title: contents.title, + slug: contents.slug, + type: contents.type, + storageKey: contents.storageKey, + mimeType: contents.mimeType, + fileSize: contents.fileSize, + views: contents.views, + usageCount: contents.usageCount, + createdAt: contents.createdAt, + updatedAt: contents.updatedAt, + author: { + id: users.uuid, + username: users.username, + avatarUrl: users.avatarUrl, + }, + category: { + id: categories.id, + name: categories.name, + slug: categories.slug, + }, + }) + .from(contents) + .leftJoin(users, eq(contents.userId, users.uuid)) + .leftJoin(categories, eq(contents.categoryId, categories.id)) + .where(whereClause) + .orderBy(orderBy) + .limit(limit) + .offset(offset); + + const contentIds = results.map((r) => r.id); + const tagsForContents = contentIds.length + ? await this.databaseService.db + .select({ + contentId: contentsToTags.contentId, + name: tags.name, + }) + .from(contentsToTags) + .innerJoin(tags, eq(contentsToTags.tagId, tags.id)) + .where(sql`${contentsToTags.contentId} IN ${contentIds}`) + : []; + + return results.map((r) => ({ + ...r, + tags: tagsForContents + .filter((t) => t.contentId === r.id) + .map((t) => t.name), + })); + } + + async create(data: NewContentInDb & { userId: string }, tagNames?: string[]) { + return await this.databaseService.db.transaction(async (tx) => { + const [newContent] = await tx + .insert(contents) + .values(data) + .returning(); + + if (tagNames && tagNames.length > 0) { + for (const tagName of tagNames) { + const slug = tagName + .toLowerCase() + .replace(/ /g, "-") + .replace(/[^\w-]/g, ""); + + let [tag] = await tx + .select() + .from(tags) + .where(eq(tags.slug, slug)) + .limit(1); + + if (!tag) { + [tag] = await tx + .insert(tags) + .values({ + name: tagName, + slug, + userId: data.userId, + }) + .returning(); + } + + await tx + .insert(contentsToTags) + .values({ + contentId: newContent.id, + tagId: tag.id, + }) + .onConflictDoNothing(); + } + } + + return newContent; + }); + } + + async findOne(idOrSlug: string) { + const [result] = await this.databaseService.db + .select({ + id: contents.id, + title: contents.title, + slug: contents.slug, + type: contents.type, + storageKey: contents.storageKey, + mimeType: contents.mimeType, + fileSize: contents.fileSize, + views: contents.views, + usageCount: contents.usageCount, + createdAt: contents.createdAt, + updatedAt: contents.updatedAt, + userId: contents.userId, + }) + .from(contents) + .where( + and( + isNull(contents.deletedAt), + sql`(${contents.id}::text = ${idOrSlug} OR ${contents.slug} = ${idOrSlug})`, + ), + ) + .limit(1); + + return result; + } + + async count(options: { + tag?: string; + category?: string; + author?: string; + query?: string; + favoritesOnly?: boolean; + userId?: string; + }) { + const { tag, category, author, query, favoritesOnly, userId } = options; + + let whereClause: SQL | undefined = isNull(contents.deletedAt); + + if (tag) { + whereClause = and( + whereClause, + exists( + this.databaseService.db + .select() + .from(contentsToTags) + .innerJoin(tags, eq(contentsToTags.tagId, tags.id)) + .where( + and(eq(contentsToTags.contentId, contents.id), eq(tags.name, tag)), + ), + ), + ); + } + + if (category) { + whereClause = and( + whereClause, + exists( + this.databaseService.db + .select() + .from(categories) + .where( + and( + eq(contents.categoryId, categories.id), + sql`(${categories.id}::text = ${category} OR ${categories.slug} = ${category})`, + ), + ), + ), + ); + } + + if (author) { + whereClause = and( + whereClause, + exists( + this.databaseService.db + .select() + .from(users) + .where( + and( + eq(contents.userId, users.uuid), + sql`(${users.uuid}::text = ${author} OR ${users.username} = ${author})`, + ), + ), + ), + ); + } + + if (query) { + whereClause = and(whereClause, ilike(contents.title, `%${query}%`)); + } + + if (favoritesOnly && userId) { + whereClause = and( + whereClause, + exists( + this.databaseService.db + .select() + .from(favorites) + .where( + and( + eq(favorites.contentId, contents.id), + eq(favorites.userId, userId), + ), + ), + ), + ); + } + + const [result] = await this.databaseService.db + .select({ count: sql`count(*)` }) + .from(contents) + .where(whereClause); + + return Number(result.count); + } + + async incrementViews(id: string) { + await this.databaseService.db + .update(contents) + .set({ views: sql`${contents.views} + 1` }) + .where(eq(contents.id, id)); + } + + async incrementUsage(id: string) { + await this.databaseService.db + .update(contents) + .set({ usageCount: sql`${contents.usageCount} + 1` }) + .where(eq(contents.id, id)); + } + + async softDelete(id: string, userId: string) { + const [deleted] = await this.databaseService.db + .update(contents) + .set({ deletedAt: new Date() }) + .where(and(eq(contents.id, id), eq(contents.userId, userId))) + .returning(); + return deleted; + } + + async findBySlug(slug: string) { + const [result] = await this.databaseService.db + .select() + .from(contents) + .where(eq(contents.slug, slug)) + .limit(1); + return result; + } + + async purgeSoftDeleted(before: Date) { + return await this.databaseService.db + .delete(contents) + .where(and(sql`${contents.deletedAt} IS NOT NULL`, lte(contents.deletedAt, before))) + .returning(); + } +} diff --git a/backend/src/crypto/crypto.module.ts b/backend/src/crypto/crypto.module.ts index a17d37c..75836b1 100644 --- a/backend/src/crypto/crypto.module.ts +++ b/backend/src/crypto/crypto.module.ts @@ -1,8 +1,24 @@ import { Module } from "@nestjs/common"; import { CryptoService } from "./crypto.service"; +import { HashingService } from "./services/hashing.service"; +import { JwtService } from "./services/jwt.service"; +import { EncryptionService } from "./services/encryption.service"; +import { PostQuantumService } from "./services/post-quantum.service"; @Module({ - providers: [CryptoService], - exports: [CryptoService], + providers: [ + CryptoService, + HashingService, + JwtService, + EncryptionService, + PostQuantumService, + ], + exports: [ + CryptoService, + HashingService, + JwtService, + EncryptionService, + PostQuantumService, + ], }) export class CryptoModule {} diff --git a/backend/src/crypto/crypto.service.spec.ts b/backend/src/crypto/crypto.service.spec.ts index 6fde68a..e46fe9b 100644 --- a/backend/src/crypto/crypto.service.spec.ts +++ b/backend/src/crypto/crypto.service.spec.ts @@ -64,6 +64,10 @@ jest.mock("jose", () => ({ })); import { CryptoService } from "./crypto.service"; +import { HashingService } from "./services/hashing.service"; +import { JwtService } from "./services/jwt.service"; +import { EncryptionService } from "./services/encryption.service"; +import { PostQuantumService } from "./services/post-quantum.service"; describe("CryptoService", () => { let service: CryptoService; @@ -72,6 +76,10 @@ describe("CryptoService", () => { const module: TestingModule = await Test.createTestingModule({ providers: [ CryptoService, + HashingService, + JwtService, + EncryptionService, + PostQuantumService, { provide: ConfigService, useValue: { diff --git a/backend/src/crypto/crypto.service.ts b/backend/src/crypto/crypto.service.ts index a68ab2e..c1b4537 100644 --- a/backend/src/crypto/crypto.service.ts +++ b/backend/src/crypto/crypto.service.ts @@ -1,151 +1,79 @@ -import { Injectable, Logger } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { ml_kem768 } from "@noble/post-quantum/ml-kem.js"; -import { hash, verify } from "@node-rs/argon2"; -import * as jose from "jose"; +import { Injectable } from "@nestjs/common"; +import type * as jose from "jose"; +import { EncryptionService } from "./services/encryption.service"; +import { HashingService } from "./services/hashing.service"; +import { JwtService } from "./services/jwt.service"; +import { PostQuantumService } from "./services/post-quantum.service"; +/** + * @deprecated Use HashingService, JwtService, EncryptionService or PostQuantumService directly. + * This service acts as a Facade for backward compatibility. + */ @Injectable() export class CryptoService { - private readonly logger = new Logger(CryptoService.name); - private readonly jwtSecret: Uint8Array; - private readonly encryptionKey: Uint8Array; - - constructor(private configService: ConfigService) { - const secret = this.configService.get("JWT_SECRET"); - if (!secret) { - this.logger.warn( - "JWT_SECRET is not defined, using a default insecure secret for development", - ); - } - this.jwtSecret = new TextEncoder().encode( - secret || "default-secret-change-me-in-production", - ); - - const encKey = this.configService.get("ENCRYPTION_KEY"); - if (!encKey) { - this.logger.warn( - "ENCRYPTION_KEY is not defined, using a default insecure key for development", - ); - } - // Pour AES-GCM 256, on a besoin de 32 octets (256 bits) - const rawKey = encKey || "default-encryption-key-32-chars-"; - this.encryptionKey = new TextEncoder().encode( - rawKey.padEnd(32, "0").substring(0, 32), - ); - } - - // --- Blind Indexing (for search on encrypted data) --- + constructor( + private readonly hashingService: HashingService, + private readonly jwtService: JwtService, + private readonly encryptionService: EncryptionService, + private readonly postQuantumService: PostQuantumService, + ) {} async hashEmail(email: string): Promise { - const normalizedEmail = email.toLowerCase().trim(); - const data = new TextEncoder().encode(normalizedEmail); - const hashBuffer = await crypto.subtle.digest("SHA-256", data); - return Array.from(new Uint8Array(hashBuffer)) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); + return this.hashingService.hashEmail(email); } async hashIp(ip: string): Promise { - const data = new TextEncoder().encode(ip); - const hashBuffer = await crypto.subtle.digest("SHA-256", data); - return Array.from(new Uint8Array(hashBuffer)) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); + return this.hashingService.hashIp(ip); } getPgpEncryptionKey(): string { - return ( - this.configService.get("PGP_ENCRYPTION_KEY") || "default-pgp-key" - ); + return this.encryptionService.getPgpEncryptionKey(); } - // --- Argon2 Hashing --- - async hashPassword(password: string): Promise { - return hash(password, { - algorithm: 2, - }); + return this.hashingService.hashPassword(password); } async verifyPassword(password: string, hash: string): Promise { - return verify(hash, password); + return this.hashingService.verifyPassword(password, hash); } - // --- JWT Operations via jose --- - async generateJwt( payload: jose.JWTPayload, expiresIn = "2h", ): Promise { - return new jose.SignJWT(payload) - .setProtectedHeader({ alg: "HS256" }) - .setIssuedAt() - .setExpirationTime(expiresIn) - .sign(this.jwtSecret); + return this.jwtService.generateJwt(payload, expiresIn); } async verifyJwt(token: string): Promise { - const { payload } = await jose.jwtVerify(token, this.jwtSecret); - return payload as T; + return this.jwtService.verifyJwt(token); } - // --- Encryption & Decryption (JWE) --- - - /** - * Chiffre un contenu textuel en utilisant JWE (Compact Serialization) - * Algorithme: A256GCMKW pour la gestion des clés, A256GCM pour le chiffrement de contenu - */ async encryptContent(content: string): Promise { - const data = new TextEncoder().encode(content); - return new jose.CompactEncrypt(data) - .setProtectedHeader({ alg: "dir", enc: "A256GCM" }) - .encrypt(this.encryptionKey); + return this.encryptionService.encryptContent(content); } - /** - * Déchiffre un contenu JWE - */ async decryptContent(jwe: string): Promise { - const { plaintext } = await jose.compactDecrypt(jwe, this.encryptionKey); - return new TextDecoder().decode(plaintext); + return this.encryptionService.decryptContent(jwe); } - // --- Signature & Verification (JWS) --- - - /** - * Signe un contenu textuel en utilisant JWS (Compact Serialization) - * Algorithme: HS256 (HMAC-SHA256) - */ async signContent(content: string): Promise { - const data = new TextEncoder().encode(content); - return new jose.CompactSign(data) - .setProtectedHeader({ alg: "HS256" }) - .sign(this.jwtSecret); + return this.encryptionService.signContent(content); } - /** - * Vérifie la signature JWS d'un contenu - */ async verifyContentSignature(jws: string): Promise { - const { payload } = await jose.compactVerify(jws, this.jwtSecret); - return new TextDecoder().decode(payload); + return this.encryptionService.verifyContentSignature(jws); } - // --- Post-Quantum Cryptography via @noble/post-quantum --- - // Example: Kyber (ML-KEM) key encapsulation - generatePostQuantumKeyPair() { - const seed = new Uint8Array(64); - crypto.getRandomValues(seed); - const { publicKey, secretKey } = ml_kem768.keygen(seed); - return { publicKey, secretKey }; + return this.postQuantumService.generatePostQuantumKeyPair(); } encapsulate(publicKey: Uint8Array) { - return ml_kem768.encapsulate(publicKey); + return this.postQuantumService.encapsulate(publicKey); } decapsulate(cipherText: Uint8Array, secretKey: Uint8Array) { - return ml_kem768.decapsulate(cipherText, secretKey); + return this.postQuantumService.decapsulate(cipherText, secretKey); } } diff --git a/backend/src/crypto/services/encryption.service.ts b/backend/src/crypto/services/encryption.service.ts new file mode 100644 index 0000000..5dcd44c --- /dev/null +++ b/backend/src/crypto/services/encryption.service.ts @@ -0,0 +1,58 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import * as jose from "jose"; + +@Injectable() +export class EncryptionService { + private readonly logger = new Logger(EncryptionService.name); + private readonly jwtSecret: Uint8Array; + private readonly encryptionKey: Uint8Array; + + constructor(private configService: ConfigService) { + const secret = this.configService.get("JWT_SECRET"); + this.jwtSecret = new TextEncoder().encode( + secret || "default-secret-change-me-in-production", + ); + + const encKey = this.configService.get("ENCRYPTION_KEY"); + if (!encKey) { + this.logger.warn( + "ENCRYPTION_KEY is not defined, using a default insecure key for development", + ); + } + const rawKey = encKey || "default-encryption-key-32-chars-"; + this.encryptionKey = new TextEncoder().encode( + rawKey.padEnd(32, "0").substring(0, 32), + ); + } + + async encryptContent(content: string): Promise { + const data = new TextEncoder().encode(content); + return new jose.CompactEncrypt(data) + .setProtectedHeader({ alg: "dir", enc: "A256GCM" }) + .encrypt(this.encryptionKey); + } + + async decryptContent(jwe: string): Promise { + const { plaintext } = await jose.compactDecrypt(jwe, this.encryptionKey); + return new TextDecoder().decode(plaintext); + } + + async signContent(content: string): Promise { + const data = new TextEncoder().encode(content); + return new jose.CompactSign(data) + .setProtectedHeader({ alg: "HS256" }) + .sign(this.jwtSecret); + } + + async verifyContentSignature(jws: string): Promise { + const { payload } = await jose.compactVerify(jws, this.jwtSecret); + return new TextDecoder().decode(payload); + } + + getPgpEncryptionKey(): string { + return ( + this.configService.get("PGP_ENCRYPTION_KEY") || "default-pgp-key" + ); + } +} diff --git a/backend/src/crypto/services/hashing.service.ts b/backend/src/crypto/services/hashing.service.ts new file mode 100644 index 0000000..adb234d --- /dev/null +++ b/backend/src/crypto/services/hashing.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from "@nestjs/common"; +import { hash, verify } from "@node-rs/argon2"; + +@Injectable() +export class HashingService { + async hashEmail(email: string): Promise { + const normalizedEmail = email.toLowerCase().trim(); + return this.hashSha256(normalizedEmail); + } + + async hashIp(ip: string): Promise { + return this.hashSha256(ip); + } + + async hashSha256(text: string): Promise { + const data = new TextEncoder().encode(text); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + return Array.from(new Uint8Array(hashBuffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + } + + async hashPassword(password: string): Promise { + return hash(password, { + algorithm: 2, + }); + } + + async verifyPassword(password: string, hash: string): Promise { + return verify(hash, password); + } +} diff --git a/backend/src/crypto/services/jwt.service.ts b/backend/src/crypto/services/jwt.service.ts new file mode 100644 index 0000000..e870974 --- /dev/null +++ b/backend/src/crypto/services/jwt.service.ts @@ -0,0 +1,37 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import * as jose from "jose"; + +@Injectable() +export class JwtService { + private readonly logger = new Logger(JwtService.name); + private readonly jwtSecret: Uint8Array; + + constructor(private configService: ConfigService) { + const secret = this.configService.get("JWT_SECRET"); + if (!secret) { + this.logger.warn( + "JWT_SECRET is not defined, using a default insecure secret for development", + ); + } + this.jwtSecret = new TextEncoder().encode( + secret || "default-secret-change-me-in-production", + ); + } + + async generateJwt( + payload: jose.JWTPayload, + expiresIn = "2h", + ): Promise { + return new jose.SignJWT(payload) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime(expiresIn) + .sign(this.jwtSecret); + } + + async verifyJwt(token: string): Promise { + const { payload } = await jose.jwtVerify(token, this.jwtSecret); + return payload as T; + } +} diff --git a/backend/src/crypto/services/post-quantum.service.ts b/backend/src/crypto/services/post-quantum.service.ts new file mode 100644 index 0000000..d2ba0c3 --- /dev/null +++ b/backend/src/crypto/services/post-quantum.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from "@nestjs/common"; +import { ml_kem768 } from "@noble/post-quantum/ml-kem.js"; + +@Injectable() +export class PostQuantumService { + generatePostQuantumKeyPair() { + const seed = new Uint8Array(64); + crypto.getRandomValues(seed); + const { publicKey, secretKey } = ml_kem768.keygen(seed); + return { publicKey, secretKey }; + } + + encapsulate(publicKey: Uint8Array) { + return ml_kem768.encapsulate(publicKey); + } + + decapsulate(cipherText: Uint8Array, secretKey: Uint8Array) { + return ml_kem768.decapsulate(cipherText, secretKey); + } +} diff --git a/backend/src/favorites/favorites.module.ts b/backend/src/favorites/favorites.module.ts index e6cc606..cd58de9 100644 --- a/backend/src/favorites/favorites.module.ts +++ b/backend/src/favorites/favorites.module.ts @@ -2,11 +2,12 @@ import { Module } from "@nestjs/common"; import { DatabaseModule } from "../database/database.module"; import { FavoritesController } from "./favorites.controller"; import { FavoritesService } from "./favorites.service"; +import { FavoritesRepository } from "./repositories/favorites.repository"; @Module({ imports: [DatabaseModule], controllers: [FavoritesController], - providers: [FavoritesService], + providers: [FavoritesService, FavoritesRepository], exports: [FavoritesService], }) export class FavoritesModule {} diff --git a/backend/src/favorites/favorites.service.spec.ts b/backend/src/favorites/favorites.service.spec.ts index 771d899..a2b636b 100644 --- a/backend/src/favorites/favorites.service.spec.ts +++ b/backend/src/favorites/favorites.service.spec.ts @@ -1,54 +1,31 @@ import { ConflictException, NotFoundException } from "@nestjs/common"; import { Test, TestingModule } from "@nestjs/testing"; -import { DatabaseService } from "../database/database.service"; import { FavoritesService } from "./favorites.service"; +import { FavoritesRepository } from "./repositories/favorites.repository"; describe("FavoritesService", () => { let service: FavoritesService; + let repository: FavoritesRepository; - const mockDb = { - select: jest.fn(), - from: jest.fn(), - where: jest.fn(), - limit: jest.fn(), - offset: jest.fn(), - innerJoin: jest.fn(), - insert: jest.fn(), - values: jest.fn(), - delete: jest.fn(), - returning: jest.fn(), + const mockFavoritesRepository = { + findContentById: jest.fn(), + add: jest.fn(), + remove: jest.fn(), + findByUserId: jest.fn(), }; beforeEach(async () => { jest.clearAllMocks(); - const chain = { - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - offset: jest.fn().mockReturnThis(), - innerJoin: jest.fn().mockReturnThis(), - insert: jest.fn().mockReturnThis(), - values: jest.fn().mockReturnThis(), - delete: jest.fn().mockReturnThis(), - returning: jest.fn().mockReturnThis(), - }; - - const mockImplementation = () => Object.assign(Promise.resolve([]), chain); - for (const mock of Object.values(chain)) { - mock.mockImplementation(mockImplementation); - } - Object.assign(mockDb, chain); - const module: TestingModule = await Test.createTestingModule({ providers: [ FavoritesService, - { provide: DatabaseService, useValue: { db: mockDb } }, + { provide: FavoritesRepository, useValue: mockFavoritesRepository }, ], }).compile(); service = module.get(FavoritesService); + repository = module.get(FavoritesRepository); }); it("should be defined", () => { @@ -57,26 +34,27 @@ describe("FavoritesService", () => { describe("addFavorite", () => { it("should add a favorite", async () => { - mockDb.limit.mockResolvedValue([{ id: "content1" }]); - mockDb.returning.mockResolvedValue([ + mockFavoritesRepository.findContentById.mockResolvedValue({ id: "content1" }); + mockFavoritesRepository.add.mockResolvedValue([ { userId: "u1", contentId: "content1" }, ]); const result = await service.addFavorite("u1", "content1"); expect(result).toEqual([{ userId: "u1", contentId: "content1" }]); + expect(repository.add).toHaveBeenCalledWith("u1", "content1"); }); it("should throw NotFoundException if content does not exist", async () => { - mockDb.limit.mockResolvedValue([]); + mockFavoritesRepository.findContentById.mockResolvedValue(null); await expect(service.addFavorite("u1", "invalid")).rejects.toThrow( NotFoundException, ); }); it("should throw ConflictException on duplicate favorite", async () => { - mockDb.limit.mockResolvedValue([{ id: "content1" }]); - mockDb.returning.mockRejectedValue(new Error("Duplicate")); + mockFavoritesRepository.findContentById.mockResolvedValue({ id: "content1" }); + mockFavoritesRepository.add.mockRejectedValue(new Error("Duplicate")); await expect(service.addFavorite("u1", "content1")).rejects.toThrow( ConflictException, ); @@ -85,13 +63,14 @@ describe("FavoritesService", () => { describe("removeFavorite", () => { it("should remove a favorite", async () => { - mockDb.returning.mockResolvedValue([{ userId: "u1", contentId: "c1" }]); + mockFavoritesRepository.remove.mockResolvedValue([{ userId: "u1", contentId: "c1" }]); const result = await service.removeFavorite("u1", "c1"); expect(result).toEqual({ userId: "u1", contentId: "c1" }); + expect(repository.remove).toHaveBeenCalledWith("u1", "c1"); }); it("should throw NotFoundException if favorite not found", async () => { - mockDb.returning.mockResolvedValue([]); + mockFavoritesRepository.remove.mockResolvedValue([]); await expect(service.removeFavorite("u1", "c1")).rejects.toThrow( NotFoundException, ); diff --git a/backend/src/favorites/favorites.service.ts b/backend/src/favorites/favorites.service.ts index b6efcd0..b001b16 100644 --- a/backend/src/favorites/favorites.service.ts +++ b/backend/src/favorites/favorites.service.ts @@ -4,46 +4,32 @@ import { Logger, NotFoundException, } from "@nestjs/common"; -import { and, eq } from "drizzle-orm"; -import { DatabaseService } from "../database/database.service"; -import { contents, favorites } from "../database/schemas"; +import { FavoritesRepository } from "./repositories/favorites.repository"; @Injectable() export class FavoritesService { private readonly logger = new Logger(FavoritesService.name); - constructor(private readonly databaseService: DatabaseService) {} + constructor(private readonly favoritesRepository: FavoritesRepository) {} async addFavorite(userId: string, contentId: string) { this.logger.log(`Adding favorite: user ${userId}, content ${contentId}`); - // Vérifier si le contenu existe - const content = await this.databaseService.db - .select() - .from(contents) - .where(eq(contents.id, contentId)) - .limit(1); - - if (content.length === 0) { + + const content = await this.favoritesRepository.findContentById(contentId); + if (!content) { throw new NotFoundException("Content not found"); } try { - return await this.databaseService.db - .insert(favorites) - .values({ userId, contentId }) - .returning(); + return await this.favoritesRepository.add(userId, contentId); } catch (_error) { - // Probablement une violation de clé primaire (déjà en favori) throw new ConflictException("Content already in favorites"); } } async removeFavorite(userId: string, contentId: string) { this.logger.log(`Removing favorite: user ${userId}, content ${contentId}`); - const result = await this.databaseService.db - .delete(favorites) - .where(and(eq(favorites.userId, userId), eq(favorites.contentId, contentId))) - .returning(); + const result = await this.favoritesRepository.remove(userId, contentId); if (result.length === 0) { throw new NotFoundException("Favorite not found"); @@ -53,16 +39,6 @@ export class FavoritesService { } async getUserFavorites(userId: string, limit: number, offset: number) { - const data = await this.databaseService.db - .select({ - content: contents, - }) - .from(favorites) - .innerJoin(contents, eq(favorites.contentId, contents.id)) - .where(eq(favorites.userId, userId)) - .limit(limit) - .offset(offset); - - return data.map((item) => item.content); + return await this.favoritesRepository.findByUserId(userId, limit, offset); } } diff --git a/backend/src/favorites/repositories/favorites.repository.ts b/backend/src/favorites/repositories/favorites.repository.ts new file mode 100644 index 0000000..8fa41c0 --- /dev/null +++ b/backend/src/favorites/repositories/favorites.repository.ts @@ -0,0 +1,46 @@ +import { Injectable } from "@nestjs/common"; +import { and, eq } from "drizzle-orm"; +import { DatabaseService } from "../../database/database.service"; +import { contents, favorites } from "../../database/schemas"; + +@Injectable() +export class FavoritesRepository { + constructor(private readonly databaseService: DatabaseService) {} + + async findContentById(contentId: string) { + const result = await this.databaseService.db + .select() + .from(contents) + .where(eq(contents.id, contentId)) + .limit(1); + return result[0] || null; + } + + async add(userId: string, contentId: string) { + return await this.databaseService.db + .insert(favorites) + .values({ userId, contentId }) + .returning(); + } + + async remove(userId: string, contentId: string) { + return await this.databaseService.db + .delete(favorites) + .where(and(eq(favorites.userId, userId), eq(favorites.contentId, contentId))) + .returning(); + } + + async findByUserId(userId: string, limit: number, offset: number) { + const data = await this.databaseService.db + .select({ + content: contents, + }) + .from(favorites) + .innerJoin(contents, eq(favorites.contentId, contents.id)) + .where(eq(favorites.userId, userId)) + .limit(limit) + .offset(offset); + + return data.map((item) => item.content); + } +} diff --git a/backend/src/mail/mail.service.ts b/backend/src/mail/mail.service.ts index 135c2fc..9ecd1f5 100644 --- a/backend/src/mail/mail.service.ts +++ b/backend/src/mail/mail.service.ts @@ -1,9 +1,10 @@ import { Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { MailerService } from "@nestjs-modules/mailer"; +import type { IMailService } from "../common/interfaces/mail.interface"; @Injectable() -export class MailService { +export class MailService implements IMailService { private readonly logger = new Logger(MailService.name); private readonly domain: string; diff --git a/backend/src/media/media.module.ts b/backend/src/media/media.module.ts index 7972839..6118f3b 100644 --- a/backend/src/media/media.module.ts +++ b/backend/src/media/media.module.ts @@ -1,8 +1,10 @@ import { Module } from "@nestjs/common"; import { MediaService } from "./media.service"; +import { ImageProcessorStrategy } from "./strategies/image-processor.strategy"; +import { VideoProcessorStrategy } from "./strategies/video-processor.strategy"; @Module({ - providers: [MediaService], + providers: [MediaService, ImageProcessorStrategy, VideoProcessorStrategy], exports: [MediaService], }) export class MediaModule {} diff --git a/backend/src/media/media.service.spec.ts b/backend/src/media/media.service.spec.ts index 4b6fde9..0250423 100644 --- a/backend/src/media/media.service.spec.ts +++ b/backend/src/media/media.service.spec.ts @@ -6,6 +6,9 @@ import ffmpeg from "fluent-ffmpeg"; import sharp from "sharp"; import { MediaService } from "./media.service"; +import { ImageProcessorStrategy } from "./strategies/image-processor.strategy"; +import { VideoProcessorStrategy } from "./strategies/video-processor.strategy"; + jest.mock("sharp"); jest.mock("fluent-ffmpeg"); jest.mock("node:fs/promises"); @@ -29,6 +32,8 @@ describe("MediaService", () => { const module: TestingModule = await Test.createTestingModule({ providers: [ MediaService, + ImageProcessorStrategy, + VideoProcessorStrategy, { provide: ConfigService, useValue: { diff --git a/backend/src/media/media.service.ts b/backend/src/media/media.service.ts index d739493..982261d 100644 --- a/backend/src/media/media.service.ts +++ b/backend/src/media/media.service.ts @@ -1,22 +1,18 @@ -import { readFile, unlink, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { Readable } from "node:stream"; import { - BadRequestException, Injectable, InternalServerErrorException, Logger, } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import * as NodeClam from "clamscan"; -import ffmpeg from "fluent-ffmpeg"; -import sharp from "sharp"; -import { v4 as uuidv4 } from "uuid"; import type { + IMediaService, MediaProcessingResult, ScanResult, -} from "./interfaces/media.interface"; +} from "../common/interfaces/media.interface"; +import { ImageProcessorStrategy } from "./strategies/image-processor.strategy"; +import { VideoProcessorStrategy } from "./strategies/video-processor.strategy"; interface ClamScanner { scanStream( @@ -25,12 +21,16 @@ interface ClamScanner { } @Injectable() -export class MediaService { +export class MediaService implements IMediaService { private readonly logger = new Logger(MediaService.name); private clamscan: ClamScanner | null = null; private isClamAvInitialized = false; - constructor(private readonly configService: ConfigService) { + constructor( + private readonly configService: ConfigService, + private readonly imageProcessor: ImageProcessorStrategy, + private readonly videoProcessor: VideoProcessorStrategy, + ) { this.initClamScan(); } @@ -84,82 +84,13 @@ export class MediaService { buffer: Buffer, format: "webp" | "avif" = "webp", ): Promise { - try { - let pipeline = sharp(buffer); - const metadata = await pipeline.metadata(); - - if (format === "webp") { - pipeline = pipeline.webp({ quality: 80, effort: 6 }); - } else { - pipeline = pipeline.avif({ quality: 65, effort: 6 }); - } - - const processedBuffer = await pipeline.toBuffer(); - - return { - buffer: processedBuffer, - mimeType: `image/${format}`, - extension: format, - width: metadata.width, - height: metadata.height, - size: processedBuffer.length, - }; - } catch (error) { - this.logger.error(`Error processing image: ${error.message}`); - throw new BadRequestException("Failed to process image"); - } + return this.imageProcessor.process(buffer, { format }); } async processVideo( buffer: Buffer, format: "webm" | "av1" = "webm", ): Promise { - const tempInput = join(tmpdir(), `${uuidv4()}.tmp`); - const tempOutput = join( - tmpdir(), - `${uuidv4()}.${format === "av1" ? "mp4" : "webm"}`, - ); - - try { - await writeFile(tempInput, buffer); - - await new Promise((resolve, reject) => { - let command = ffmpeg(tempInput); - - if (format === "webm") { - command = command - .toFormat("webm") - .videoCodec("libvpx-vp9") - .audioCodec("libopus") - .outputOptions("-crf 30", "-b:v 0"); - } else { - command = command - .toFormat("mp4") - .videoCodec("libaom-av1") - .audioCodec("libopus") - .outputOptions("-crf 34", "-b:v 0", "-strict experimental"); - } - - command - .on("end", () => resolve()) - .on("error", (err) => reject(err)) - .save(tempOutput); - }); - - const processedBuffer = await readFile(tempOutput); - - return { - buffer: processedBuffer, - mimeType: format === "av1" ? "video/mp4" : "video/webm", - extension: format === "av1" ? "mp4" : "webm", - size: processedBuffer.length, - }; - } catch (error) { - this.logger.error(`Error processing video: ${error.message}`); - throw new BadRequestException("Failed to process video"); - } finally { - await unlink(tempInput).catch(() => {}); - await unlink(tempOutput).catch(() => {}); - } + return this.videoProcessor.process(buffer, { format }); } } diff --git a/backend/src/media/strategies/image-processor.strategy.ts b/backend/src/media/strategies/image-processor.strategy.ts new file mode 100644 index 0000000..49f8acf --- /dev/null +++ b/backend/src/media/strategies/image-processor.strategy.ts @@ -0,0 +1,44 @@ +import { BadRequestException, Injectable, Logger } from "@nestjs/common"; +import sharp from "sharp"; +import type { MediaProcessingResult } from "../../common/interfaces/media.interface"; +import type { IMediaProcessorStrategy } from "./media-processor.strategy"; + +@Injectable() +export class ImageProcessorStrategy implements IMediaProcessorStrategy { + private readonly logger = new Logger(ImageProcessorStrategy.name); + + canHandle(mimeType: string): boolean { + return mimeType.startsWith("image/"); + } + + async process( + buffer: Buffer, + options: { format: "webp" | "avif" } = { format: "webp" }, + ): Promise { + try { + const { format } = options; + let pipeline = sharp(buffer); + const metadata = await pipeline.metadata(); + + if (format === "webp") { + pipeline = pipeline.webp({ quality: 80, effort: 6 }); + } else { + pipeline = pipeline.avif({ quality: 65, effort: 6 }); + } + + const processedBuffer = await pipeline.toBuffer(); + + return { + buffer: processedBuffer, + mimeType: `image/${format}`, + extension: format, + width: metadata.width, + height: metadata.height, + size: processedBuffer.length, + }; + } catch (error) { + this.logger.error(`Error processing image: ${error.message}`); + throw new BadRequestException("Failed to process image"); + } + } +} diff --git a/backend/src/media/strategies/media-processor.strategy.ts b/backend/src/media/strategies/media-processor.strategy.ts new file mode 100644 index 0000000..330ca77 --- /dev/null +++ b/backend/src/media/strategies/media-processor.strategy.ts @@ -0,0 +1,6 @@ +import type { MediaProcessingResult } from "../../common/interfaces/media.interface"; + +export interface IMediaProcessorStrategy { + canHandle(mimeType: string): boolean; + process(buffer: Buffer, options?: any): Promise; +} diff --git a/backend/src/media/strategies/video-processor.strategy.ts b/backend/src/media/strategies/video-processor.strategy.ts new file mode 100644 index 0000000..2deffb5 --- /dev/null +++ b/backend/src/media/strategies/video-processor.strategy.ts @@ -0,0 +1,71 @@ +import { readFile, unlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BadRequestException, Injectable, Logger } from "@nestjs/common"; +import ffmpeg from "fluent-ffmpeg"; +import { v4 as uuidv4 } from "uuid"; +import type { MediaProcessingResult } from "../../common/interfaces/media.interface"; +import type { IMediaProcessorStrategy } from "./media-processor.strategy"; + +@Injectable() +export class VideoProcessorStrategy implements IMediaProcessorStrategy { + private readonly logger = new Logger(VideoProcessorStrategy.name); + + canHandle(mimeType: string): boolean { + return mimeType.startsWith("video/"); + } + + async process( + buffer: Buffer, + options: { format: "webm" | "av1" } = { format: "webm" }, + ): Promise { + const { format } = options; + const tempInput = join(tmpdir(), `${uuidv4()}.tmp`); + const tempOutput = join( + tmpdir(), + `${uuidv4()}.${format === "av1" ? "mp4" : "webm"}`, + ); + + try { + await writeFile(tempInput, buffer); + + await new Promise((resolve, reject) => { + let command = ffmpeg(tempInput); + + if (format === "webm") { + command = command + .toFormat("webm") + .videoCodec("libvpx-vp9") + .audioCodec("libopus") + .outputOptions("-crf 30", "-b:v 0"); + } else { + command = command + .toFormat("mp4") + .videoCodec("libaom-av1") + .audioCodec("libopus") + .outputOptions("-crf 34", "-b:v 0", "-strict experimental"); + } + + command + .on("end", () => resolve()) + .on("error", (err) => reject(err)) + .save(tempOutput); + }); + + const processedBuffer = await readFile(tempOutput); + + return { + buffer: processedBuffer, + mimeType: format === "av1" ? "video/mp4" : "video/webm", + extension: format === "av1" ? "mp4" : "webm", + size: processedBuffer.length, + }; + } catch (error) { + this.logger.error(`Error processing video: ${error.message}`); + throw new BadRequestException("Failed to process video"); + } finally { + await unlink(tempInput).catch(() => {}); + await unlink(tempOutput).catch(() => {}); + } + } +} diff --git a/backend/src/reports/reports.module.ts b/backend/src/reports/reports.module.ts index 5e43023..6e6254a 100644 --- a/backend/src/reports/reports.module.ts +++ b/backend/src/reports/reports.module.ts @@ -4,10 +4,11 @@ import { CryptoModule } from "../crypto/crypto.module"; import { DatabaseModule } from "../database/database.module"; import { ReportsController } from "./reports.controller"; import { ReportsService } from "./reports.service"; +import { ReportsRepository } from "./repositories/reports.repository"; @Module({ imports: [DatabaseModule, AuthModule, CryptoModule], controllers: [ReportsController], - providers: [ReportsService], + providers: [ReportsService, ReportsRepository], }) export class ReportsModule {} diff --git a/backend/src/reports/reports.service.spec.ts b/backend/src/reports/reports.service.spec.ts index e42ca14..9564996 100644 --- a/backend/src/reports/reports.service.spec.ts +++ b/backend/src/reports/reports.service.spec.ts @@ -1,56 +1,29 @@ import { Test, TestingModule } from "@nestjs/testing"; -import { DatabaseService } from "../database/database.service"; -import { CreateReportDto } from "./dto/create-report.dto"; import { ReportsService } from "./reports.service"; +import { ReportsRepository } from "./repositories/reports.repository"; describe("ReportsService", () => { let service: ReportsService; + let repository: ReportsRepository; - const mockDb = { - insert: jest.fn(), - values: jest.fn(), - returning: jest.fn(), - select: jest.fn(), - from: jest.fn(), - orderBy: jest.fn(), - limit: jest.fn(), - offset: jest.fn(), - update: jest.fn(), - set: jest.fn(), - where: jest.fn(), + const mockReportsRepository = { + create: jest.fn(), + findAll: jest.fn(), + updateStatus: jest.fn(), }; beforeEach(async () => { jest.clearAllMocks(); - const chain = { - insert: jest.fn().mockReturnThis(), - values: jest.fn().mockReturnThis(), - returning: jest.fn().mockReturnThis(), - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - orderBy: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - offset: jest.fn().mockReturnThis(), - update: jest.fn().mockReturnThis(), - set: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - }; - - const mockImplementation = () => Object.assign(Promise.resolve([]), chain); - for (const mock of Object.values(chain)) { - mock.mockImplementation(mockImplementation); - } - Object.assign(mockDb, chain); - const module: TestingModule = await Test.createTestingModule({ providers: [ ReportsService, - { provide: DatabaseService, useValue: { db: mockDb } }, + { provide: ReportsRepository, useValue: mockReportsRepository }, ], }).compile(); service = module.get(ReportsService); + repository = module.get(ReportsRepository); }); it("should be defined", () => { @@ -60,29 +33,31 @@ describe("ReportsService", () => { describe("create", () => { it("should create a report", async () => { const reporterId = "u1"; - const data: CreateReportDto = { contentId: "c1", reason: "spam" }; - mockDb.returning.mockResolvedValue([{ id: "r1", ...data, reporterId }]); + const data = { contentId: "c1", reason: "spam" }; + mockReportsRepository.create.mockResolvedValue({ id: "r1", ...data, reporterId }); const result = await service.create(reporterId, data); expect(result.id).toBe("r1"); - expect(mockDb.insert).toHaveBeenCalled(); + expect(repository.create).toHaveBeenCalled(); }); }); describe("findAll", () => { it("should return reports", async () => { - mockDb.offset.mockResolvedValue([{ id: "r1" }]); + mockReportsRepository.findAll.mockResolvedValue([{ id: "r1" }]); const result = await service.findAll(10, 0); expect(result).toHaveLength(1); + expect(repository.findAll).toHaveBeenCalledWith(10, 0); }); }); describe("updateStatus", () => { it("should update report status", async () => { - mockDb.returning.mockResolvedValue([{ id: "r1", status: "resolved" }]); + mockReportsRepository.updateStatus.mockResolvedValue([{ id: "r1", status: "resolved" }]); const result = await service.updateStatus("r1", "resolved"); expect(result[0].status).toBe("resolved"); + expect(repository.updateStatus).toHaveBeenCalledWith("r1", "resolved"); }); }); }); diff --git a/backend/src/reports/reports.service.ts b/backend/src/reports/reports.service.ts index ca09d58..b014831 100644 --- a/backend/src/reports/reports.service.ts +++ b/backend/src/reports/reports.service.ts @@ -1,37 +1,26 @@ import { Injectable, Logger } from "@nestjs/common"; -import { desc, eq } from "drizzle-orm"; -import { DatabaseService } from "../database/database.service"; -import { reports } from "../database/schemas"; +import { ReportsRepository } from "./repositories/reports.repository"; import { CreateReportDto } from "./dto/create-report.dto"; @Injectable() export class ReportsService { private readonly logger = new Logger(ReportsService.name); - constructor(private readonly databaseService: DatabaseService) {} + constructor(private readonly reportsRepository: ReportsRepository) {} async create(reporterId: string, data: CreateReportDto) { this.logger.log(`Creating report from user ${reporterId}`); - const [newReport] = await this.databaseService.db - .insert(reports) - .values({ - reporterId, - contentId: data.contentId, - tagId: data.tagId, - reason: data.reason, - description: data.description, - }) - .returning(); - return newReport; + return await this.reportsRepository.create({ + reporterId, + contentId: data.contentId, + tagId: data.tagId, + reason: data.reason, + description: data.description, + }); } async findAll(limit: number, offset: number) { - return await this.databaseService.db - .select() - .from(reports) - .orderBy(desc(reports.createdAt)) - .limit(limit) - .offset(offset); + return await this.reportsRepository.findAll(limit, offset); } async updateStatus( @@ -39,10 +28,6 @@ export class ReportsService { status: "pending" | "reviewed" | "resolved" | "dismissed", ) { this.logger.log(`Updating report ${id} status to ${status}`); - return await this.databaseService.db - .update(reports) - .set({ status, updatedAt: new Date() }) - .where(eq(reports.id, id)) - .returning(); + return await this.reportsRepository.updateStatus(id, status); } } diff --git a/backend/src/reports/repositories/reports.repository.ts b/backend/src/reports/repositories/reports.repository.ts new file mode 100644 index 0000000..7404d46 --- /dev/null +++ b/backend/src/reports/repositories/reports.repository.ts @@ -0,0 +1,50 @@ +import { Injectable } from "@nestjs/common"; +import { desc, eq, lte } from "drizzle-orm"; +import { DatabaseService } from "../../database/database.service"; +import { reports } from "../../database/schemas"; + +@Injectable() +export class ReportsRepository { + constructor(private readonly databaseService: DatabaseService) {} + + async create(data: { + reporterId: string; + contentId?: string; + tagId?: string; + reason: string; + description?: string; + }) { + const [newReport] = await this.databaseService.db + .insert(reports) + .values(data) + .returning(); + return newReport; + } + + async findAll(limit: number, offset: number) { + return await this.databaseService.db + .select() + .from(reports) + .orderBy(desc(reports.createdAt)) + .limit(limit) + .offset(offset); + } + + async updateStatus( + id: string, + status: "pending" | "reviewed" | "resolved" | "dismissed", + ) { + return await this.databaseService.db + .update(reports) + .set({ status, updatedAt: new Date() }) + .where(eq(reports.id, id)) + .returning(); + } + + async purgeObsolete(now: Date) { + return await this.databaseService.db + .delete(reports) + .where(lte(reports.expiresAt, now)) + .returning(); + } +} diff --git a/backend/src/s3/s3.service.ts b/backend/src/s3/s3.service.ts index 3beb7bd..e511426 100644 --- a/backend/src/s3/s3.service.ts +++ b/backend/src/s3/s3.service.ts @@ -1,9 +1,10 @@ import { Injectable, Logger, OnModuleInit } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import * as Minio from "minio"; +import type { IStorageService } from "../common/interfaces/storage.interface"; @Injectable() -export class S3Service implements OnModuleInit { +export class S3Service implements OnModuleInit, IStorageService { private readonly logger = new Logger(S3Service.name); private minioClient: Minio.Client; private readonly bucketName: string; diff --git a/backend/src/sessions/repositories/sessions.repository.ts b/backend/src/sessions/repositories/sessions.repository.ts new file mode 100644 index 0000000..050f368 --- /dev/null +++ b/backend/src/sessions/repositories/sessions.repository.ts @@ -0,0 +1,64 @@ +import { Injectable } from "@nestjs/common"; +import { and, eq, lte } from "drizzle-orm"; +import { DatabaseService } from "../../database/database.service"; +import { sessions } from "../../database/schemas"; + +@Injectable() +export class SessionsRepository { + constructor(private readonly databaseService: DatabaseService) {} + + async create(data: { + userId: string; + refreshToken: string; + userAgent?: string; + ipHash?: string | null; + expiresAt: Date; + }) { + const [session] = await this.databaseService.db + .insert(sessions) + .values(data) + .returning(); + return session; + } + + async findValidByRefreshToken(refreshToken: string) { + const result = await this.databaseService.db + .select() + .from(sessions) + .where( + and(eq(sessions.refreshToken, refreshToken), eq(sessions.isValid, true)), + ) + .limit(1); + return result[0] || null; + } + + async update(sessionId: string, data: any) { + const [updatedSession] = await this.databaseService.db + .update(sessions) + .set({ ...data, updatedAt: new Date() }) + .where(eq(sessions.id, sessionId)) + .returning(); + return updatedSession; + } + + async revoke(sessionId: string) { + await this.databaseService.db + .update(sessions) + .set({ isValid: false, updatedAt: new Date() }) + .where(eq(sessions.id, sessionId)); + } + + async revokeAllByUserId(userId: string) { + await this.databaseService.db + .update(sessions) + .set({ isValid: false, updatedAt: new Date() }) + .where(eq(sessions.userId, userId)); + } + + async purgeExpired(now: Date) { + return await this.databaseService.db + .delete(sessions) + .where(lte(sessions.expiresAt, now)) + .returning(); + } +} diff --git a/backend/src/sessions/sessions.module.ts b/backend/src/sessions/sessions.module.ts index 0df6df5..c69d49b 100644 --- a/backend/src/sessions/sessions.module.ts +++ b/backend/src/sessions/sessions.module.ts @@ -2,10 +2,11 @@ import { Module } from "@nestjs/common"; import { CryptoModule } from "../crypto/crypto.module"; import { DatabaseModule } from "../database/database.module"; import { SessionsService } from "./sessions.service"; +import { SessionsRepository } from "./repositories/sessions.repository"; @Module({ imports: [DatabaseModule, CryptoModule], - providers: [SessionsService], + providers: [SessionsService, SessionsRepository], exports: [SessionsService], }) export class SessionsModule {} diff --git a/backend/src/sessions/sessions.service.spec.ts b/backend/src/sessions/sessions.service.spec.ts index 7972ac0..37343cc 100644 --- a/backend/src/sessions/sessions.service.spec.ts +++ b/backend/src/sessions/sessions.service.spec.ts @@ -13,60 +13,45 @@ jest.mock("jose", () => ({ import { UnauthorizedException } from "@nestjs/common"; import { Test, TestingModule } from "@nestjs/testing"; -import { CryptoService } from "../crypto/crypto.service"; -import { DatabaseService } from "../database/database.service"; +import { HashingService } from "../crypto/services/hashing.service"; +import { JwtService } from "../crypto/services/jwt.service"; import { SessionsService } from "./sessions.service"; +import { SessionsRepository } from "./repositories/sessions.repository"; describe("SessionsService", () => { let service: SessionsService; + let repository: SessionsRepository; - const mockDb = { - insert: jest.fn(), - select: jest.fn(), + const mockSessionsRepository = { + create: jest.fn(), + findValidByRefreshToken: jest.fn(), update: jest.fn(), + revoke: jest.fn(), + revokeAllByUserId: jest.fn(), }; - const mockCryptoService = { - generateJwt: jest.fn().mockResolvedValue("mock-jwt"), - hashIp: jest.fn().mockResolvedValue("mock-ip-hash"), + const mockHashingService = { + hashIp: jest.fn().mockResolvedValue("hashed-ip"), + }; + + const mockJwtService = { + generateJwt: jest.fn().mockResolvedValue("new-token"), }; beforeEach(async () => { jest.clearAllMocks(); - const chain = { - insert: jest.fn().mockReturnThis(), - values: jest.fn().mockReturnThis(), - returning: jest.fn().mockReturnThis(), - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - update: jest.fn().mockReturnThis(), - set: jest.fn().mockReturnThis(), - // biome-ignore lint/suspicious/noThenProperty: Fine for testing purposes - then: jest.fn().mockImplementation(function (cb) { - return Promise.resolve(this).then(cb); - }), - }; - - const mockImplementation = () => Object.assign(Promise.resolve([]), chain); - for (const mock of Object.values(chain)) { - if (mock !== chain.then) { - mock.mockImplementation(mockImplementation); - } - } - Object.assign(mockDb, chain); - const module: TestingModule = await Test.createTestingModule({ providers: [ SessionsService, - { provide: DatabaseService, useValue: { db: mockDb } }, - { provide: CryptoService, useValue: mockCryptoService }, + { provide: SessionsRepository, useValue: mockSessionsRepository }, + { provide: HashingService, useValue: mockHashingService }, + { provide: JwtService, useValue: mockJwtService }, ], }).compile(); service = module.get(SessionsService); + repository = module.get(SessionsRepository); }); it("should be defined", () => { @@ -75,13 +60,10 @@ describe("SessionsService", () => { describe("createSession", () => { it("should create a session", async () => { - mockDb.returning.mockResolvedValue([{ id: "s1", refreshToken: "mock-jwt" }]); + mockSessionsRepository.create.mockResolvedValue({ id: "s1" }); const result = await service.createSession("u1", "agent", "1.2.3.4"); - expect(result.id).toBe("s1"); - expect(mockCryptoService.generateJwt).toHaveBeenCalledWith( - { sub: "u1", type: "refresh" }, - "7d", - ); + expect(result).toEqual({ id: "s1" }); + expect(repository.create).toHaveBeenCalled(); }); }); @@ -89,36 +71,46 @@ describe("SessionsService", () => { it("should refresh a valid session", async () => { const expiresAt = new Date(); expiresAt.setDate(expiresAt.getDate() + 1); - const session = { id: "s1", userId: "u1", expiresAt, isValid: true }; - - mockDb.where.mockImplementation(() => { - const chain = { - limit: jest.fn().mockReturnThis(), - returning: jest - .fn() - .mockResolvedValue([{ ...session, refreshToken: "new-jwt" }]), - // biome-ignore lint/suspicious/noThenProperty: Fine for testing purposes - then: jest.fn().mockResolvedValue(session), - }; - return Object.assign(Promise.resolve([session]), chain); + mockSessionsRepository.findValidByRefreshToken.mockResolvedValue({ + id: "s1", + userId: "u1", + expiresAt, }); + mockSessionsRepository.update.mockResolvedValue({ id: "s1", refreshToken: "new-token" }); - const result = await service.refreshSession("old-jwt"); - expect(result.refreshToken).toBe("new-jwt"); + const result = await service.refreshSession("old-token"); + + expect(result.refreshToken).toBe("new-token"); + expect(repository.update).toHaveBeenCalled(); }); - it("should throw UnauthorizedException if session not found", async () => { - mockDb.where.mockImplementation(() => { - const chain = { - limit: jest.fn().mockReturnThis(), - // biome-ignore lint/suspicious/noThenProperty: Fine for testing purposes - then: jest.fn().mockResolvedValue(null), - }; - return Object.assign(Promise.resolve([]), chain); - }); + it("should throw if session not found", async () => { + mockSessionsRepository.findValidByRefreshToken.mockResolvedValue(null); await expect(service.refreshSession("invalid")).rejects.toThrow( UnauthorizedException, ); }); + + it("should throw and revoke if session expired", async () => { + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() - 1); + mockSessionsRepository.findValidByRefreshToken.mockResolvedValue({ + id: "s1", + userId: "u1", + expiresAt, + }); + + await expect(service.refreshSession("expired")).rejects.toThrow( + UnauthorizedException, + ); + expect(repository.revoke).toHaveBeenCalledWith("s1"); + }); + }); + + describe("revokeSession", () => { + it("should revoke a session", async () => { + await service.revokeSession("s1"); + expect(repository.revoke).toHaveBeenCalledWith("s1"); + }); }); }); diff --git a/backend/src/sessions/sessions.service.ts b/backend/src/sessions/sessions.service.ts index d9546b2..8659765 100644 --- a/backend/src/sessions/sessions.service.ts +++ b/backend/src/sessions/sessions.service.ts @@ -1,48 +1,36 @@ import { Injectable, UnauthorizedException } from "@nestjs/common"; -import { and, eq } from "drizzle-orm"; -import { CryptoService } from "../crypto/crypto.service"; -import { DatabaseService } from "../database/database.service"; -import { sessions } from "../database/schemas"; +import { HashingService } from "../crypto/services/hashing.service"; +import { JwtService } from "../crypto/services/jwt.service"; +import { SessionsRepository } from "./repositories/sessions.repository"; @Injectable() export class SessionsService { constructor( - private readonly databaseService: DatabaseService, - private readonly cryptoService: CryptoService, + private readonly sessionsRepository: SessionsRepository, + private readonly hashingService: HashingService, + private readonly jwtService: JwtService, ) {} async createSession(userId: string, userAgent?: string, ip?: string) { - const refreshToken = await this.cryptoService.generateJwt( + const refreshToken = await this.jwtService.generateJwt( { sub: userId, type: "refresh" }, "7d", ); - const ipHash = ip ? await this.cryptoService.hashIp(ip) : null; + const ipHash = ip ? await this.hashingService.hashIp(ip) : null; const expiresAt = new Date(); expiresAt.setDate(expiresAt.getDate() + 7); - const [session] = await this.databaseService.db - .insert(sessions) - .values({ - userId, - refreshToken, - userAgent, - ipHash, - expiresAt, - }) - .returning(); - - return session; + return await this.sessionsRepository.create({ + userId, + refreshToken, + userAgent, + ipHash, + expiresAt, + }); } async refreshSession(oldRefreshToken: string) { - const session = await this.databaseService.db - .select() - .from(sessions) - .where( - and(eq(sessions.refreshToken, oldRefreshToken), eq(sessions.isValid, true)), - ) - .limit(1) - .then((res) => res[0]); + const session = await this.sessionsRepository.findValidByRefreshToken(oldRefreshToken); if (!session || session.expiresAt < new Date()) { if (session) { @@ -52,37 +40,24 @@ export class SessionsService { } // Rotation du refresh token - const newRefreshToken = await this.cryptoService.generateJwt( + const newRefreshToken = await this.jwtService.generateJwt( { sub: session.userId, type: "refresh" }, "7d", ); const expiresAt = new Date(); expiresAt.setDate(expiresAt.getDate() + 7); - const [updatedSession] = await this.databaseService.db - .update(sessions) - .set({ - refreshToken: newRefreshToken, - expiresAt, - updatedAt: new Date(), - }) - .where(eq(sessions.id, session.id)) - .returning(); - - return updatedSession; + return await this.sessionsRepository.update(session.id, { + refreshToken: newRefreshToken, + expiresAt, + }); } async revokeSession(sessionId: string) { - await this.databaseService.db - .update(sessions) - .set({ isValid: false, updatedAt: new Date() }) - .where(eq(sessions.id, sessionId)); + await this.sessionsRepository.revoke(sessionId); } async revokeAllUserSessions(userId: string) { - await this.databaseService.db - .update(sessions) - .set({ isValid: false, updatedAt: new Date() }) - .where(eq(sessions.userId, userId)); + await this.sessionsRepository.revokeAllByUserId(userId); } } diff --git a/backend/src/tags/repositories/tags.repository.ts b/backend/src/tags/repositories/tags.repository.ts new file mode 100644 index 0000000..afabb68 --- /dev/null +++ b/backend/src/tags/repositories/tags.repository.ts @@ -0,0 +1,48 @@ +import { Injectable } from "@nestjs/common"; +import { desc, eq, ilike, sql } from "drizzle-orm"; +import { DatabaseService } from "../../database/database.service"; +import { contentsToTags, tags } from "../../database/schemas"; + +@Injectable() +export class TagsRepository { + constructor(private readonly databaseService: DatabaseService) {} + + async findAll(options: { + limit: number; + offset: number; + query?: string; + sortBy?: "popular" | "recent"; + }) { + const { limit, offset, query, sortBy } = options; + + let whereClause = sql`1=1`; + if (query) { + whereClause = ilike(tags.name, `%${query}%`); + } + + if (sortBy === "popular") { + return await this.databaseService.db + .select({ + id: tags.id, + name: tags.name, + slug: tags.slug, + count: sql`count(${contentsToTags.contentId})`.as("usage_count"), + }) + .from(tags) + .leftJoin(contentsToTags, eq(tags.id, contentsToTags.tagId)) + .where(whereClause) + .groupBy(tags.id) + .orderBy(desc(sql`usage_count`)) + .limit(limit) + .offset(offset); + } + + return await this.databaseService.db + .select() + .from(tags) + .where(whereClause) + .orderBy(sortBy === "recent" ? desc(tags.createdAt) : desc(tags.name)) + .limit(limit) + .offset(offset); + } +} diff --git a/backend/src/tags/tags.module.ts b/backend/src/tags/tags.module.ts index 7b3438a..f3fba4a 100644 --- a/backend/src/tags/tags.module.ts +++ b/backend/src/tags/tags.module.ts @@ -2,11 +2,12 @@ import { Module } from "@nestjs/common"; import { DatabaseModule } from "../database/database.module"; import { TagsController } from "./tags.controller"; import { TagsService } from "./tags.service"; +import { TagsRepository } from "./repositories/tags.repository"; @Module({ imports: [DatabaseModule], controllers: [TagsController], - providers: [TagsService], + providers: [TagsService, TagsRepository], exports: [TagsService], }) export class TagsModule {} diff --git a/backend/src/tags/tags.service.spec.ts b/backend/src/tags/tags.service.spec.ts index 145451b..8eddf61 100644 --- a/backend/src/tags/tags.service.spec.ts +++ b/backend/src/tags/tags.service.spec.ts @@ -1,49 +1,27 @@ import { Test, TestingModule } from "@nestjs/testing"; -import { DatabaseService } from "../database/database.service"; import { TagsService } from "./tags.service"; +import { TagsRepository } from "./repositories/tags.repository"; describe("TagsService", () => { let service: TagsService; + let repository: TagsRepository; - const mockDb = { - select: jest.fn(), - from: jest.fn(), - leftJoin: jest.fn(), - where: jest.fn(), - groupBy: jest.fn(), - orderBy: jest.fn(), - limit: jest.fn(), - offset: jest.fn(), + const mockTagsRepository = { + findAll: jest.fn(), }; beforeEach(async () => { jest.clearAllMocks(); - const chain = { - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - leftJoin: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - groupBy: jest.fn().mockReturnThis(), - orderBy: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - offset: jest.fn().mockReturnThis(), - }; - - const mockImplementation = () => Object.assign(Promise.resolve([]), chain); - for (const mock of Object.values(chain)) { - mock.mockImplementation(mockImplementation); - } - Object.assign(mockDb, chain); - const module: TestingModule = await Test.createTestingModule({ providers: [ TagsService, - { provide: DatabaseService, useValue: { db: mockDb } }, + { provide: TagsRepository, useValue: mockTagsRepository }, ], }).compile(); service = module.get(TagsService); + repository = module.get(TagsRepository); }); it("should be defined", () => { @@ -53,24 +31,12 @@ describe("TagsService", () => { describe("findAll", () => { it("should return tags", async () => { const mockTags = [{ id: "1", name: "tag1" }]; - mockDb.offset.mockResolvedValue(mockTags); + mockTagsRepository.findAll.mockResolvedValue(mockTags); const result = await service.findAll({ limit: 10, offset: 0 }); expect(result).toEqual(mockTags); - expect(mockDb.select).toHaveBeenCalled(); - }); - - it("should handle popular sort", async () => { - mockDb.offset.mockResolvedValue([{ id: "1", name: "tag1", count: 5 }]); - const result = await service.findAll({ - limit: 10, - offset: 0, - sortBy: "popular", - }); - expect(result[0].count).toBe(5); - expect(mockDb.leftJoin).toHaveBeenCalled(); - expect(mockDb.groupBy).toHaveBeenCalled(); + expect(repository.findAll).toHaveBeenCalledWith({ limit: 10, offset: 0 }); }); }); }); diff --git a/backend/src/tags/tags.service.ts b/backend/src/tags/tags.service.ts index f26bc86..6494f50 100644 --- a/backend/src/tags/tags.service.ts +++ b/backend/src/tags/tags.service.ts @@ -1,13 +1,11 @@ import { Injectable, Logger } from "@nestjs/common"; -import { desc, eq, ilike, sql } from "drizzle-orm"; -import { DatabaseService } from "../database/database.service"; -import { contentsToTags, tags } from "../database/schemas"; +import { TagsRepository } from "./repositories/tags.repository"; @Injectable() export class TagsService { private readonly logger = new Logger(TagsService.name); - constructor(private readonly databaseService: DatabaseService) {} + constructor(private readonly tagsRepository: TagsRepository) {} async findAll(options: { limit: number; @@ -16,41 +14,6 @@ export class TagsService { sortBy?: "popular" | "recent"; }) { this.logger.log(`Fetching tags with options: ${JSON.stringify(options)}`); - const { limit, offset, query, sortBy } = options; - - let whereClause = sql`1=1`; - if (query) { - whereClause = ilike(tags.name, `%${query}%`); - } - - // Pour la popularité, on compte le nombre d'associations dans contentsToTags - if (sortBy === "popular") { - const data = await this.databaseService.db - .select({ - id: tags.id, - name: tags.name, - slug: tags.slug, - count: sql`count(${contentsToTags.contentId})`.as("usage_count"), - }) - .from(tags) - .leftJoin(contentsToTags, eq(tags.id, contentsToTags.tagId)) - .where(whereClause) - .groupBy(tags.id) - .orderBy(desc(sql`usage_count`)) - .limit(limit) - .offset(offset); - - return data; - } - - const data = await this.databaseService.db - .select() - .from(tags) - .where(whereClause) - .orderBy(sortBy === "recent" ? desc(tags.createdAt) : desc(tags.name)) - .limit(limit) - .offset(offset); - - return data; + return await this.tagsRepository.findAll(options); } } diff --git a/backend/src/users/repositories/users.repository.ts b/backend/src/users/repositories/users.repository.ts new file mode 100644 index 0000000..c56ccf2 --- /dev/null +++ b/backend/src/users/repositories/users.repository.ts @@ -0,0 +1,158 @@ +import { Injectable } from "@nestjs/common"; +import { and, eq, lte, sql } from "drizzle-orm"; +import { DatabaseService } from "../../database/database.service"; +import { users, contents, favorites } from "../../database/schemas"; +import type { UpdateUserDto } from "../dto/update-user.dto"; + +@Injectable() +export class UsersRepository { + constructor(private readonly databaseService: DatabaseService) {} + + async create(data: { + username: string; + email: string; + passwordHash: string; + emailHash: string; + }) { + const [newUser] = await this.databaseService.db + .insert(users) + .values(data) + .returning(); + return newUser; + } + + async findByEmailHash(emailHash: string) { + const result = await this.databaseService.db + .select({ + uuid: users.uuid, + username: users.username, + email: users.email, + passwordHash: users.passwordHash, + status: users.status, + isTwoFactorEnabled: users.isTwoFactorEnabled, + }) + .from(users) + .where(eq(users.emailHash, emailHash)) + .limit(1); + return result[0] || null; + } + + async findOneWithPrivateData(uuid: string) { + const result = await this.databaseService.db + .select({ + uuid: users.uuid, + username: users.username, + email: users.email, + displayName: users.displayName, + status: users.status, + isTwoFactorEnabled: users.isTwoFactorEnabled, + createdAt: users.createdAt, + updatedAt: users.updatedAt, + }) + .from(users) + .where(eq(users.uuid, uuid)) + .limit(1); + return result[0] || null; + } + + async countAll() { + const result = await this.databaseService.db + .select({ count: sql`count(*)` }) + .from(users); + return Number(result[0].count); + } + + async findAll(limit: number, offset: number) { + return await this.databaseService.db + .select({ + uuid: users.uuid, + username: users.username, + displayName: users.displayName, + status: users.status, + createdAt: users.createdAt, + }) + .from(users) + .limit(limit) + .offset(offset); + } + + async findByUsername(username: string) { + const result = await this.databaseService.db + .select({ + uuid: users.uuid, + username: users.username, + displayName: users.displayName, + createdAt: users.createdAt, + }) + .from(users) + .where(eq(users.username, username)) + .limit(1); + return result[0] || null; + } + + async findOne(uuid: string) { + const result = await this.databaseService.db + .select() + .from(users) + .where(eq(users.uuid, uuid)) + .limit(1); + return result[0] || null; + } + + async update(uuid: string, data: any) { + return await this.databaseService.db + .update(users) + .set({ ...data, updatedAt: new Date() }) + .where(eq(users.uuid, uuid)) + .returning(); + } + + async getTwoFactorSecret(uuid: string) { + const result = await this.databaseService.db + .select({ + secret: users.twoFactorSecret, + }) + .from(users) + .where(eq(users.uuid, uuid)) + .limit(1); + return result[0]?.secret || null; + } + + async getUserContents(uuid: string) { + return await this.databaseService.db + .select() + .from(contents) + .where(eq(contents.userId, uuid)); + } + + async getUserFavorites(uuid: string) { + return await this.databaseService.db + .select() + .from(favorites) + .where(eq(favorites.userId, uuid)); + } + + async softDeleteUserAndContents(uuid: string) { + return await this.databaseService.db.transaction(async (tx) => { + const userResult = await tx + .update(users) + .set({ status: "deleted", deletedAt: new Date() }) + .where(eq(users.uuid, uuid)) + .returning(); + + await tx + .update(contents) + .set({ deletedAt: new Date() }) + .where(eq(contents.userId, uuid)); + + return userResult; + }); + } + + async purgeDeleted(before: Date) { + return await this.databaseService.db + .delete(users) + .where(and(eq(users.status, "deleted"), lte(users.deletedAt, before))) + .returning(); + } +} diff --git a/backend/src/users/users.module.ts b/backend/src/users/users.module.ts index c78bc42..317a7a7 100644 --- a/backend/src/users/users.module.ts +++ b/backend/src/users/users.module.ts @@ -4,11 +4,12 @@ import { CryptoModule } from "../crypto/crypto.module"; import { DatabaseModule } from "../database/database.module"; import { UsersController } from "./users.controller"; import { UsersService } from "./users.service"; +import { UsersRepository } from "./repositories/users.repository"; @Module({ imports: [DatabaseModule, CryptoModule, AuthModule], controllers: [UsersController], - providers: [UsersService], + providers: [UsersService, UsersRepository], exports: [UsersService], }) export class UsersModule {} diff --git a/backend/src/users/users.service.spec.ts b/backend/src/users/users.service.spec.ts index 87f17a3..637017f 100644 --- a/backend/src/users/users.service.spec.ts +++ b/backend/src/users/users.service.spec.ts @@ -12,61 +12,46 @@ jest.mock("jose", () => ({ })); import { Test, TestingModule } from "@nestjs/testing"; -import { CryptoService } from "../crypto/crypto.service"; -import { DatabaseService } from "../database/database.service"; +import { CACHE_MANAGER } from "@nestjs/cache-manager"; import { UsersService } from "./users.service"; +import { UsersRepository } from "./repositories/users.repository"; describe("UsersService", () => { let service: UsersService; + let repository: UsersRepository; - const mockDb = { - insert: jest.fn(), - select: jest.fn(), + const mockUsersRepository = { + create: jest.fn(), + findOne: jest.fn(), + findByEmailHash: jest.fn(), + findOneWithPrivateData: jest.fn(), + countAll: jest.fn(), + findAll: jest.fn(), + findByUsername: jest.fn(), update: jest.fn(), - transaction: jest.fn().mockImplementation((cb) => cb(mockDb)), + getTwoFactorSecret: jest.fn(), + getUserContents: jest.fn(), + getUserFavorites: jest.fn(), + softDeleteUserAndContents: jest.fn(), }; - const mockCryptoService = { - getPgpEncryptionKey: jest.fn().mockReturnValue("mock-pgp-key"), + const mockCacheManager = { + del: jest.fn(), }; beforeEach(async () => { jest.clearAllMocks(); - const chain = { - insert: jest.fn().mockReturnThis(), - values: jest.fn().mockReturnThis(), - returning: jest.fn().mockReturnThis(), - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - offset: jest.fn().mockReturnThis(), - update: jest.fn().mockReturnThis(), - set: jest.fn().mockReturnThis(), - // biome-ignore lint/suspicious/noThenProperty: Fine for testing purposes - then: jest.fn().mockImplementation(function (cb) { - return Promise.resolve(this).then(cb); - }), - }; - - const mockImplementation = () => Object.assign(Promise.resolve([]), chain); - for (const mock of Object.values(chain)) { - if (mock !== chain.then) { - mock.mockImplementation(mockImplementation); - } - } - Object.assign(mockDb, chain); - const module: TestingModule = await Test.createTestingModule({ providers: [ UsersService, - { provide: DatabaseService, useValue: { db: mockDb } }, - { provide: CryptoService, useValue: mockCryptoService }, + { provide: UsersRepository, useValue: mockUsersRepository }, + { provide: CACHE_MANAGER, useValue: mockCacheManager }, ], }).compile(); service = module.get(UsersService); + repository = module.get(UsersRepository); }); it("should be defined", () => { @@ -81,24 +66,24 @@ describe("UsersService", () => { passwordHash: "p1", emailHash: "eh1", }; - mockDb.returning.mockResolvedValue([{ uuid: "uuid1", ...data }]); + mockUsersRepository.create.mockResolvedValue({ uuid: "uuid1", ...data }); const result = await service.create(data); expect(result.uuid).toBe("uuid1"); - expect(mockDb.insert).toHaveBeenCalled(); + expect(repository.create).toHaveBeenCalledWith(data); }); }); describe("findOne", () => { it("should find a user", async () => { - mockDb.limit.mockResolvedValue([{ uuid: "uuid1" }]); + mockUsersRepository.findOne.mockResolvedValue({ uuid: "uuid1" }); const result = await service.findOne("uuid1"); expect(result.uuid).toBe("uuid1"); }); it("should return null if not found", async () => { - mockDb.limit.mockResolvedValue([]); + mockUsersRepository.findOne.mockResolvedValue(null); const result = await service.findOne("uuid1"); expect(result).toBeNull(); }); @@ -106,7 +91,7 @@ describe("UsersService", () => { describe("update", () => { it("should update a user", async () => { - mockDb.returning.mockResolvedValue([{ uuid: "uuid1", displayName: "New" }]); + mockUsersRepository.update.mockResolvedValue([{ uuid: "uuid1", displayName: "New" }]); const result = await service.update("uuid1", { displayName: "New" }); expect(result[0].displayName).toBe("New"); }); diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index 19690ec..59e68bd 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -1,14 +1,7 @@ import { Injectable, Logger, Inject } from "@nestjs/common"; import { CACHE_MANAGER } from "@nestjs/cache-manager"; import { Cache } from "cache-manager"; -import { eq, sql } from "drizzle-orm"; -import { CryptoService } from "../crypto/crypto.service"; -import { DatabaseService } from "../database/database.service"; -import { - contents, - favorites, - users, -} from "../database/schemas"; +import { UsersRepository } from "./repositories/users.repository"; import { UpdateUserDto } from "./dto/update-user.dto"; @Injectable() @@ -16,8 +9,7 @@ export class UsersService { private readonly logger = new Logger(UsersService.name); constructor( - private readonly databaseService: DatabaseService, - private readonly cryptoService: CryptoService, + private readonly usersRepository: UsersRepository, @Inject(CACHE_MANAGER) private cacheManager: Cache, ) {} @@ -33,109 +25,37 @@ export class UsersService { passwordHash: string; emailHash: string; }) { - const [newUser] = await this.databaseService.db - .insert(users) - .values({ - username: data.username, - email: data.email, - emailHash: data.emailHash, - passwordHash: data.passwordHash, - }) - .returning(); - - return newUser; + return await this.usersRepository.create(data); } async findByEmailHash(emailHash: string) { - const result = await this.databaseService.db - .select({ - uuid: users.uuid, - username: users.username, - email: users.email, - passwordHash: users.passwordHash, - status: users.status, - isTwoFactorEnabled: users.isTwoFactorEnabled, - }) - .from(users) - .where(eq(users.emailHash, emailHash)) - .limit(1); - - return result[0] || null; + return await this.usersRepository.findByEmailHash(emailHash); } async findOneWithPrivateData(uuid: string) { - const result = await this.databaseService.db - .select({ - uuid: users.uuid, - username: users.username, - email: users.email, - displayName: users.displayName, - status: users.status, - isTwoFactorEnabled: users.isTwoFactorEnabled, - createdAt: users.createdAt, - updatedAt: users.updatedAt, - }) - .from(users) - .where(eq(users.uuid, uuid)) - .limit(1); - - return result[0] || null; + return await this.usersRepository.findOneWithPrivateData(uuid); } async findAll(limit: number, offset: number) { - const totalCountResult = await this.databaseService.db - .select({ count: sql`count(*)` }) - .from(users); - - const totalCount = Number(totalCountResult[0].count); - - const data = await this.databaseService.db - .select({ - uuid: users.uuid, - username: users.username, - displayName: users.displayName, - status: users.status, - createdAt: users.createdAt, - }) - .from(users) - .limit(limit) - .offset(offset); + const [data, totalCount] = await Promise.all([ + this.usersRepository.findAll(limit, offset), + this.usersRepository.countAll(), + ]); return { data, totalCount }; } async findPublicProfile(username: string) { - const result = await this.databaseService.db - .select({ - uuid: users.uuid, - username: users.username, - displayName: users.displayName, - createdAt: users.createdAt, - }) - .from(users) - .where(eq(users.username, username)) - .limit(1); - - return result[0] || null; + return await this.usersRepository.findByUsername(username); } async findOne(uuid: string) { - const result = await this.databaseService.db - .select() - .from(users) - .where(eq(users.uuid, uuid)) - .limit(1); - - return result[0] || null; + return await this.usersRepository.findOne(uuid); } async update(uuid: string, data: UpdateUserDto) { this.logger.log(`Updating user profile for ${uuid}`); - const result = await this.databaseService.db - .update(users) - .set({ ...data, updatedAt: new Date() }) - .where(eq(users.uuid, uuid)) - .returning(); + const result = await this.usersRepository.update(uuid, data); if (result[0]) { await this.clearUserCache(result[0].username); @@ -148,65 +68,37 @@ export class UsersService { termsVersion: string, privacyVersion: string, ) { - return await this.databaseService.db - .update(users) - .set({ - termsVersion, - privacyVersion, - gdprAcceptedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(users.uuid, uuid)) - .returning(); + return await this.usersRepository.update(uuid, { + termsVersion, + privacyVersion, + gdprAcceptedAt: new Date(), + }); } async setTwoFactorSecret(uuid: string, secret: string) { - return await this.databaseService.db - .update(users) - .set({ - twoFactorSecret: secret, - updatedAt: new Date(), - }) - .where(eq(users.uuid, uuid)) - .returning(); + return await this.usersRepository.update(uuid, { + twoFactorSecret: secret, + }); } async toggleTwoFactor(uuid: string, enabled: boolean) { - return await this.databaseService.db - .update(users) - .set({ - isTwoFactorEnabled: enabled, - updatedAt: new Date(), - }) - .where(eq(users.uuid, uuid)) - .returning(); + return await this.usersRepository.update(uuid, { + isTwoFactorEnabled: enabled, + }); } async getTwoFactorSecret(uuid: string): Promise { - const result = await this.databaseService.db - .select({ - secret: users.twoFactorSecret, - }) - .from(users) - .where(eq(users.uuid, uuid)) - .limit(1); - - return result[0]?.secret || null; + return await this.usersRepository.getTwoFactorSecret(uuid); } async exportUserData(uuid: string) { const user = await this.findOneWithPrivateData(uuid); if (!user) return null; - const userContents = await this.databaseService.db - .select() - .from(contents) - .where(eq(contents.userId, uuid)); - - const userFavorites = await this.databaseService.db - .select() - .from(favorites) - .where(eq(favorites.userId, uuid)); + const [userContents, userFavorites] = await Promise.all([ + this.usersRepository.getUserContents(uuid), + this.usersRepository.getUserFavorites(uuid), + ]); return { profile: user, @@ -217,21 +109,6 @@ export class UsersService { } async remove(uuid: string) { - return await this.databaseService.db.transaction(async (tx) => { - // Soft delete de l'utilisateur - const userResult = await tx - .update(users) - .set({ status: "deleted", deletedAt: new Date() }) - .where(eq(users.uuid, uuid)) - .returning(); - - // Soft delete de tous ses contenus - await tx - .update(contents) - .set({ deletedAt: new Date() }) - .where(eq(contents.userId, uuid)); - - return userResult; - }); + return await this.usersRepository.softDeleteUserAndContents(uuid); } } diff --git a/backend/test/__mocks__/cuid2.js b/backend/test/__mocks__/cuid2.js new file mode 100644 index 0000000..e95da3d --- /dev/null +++ b/backend/test/__mocks__/cuid2.js @@ -0,0 +1,3 @@ +module.exports = { + createCuid: () => () => 'mocked-cuid', +}; \ No newline at end of file diff --git a/backend/test/__mocks__/jose.js b/backend/test/__mocks__/jose.js new file mode 100644 index 0000000..006ccab --- /dev/null +++ b/backend/test/__mocks__/jose.js @@ -0,0 +1,13 @@ +module.exports = { + SignJWT: class { + constructor() { return this; } + setProtectedHeader() { return this; } + setIssuedAt() { return this; } + setExpirationTime() { return this; } + sign() { return Promise.resolve('mocked-token'); } + }, + jwtVerify: () => Promise.resolve({ payload: { sub: 'mocked-user' } }), + importJWK: () => Promise.resolve({}), + exportJWK: () => Promise.resolve({}), + generateKeyPair: () => Promise.resolve({ publicKey: {}, privateKey: {} }), +}; \ No newline at end of file diff --git a/backend/test/__mocks__/ml-kem.js b/backend/test/__mocks__/ml-kem.js new file mode 100644 index 0000000..2ef4c06 --- /dev/null +++ b/backend/test/__mocks__/ml-kem.js @@ -0,0 +1,7 @@ +module.exports = { + ml_kem768: { + keygen: () => ({ publicKey: Buffer.alloc(1184), secretKey: Buffer.alloc(2400) }), + encapsulate: () => ({ cipherText: Buffer.alloc(1088), sharedSecret: Buffer.alloc(32) }), + decapsulate: () => Buffer.alloc(32), + } +}; \ No newline at end of file diff --git a/backend/test/__mocks__/sha3.js b/backend/test/__mocks__/sha3.js new file mode 100644 index 0000000..b1a25f2 --- /dev/null +++ b/backend/test/__mocks__/sha3.js @@ -0,0 +1,5 @@ +module.exports = { + sha3_256: () => ({ update: () => ({ digest: () => Buffer.alloc(32) }) }), + sha3_512: () => ({ update: () => ({ digest: () => Buffer.alloc(64) }) }), + shake256: () => ({ update: () => ({ digest: () => Buffer.alloc(32) }) }), +}; \ No newline at end of file