diff --git a/src/app/api-keys/page.test.tsx b/src/app/api-keys/page.test.tsx new file mode 100644 index 0000000..8910ea5 --- /dev/null +++ b/src/app/api-keys/page.test.tsx @@ -0,0 +1,70 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import ApiKeysPage from "./page"; + +const mockItems = [{ prefix: "abc123", label: "my-key", createdAt: 1700000000 }]; + +function mockFetchSuccess() { + globalThis.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ items: mockItems }), + } as unknown as Response); +} + +afterEach(() => jest.restoreAllMocks()); + +it("does not delete immediately when Revoke is clicked", async () => { + mockFetchSuccess(); + render(); + await screen.findByText("my-key"); + const fetchMock = globalThis.fetch as jest.Mock; + fetchMock.mockClear(); + + fireEvent.click(screen.getByRole("button", { name: /^revoke$/i })); + expect(fetchMock).not.toHaveBeenCalled(); +}); + +it("shows confirm dialog when Revoke is clicked", async () => { + mockFetchSuccess(); + render(); + await screen.findByText("my-key"); + + fireEvent.click(screen.getByRole("button", { name: /^revoke$/i })); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByText(/revoke api key/i)).toBeInTheDocument(); +}); + +it("cancels without deleting when Cancel is clicked", async () => { + mockFetchSuccess(); + render(); + await screen.findByText("my-key"); + const fetchMock = globalThis.fetch as jest.Mock; + fetchMock.mockClear(); + + fireEvent.click(screen.getByRole("button", { name: /^revoke$/i })); + fireEvent.click(screen.getByRole("button", { name: /cancel/i })); + expect(fetchMock).not.toHaveBeenCalled(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); +}); + +it("calls DELETE and closes dialog when confirmed", async () => { + mockFetchSuccess(); + render(); + await screen.findByText("my-key"); + + // stub DELETE + reload + (globalThis.fetch as jest.Mock) + .mockResolvedValueOnce({ ok: true, status: 204, json: async () => ({}) } as unknown as Response) + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ items: [] }) } as unknown as Response); + + fireEvent.click(screen.getByRole("button", { name: /^revoke$/i })); + // click the Revoke confirm button inside the dialog + const confirmBtn = screen.getAllByRole("button", { name: /^revoke$/i })[0]; + fireEvent.click(confirmBtn); + + await waitFor(() => { + const calls = (globalThis.fetch as jest.Mock).mock.calls; + expect(calls.some((c: string[]) => c[0].includes("/api/v1/api-keys/abc123"))).toBe(true); + }); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); +}); diff --git a/src/app/api-keys/page.tsx b/src/app/api-keys/page.tsx index df0346f..443d242 100644 --- a/src/app/api-keys/page.tsx +++ b/src/app/api-keys/page.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { apiGet, apiPost, apiDelete } from "@/lib/apiClient"; +import { ConfirmDialog } from "@/components/ConfirmDialog"; type KeyItem = { prefix: string; label: string; createdAt: number }; @@ -10,6 +11,7 @@ export default function ApiKeysPage() { const [label, setLabel] = useState(""); const [created, setCreated] = useState(null); const [error, setError] = useState(null); + const [pendingRevoke, setPendingRevoke] = useState(null); const load = () => apiGet<{ items: KeyItem[] }>("/api/v1/api-keys") @@ -48,6 +50,17 @@ export default function ApiKeysPage() { tabIndex={-1} className="mx-auto flex min-h-[60vh] max-w-3xl flex-col gap-6 p-8 focus:outline-none" > + { + if (pendingRevoke) onDelete(pendingRevoke.prefix); + setPendingRevoke(null); + }} + onCancel={() => setPendingRevoke(null)} + />

API keys