Files
gitmost/apps/client/src/features/api-key/services/api-key-service.test.ts
T
agent_coder 8d8aaf7c7f feat(api-key): фаза 3A — снять kill-switch API_KEYS_ENABLED + копируемый ключ (reveal под step-up)
Removes the API_KEYS_ENABLED kill-switch entirely and makes api-keys copyable via
a deterministic re-mint revealed under a password step-up (#557, epic #556).

Part 1 — remove the kill-switch (keys are now always on):
- environment.service.ts: delete isApiKeysEnabled()/getApiKeysEnabledRaw().
- environment.validation.ts: delete the API_KEYS_ENABLED field + decorators
  (validation has no forbidNonWhitelisted, so a stray env value is ignored).
- api-key.service.ts: drop OnModuleInit boot-log, the validate kill-switch branch,
  and the now-unused EnvironmentService injection.
- api-key.controller.ts: drop assertEnabled() + its call sites and the
  EnvironmentService injection.
- .env.example: delete the API_KEYS_ENABLED block.

Part 2 — deterministic mint: apiKeyJwtService gets noTimestamp:true, so the
api-key token carries neither exp nor iat and re-minting the same key is
byte-identical (basis of copyable reveal).

Part 3 — POST /api-keys/reveal { id, password }: JwtAuthGuard +
rejectApiKeyPrincipal (403) + AUTH throttler; step-up via
AuthService.verifyUserCredentials BEFORE any key lookup (401, no oracle);
owner-only even for admin; uniform 404 for absent/revoked/expired/other-owner/
disabled-creator (ForbiddenException from generateApiToken normalized to 404);
re-mint via the existing deterministic path; token never persisted; durable
AuditEvent.API_KEY_REVEALED + structured log without token material.

Part 4 — UI: replace the show-once modal with a per-row Copy action -> password
prompt -> reveal -> clipboard write + toast. The token never enters React state,
localStorage or the query cache (gcTime:0 + reset-after-read, mirroring #506).
i18n en+ru added; immediate-revoke behavior kept.

Tests: server api-key + token.service specs (reveal, deterministic no-exp/no-iat,
byte-identical, kill-switch-removal) and client api-key vitest (copy flow, no-leak,
wrong-password) all green. Mutation-verified: skipping the owner check reds the
non-owner-404 test, skipping step-up reds the wrong-password test, dropping
noTimestamp reds the deterministic/no-iat tests.

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

99 lines
3.1 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock the api-client (axios instance). vi.mock replaces the whole module, so
// the real response interceptor — which returns `response.data`, i.e. the
// server envelope { data, success, status } — is bypassed. Our mocked post/get
// therefore resolve to that post-interceptor envelope shape directly, and the
// service under test reads `.data` off it to unwrap the inner payload. This is
// the one bug-prone line the component tests (which fully mock the service)
// never exercise.
const { post, get } = vi.hoisted(() => ({ post: vi.fn(), get: vi.fn() }));
vi.mock("@/lib/api-client", () => ({
default: { post, get },
}));
import {
createApiKey,
getApiKeys,
revealApiKey,
} from "@/features/api-key/services/api-key-service";
beforeEach(() => {
vi.clearAllMocks();
});
describe("api-key-service response-contract unwrap", () => {
it("createApiKey unwraps the envelope to { token, apiKey }", async () => {
const payload = {
token: "gm_secret-abc",
apiKey: {
id: "key-1",
name: "CI token",
expiresAt: "2027-07-11T12:00:00.000Z",
createdAt: "2026-07-11T12:00:00.000Z",
},
};
// The server envelope as the interceptor hands it to the service.
post.mockResolvedValue({ data: payload, success: true, status: 200 });
const result = await createApiKey({
name: "CI token",
expiresAt: "2027-07-11T12:00:00.000Z",
});
// The inner payload only — not the { data, success, status } wrapper.
expect(result).toEqual(payload);
expect(result.token).toBe("gm_secret-abc");
expect(result.apiKey).toEqual(payload.apiKey);
expect(post).toHaveBeenCalledWith("/api-keys/create", {
name: "CI token",
expiresAt: "2027-07-11T12:00:00.000Z",
});
});
it("getApiKeys unwraps the envelope to the array of rows", async () => {
const rows = [
{
id: "key-1",
name: "CI token",
expiresAt: null,
lastUsedAt: null,
createdAt: "2026-01-01T00:00:00.000Z",
},
{
id: "key-2",
name: "Deploy token",
expiresAt: "2027-01-01T00:00:00.000Z",
lastUsedAt: null,
createdAt: "2026-02-01T00:00:00.000Z",
},
];
post.mockResolvedValue({ data: rows, success: true, status: 200 });
const result = await getApiKeys();
expect(result).toEqual(rows);
expect(result).toHaveLength(2);
expect(post).toHaveBeenCalledWith("/api-keys/list");
});
it("revealApiKey unwraps the envelope to the bare token string", async () => {
const SECRET = "gm_revealed-token";
post.mockResolvedValue({
data: { token: SECRET },
success: true,
status: 200,
});
const result = await revealApiKey({ id: "key-1", password: "pw" });
// The service returns ONLY the token string (not the { token } wrapper), so
// the caller can copy it straight to the clipboard.
expect(result).toBe(SECRET);
expect(post).toHaveBeenCalledWith("/api-keys/reveal", {
id: "key-1",
password: "pw",
});
});
});