qwen_2.5_model / src /modules /auth /guards /api-key.guard.spec.ts
Muhammad Noman
Deploy OpenWA to Hugging Face Spaces
46252cd
Raw
History Blame Contribute Delete
11.4 kB
import { ExecutionContext, UnauthorizedException, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { ApiKeyGuard } from './api-key.guard';
import { AuthService } from '../auth.service';
import { ApiKey, ApiKeyRole } from '../entities/api-key.entity';
import { AuditService } from '../../audit/audit.service';
import { AuditAction } from '../../audit/entities/audit-log.entity';
function createMockApiKey(overrides: Partial<ApiKey> = {}): ApiKey {
return {
id: 'uuid-1',
name: 'Test Key',
keyHash: 'hash',
keyPrefix: 'owa_k1_xxxx',
role: ApiKeyRole.OPERATOR,
allowedIps: null,
allowedSessions: null,
isActive: true,
expiresAt: null,
lastUsedAt: null,
usageCount: 0,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function createMockContext(
headers: Record<string, string> = {},
params: Record<string, string> = {},
socketIp = '127.0.0.1',
): ExecutionContext {
const request = {
headers,
params,
ip: socketIp,
socket: { remoteAddress: socketIp },
};
return {
switchToHttp: () => ({
getRequest: () => request,
}),
getHandler: () => ({}),
getClass: () => ({}),
} as unknown as ExecutionContext;
}
describe('ApiKeyGuard', () => {
let guard: ApiKeyGuard;
let authService: jest.Mocked<Partial<AuthService>>;
let reflector: jest.Mocked<Reflector>;
let configService: jest.Mocked<Partial<ConfigService>>;
let auditService: jest.Mocked<Partial<AuditService>>;
function buildGuard(trustedProxies: string[] = []): ApiKeyGuard {
configService = {
get: jest.fn().mockReturnValue(trustedProxies),
};
return new ApiKeyGuard(
authService as AuthService,
reflector,
configService as ConfigService,
auditService as AuditService,
);
}
beforeEach(() => {
authService = {
validateApiKey: jest.fn(),
hasPermission: jest.fn(),
};
reflector = {
getAllAndOverride: jest.fn(),
} as unknown as jest.Mocked<Reflector>;
auditService = {
logWarn: jest.fn().mockResolvedValue(null),
};
guard = buildGuard();
});
it('should allow access to @Public() routes without API key', async () => {
reflector.getAllAndOverride.mockReturnValueOnce(true); // isPublic = true
const context = createMockContext();
const result = await guard.canActivate(context);
expect(result).toBe(true);
expect(authService.validateApiKey).not.toHaveBeenCalled();
});
it('should reject requests without X-API-Key header', async () => {
reflector.getAllAndOverride.mockReturnValueOnce(false); // not public
const context = createMockContext({});
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
await expect(guard.canActivate(context)).rejects.toThrow('API key is required');
});
it('should accept X-API-Key header', async () => {
reflector.getAllAndOverride
.mockReturnValueOnce(false) // not public
.mockReturnValueOnce(undefined); // no required role
const apiKey = createMockApiKey();
(authService.validateApiKey as jest.Mock).mockResolvedValue(apiKey);
const context = createMockContext({ 'x-api-key': 'my-key' });
const result = await guard.canActivate(context);
expect(result).toBe(true);
expect(authService.validateApiKey).toHaveBeenCalledWith('my-key', '127.0.0.1', undefined);
});
it('should accept Authorization Bearer header', async () => {
reflector.getAllAndOverride.mockReturnValueOnce(false).mockReturnValueOnce(undefined);
const apiKey = createMockApiKey();
(authService.validateApiKey as jest.Mock).mockResolvedValue(apiKey);
const context = createMockContext({ authorization: 'Bearer my-bearer-key' });
const result = await guard.canActivate(context);
expect(result).toBe(true);
expect(authService.validateApiKey).toHaveBeenCalledWith('my-bearer-key', '127.0.0.1', undefined);
});
it('should reject when API key validation fails', async () => {
reflector.getAllAndOverride.mockReturnValueOnce(false);
(authService.validateApiKey as jest.Mock).mockRejectedValue(new UnauthorizedException('Invalid API key'));
const context = createMockContext({ 'x-api-key': 'bad-key' });
await expect(guard.canActivate(context)).rejects.toThrow('Invalid API key');
});
it('records an API_KEY_AUTH_FAILED audit event when a key is rejected (with ip + reason)', async () => {
reflector.getAllAndOverride.mockReturnValueOnce(false); // not public
(authService.validateApiKey as jest.Mock).mockRejectedValue(new UnauthorizedException('Invalid API key'));
const context = createMockContext({ 'x-api-key': 'bad-key' }, {}, '203.0.113.9');
await expect(guard.canActivate(context)).rejects.toThrow('Invalid API key');
await new Promise(resolve => setImmediate(resolve)); // let the fire-and-forget audit write settle
expect(auditService.logWarn).toHaveBeenCalledWith(
AuditAction.API_KEY_AUTH_FAILED,
expect.objectContaining({ ipAddress: '203.0.113.9', errorMessage: 'Invalid API key' }),
);
});
it('records an audit event when a missing key is rejected', async () => {
reflector.getAllAndOverride.mockReturnValueOnce(false);
const context = createMockContext({}); // no key
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
await new Promise(resolve => setImmediate(resolve));
expect(auditService.logWarn).toHaveBeenCalledWith(AuditAction.API_KEY_AUTH_FAILED, expect.any(Object));
});
it('does not record an audit event on a successful authorization', async () => {
reflector.getAllAndOverride.mockReturnValueOnce(false).mockReturnValueOnce(undefined);
(authService.validateApiKey as jest.Mock).mockResolvedValue(createMockApiKey());
const context = createMockContext({ 'x-api-key': 'good-key' });
await guard.canActivate(context);
await new Promise(resolve => setImmediate(resolve));
expect(auditService.logWarn).not.toHaveBeenCalled();
});
it('should reject when role permission is insufficient', async () => {
reflector.getAllAndOverride
.mockReturnValueOnce(false) // not public
.mockReturnValueOnce(ApiKeyRole.ADMIN); // required role = ADMIN
const apiKey = createMockApiKey({ role: ApiKeyRole.VIEWER });
(authService.validateApiKey as jest.Mock).mockResolvedValue(apiKey);
(authService.hasPermission as jest.Mock).mockReturnValue(false);
const context = createMockContext({ 'x-api-key': 'viewer-key' });
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
});
it('should pass session ID from route params to validateApiKey', async () => {
reflector.getAllAndOverride.mockReturnValueOnce(false).mockReturnValueOnce(undefined);
const apiKey = createMockApiKey();
(authService.validateApiKey as jest.Mock).mockResolvedValue(apiKey);
const context = createMockContext({ 'x-api-key': 'key' }, { sessionId: 'sess-123' });
await guard.canActivate(context);
expect(authService.validateApiKey).toHaveBeenCalledWith('key', '127.0.0.1', 'sess-123');
});
it('does not treat a non-session route :id as a session id (no @SessionScoped)', async () => {
reflector.getAllAndOverride
.mockReturnValueOnce(false) // not public
.mockReturnValueOnce(undefined) // no required role
.mockReturnValueOnce(undefined); // controller is NOT @SessionScoped
const apiKey = createMockApiKey();
(authService.validateApiKey as jest.Mock).mockResolvedValue(apiKey);
// e.g. GET /plugins/:id or /auth/api-keys/:id — :id is a plugin/key id, not a session.
const context = createMockContext({ 'x-api-key': 'key' }, { id: 'plugin-x' });
await guard.canActivate(context);
expect(authService.validateApiKey).toHaveBeenCalledWith('key', '127.0.0.1', undefined);
});
it('treats :id as the session id on a @SessionScoped controller (session scoping preserved)', async () => {
reflector.getAllAndOverride
.mockReturnValueOnce(false) // not public
.mockReturnValueOnce(undefined) // no required role
.mockReturnValueOnce(true); // controller IS @SessionScoped (SessionController)
const apiKey = createMockApiKey();
(authService.validateApiKey as jest.Mock).mockResolvedValue(apiKey);
// GET /sessions/:id/... — :id IS the session, so allowedSessions must still be enforced.
const context = createMockContext({ 'x-api-key': 'key' }, { id: 'sess-B' });
await guard.canActivate(context);
expect(authService.validateApiKey).toHaveBeenCalledWith('key', '127.0.0.1', 'sess-B');
});
it('ignores X-Forwarded-For by default (no trusted proxies) to prevent IP spoofing', async () => {
reflector.getAllAndOverride.mockReturnValueOnce(false).mockReturnValueOnce(undefined);
const apiKey = createMockApiKey();
(authService.validateApiKey as jest.Mock).mockResolvedValue(apiKey);
// Attacker forges X-Forwarded-For; the direct socket IP must win.
const context = createMockContext({
'x-api-key': 'key',
'x-forwarded-for': '203.0.113.50, 70.41.3.18',
});
await guard.canActivate(context);
expect(authService.validateApiKey).toHaveBeenCalledWith('key', '127.0.0.1', undefined);
});
it('uses the rightmost untrusted hop when the request comes from a trusted proxy', async () => {
guard = buildGuard(['10.0.0.0/8']);
reflector.getAllAndOverride.mockReturnValueOnce(false).mockReturnValueOnce(undefined);
const apiKey = createMockApiKey();
(authService.validateApiKey as jest.Mock).mockResolvedValue(apiKey);
// Direct peer 10.0.0.1 is a trusted proxy; XFF = [real client, inner proxy].
const context = createMockContext(
{ 'x-api-key': 'key', 'x-forwarded-for': '203.0.113.50, 10.0.0.5' },
{},
'10.0.0.1',
);
await guard.canActivate(context);
expect(authService.validateApiKey).toHaveBeenCalledWith('key', '203.0.113.50', undefined);
});
it('ignores X-Forwarded-For when the direct peer is not a trusted proxy', async () => {
guard = buildGuard(['10.0.0.0/8']);
reflector.getAllAndOverride.mockReturnValueOnce(false).mockReturnValueOnce(undefined);
const apiKey = createMockApiKey();
(authService.validateApiKey as jest.Mock).mockResolvedValue(apiKey);
// Attacker connects directly (203.0.113.99) and forges a trusted-looking XFF.
const context = createMockContext({ 'x-api-key': 'key', 'x-forwarded-for': '10.0.0.5' }, {}, '203.0.113.99');
await guard.canActivate(context);
expect(authService.validateApiKey).toHaveBeenCalledWith('key', '203.0.113.99', undefined);
});
it('normalizes an IPv4-mapped IPv6 proxy address (e.g. ::ffff:10.0.0.1)', async () => {
guard = buildGuard(['10.0.0.0/8']);
reflector.getAllAndOverride.mockReturnValueOnce(false).mockReturnValueOnce(undefined);
const apiKey = createMockApiKey();
(authService.validateApiKey as jest.Mock).mockResolvedValue(apiKey);
const context = createMockContext({ 'x-api-key': 'key', 'x-forwarded-for': '203.0.113.50' }, {}, '::ffff:10.0.0.1');
await guard.canActivate(context);
expect(authService.validateApiKey).toHaveBeenCalledWith('key', '203.0.113.50', undefined);
});
});