Files
gitmost/apps/server/src/core/api-key/api-key.controller.spec.ts
T
agent_coder d3a049d176 feat(api-key): core-модуль выдачи и валидации api-ключей + kill-switch
Форк не несёт EE-модуль ee/api-key, поэтому api_key-токен сегодня отвергается на
любом /api/*. Вводим core-модуль:

- ApiKeyRepo (kysely): insert/findById/findByCreator/findAllInWorkspace/
  softDelete/touchLastUsed, фильтр deleted_at IS NULL.
- ApiKeyService.create — mint-then-insert: сперва uuid7 id, затем чеканка JWT
  без exp, затем строка (сбой чеканки → строки нет; потерянный ответ →
  осиротевшая строка видна в list и самолечится). Токен отдаётся один раз, в
  таблице не хранится.
- ApiKeyService.validate — единый валидатор для jwt.strategy И /mcp. Владелец
  истины о сроке/отзыве — строка api_keys, не JWT. Любой определённый негатив
  (нет строки / expired / disabled / workspace mismatch / kill-switch off) →
  единый bare-401 (анти-enumeration); инфра-ошибка пробрасывается (→5xx), не
  маскируется. Без кэша — немедленная ревокация. last_used_at троттлится 1ч,
  fire-and-forget.
- REST create/list/revoke: create — self + UserThrottlerGuard; list/revoke —
  admin (CASL Manage на API) видит/отзывает все ключи воркспейса, член — только
  свои. api_key-принципал отвергается на всех трёх (токен не управляет
  токенами — GitHub-PAT-семантика). Аудит API_KEY_CREATED/DELETED + структурный
  лог с apiKeyId и IP.
- jwt.strategy: прямой вызов ApiKeyService.validate вместо динамического
  EE-require; штампует req.raw.authType='api_key' и apiKeyId для гварда выше.
- Kill-switch API_KEYS_ENABLED: дефолт ON (деплой без переменной не убивает
  агентов), строгий парс @IsIn(['true','false']) (=0/=off падают на boot, а не
  молча значат «включено»), boot-лог состояния. off → validate deny и
  эндпоинты 404.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:57:15 +03:00

194 lines
6.7 KiB
TypeScript

import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { ApiKeyController } from './api-key.controller';
import {
WorkspaceCaslAction,
WorkspaceCaslSubject,
} from '../casl/interfaces/workspace-ability.type';
/**
* Authorization contract for the /api-keys management surface.
*
* - A token cannot manage tokens (an api_key PRINCIPAL is 403 on every method)
* — GitHub-PAT semantics closing post-revocation laundering.
* - admin (CASL Manage on API) sees/revokes all workspace keys; a member only
* their own.
* - kill-switch OFF -> the surface 404s (looks like the feature does not exist).
*/
function makeController(over: any = {}) {
const apiKeyService = {
create: jest.fn().mockResolvedValue({
token: 'tok',
key: { id: 'k1', name: 'n', expiresAt: null, createdAt: new Date() },
}),
revoke: jest.fn().mockResolvedValue(undefined),
...(over.apiKeyService ?? {}),
};
const apiKeyRepo = {
findAllInWorkspace: jest.fn().mockResolvedValue([]),
findByCreator: jest.fn().mockResolvedValue([]),
findById: jest.fn(),
...(over.apiKeyRepo ?? {}),
};
const workspaceAbility = {
createForUser: jest.fn().mockReturnValue({
can: (_a: any, _s: any) => over.canManage ?? false,
}),
...(over.workspaceAbility ?? {}),
};
const environmentService = {
isApiKeysEnabled: jest.fn().mockReturnValue(over.enabled ?? true),
...(over.environmentService ?? {}),
};
const auditService = { log: jest.fn() };
const controller = new ApiKeyController(
apiKeyService as any,
apiKeyRepo as any,
workspaceAbility as any,
environmentService as any,
auditService as any,
);
return {
controller,
apiKeyService,
apiKeyRepo,
workspaceAbility,
environmentService,
auditService,
};
}
const user = { id: 'u-1', workspaceId: 'ws-1' } as any;
const workspace = { id: 'ws-1' } as any;
const reqAccess = () => ({ raw: {}, ip: '1.2.3.4', socket: {} }) as any;
const reqApiKey = () =>
({ raw: { authType: 'api_key', apiKeyId: 'k-self' }, ip: '1.2.3.4', socket: {} }) as any;
describe('ApiKeyController — a token cannot manage tokens', () => {
it('403 on create for an api_key principal', async () => {
const { controller, apiKeyService } = makeController();
await expect(
controller.create({ name: 'x' } as any, user, reqApiKey()),
).rejects.toBeInstanceOf(ForbiddenException);
expect(apiKeyService.create).not.toHaveBeenCalled();
});
it('403 on list for an api_key principal', async () => {
const { controller } = makeController();
await expect(
controller.list(user, workspace, reqApiKey()),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('403 on revoke for an api_key principal', async () => {
const { controller } = makeController();
await expect(
controller.revoke({ id: 'k1' } as any, user, workspace, reqApiKey()),
).rejects.toBeInstanceOf(ForbiddenException);
});
});
describe('ApiKeyController — kill-switch OFF → 404', () => {
it('create/list/revoke all 404 when disabled', async () => {
const { controller } = makeController({ enabled: false });
await expect(
controller.create({ name: 'x' } as any, user, reqAccess()),
).rejects.toBeInstanceOf(NotFoundException);
await expect(
controller.list(user, workspace, reqAccess()),
).rejects.toBeInstanceOf(NotFoundException);
await expect(
controller.revoke({ id: 'k1' } as any, user, workspace, reqAccess()),
).rejects.toBeInstanceOf(NotFoundException);
});
});
describe('ApiKeyController — list scoping', () => {
it('admin (Manage on API) lists ALL workspace keys', async () => {
const { controller, apiKeyRepo } = makeController({ canManage: true });
await controller.list(user, workspace, reqAccess());
expect(apiKeyRepo.findAllInWorkspace).toHaveBeenCalledWith('ws-1');
expect(apiKeyRepo.findByCreator).not.toHaveBeenCalled();
});
it('member lists ONLY their own keys', async () => {
const { controller, apiKeyRepo } = makeController({ canManage: false });
await controller.list(user, workspace, reqAccess());
expect(apiKeyRepo.findByCreator).toHaveBeenCalledWith('u-1', 'ws-1');
expect(apiKeyRepo.findAllInWorkspace).not.toHaveBeenCalled();
});
});
describe('ApiKeyController — revoke scoping', () => {
it("member cannot revoke another user's key (403)", async () => {
const { controller, apiKeyRepo, apiKeyService } = makeController({
canManage: false,
});
apiKeyRepo.findById.mockResolvedValue({
id: 'k1',
creatorId: 'someone-else',
workspaceId: 'ws-1',
});
await expect(
controller.revoke({ id: 'k1' } as any, user, workspace, reqAccess()),
).rejects.toBeInstanceOf(ForbiddenException);
expect(apiKeyService.revoke).not.toHaveBeenCalled();
});
it('member CAN revoke their own key', async () => {
const { controller, apiKeyRepo, apiKeyService } = makeController({
canManage: false,
});
apiKeyRepo.findById.mockResolvedValue({
id: 'k1',
creatorId: 'u-1',
workspaceId: 'ws-1',
});
await controller.revoke({ id: 'k1' } as any, user, workspace, reqAccess());
expect(apiKeyService.revoke).toHaveBeenCalledWith('k1', 'ws-1');
});
it("admin CAN revoke another user's key", async () => {
const { controller, apiKeyRepo, apiKeyService } = makeController({
canManage: true,
});
apiKeyRepo.findById.mockResolvedValue({
id: 'k1',
creatorId: 'someone-else',
workspaceId: 'ws-1',
});
await controller.revoke({ id: 'k1' } as any, user, workspace, reqAccess());
expect(apiKeyService.revoke).toHaveBeenCalledWith('k1', 'ws-1');
});
it('404 when the key does not exist', async () => {
const { controller, apiKeyRepo } = makeController({ canManage: true });
apiKeyRepo.findById.mockResolvedValue(undefined);
await expect(
controller.revoke({ id: 'k1' } as any, user, workspace, reqAccess()),
).rejects.toBeInstanceOf(NotFoundException);
});
});
describe('ApiKeyController — create emits audit + returns token once', () => {
it('logs API_KEY_CREATED and returns the token + computed expiry', async () => {
const { controller, auditService } = makeController();
const res = await controller.create(
{ name: 'ci' } as any,
user,
reqAccess(),
);
expect(res.token).toBe('tok');
expect(res.apiKey).toMatchObject({ id: 'k1', name: 'n' });
expect(auditService.log).toHaveBeenCalledWith(
expect.objectContaining({ event: 'api_key.created' }),
);
});
});
// Sanity: the CASL subject used is the workspace API subject.
it('uses WorkspaceCaslSubject.API with the Manage action', () => {
expect(WorkspaceCaslSubject.API).toBe('api_key');
expect(WorkspaceCaslAction.Manage).toBeDefined();
});