This commit is contained in:
Rayyan Syahbani Hermanto
2026-09-07 22:57:18 +07:00
parent fdbfb34842
commit 1bba2b518e
101 changed files with 2503 additions and 8 deletions
+87
View File
@@ -0,0 +1,87 @@
import { CoreApiClient, CoreApiClientError } from '../../src/adapters/notification/core-api-client';
describe('CoreApiClient', () => {
const API_URL = 'http://core.local';
const CLIENT_ID = 'cid';
const CLIENT_SECRET = 'csec';
beforeEach(() => {
(global as any).fetch = jest.fn();
jest.clearAllMocks();
});
it('requests token successfully and caches it', async () => {
// token endpoint response
(global.fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: async () => ({ success: true, data: { access_token: 't1', token_type: 'Bearer', expires_in: 3600 } }) });
const client = new CoreApiClient({ apiUrl: API_URL, clientId: CLIENT_ID, clientSecret: CLIENT_SECRET });
const token = await client.getToken();
expect(token).toBe('t1');
// second call should not call fetch again for token
const token2 = await client.getToken();
expect(token2).toBe('t1');
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it('throws on invalid credentials (401)', async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({ status: 401, json: async () => ({}) });
const client = new CoreApiClient({ apiUrl: API_URL, clientId: CLIENT_ID, clientSecret: CLIENT_SECRET });
await expect(client.getToken()).rejects.toThrow(CoreApiClientError);
});
it('retries once on 401 during authenticatedRequest', async () => {
// initial token fetch
(global.fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: async () => ({ success: true, data: { access_token: 't1', token_type: 'Bearer', expires_in: 3600 } }) });
// first API call returns 401
(global.fetch as jest.Mock).mockResolvedValueOnce({ status: 401, json: async () => ({}) });
// token refresh fetch returns new token
(global.fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: async () => ({ success: true, data: { access_token: 't2', token_type: 'Bearer', expires_in: 3600 } }) });
// retried API call returns 200
(global.fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: async () => ({ ok: true }) });
const client = new CoreApiClient({ apiUrl: API_URL, clientId: CLIENT_ID, clientSecret: CLIENT_SECRET });
const res = await client.authenticatedRequest(`${API_URL}/api/v1/users`, { method: 'GET' });
expect(res.status).toBe(200);
expect(global.fetch).toHaveBeenCalledTimes(4);
});
it('does not retry on 403', async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: async () => ({ success: true, data: { access_token: 't1', token_type: 'Bearer', expires_in: 3600 } }) });
(global.fetch as jest.Mock).mockResolvedValueOnce({ status: 403, json: async () => ({}) });
const client = new CoreApiClient({ apiUrl: API_URL, clientId: CLIENT_ID, clientSecret: CLIENT_SECRET });
const res = await client.authenticatedRequest(`${API_URL}/api/v1/forbidden`, { method: 'GET' });
expect(res.status).toBe(403);
// only token fetch + one API call
expect(global.fetch).toHaveBeenCalledTimes(2);
});
it('handles token refresh before expiry', async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: async () => ({ success: true, data: { access_token: 't1', token_type: 'Bearer', expires_in: 2 } }) });
const client = new CoreApiClient({ apiUrl: API_URL, clientId: CLIENT_ID, clientSecret: CLIENT_SECRET });
const token1 = await client.getToken();
expect(token1).toBe('t1');
// force expiry by manipulating internal state
(client as any).tokenExpiry = Date.now() - 1000;
(global.fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: async () => ({ success: true, data: { access_token: 't2', token_type: 'Bearer', expires_in: 3600 } }) });
const token2 = await client.getToken();
expect(token2).toBe('t2');
});
it('handles network timeout', async () => {
(global.fetch as jest.Mock).mockRejectedValueOnce(Object.assign(new Error('AbortError'), { name: 'AbortError' }));
const client = new CoreApiClient({ apiUrl: API_URL, clientId: CLIENT_ID, clientSecret: CLIENT_SECRET, timeoutMs: 1 });
await expect(client.getToken()).rejects.toThrow(CoreApiClientError);
});
});
@@ -0,0 +1,35 @@
import { PrismaService } from '../../src/shared/prisma.service';
import { PrismaServiceAccountRepository } from '../../src/modules/identity/infrastructure/repositories/prisma-service-account.repository';
import { ServiceAccount } from '../../src/modules/identity/domain/entities/service-account.entity';
describe('PrismaServiceAccountRepository', () => {
it('maps prisma model to domain entity for findByClientId', async () => {
const fakeModel = {
id: 'id-1',
client_id: 'cid',
client_secret_hash: 'hash',
name: 'Bot',
role: 'BOT',
permissions: ['DEBT_READ'],
status: 'ACTIVE',
metadata: { hello: 'world' },
};
const mockPrisma: Partial<PrismaService> = {
serviceAccount: {
findUnique: jest.fn().mockResolvedValue(fakeModel),
} as any,
};
const repo = new PrismaServiceAccountRepository(mockPrisma as any);
const sa = await repo.findByClientId('cid');
expect(sa).not.toBeNull();
expect(sa!.clientId).toBe('cid');
expect(sa!.name).toBe('Bot');
expect(sa!.role).toBe('BOT');
expect(sa!.permissions).toEqual(fakeModel.permissions);
expect(sa!.metadata).toEqual(fakeModel.metadata);
});
});
+98
View File
@@ -0,0 +1,98 @@
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { TokenHandler } from '../../src/modules/identity/application/handlers/token.handler';
import { ServiceAccount } from '../../src/modules/identity/domain/entities/service-account.entity';
import { ServiceAccountRepository } from '../../src/modules/identity/domain/repositories/service-account.repository.interface';
describe('TokenHandler', () => {
let mockRepo: Partial<ServiceAccountRepository>;
let mockJwtService: any;
let handler: TokenHandler;
beforeEach(() => {
mockRepo = {
findByClientId: jest.fn(),
};
mockJwtService = {
signAsync: jest.fn().mockResolvedValue('signed-token'),
};
handler = new TokenHandler(mockRepo as any, mockJwtService as any);
});
it('should throw BadRequestException for invalid grant_type', async () => {
await expect(handler.execute({ client_id: 'a', client_secret: 'b', grant_type: 'foo' })).rejects.toBeInstanceOf(BadRequestException);
});
it('should throw UnauthorizedException when client_id not found', async () => {
(mockRepo.findByClientId as jest.Mock).mockResolvedValue(null);
await expect(handler.execute({ client_id: 'notfound', client_secret: 'x', grant_type: 'client_credentials' })).rejects.toBeInstanceOf(UnauthorizedException);
});
it('should throw UnauthorizedException when service account disabled', async () => {
const sa = ServiceAccount.restore({
id: 'sa-id',
clientId: 'cid',
clientSecretHash: await bcrypt.hash('secret', 1),
name: 'Bot',
role: 'BOT',
permissions: ['DEBT_READ'],
status: 'DISABLED',
metadata: {},
});
(mockRepo.findByClientId as jest.Mock).mockResolvedValue(sa);
await expect(handler.execute({ client_id: 'cid', client_secret: 'secret', grant_type: 'client_credentials' })).rejects.toBeInstanceOf(UnauthorizedException);
});
it('should throw UnauthorizedException when secret mismatch', async () => {
const sa = ServiceAccount.restore({
id: 'sa-id',
clientId: 'cid',
clientSecretHash: await bcrypt.hash('secret', 1),
name: 'Bot',
role: 'BOT',
permissions: ['DEBT_READ'],
status: 'ACTIVE',
metadata: {},
});
(mockRepo.findByClientId as jest.Mock).mockResolvedValue(sa);
await expect(handler.execute({ client_id: 'cid', client_secret: 'wrong', grant_type: 'client_credentials' })).rejects.toBeInstanceOf(UnauthorizedException);
});
it('should sign JWT and return token for valid credentials', async () => {
const hash = await bcrypt.hash('secret', 1);
const sa = ServiceAccount.restore({
id: 'sa-id',
clientId: 'cid',
clientSecretHash: hash,
name: 'Bot',
role: 'BOT',
permissions: ['DEBT_READ', 'DEBT_CREATE'],
status: 'ACTIVE',
metadata: {},
});
(mockRepo.findByClientId as jest.Mock).mockResolvedValue(sa);
const result = await handler.execute({ client_id: 'cid', client_secret: 'secret', grant_type: 'client_credentials' });
expect(mockJwtService.signAsync).toHaveBeenCalledTimes(1);
const signCallArgs = (mockJwtService.signAsync as jest.Mock).mock.calls[0];
const payload = signCallArgs[0];
const options = signCallArgs[1];
expect(payload).toMatchObject({ sub: 'sa-id', client_id: 'cid', role: 'BOT', permissions: expect.any(Array) });
expect(options).toMatchObject({ expiresIn: '1h' });
expect(result).toMatchObject({ access_token: 'signed-token', token_type: 'Bearer', expires_in: 3600 });
});
});