Files
DDI/web/src/deploy.test.tsx
T

486 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
});
});
});