8d8aaf7c7f
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>
99 lines
3.1 KiB
TypeScript
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",
|
|
});
|
|
});
|
|
});
|