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
128 changes: 44 additions & 84 deletions frontend/src/app/admin/__tests__/layout.test.tsx
Original file line number Diff line number Diff line change
@@ -1,98 +1,58 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import AdminLayout from "../layout";
jest.mock('next/navigation', () => ({ usePathname: () => '/admin' }));
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import AdminLayout from '../layout';

const ADMIN_KEY_STORAGE = "admin_api_key";
const KEY = 'predictiq-admin-key';

function mockSessionResponse(ok: boolean) {
function mockFetch(ok: boolean) {
return jest.fn().mockResolvedValue({
ok,
status: ok ? 200 : 401,
json: async () => ({ ok }),
statusText: ok ? 'OK' : 'Unauthorized',
headers: new Headers(),
text: async () => (ok ? '{"keys":[]}' : '{"code":"UNAUTHORIZED"}'),
json: async () => ({ code: 'UNAUTHORIZED' }),
});
}

describe("AdminAuthGate", () => {
beforeEach(() => {
sessionStorage.clear();
const renderLayout = () =>
render(
<AdminLayout>
<div>admin content</div>
</AdminLayout>
);

describe('AdminAuthGate', () => {
beforeEach(() => sessionStorage.clear());

it('shows the form and makes no request without a stored key', () => {
const f = mockFetch(true);
global.fetch = f as unknown as typeof fetch;
renderLayout();
expect(screen.queryByText('admin content')).not.toBeInTheDocument();
expect(f).not.toHaveBeenCalled();
});

afterEach(() => {
jest.restoreAllMocks();
it('validates a submitted key against the api-keys endpoint before unlocking', async () => {
const f = mockFetch(true);
global.fetch = f as unknown as typeof fetch;
renderLayout();
await userEvent.type(document.querySelector('input')!, 'good-key');
await userEvent.click(screen.getAllByRole('button')[0]);
await waitFor(() => expect(screen.getByText('admin content')).toBeInTheDocument());
expect(f.mock.calls[0][0]).toContain('/api/v1/admin/api-keys');
expect(f.mock.calls[0][1].headers['X-API-Key']).toBe('good-key');
expect(sessionStorage.getItem(KEY)).toBe('good-key');
});

it("renders the login form when no key is stored", () => {
const fetchMock = mockSessionResponse(false);
global.fetch = fetchMock as unknown as typeof fetch;

render(
<AdminLayout>
<div>admin content</div>
</AdminLayout>,
);

expect(screen.getByLabelText(/admin key/i)).toBeInTheDocument();
expect(screen.queryByText("admin content")).not.toBeInTheDocument();
expect(fetchMock).not.toHaveBeenCalled();
});

it("renders children when a valid stored key is present", async () => {
sessionStorage.setItem(ADMIN_KEY_STORAGE, "valid-key");
const fetchMock = mockSessionResponse(true);
global.fetch = fetchMock as unknown as typeof fetch;

render(
<AdminLayout>
<div>admin content</div>
</AdminLayout>,
);

await waitFor(() => {
expect(screen.getByText("admin content")).toBeInTheDocument();
});

expect(fetchMock).toHaveBeenCalledWith(
"/api/v1/admin/session",
expect.objectContaining({
headers: expect.objectContaining({ "x-admin-key": "valid-key" }),
}),
);
});

it("keeps the login form when the stored key is invalid", async () => {
sessionStorage.setItem(ADMIN_KEY_STORAGE, "invalid-key");
const fetchMock = mockSessionResponse(false);
global.fetch = fetchMock as unknown as typeof fetch;

render(
<AdminLayout>
<div>admin content</div>
</AdminLayout>,
);

await waitFor(() => {
expect(fetchMock).toHaveBeenCalled();
});

expect(screen.getByLabelText(/admin key/i)).toBeInTheDocument();
expect(screen.queryByText("admin content")).not.toBeInTheDocument();
});

it("persists a submitted key to sessionStorage", async () => {
const fetchMock = mockSessionResponse(true);
global.fetch = fetchMock as unknown as typeof fetch;

render(
<AdminLayout>
<div>admin content</div>
</AdminLayout>,
);

await userEvent.type(screen.getByLabelText(/admin key/i), "new-key");
await userEvent.click(screen.getByRole("button", { name: /sign in|submit|unlock/i }));

await waitFor(() => {
expect(sessionStorage.getItem(ADMIN_KEY_STORAGE)).toBe("new-key");
});
it('rejects an invalid key without storing it', async () => {
global.fetch = mockFetch(false) as unknown as typeof fetch;
renderLayout();
await userEvent.type(document.querySelector('input')!, 'bad-key');
await userEvent.click(screen.getAllByRole('button')[0]);
await waitFor(() => expect(screen.getByText(/invalid api key/i)).toBeInTheDocument());
expect(screen.queryByText('admin content')).not.toBeInTheDocument();
expect(sessionStorage.getItem(KEY)).toBeNull();
});
});
55 changes: 55 additions & 0 deletions frontend/src/app/admin/api-keys/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ApiKeys from './page';

const KEY = 'predictiq-admin-key';
const res = (ok: boolean, body: unknown) => ({
ok,
status: ok ? 200 : 422,
statusText: '',
headers: new Headers(),
text: async () => JSON.stringify(body),
json: async () => body,
});

describe('ApiKeys rotate', () => {
beforeEach(() => {
sessionStorage.clear();
sessionStorage.setItem(KEY, 'old-key');
});

it('sends the label, shows the new key and swaps the stored key on success', async () => {
const f = jest
.fn()
.mockImplementation((_u: string, init: RequestInit) =>
Promise.resolve(
init.method === 'POST'
? res(true, { new_key: 'new-secret', new_key_label: 'l' })
: res(true, { keys: [] })
)
);
global.fetch = f as unknown as typeof fetch;
render(<ApiKeys />);
await userEvent.type(screen.getByLabelText(/key label/i), 'ci-1');
await userEvent.click(screen.getByRole('button', { name: /rotate key/i }));
await waitFor(() => expect(screen.getByTestId('new-key')).toHaveTextContent('new-secret'));
const post = f.mock.calls.find((c) => c[1].method === 'POST')!;
expect(JSON.parse(post[1].body)).toEqual({ key_label: 'ci-1' });
expect(sessionStorage.getItem(KEY)).toBe('new-secret');
});

it('keeps the stored key when rotation fails', async () => {
global.fetch = jest
.fn()
.mockImplementation((_u: string, init: RequestInit) =>
Promise.resolve(
init.method === 'POST' ? res(false, { code: 'X' }) : res(true, { keys: [] })
)
) as unknown as typeof fetch;
render(<ApiKeys />);
await userEvent.type(screen.getByLabelText(/key label/i), 'ci-1');
await userEvent.click(screen.getByRole('button', { name: /rotate key/i }));
await waitFor(() => expect(screen.getByText(/rotation failed/i)).toBeInTheDocument());
expect(sessionStorage.getItem(KEY)).toBe('old-key');
});
});
79 changes: 76 additions & 3 deletions frontend/src/app/admin/api-keys/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,77 @@
'use client';
import { useEffect,useState } from 'react';
type Key={id:string;name?:string;created_at?:string};
export default function ApiKeys(){const [keys,setKeys]=useState<Key[]>([]),[message,setMessage]=useState('');const auth=()=>({'X-API-Key':sessionStorage.getItem('predictiq-admin-key')||''});const load=()=>fetch('/api/v1/admin/api-keys',{headers:auth()}).then(r=>r.json()).then(d=>setKeys(d.keys??d));useEffect(()=>{load()},[]);const rotate=async()=>{if(!confirm('Rotate this key? Your current session will be invalidated.'))return;const r=await fetch('/api/v1/admin/api-keys/rotate',{method:'POST',headers:{...auth(),'Content-Type':'application/json'}});sessionStorage.removeItem('predictiq-admin-key');setMessage(r.ok?'Key rotated. Please authenticate again.':'Rotation failed.');setKeys([])};return <main><h1>API keys</h1>{message&&<p role="alert">{message}</p>}<button onClick={rotate}>Rotate key</button><ul>{keys.map(k=><li key={k.id}>{k.name??k.id} {k.created_at&&`(${k.created_at})`}</li>)}</ul></main>}

import { useCallback, useEffect, useState } from 'react';
import { api, ADMIN_KEY_STORAGE, type AdminApiKey } from '@/lib/api/admin-client';

export default function ApiKeys() {
const [keys, setKeys] = useState<AdminApiKey[]>([]);
const [message, setMessage] = useState('');
const [label, setLabel] = useState('');
const [newKey, setNewKey] = useState('');
const [copied, setCopied] = useState(false);

const load = useCallback(async () => {
try {
const d = await api.listApiKeys();
setKeys(Array.isArray(d) ? d : (d.keys ?? []));
} catch {
setMessage('Unable to load API keys.');
}
}, []);

useEffect(() => {
load();
}, [load]);

const rotate = async (e: React.FormEvent) => {
e.preventDefault();
if (!label.trim()) return;
try {
const res = await api.rotateApiKey({ key_label: label.trim() });
// Only swap the stored key once the server has confirmed the rotation.
sessionStorage.setItem(ADMIN_KEY_STORAGE, res.new_key);
setNewKey(res.new_key);
setLabel('');
setMessage('Key rotated. Copy the new key now; it will not be shown again.');
load();
} catch {
setMessage('Rotation failed. Your current key is unchanged.');
}
};

const copy = async () => {
try {
await navigator.clipboard.writeText(newKey);
setCopied(true);
} catch {
setCopied(false);
}
};

return (
<main>
<h1>API keys</h1>
{message && <p role="alert">{message}</p>}
{newKey && (
<p>
<code data-testid="new-key">{newKey}</code>{' '}
<button onClick={copy}>{copied ? 'Copied' : 'Copy'}</button>
</p>
)}
<form onSubmit={rotate}>
<label>
Key label
<input value={label} onChange={(e) => setLabel(e.target.value)} required />
</label>
<button type="submit">Rotate key</button>
</form>
<ul>
{keys.map((k) => (
<li key={k.id}>
{k.name ?? k.label ?? k.id} {k.created_at && `(${k.created_at})`}
</li>
))}
</ul>
</main>
);
}
18 changes: 18 additions & 0 deletions frontend/src/app/admin/audit/statistics/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { render, screen } from '@testing-library/react';
import AuditStatistics from './page';

it('fetches statistics with the admin key', async () => {
sessionStorage.setItem('predictiq-admin-key', 'k');
const f = jest.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: '',
headers: new Headers(),
text: async () => JSON.stringify({ actions_by_actor: { alice: 3 } }),
});
global.fetch = f as unknown as typeof fetch;
render(<AuditStatistics />);
expect(await screen.findByText('alice: 3')).toBeInTheDocument();
expect(f.mock.calls[0][0]).toContain('/api/v1/audit/statistics');
expect(f.mock.calls[0][1].headers['X-API-Key']).toBe('k');
});
49 changes: 47 additions & 2 deletions frontend/src/app/admin/audit/statistics/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,48 @@
'use client';
import { useEffect,useState } from 'react';
export default function AuditStatistics(){const [data,setData]=useState<any>(),[error,setError]=useState('');useEffect(()=>{fetch('/api/v1/audit/statistics').then(r=>r.ok?r.json():Promise.reject(Error('Unable to load audit statistics'))).then(setData).catch(e=>setError(e.message))},[]);if(error)return <p role="alert">{error}</p>;if(!data)return <p>Loading…</p>;const actors=data.actions_by_actor??data.by_actor??{},days=data.actions_by_day??data.by_day??{};if(!Object.keys(actors).length&&!Object.keys(days).length)return <p role="status">No audit events recorded.</p>;return <main><h1>Audit statistics</h1><section><h2>Actions by actor</h2>{Object.entries(actors).map(([k,v])=><p key={k}>{k}: {String(v)}</p>)}</section><section><h2>Actions by day</h2>{Object.entries(days).map(([k,v])=><p key={k}>{k}: {String(v)}</p>)}</section></main>}

import { useEffect, useState } from 'react';
import { api } from '@/lib/api/admin-client';

type Stats = Record<string, Record<string, unknown> | undefined>;

function Section({ title, rows }: { title: string; rows: Record<string, unknown> }) {
return (
<section>
<h2>{title}</h2>
{Object.entries(rows).map(([k, v]) => (
<p key={k}>
{k}: {String(v)}
</p>
))}
</section>
);
}

export default function AuditStatistics() {
const [data, setData] = useState<Stats>();
const [error, setError] = useState('');

useEffect(() => {
api
.getAuditStatistics()
.then((d) => setData(d as Stats))
.catch(() => setError('Unable to load audit statistics'));
}, []);

if (error) return <p role="alert">{error}</p>;
if (!data) return <p>Loading…</p>;

const actors = data.actions_by_actor ?? data.by_actor ?? {};
const days = data.actions_by_day ?? data.by_day ?? {};
if (!Object.keys(actors).length && !Object.keys(days).length) {
return <p role="status">No audit events recorded.</p>;
}

return (
<main>
<h1>Audit statistics</h1>
<Section title="Actions by actor" rows={actors} />
<Section title="Actions by day" rows={days} />
</main>
);
}
32 changes: 32 additions & 0 deletions frontend/src/app/admin/email/queue/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import EmailQueue from './page';

const res = (body: unknown) => ({
ok: true,
status: 200,
statusText: '',
headers: new Headers(),
text: async () => JSON.stringify(body),
});

it('loads and requeues through the authenticated admin client', async () => {
sessionStorage.setItem('predictiq-admin-key', 'k');
const f = jest
.fn()
.mockImplementation((_u: string, init: RequestInit) =>
Promise.resolve(
res(init.method === 'POST' ? {} : { jobs: [{ id: 'j1', recipient: 'a@b.c' }] })
)
);
global.fetch = f as unknown as typeof fetch;
render(<EmailQueue />);
await userEvent.click(await screen.findByRole('button', { name: /requeue/i }));
await waitFor(() => expect(screen.getByRole('status')).toBeInTheDocument());
const [getUrl, getInit] = f.mock.calls[0];
expect(getUrl).toContain('/api/v1/email/queue/dead-letter');
expect(getInit.headers['X-API-Key']).toBe('k');
const post = f.mock.calls[1][1];
expect(post.headers['X-API-Key']).toBe('k');
expect(post.headers['Idempotency-Key']).toBeTruthy();
});
Loading