Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
486 lines
17 KiB
TypeScript
486 lines
17 KiB
TypeScript
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
|
||
import { act, screen, waitFor, within } from '@testing-library/react';
|
||
import userEvent from '@testing-library/user-event';
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||
|
||
import { parseUnifiedDiff } from './diff';
|
||
import { changeLanguage } from './i18n';
|
||
import { adminSession, mockApi, problem, renderApp, validLicense } from './test-utils';
|
||
|
||
/** Minimal EventSource stand-in; tests open it and emit events. */
|
||
class MockEventSource extends EventTarget {
|
||
static instances: MockEventSource[] = [];
|
||
readonly url: string;
|
||
closed = false;
|
||
|
||
constructor(url: string) {
|
||
super();
|
||
this.url = url;
|
||
MockEventSource.instances.push(this);
|
||
}
|
||
|
||
close() {
|
||
this.closed = true;
|
||
}
|
||
|
||
open() {
|
||
this.dispatchEvent(new Event('open'));
|
||
}
|
||
|
||
emit(type: string, data: unknown) {
|
||
this.dispatchEvent(new MessageEvent(type, { data: JSON.stringify(data) }));
|
||
}
|
||
}
|
||
|
||
const base = {
|
||
'GET /api/v1/auth/session': () => ({ body: adminSession }),
|
||
'GET /api/v1/license': () => ({ body: validLicense }),
|
||
};
|
||
|
||
const noPending = {
|
||
latest_revision: 4,
|
||
changed: false,
|
||
files: [],
|
||
changes: [],
|
||
};
|
||
|
||
const fileDiff = {
|
||
role: 'dns',
|
||
path: 'zones/db.office.test',
|
||
status: 'modified',
|
||
diff: '@@ -2,3 +2,3 @@\n $TTL 3600\n-printer A 192.0.2.10\n+printer A 192.0.2.11\n ns1 A 192.0.2.53\n',
|
||
inserted: 1,
|
||
deleted: 1,
|
||
};
|
||
|
||
const auditEntry = {
|
||
id: 41,
|
||
at: '2026-10-13T12:00:00Z',
|
||
actor_name: 'alice',
|
||
action: 'address.update',
|
||
object_type: 'address',
|
||
object_name: '192.0.2.11',
|
||
request_id: 'r1',
|
||
};
|
||
|
||
function agent(over: Record<string, unknown>) {
|
||
return {
|
||
id: 'a-dns',
|
||
role: 'dns',
|
||
hostname: 'dns1',
|
||
agent_version: '0.5.0',
|
||
service_version: 'BIND 9.20.3',
|
||
connected: true,
|
||
mode: 'auto_pull',
|
||
enrolled_at: '2026-10-13T10:00:00Z',
|
||
last_seen_at: '2026-10-13T11:00:00Z',
|
||
cert_expires_at: '2026-10-27T10:00:00Z',
|
||
applied_revision: 3,
|
||
last_good_revision: 3,
|
||
desired_revision: 4,
|
||
target_revision: 0,
|
||
health: { state: 'ok', checks: [] },
|
||
...over,
|
||
};
|
||
}
|
||
|
||
const dns1 = agent({});
|
||
const dhcp1 = agent({
|
||
id: 'a-dhcp',
|
||
role: 'dhcp',
|
||
hostname: 'dhcp1',
|
||
mode: 'manual',
|
||
connected: false,
|
||
target_revision: 3,
|
||
});
|
||
const old1 = agent({ id: 'a-old', hostname: 'old1', revoked_at: '2026-10-13T09:00:00Z' });
|
||
|
||
function revision(number: number) {
|
||
return {
|
||
number,
|
||
created_at: '2026-10-13T11:00:00Z',
|
||
created_by_name: 'admin',
|
||
comment: `rev ${String(number)}`,
|
||
dns: [],
|
||
dhcp: [],
|
||
warnings: [],
|
||
};
|
||
}
|
||
|
||
function summary(number: number) {
|
||
return {
|
||
number,
|
||
created_at: '2026-10-13T11:00:00Z',
|
||
created_by_name: 'admin',
|
||
comment: `rev ${String(number)}`,
|
||
dns_files: 2,
|
||
dhcp_files: 1,
|
||
warnings: 0,
|
||
};
|
||
}
|
||
|
||
/** Handlers for the revision detail page of revision n (latest = 4). */
|
||
function revisionPage(n: number) {
|
||
return {
|
||
...base,
|
||
'GET /api/v1/config/pending': () => ({ body: noPending }),
|
||
'GET /api/v1/revisions': () => ({
|
||
body: { items: [4, 3, 2, 1].map(summary), next_before: null },
|
||
}),
|
||
[`GET /api/v1/revisions/${String(n)}`]: () => ({ body: revision(n) }),
|
||
[`GET /api/v1/revisions/${String(n)}/diff`]: () => ({
|
||
body: { number: n, against: n - 1, files: [fileDiff] },
|
||
}),
|
||
'GET /api/v1/deployments': () => ({ body: { items: [], next_before_id: null } }),
|
||
'GET /api/v1/agents': () => ({ body: { items: [dns1, dhcp1, old1] } }),
|
||
};
|
||
}
|
||
|
||
function requestedUrls(): string[] {
|
||
return vi
|
||
.mocked(globalThis.fetch)
|
||
.mock.calls.map(([input]) => (input instanceof Request ? input.url : String(input)));
|
||
}
|
||
|
||
beforeEach(async () => {
|
||
window.localStorage.clear();
|
||
MockEventSource.instances = [];
|
||
await changeLanguage('de');
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals();
|
||
});
|
||
|
||
describe('diff parsing', () => {
|
||
it('numbers old and new lines from the hunk header', () => {
|
||
const lines = parseUnifiedDiff(fileDiff.diff);
|
||
expect(lines.map((l) => [l.kind, l.oldNo, l.newNo])).toEqual([
|
||
['hunk', undefined, undefined],
|
||
['ctx', 2, 2],
|
||
['del', 3, undefined],
|
||
['add', undefined, 3],
|
||
['ctx', 4, 4],
|
||
]);
|
||
expect(lines[2]?.text).toBe('printer A 192.0.2.10');
|
||
});
|
||
});
|
||
|
||
describe('pending changes', () => {
|
||
it('shows the header notice with a link to the deploy page', async () => {
|
||
mockApi({
|
||
...base,
|
||
'GET /api/v1/networks': () => ({ body: { items: [] } }),
|
||
'GET /api/v1/config/pending': () => ({
|
||
body: {
|
||
...noPending,
|
||
changed: true,
|
||
files: [fileDiff],
|
||
changes: [auditEntry, { ...auditEntry, id: 42 }],
|
||
},
|
||
}),
|
||
});
|
||
renderApp('/networks');
|
||
const text = await screen.findByText('2 Änderungen warten auf Ausrollen.');
|
||
const banner = text.closest('.banner');
|
||
if (!(banner instanceof HTMLElement)) throw new Error('banner not found');
|
||
expect(within(banner).getByRole('link', { name: 'Zum Ausrollen' })).toHaveAttribute(
|
||
'href',
|
||
'/revisions',
|
||
);
|
||
});
|
||
|
||
it('lists pending audit entries and renders the file diff', async () => {
|
||
mockApi({
|
||
...base,
|
||
'GET /api/v1/revisions': () => ({ body: { items: [summary(4)], next_before: null } }),
|
||
'GET /api/v1/config/pending': () => ({
|
||
body: { ...noPending, changed: true, files: [fileDiff], changes: [auditEntry] },
|
||
}),
|
||
});
|
||
renderApp('/revisions');
|
||
expect(await screen.findByText('Offene Änderungen')).toBeInTheDocument();
|
||
expect(await screen.findByText('address.update')).toBeInTheDocument();
|
||
expect(screen.getByText('alice')).toBeInTheDocument();
|
||
expect(screen.getByText('zones/db.office.test')).toBeInTheDocument();
|
||
expect(screen.getByText('geändert')).toBeInTheDocument();
|
||
expect(screen.getByText('1 Zeilen hinzugefügt, 1 Zeilen entfernt')).toBeInTheDocument();
|
||
const removed = screen.getByText('printer A 192.0.2.10').closest('tr');
|
||
const added = screen.getByText('printer A 192.0.2.11').closest('tr');
|
||
expect(removed).toHaveClass('diff-del');
|
||
expect(added).toHaveClass('diff-add');
|
||
// Not by color only: the sign column names the kind of change.
|
||
if (!added || !removed) throw new Error('rows not found');
|
||
expect(within(added).getByText('hinzugefügt:')).toBeInTheDocument();
|
||
expect(within(removed).getByText('entfernt:')).toBeInTheDocument();
|
||
|
||
// Collapsible per file.
|
||
const toggle = screen.getByRole('button', { name: /zones\/db\.office\.test/ });
|
||
expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||
await userEvent.click(toggle);
|
||
expect(screen.queryByText('printer A 192.0.2.11')).not.toBeInTheDocument();
|
||
});
|
||
|
||
it('says when nothing is pending', async () => {
|
||
mockApi({
|
||
...base,
|
||
'GET /api/v1/revisions': () => ({ body: { items: [summary(4)], next_before: null } }),
|
||
'GET /api/v1/config/pending': () => ({ body: noPending }),
|
||
});
|
||
renderApp('/revisions');
|
||
expect(
|
||
await screen.findByText('Keine offenen Änderungen – der Datenstand entspricht Revision 4.'),
|
||
).toBeInTheDocument();
|
||
expect(screen.queryByText(/warten auf Ausrollen/)).not.toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
describe('revision detail', () => {
|
||
it('compares with the previous revision and with an empty one', async () => {
|
||
mockApi(revisionPage(4));
|
||
renderApp('/revisions/4');
|
||
expect(await screen.findByText('printer A 192.0.2.11')).toBeInTheDocument();
|
||
expect(requestedUrls().some((u) => u.includes('/revisions/4/diff?against=3'))).toBe(true);
|
||
const select = screen.getByLabelText('Vergleichen mit');
|
||
await waitFor(() => {
|
||
expect(within(select).getAllByRole('option')).toHaveLength(4);
|
||
});
|
||
await userEvent.selectOptions(select, '0');
|
||
await waitFor(() => {
|
||
expect(requestedUrls().some((u) => u.includes('/revisions/4/diff?against=0'))).toBe(true);
|
||
});
|
||
});
|
||
|
||
it('pushes the revision to the selected agents and shows the delivery', async () => {
|
||
const calls = mockApi({
|
||
...revisionPage(4),
|
||
'POST /api/v1/revisions/4/deploy': () => ({
|
||
body: {
|
||
items: [{ agent_id: 'a-dns', agent_hostname: 'dns1', deployment_id: 9, sent: true }],
|
||
},
|
||
}),
|
||
});
|
||
renderApp('/revisions/4');
|
||
await userEvent.click(await screen.findByRole('button', { name: 'Jetzt ausrollen' }));
|
||
const dialog = await screen.findByRole('dialog');
|
||
const dnsBox = await within(dialog).findByRole('checkbox', { name: 'dns1 auswählen' });
|
||
const dhcpBox = within(dialog).getByRole('checkbox', { name: 'dhcp1 auswählen' });
|
||
const oldBox = within(dialog).getByRole('checkbox', { name: 'old1 auswählen' });
|
||
expect(dnsBox).toBeChecked();
|
||
expect(dhcpBox).toBeChecked();
|
||
expect(oldBox).not.toBeChecked();
|
||
expect(oldBox).toBeDisabled();
|
||
expect(within(dialog).getByText('getrennt')).toBeInTheDocument();
|
||
|
||
await userEvent.click(dhcpBox);
|
||
await userEvent.click(within(dialog).getByRole('button', { name: 'An 1 Agent senden' }));
|
||
expect(await within(dialog).findByText('sofort gesendet')).toBeInTheDocument();
|
||
expect(within(dialog).getByText('Auftrag für 1 Agent angelegt.')).toBeInTheDocument();
|
||
const post = calls.find((c) => c.method === 'POST');
|
||
expect(post?.body).toEqual({ agent_ids: ['a-dns'] });
|
||
expect(post?.headers.get('X-CSRF-Token')).toBe('csrf-1');
|
||
});
|
||
|
||
it('explains rollback_required for older revisions on auto_pull agents', async () => {
|
||
mockApi({
|
||
...revisionPage(3),
|
||
'POST /api/v1/revisions/3/deploy': () => ({
|
||
status: 422,
|
||
body: problem(422, 'rollback_required'),
|
||
}),
|
||
});
|
||
renderApp('/revisions/3');
|
||
await userEvent.click(await screen.findByRole('button', { name: 'Jetzt ausrollen' }));
|
||
const dialog = await screen.findByRole('dialog');
|
||
const dnsBox = await within(dialog).findByRole('checkbox', { name: 'dns1 auswählen' });
|
||
// auto_pull agents take only the latest revision: not preselected.
|
||
expect(dnsBox).not.toBeChecked();
|
||
expect(
|
||
within(dialog).getByText(/Das ist nicht die neueste Revision \(4\)/),
|
||
).toBeInTheDocument();
|
||
// dhcp1 (manual) already runs revision 3: not preselected either.
|
||
expect(within(dialog).getByRole('checkbox', { name: 'dhcp1 auswählen' })).not.toBeChecked();
|
||
expect(within(dialog).getAllByText('(bereits aktiv)')).toHaveLength(3);
|
||
await userEvent.click(dnsBox);
|
||
await userEvent.click(within(dialog).getByRole('button', { name: 'An 1 Agent senden' }));
|
||
expect(
|
||
await within(dialog).findByText(/Ältere Revisionen lassen sich nicht auf Agents/),
|
||
).toBeInTheDocument();
|
||
});
|
||
|
||
it('rolls back to an older revision and opens the new one', async () => {
|
||
const calls = mockApi({
|
||
...revisionPage(3),
|
||
'POST /api/v1/revisions/3/rollback': () => ({ status: 201, body: revision(5) }),
|
||
'GET /api/v1/revisions/5': () => ({ body: revision(5) }),
|
||
'GET /api/v1/revisions/5/diff': () => ({ body: { number: 5, against: 4, files: [] } }),
|
||
});
|
||
const { router } = renderApp('/revisions/3');
|
||
await userEvent.click(
|
||
await screen.findByRole('button', { name: 'Auf diese Revision zurücksetzen' }),
|
||
);
|
||
const dialog = await screen.findByRole('dialog');
|
||
await userEvent.type(within(dialog).getByLabelText('Kommentar'), 'printer broke');
|
||
await userEvent.click(within(dialog).getByRole('button', { name: 'Zurücksetzen' }));
|
||
await waitFor(() => {
|
||
expect(router.state.location.pathname).toBe('/revisions/5');
|
||
});
|
||
expect(await screen.findByRole('heading', { name: 'Revision 5' })).toBeInTheDocument();
|
||
expect(calls.find((c) => c.path.endsWith('/rollback'))?.body).toEqual({
|
||
comment: 'printer broke',
|
||
});
|
||
});
|
||
|
||
it('translates rollback_unavailable', async () => {
|
||
mockApi({
|
||
...revisionPage(3),
|
||
'POST /api/v1/revisions/3/rollback': () => ({
|
||
status: 409,
|
||
body: problem(409, 'rollback_unavailable'),
|
||
}),
|
||
});
|
||
renderApp('/revisions/3');
|
||
await userEvent.click(
|
||
await screen.findByRole('button', { name: 'Auf diese Revision zurücksetzen' }),
|
||
);
|
||
const dialog = await screen.findByRole('dialog');
|
||
await userEvent.click(within(dialog).getByRole('button', { name: 'Zurücksetzen' }));
|
||
expect(
|
||
await within(dialog).findByText(/Diese Revision kann nicht wiederhergestellt werden/),
|
||
).toBeInTheDocument();
|
||
});
|
||
|
||
it('hides rollback on the latest revision', async () => {
|
||
mockApi(revisionPage(4));
|
||
renderApp('/revisions/4');
|
||
expect(await screen.findByText('neueste Revision')).toBeInTheDocument();
|
||
expect(
|
||
screen.queryByRole('button', { name: 'Auf diese Revision zurücksetzen' }),
|
||
).not.toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
describe('agent mode', () => {
|
||
it('switches an agent to manual mode and shows its target revision', async () => {
|
||
let current = dns1;
|
||
const calls = mockApi({
|
||
...base,
|
||
'GET /api/v1/config/pending': () => ({ body: noPending }),
|
||
'GET /api/v1/agents': () => ({ body: { items: [current] } }),
|
||
'GET /api/v1/agents/a-dns': () => ({ body: current }),
|
||
'PATCH /api/v1/agents/a-dns': () => {
|
||
current = { ...dns1, mode: 'manual' };
|
||
return { body: current };
|
||
},
|
||
'GET /api/v1/deployments': () => ({
|
||
body: {
|
||
items: [
|
||
{
|
||
id: 9,
|
||
agent_id: 'a-dns',
|
||
agent_hostname: 'dns1',
|
||
revision: 4,
|
||
trigger: 'push',
|
||
outcome: 'pending',
|
||
message: '',
|
||
active_revision: 3,
|
||
started_at: '2026-10-13T11:00:00Z',
|
||
requested_by_name: 'admin',
|
||
},
|
||
{
|
||
id: 8,
|
||
agent_id: 'a-dns',
|
||
agent_hostname: 'dns1',
|
||
revision: 3,
|
||
trigger: 'push',
|
||
outcome: 'superseded',
|
||
message: '',
|
||
active_revision: 3,
|
||
started_at: '2026-10-13T10:00:00Z',
|
||
finished_at: '2026-10-13T11:00:00Z',
|
||
},
|
||
],
|
||
next_before_id: null,
|
||
},
|
||
}),
|
||
});
|
||
renderApp('/agents/a-dns');
|
||
expect(await screen.findByText('Der Agent übernimmt jede neue Revision sofort.')).toBeVisible();
|
||
expect(await screen.findByText('wartet')).toBeInTheDocument();
|
||
expect(screen.getByText('ersetzt')).toBeInTheDocument();
|
||
expect(screen.getByText('von admin')).toBeInTheDocument();
|
||
expect(screen.queryByText('(aktiv: 3)')).not.toBeInTheDocument();
|
||
|
||
await userEvent.selectOptions(screen.getByLabelText('Betriebsmodus'), 'manual');
|
||
expect(await screen.findByText(/Der Agent bleibt auf seiner Soll-Revision/)).toBeVisible();
|
||
expect(screen.getByText('Soll-Revision (manuell)')).toBeInTheDocument();
|
||
expect(screen.getByText('noch keine – erst nach „Jetzt ausrollen“')).toBeInTheDocument();
|
||
expect(calls.find((c) => c.method === 'PATCH')?.body).toEqual({ mode: 'manual' });
|
||
});
|
||
|
||
it('shows the mode in the agent list', async () => {
|
||
mockApi({
|
||
...base,
|
||
'GET /api/v1/config/pending': () => ({ body: noPending }),
|
||
'GET /api/v1/agents': () => ({ body: { items: [dns1, dhcp1] } }),
|
||
'GET /api/v1/enrollment-tokens': () => ({ body: { items: [] } }),
|
||
});
|
||
renderApp('/agents');
|
||
const row = (await screen.findByRole('link', { name: 'dhcp1' })).closest('tr');
|
||
if (!row) throw new Error('row not found');
|
||
expect(within(row).getByText('Manuell')).toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
describe('live updates', () => {
|
||
it('is offline without EventSource', async () => {
|
||
mockApi({
|
||
...base,
|
||
'GET /api/v1/config/pending': () => ({ body: noPending }),
|
||
'GET /api/v1/networks': () => ({ body: { items: [] } }),
|
||
});
|
||
renderApp('/networks');
|
||
expect(await screen.findByText('getrennt')).toBeInTheDocument();
|
||
});
|
||
|
||
it('reloads agents on an agent event and closes the stream on logout', async () => {
|
||
vi.stubGlobal('EventSource', MockEventSource);
|
||
let hostname = 'dns1';
|
||
mockApi({
|
||
...base,
|
||
'GET /api/v1/config/pending': () => ({ body: noPending }),
|
||
'GET /api/v1/agents': () => ({ body: { items: [{ ...dns1, hostname }] } }),
|
||
'GET /api/v1/enrollment-tokens': () => ({ body: { items: [] } }),
|
||
'POST /api/v1/auth/logout': () => ({ status: 204 }),
|
||
'GET /api/v1/system/info': () => ({
|
||
body: { product: 'DDI-Suite', version: 'test', auth: { local: true } },
|
||
}),
|
||
});
|
||
renderApp('/agents');
|
||
expect(await screen.findByRole('link', { name: 'dns1' })).toBeInTheDocument();
|
||
await waitFor(() => {
|
||
expect(MockEventSource.instances).toHaveLength(1);
|
||
});
|
||
const es = MockEventSource.instances[0];
|
||
if (!es) throw new Error('no event source');
|
||
expect(es.url).toBe('/api/v1/events');
|
||
|
||
act(() => {
|
||
es.open();
|
||
});
|
||
const footer = screen.getByRole('contentinfo');
|
||
expect(await within(footer).findByText('verbunden')).toBeInTheDocument();
|
||
|
||
hostname = 'dns1-renamed';
|
||
act(() => {
|
||
es.emit('agent', { id: 'a-dns' });
|
||
});
|
||
expect(await screen.findByRole('link', { name: 'dns1-renamed' })).toBeInTheDocument();
|
||
|
||
await userEvent.click(screen.getByRole('button', { name: 'Abmelden' }));
|
||
await waitFor(() => {
|
||
expect(es.closed).toBe(true);
|
||
});
|
||
});
|
||
});
|