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:
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
58
backend/src/api-keys/repositories/api-keys.repository.ts
Normal file
58
backend/src/api-keys/repositories/api-keys.repository.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user