Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ Backend endpoints are taken from the companion documentation page `src/app/docs/
| `/admin` | Admin control surface (pause/unpause/status) | `POST /api/v1/admin/pause`, `POST /api/v1/admin/unpause`, _(reads status via GET `/api/v1/admin/status` in code)_ |
| `/agents` | Agents overview | _(reads agents list via `/api/v1/agents` in code)_ |
| `/agents/:agent` | Single-agent view | _(reads agent details via `/api/v1/agents/:agent` in code)_ |
| `/api-keys` | API keys management | _(list/create/delete/update endpoints in code)_ |
| `/api-keys` | API keys management with created-at timestamps and a no-keys empty state | _(list/create/delete/update endpoints in code)_ |
| `/changelog` | Changelog | _(static or calls `/api/v1/changelog` depending on implementation)_ |
| `/docs` | Short API endpoint reference | `GET /api/v1/openapi.json` plus the prose list rendered from `sections` in `src/app/docs/page.tsx` (usage, settle, services, admin pause/unpause) |
| `/events` | Event log renderer | _(reads events stream/poll via `/api/v1/events` endpoints in code)_ |
Expand All @@ -117,6 +117,10 @@ Backend endpoints are taken from the companion documentation page `src/app/docs/
| `/usage` | Usage totals & settlement workflow | `POST /api/v1/usage`, `GET /api/v1/usage/:agent/:serviceId`, `POST /api/v1/settle` |
| `/webhooks` | Webhooks management | _(calls webhooks endpoints in code)_ |

### API keys page notes

The `/api-keys` page lists each key label, prefix, and created-at age with the absolute ISO timestamp available on hover. If the account has no keys, the page renders a clear "No API keys yet" empty state instead of an empty list while preserving the create, reveal-once, copy, and revoke confirmation flows.

## Shared components

See [docs/components.md](docs/components.md) for the shared component catalog,
Expand Down
199 changes: 162 additions & 37 deletions src/app/api-keys/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import ApiKeysPage from "./page";

const FAKE_KEY = "sk_live_abc123secretvalue";
const mockItems = [{ prefix: "abc123", label: "my-key", createdAt: 1700000000 }];
const BASE_TIME = new Date("2026-06-23T12:00:00.000Z");
const mockItems = [
{
prefix: "abc123",
label: "my-key",
createdAt: Math.floor((BASE_TIME.getTime() - 60_000) / 1_000),
},
];

function mockFetchSuccess() {
globalThis.fetch = jest.fn().mockResolvedValue({
Expand All @@ -12,7 +19,71 @@ function mockFetchSuccess() {
} as unknown as Response);
}

afterEach(() => jest.restoreAllMocks());
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(BASE_TIME);
Object.assign(navigator, {
clipboard: { writeText: jest.fn().mockResolvedValue(undefined) },
});
});

afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});

it("shows each key created-at as relative time with an absolute ISO title", async () => {
mockFetchSuccess();
render(<ApiKeysPage />);

await screen.findByText("my-key");

expect(screen.getByText("1m ago")).toBeInTheDocument();
expect(screen.getByTitle("2026-06-23T11:59:00.000Z")).toBeInTheDocument();
});

it("shows an announced empty state when there are no API keys", async () => {
globalThis.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ items: [] }),
} as unknown as Response);

render(<ApiKeysPage />);

expect(await screen.findByRole("status")).toHaveTextContent(
"No API keys yet",
);
expect(screen.queryByRole("list")).not.toBeInTheDocument();
});

it("shows a safe placeholder when a key is missing created-at", async () => {
globalThis.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
items: [{ prefix: "missing", label: "missing-created", createdAt: null }],
}),
} as unknown as Response);

render(<ApiKeysPage />);

await screen.findByText("missing-created");
expect(screen.getByTitle("—")).toHaveTextContent("—");
});

it("keeps load errors visible without showing an empty state", async () => {
globalThis.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 500,
text: async () => "server down",
} as unknown as Response);

render(<ApiKeysPage />);

expect(await screen.findByRole("alert")).toHaveTextContent("Request failed");
expect(screen.queryByText("No API keys yet")).not.toBeInTheDocument();
});

it("does not delete immediately when Revoke is clicked", async () => {
mockFetchSuccess();
Expand Down Expand Up @@ -55,8 +126,16 @@ it("calls DELETE and closes dialog when confirmed", async () => {

// 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);
.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
Expand All @@ -65,88 +144,134 @@ it("calls DELETE and closes dialog when confirmed", async () => {

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(
calls.some((c: string[]) => c[0].includes("/api/v1/api-keys/abc123")),
).toBe(true);
});
await screen.findByText("No API keys yet");
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});

// --- reveal-once panel ---

function mockFetchCreate() {
globalThis.fetch = jest.fn()
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ items: [] }) } as unknown as Response)
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ key: FAKE_KEY }) } as unknown as Response)
.mockResolvedValue({ ok: true, status: 200, json: async () => ({ items: [] }) } as unknown as Response);
globalThis.fetch = jest
.fn()
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ items: [] }),
} as unknown as Response)
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ key: FAKE_KEY }),
} as unknown as Response)
.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ items: [] }),
} as unknown as Response);
}

beforeEach(() => {
Object.assign(navigator, {
clipboard: { writeText: jest.fn().mockResolvedValue(undefined) },
});
});

