diff --git a/apps/client/src/features/api-key/components/api-keys-manager.test.tsx b/apps/client/src/features/api-key/components/api-keys-manager.test.tsx index e6bbc4cc..dec6fea0 100644 --- a/apps/client/src/features/api-key/components/api-keys-manager.test.tsx +++ b/apps/client/src/features/api-key/components/api-keys-manager.test.tsx @@ -33,6 +33,7 @@ import ApiKeysManager from "./api-keys-manager"; const ISO_SOON = new Date(Date.now() + 10 * 864e5).toISOString(); const ISO_FAR = new Date(Date.now() + 200 * 864e5).toISOString(); +const ISO_EXPIRED = new Date(Date.now() - 3 * 864e5).toISOString(); // Dump the storage stub via the Web Storage API — its data lives in a closure // (see vitest.setup.ts), so JSON.stringify(localStorage) would be vacuous. @@ -102,6 +103,17 @@ describe("ApiKeysManager — list rendering", () => { expect(screen.getAllByText("Expiring soon")).toHaveLength(1); }); + it('shows "Expired" (not "Expiring soon") for an already-expired key', async () => { + vi.mocked(getApiKeys).mockResolvedValue([ + makeKey({ id: "k-dead", name: "Dead key", expiresAt: ISO_EXPIRED }), + ]); + renderManager(UserRole.MEMBER); + await screen.findByText("Dead key"); + // A past expiry is labelled "Expired", never the forward-looking badge. + expect(screen.getByText("Expired")).toBeDefined(); + expect(screen.queryByText("Expiring soon")).toBeNull(); + }); + it('shows "Never" for an unlimited key and no highlight', async () => { vi.mocked(getApiKeys).mockResolvedValue([ makeKey({ id: "k-forever", name: "Forever", expiresAt: null }), diff --git a/apps/client/src/features/api-key/components/api-keys-manager.tsx b/apps/client/src/features/api-key/components/api-keys-manager.tsx index fdf45831..ba134166 100644 --- a/apps/client/src/features/api-key/components/api-keys-manager.tsx +++ b/apps/client/src/features/api-key/components/api-keys-manager.tsx @@ -27,7 +27,11 @@ import { IApiKey, ICreateApiKeyResponse, } from "@/features/api-key/types/api-key.types"; -import { isExpiringSoon, lastUsedBucket } from "@/features/api-key/utils"; +import { + isExpired, + isExpiringSoon, + lastUsedBucket, +} from "@/features/api-key/utils"; import { CreateApiKeyModal } from "./create-api-key-modal"; import { ShowTokenModal } from "./show-token-modal"; @@ -120,7 +124,11 @@ export default function ApiKeysManager() { } const rows = (keys ?? []).map((key) => { - const soon = isExpiringSoon(key.expiresAt); + // Mutually exclusive: an already-expired key is labelled "Expired" (a past + // expiry) rather than the forward-looking "Expiring soon". isExpiringSoon + // also matches past expiries, so gate "soon" on !expired. + const expired = isExpired(key.expiresAt); + const soon = !expired && isExpiringSoon(key.expiresAt); return ( @@ -131,6 +139,18 @@ export default function ApiKeysManager() { {key.expiresAt ? ( {formatDate(key.expiresAt)} + {expired && ( + + } + > + {t("Expired")} + + + )} {soon && ( ({ post: vi.fn(), get: vi.fn() })); +vi.mock("@/lib/api-client", () => ({ + default: { post, get }, +})); + +import { + createApiKey, + getApiKeys, +} 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"); + }); +}); diff --git a/apps/client/src/features/api-key/utils.test.ts b/apps/client/src/features/api-key/utils.test.ts index 8f697067..32360286 100644 --- a/apps/client/src/features/api-key/utils.test.ts +++ b/apps/client/src/features/api-key/utils.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import { DEFAULT_LIFETIME, EXPIRY_WARNING_DAYS, + isExpired, isExpiringSoon, lastUsedBucket, lifetimeToExpiresAt, @@ -50,6 +51,36 @@ describe("isExpiringSoon (acceptance #3 highlight)", () => { }); }); +describe("isExpired (past vs future expiry)", () => { + it("an unlimited key is never expired", () => { + expect(isExpired(null, NOW)).toBe(false); + }); + + it("a past expiry is expired", () => { + expect(isExpired(daysFromNow(-3), NOW)).toBe(true); + expect(isExpired(daysFromNow(-1), NOW)).toBe(true); + }); + + it("a future expiry is not expired (even within the warning window)", () => { + expect(isExpired(daysFromNow(1), NOW)).toBe(false); + expect(isExpired(daysFromNow(EXPIRY_WARNING_DAYS - 1), NOW)).toBe(false); + expect(isExpired(daysFromNow(200), NOW)).toBe(false); + }); + + it("an expiry exactly at 'now' counts as expired (boundary is inclusive)", () => { + expect(isExpired(NOW.toISOString(), NOW)).toBe(true); + }); + + it("is mutually distinguishable from isExpiringSoon: expired vs soon-but-future", () => { + // A key 3 days in the past: expired, and (by design) also matches + // isExpiringSoon — the UI resolves this by checking isExpired first. + expect(isExpired(daysFromNow(-3), NOW)).toBe(true); + // A key 10 days in the future: NOT expired, but expiring soon. + expect(isExpired(daysFromNow(10), NOW)).toBe(false); + expect(isExpiringSoon(daysFromNow(10), NOW)).toBe(true); + }); +}); + describe("lastUsedBucket (within-the-last-hour semantics)", () => { const minutesAgo = (n: number) => new Date(NOW.getTime() - n * 60 * 1000).toISOString(); diff --git a/apps/client/src/features/api-key/utils.ts b/apps/client/src/features/api-key/utils.ts index 6582ff12..f43ffbdb 100644 --- a/apps/client/src/features/api-key/utils.ts +++ b/apps/client/src/features/api-key/utils.ts @@ -32,8 +32,24 @@ export function lifetimeToExpiresAt( } } +// True when a bounded key's expiry is already in the past (or exactly now). An +// unlimited key (null) is never expired. This is distinct from "expiring soon": +// the two states are mutually exclusive at the call site (see api-keys-manager), +// so an already-expired key is labelled "Expired", not "Expiring soon". +export function isExpired( + expiresAt: string | null, + now: Date = new Date(), +): boolean { + if (!expiresAt) return false; + const expiry = new Date(expiresAt).getTime(); + if (Number.isNaN(expiry)) return false; + return expiry <= now.getTime(); +} + // True when a bounded key expires within the warning window (or is already -// expired). An unlimited key (null) is never "expiring soon". +// expired). An unlimited key (null) is never "expiring soon". Callers that need +// to distinguish an already-past expiry should check isExpired() first, as this +// predicate deliberately also covers the already-expired case. export function isExpiringSoon( expiresAt: string | null, now: Date = new Date(),