Files
TREK/client/src/components/Settings/ApiKeysSection.test.tsx
T
Maurice d559fdda1d feat(settings): give API keys their own section
Minting a key for other software meant finding it under MCP, behind a
heading about AI assistants and gated on an addon it does not need. It now
sits on its own in Settings, above the MCP block, and works on an instance
with MCP switched off.

Deliberately separate state rather than another branch of the tab's shared
hook: the section has to stand on its own, and that hook is already carrying
forty fields through three levels of props.

Translated into all 23 languages.
2026-08-28 15:23:05 +02:00

229 lines
8.3 KiB
TypeScript

// FE-COMP-APIKEYS-001 to FE-COMP-APIKEYS-010
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { useAddonStore } from '../../store/addonStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser } from '../../../tests/helpers/factories';
import { ToastContainer } from '../shared/Toast';
import ApiKeysSection from './ApiKeysSection';
const clipboardWriteText = vi.fn().mockResolvedValue(undefined);
beforeAll(() => {
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: clipboardWriteText },
configurable: true,
writable: true,
});
});
beforeEach(() => {
clipboardWriteText.mockClear();
resetAllStores();
vi.clearAllMocks();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useAddonStore, { addons: [], loaded: true, loadAddons: vi.fn() });
server.use(http.get('/api/auth/api-tokens', () => HttpResponse.json({ tokens: [] })));
});
function renderSection() {
return render(
<>
<ApiKeysSection />
<ToastContainer />
</>,
);
}
describe('ApiKeysSection', () => {
it('FE-COMP-APIKEYS-001: renders without the MCP addon, because an API key does not need it', async () => {
renderSection();
expect(await screen.findByText('API Keys')).toBeInTheDocument();
});
it('FE-COMP-APIKEYS-002: reads its own endpoint, not the MCP token list', async () => {
const seen: string[] = [];
server.use(
http.get('/api/auth/api-tokens', ({ request }) => {
seen.push(new URL(request.url).pathname);
return HttpResponse.json({ tokens: [] });
}),
http.get('/api/auth/mcp-tokens', ({ request }) => {
seen.push(new URL(request.url).pathname);
return HttpResponse.json({ tokens: [] });
}),
);
renderSection();
await waitFor(() => expect(seen).toContain('/api/auth/api-tokens'));
expect(seen).not.toContain('/api/auth/mcp-tokens');
});
it('FE-COMP-APIKEYS-003: lists existing keys by prefix, never the key itself', async () => {
server.use(
http.get('/api/auth/api-tokens', () =>
HttpResponse.json({
tokens: [
{
id: 7,
name: 'Dawarich',
token_prefix: 'trek_abcdefg',
created_at: '2026-08-01T10:00:00Z',
last_used_at: null,
},
],
}),
),
);
renderSection();
expect(await screen.findByText('Dawarich')).toBeInTheDocument();
expect(screen.getByText(/trek_abcdefg\.\.\./)).toBeInTheDocument();
});
it('FE-COMP-APIKEYS-004: shows the empty state when there is nothing to list', async () => {
renderSection();
expect(await screen.findByText(/No keys yet/)).toBeInTheDocument();
});
it('FE-COMP-APIKEYS-005: creates a key and shows it once, with the warning', async () => {
server.use(
http.post('/api/auth/api-tokens', () =>
HttpResponse.json({
token: {
id: 1,
name: 'Dawarich',
raw_token: 'trek_thefullsecretvalue',
token_prefix: 'trek_thefull',
created_at: '2026-08-27T10:00:00Z',
},
}),
),
);
const user = userEvent.setup();
renderSection();
await user.click(await screen.findByRole('button', { name: /Create key/ }));
await user.type(screen.getByPlaceholderText('e.g. Dawarich'), 'Dawarich');
await user.click(screen.getByRole('button', { name: /^Create$/ }));
expect(await screen.findByText('trek_thefullsecretvalue')).toBeInTheDocument();
expect(screen.getByText(/shown once/)).toBeInTheDocument();
});
it('FE-COMP-APIKEYS-006: drops the raw key from the DOM once the modal is closed', async () => {
server.use(
http.post('/api/auth/api-tokens', () =>
HttpResponse.json({
token: {
id: 1,
name: 'Dawarich',
raw_token: 'trek_thefullsecretvalue',
token_prefix: 'trek_thefull',
created_at: '2026-08-27T10:00:00Z',
},
}),
),
);
const user = userEvent.setup();
renderSection();
await user.click(await screen.findByRole('button', { name: /Create key/ }));
await user.type(screen.getByPlaceholderText('e.g. Dawarich'), 'Dawarich');
await user.click(screen.getByRole('button', { name: /^Create$/ }));
await screen.findByText('trek_thefullsecretvalue');
await user.click(screen.getByRole('button', { name: /Done/ }));
await waitFor(() => expect(screen.queryByText('trek_thefullsecretvalue')).not.toBeInTheDocument());
// The list keeps only the prefix, which is what the server stores alongside the hash.
expect(screen.getByText(/trek_thefull\.\.\./)).toBeInTheDocument();
});
it('FE-COMP-APIKEYS-007: refuses to create a key without a name', async () => {
const user = userEvent.setup();
renderSection();
await user.click(await screen.findByRole('button', { name: /Create key/ }));
expect(screen.getByRole('button', { name: /^Create$/ })).toBeDisabled();
});
it('FE-COMP-APIKEYS-008: asks before deleting, and only deletes on confirm', async () => {
let deleted = 0;
server.use(
http.get('/api/auth/api-tokens', () =>
HttpResponse.json({
tokens: [
{ id: 7, name: 'Dawarich', token_prefix: 'trek_abc', created_at: '2026-08-01T10:00:00Z', last_used_at: null },
],
}),
),
http.delete('/api/auth/api-tokens/7', () => {
deleted += 1;
return HttpResponse.json({ success: true });
}),
);
const user = userEvent.setup();
renderSection();
await user.click(await screen.findByTitle('Delete key'));
expect(deleted).toBe(0);
expect(screen.getByText(/stops working immediately/)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /^Cancel$/ }));
expect(deleted).toBe(0);
await user.click(await screen.findByTitle('Delete key'));
const [, confirm] = screen.getAllByRole('button', { name: /Delete key/ });
await user.click(confirm);
await waitFor(() => expect(deleted).toBe(1));
await waitFor(() => expect(screen.queryByText('Dawarich')).not.toBeInTheDocument());
});
it('FE-COMP-APIKEYS-009: reports a failed creation instead of pretending it worked', async () => {
server.use(http.post('/api/auth/api-tokens', () => HttpResponse.json({ error: 'nope' }, { status: 500 })));
const user = userEvent.setup();
renderSection();
await user.click(await screen.findByRole('button', { name: /Create key/ }));
await user.type(screen.getByPlaceholderText('e.g. Dawarich'), 'Dawarich');
await user.click(screen.getByRole('button', { name: /^Create$/ }));
expect(await screen.findByText(/Could not create the key/)).toBeInTheDocument();
});
it('FE-COMP-APIKEYS-010: copies the new key to the clipboard on request', async () => {
server.use(
http.post('/api/auth/api-tokens', () =>
HttpResponse.json({
token: {
id: 1,
name: 'Dawarich',
raw_token: 'trek_thefullsecretvalue',
token_prefix: 'trek_thefull',
created_at: '2026-08-27T10:00:00Z',
},
}),
),
);
const user = userEvent.setup();
// userEvent.setup() installs its own clipboard stub, so the spy goes back on
// afterwards — otherwise this asserts against a stub the component never reaches.
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: clipboardWriteText },
configurable: true,
writable: true,
});
renderSection();
await user.click(await screen.findByRole('button', { name: /Create key/ }));
await user.type(screen.getByPlaceholderText('e.g. Dawarich'), 'Dawarich');
await user.click(screen.getByRole('button', { name: /^Create$/ }));
await screen.findByText('trek_thefullsecretvalue');
await user.click(screen.getByTitle('Copy'));
expect(clipboardWriteText).toHaveBeenCalledWith('trek_thefullsecretvalue');
// And the button confirms it to the reader, which is the only feedback there is.
expect(await screen.findByTitle('Copy')).toBeInTheDocument();
});
});