feat: add modular services and repositories for improved code organization

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

View File

@@ -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 {}

View File

@@ -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>(ApiKeysService);
repository = module.get<ApiKeysRepository>(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);

View File

@@ -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;
}

View File

@@ -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));
}
}