it("shows the panel masked after key creation", async () => {
mockFetchCreate();
render(<ApiKeysPage />);
fireEvent.change(screen.getByLabelText("Label"), { target: { value: "test" } });
fireEvent.submit(screen.getByRole("button", { name: "Create" }).closest("form")!);
await waitFor(() => expect(screen.getByRole("status")).toBeInTheDocument());
expect(screen.getByRole("status")).not.toHaveTextContent(FAKE_KEY);
expect(screen.getByRole("status")).toHaveTextContent("****");
fireEvent.change(screen.getByLabelText("Label"), {
target: { value: "test" },
});
fireEvent.submit(
screen.getByRole("button", { name: "Create" }).closest("form")!,
);
await waitFor(() => expect(screen.getByText(/New key/i)).toBeInTheDocument());
const panel = screen.getByText(/New key/i).closest("div")!;
expect(panel).not.toHaveTextContent(FAKE_KEY);
expect(panel).toHaveTextContent("****");
});

it("reveals the full key when Reveal is clicked", async () => {
mockFetchCreate();
render(<ApiKeysPage />);
fireEvent.change(screen.getByLabelText("Label"), { target: { value: "test" } });
fireEvent.submit(screen.getByRole("button", { name: "Create" }).closest("form")!);
fireEvent.change(screen.getByLabelText("Label"), {
target: { value: "test" },
});
fireEvent.submit(
screen.getByRole("button", { name: "Create" }).closest("form")!,
);
await waitFor(() => screen.getByRole("button", { name: "Reveal" }));
fireEvent.click(screen.getByRole("button", { name: "Reveal" }));
expect(screen.getByRole("status")).toHaveTextContent(FAKE_KEY);
expect(screen.getByRole("button", { name: "Hide" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByText(/New key/i).closest("div")!).toHaveTextContent(
FAKE_KEY,
);
expect(screen.getByRole("button", { name: "Hide" })).toHaveAttribute(
"aria-pressed",
"true",
);
});

it("hides the key again when Hide is clicked", async () => {
mockFetchCreate();
render(<ApiKeysPage />);
fireEvent.change(screen.getByLabelText("Label"), { target: { value: "test" } });
fireEvent.submit(screen.getByRole("button", { name: "Create" }).closest("form")!);
fireEvent.change(screen.getByLabelText("Label"), {
target: { value: "test" },
});
fireEvent.submit(
screen.getByRole("button", { name: "Create" }).closest("form")!,
);
await waitFor(() => screen.getByRole("button", { name: "Reveal" }));
fireEvent.click(screen.getByRole("button", { name: "Reveal" }));
fireEvent.click(screen.getByRole("button", { name: "Hide" }));
expect(screen.getByRole("status")).not.toHaveTextContent(FAKE_KEY);
expect(screen.getByText(/New key/i).closest("div")!).not.toHaveTextContent(
FAKE_KEY,
);
});

it("copies the full key to clipboard", async () => {
mockFetchCreate();
render(<ApiKeysPage />);
fireEvent.change(screen.getByLabelText("Label"), { target: { value: "test" } });
fireEvent.submit(screen.getByRole("button", { name: "Create" }).closest("form")!);
fireEvent.change(screen.getByLabelText("Label"), {
target: { value: "test" },
});
fireEvent.submit(
screen.getByRole("button", { name: "Create" }).closest("form")!,
);
await waitFor(() => screen.getByRole("button", { name: "Copy" }));
fireEvent.click(screen.getByRole("button", { name: "Copy" }));
await waitFor(() =>
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(FAKE_KEY)
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(FAKE_KEY),
);
});

it("removes the panel when Done is clicked", async () => {
mockFetchCreate();
render(<ApiKeysPage />);
fireEvent.change(screen.getByLabelText("Label"), { target: { value: "test" } });
fireEvent.submit(screen.getByRole("button", { name: "Create" }).closest("form")!);
fireEvent.change(screen.getByLabelText("Label"), {
target: { value: "test" },
});
fireEvent.submit(
screen.getByRole("button", { name: "Create" }).closest("form")!,
);
await waitFor(() => screen.getByRole("button", { name: /done/i }));
fireEvent.click(screen.getByRole("button", { name: /done/i }));
expect(screen.queryByRole("status")).not.toBeInTheDocument();
expect(screen.queryByText(/New key/i)).not.toBeInTheDocument();
});

it("handles clipboard unavailable without throwing", async () => {
Object.assign(navigator, {
clipboard: { writeText: jest.fn().mockRejectedValue(new Error("no clipboard")) },
clipboard: {
writeText: jest.fn().mockRejectedValue(new Error("no clipboard")),
},
});
mockFetchCreate();
render(<ApiKeysPage />);
fireEvent.change(screen.getByLabelText("Label"), { target: { value: "test" } });
fireEvent.submit(screen.getByRole("button", { name: "Create" }).closest("form")!);
fireEvent.change(screen.getByLabelText("Label"), {
target: { value: "test" },
});
fireEvent.submit(
screen.getByRole("button", { name: "Create" }).closest("form")!,
);
await waitFor(() => screen.getByRole("button", { name: "Copy" }));
expect(() => fireEvent.click(screen.getByRole("button", { name: "Copy" }))).not.toThrow();
expect(() =>
fireEvent.click(screen.getByRole("button", { name: "Copy" })),
).not.toThrow();
});
Loading
Loading