Skip to content

Commit 2900b86

Browse files
aberohamclaude
andcommitted
test: add E2E tests for delete trash icon CSRF token
Verify that all delete links rendered in the UI include csrf_token and that following them actually deletes the entity without CSRF errors. Covers groups, users, nameservers, zones, and records. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d4a9ef2 commit 2900b86

1 file changed

Lines changed: 228 additions & 0 deletions

File tree

client/t/e2e/delete-ui.spec.ts

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
import { test, expect } from '@playwright/test';
2+
import {
3+
apiLogin, cookieString, authGet, authPost,
4+
createGroup, createZone, createRecord, createUser, createNameserver,
5+
deleteGroup, deleteZone, deleteRecord, deleteUser, deleteNameserver,
6+
uniqueName, uniqueNsName, extractCsrf, BASE,
7+
} from './helpers';
8+
9+
// ---------------------------------------------------------------------------
10+
// These tests verify that the delete icons rendered in the UI actually work.
11+
// They fetch the listing page, extract the real delete link/form as rendered
12+
// in the HTML, follow it exactly as the browser would, and confirm the delete
13+
// succeeds (no CSRF error, entity is removed).
14+
//
15+
// This catches the bug where <a href> trash-icon links omit csrf_token.
16+
// ---------------------------------------------------------------------------
17+
18+
// ---- Helpers to extract delete links/forms from rendered HTML ----
19+
20+
/** Extract the trash-icon delete href for a group from group.cgi HTML */
21+
function extractGroupDeleteHref(html: string, gid: string): string | null {
22+
// Pattern: <a href="group.cgi?nt_group_id=PARENT&amp;delete=GID" ...><img ...trash.gif...>
23+
const re = new RegExp(`<a\\s+href="(group\\.cgi\\?[^"]*delete=${gid}[^"]*)"[^>]*>\\s*<img[^>]*trash\\.gif`);
24+
const m = html.match(re);
25+
return m ? m[1].replace(/&amp;/g, '&') : null;
26+
}
27+
28+
/** Extract the trash-icon delete href for a user from group_users.cgi HTML */
29+
function extractUserDeleteHref(html: string, uid: string): string | null {
30+
const re = new RegExp(`<a\\s+href="(group_users\\.cgi\\?[^"]*delete=1[^"]*obj_list=${uid}[^"]*)"[^>]*>\\s*<img[^>]*trash\\.gif`);
31+
const m = html.match(re);
32+
if (m) return m[1].replace(/&amp;/g, '&');
33+
// Try alternate order: obj_list before delete
34+
const re2 = new RegExp(`<a\\s+href="(group_users\\.cgi\\?[^"]*obj_list=${uid}[^"]*delete=1[^"]*)"[^>]*>\\s*<img[^>]*trash\\.gif`);
35+
const m2 = html.match(re2);
36+
return m2 ? m2[1].replace(/&amp;/g, '&') : null;
37+
}
38+
39+
/** Extract the trash-icon delete href for a nameserver from group_nameservers.cgi HTML */
40+
function extractNameserverDeleteHref(html: string, nsid: string): string | null {
41+
const re = new RegExp(`<a\\s+href="(group_nameservers\\.cgi\\?[^"]*delete=1[^"]*nt_nameserver_id=${nsid}[^"]*)"[^>]*>\\s*<img[^>]*trash\\.gif`);
42+
const m = html.match(re);
43+
if (m) return m[1].replace(/&amp;/g, '&');
44+
const re2 = new RegExp(`<a\\s+href="(group_nameservers\\.cgi\\?[^"]*nt_nameserver_id=${nsid}[^"]*delete=1[^"]*)"[^>]*>\\s*<img[^>]*trash\\.gif`);
45+
const m2 = html.match(re2);
46+
return m2 ? m2[1].replace(/&amp;/g, '&') : null;
47+
}
48+
49+
/** Extract the trash-icon delete href for a zone from group_zones.cgi HTML */
50+
function extractZoneDeleteHref(html: string, zid: string): string | null {
51+
const re = new RegExp(`<a\\s+href="(group_zones\\.cgi\\?[^"]*delete=1[^"]*zone_list=${zid}[^"]*)"[^>]*>\\s*<img[^>]*trash\\.gif`);
52+
const m = html.match(re);
53+
if (m) return m[1].replace(/&amp;/g, '&');
54+
const re2 = new RegExp(`<a\\s+href="(group_zones\\.cgi\\?[^"]*zone_list=${zid}[^"]*delete=1[^"]*)"[^>]*>\\s*<img[^>]*trash\\.gif`);
55+
const m2 = html.match(re2);
56+
return m2 ? m2[1].replace(/&amp;/g, '&') : null;
57+
}
58+
59+
/** Extract the delete form for a record from zone.cgi HTML */
60+
function extractRecordDeleteForm(html: string, rrid: string): { action: string; fields: Record<string, string> } | null {
61+
// Look for a form containing delete_record with the given rrid
62+
const formRe = new RegExp(
63+
`<form[^>]*method="post"[^>]*action="([^"]*)"[^>]*>([\\s\\S]*?)</form>`,
64+
'gi'
65+
);
66+
let match;
67+
while ((match = formRe.exec(html)) !== null) {
68+
const [, action, formBody] = match;
69+
if (formBody.includes(`name="delete_record"`) && formBody.includes(`value="${rrid}"`)) {
70+
const fields: Record<string, string> = {};
71+
const inputRe = /name="([^"]+)"\s+value="([^"]*)"/g;
72+
let im;
73+
while ((im = inputRe.exec(formBody)) !== null) {
74+
fields[im[1]] = im[2];
75+
}
76+
// Also check value="..." name="..." order
77+
const inputRe2 = /value="([^"]*)"\s+name="([^"]+)"/g;
78+
while ((im = inputRe2.exec(formBody)) !== null) {
79+
if (!fields[im[2]]) fields[im[2]] = im[1];
80+
}
81+
return { action: action.replace(/&amp;/g, '&'), fields };
82+
}
83+
}
84+
return null;
85+
}
86+
87+
88+
test.describe('Delete via UI trash icon', () => {
89+
let cookies: string;
90+
let csrfCookie: string;
91+
92+
test.beforeAll(async ({ playwright }) => {
93+
const login = await apiLogin(playwright);
94+
cookies = cookieString(login.sessionCookie, login.csrfCookie);
95+
csrfCookie = login.csrfCookie;
96+
});
97+
98+
test('delete group via rendered trash icon link', async ({ playwright }) => {
99+
const gid = await createGroup(playwright, cookies, 1);
100+
101+
// Fetch the group listing page as the browser would
102+
const { body } = await authGet(playwright, `${BASE}/group.cgi?nt_group_id=1`, cookies);
103+
104+
// Extract the actual delete link from the HTML
105+
const href = extractGroupDeleteHref(body, gid);
106+
expect(href, 'trash icon link should exist for the group').toBeTruthy();
107+
108+
// The link MUST contain csrf_token for CSRF protection to pass
109+
expect(href, 'delete link must include csrf_token').toContain('csrf_token');
110+
111+
// Follow the link exactly as the browser would
112+
const { body: afterBody, res } = await authGet(playwright, `${BASE}/${href}`, cookies);
113+
114+
// Should NOT show CSRF error
115+
expect(afterBody).not.toContain('CSRF validation failed');
116+
117+
// Group should be gone from listing
118+
const { body: listBody } = await authGet(playwright, `${BASE}/group.cgi?nt_group_id=1`, cookies);
119+
expect(listBody).not.toContain(`nt_group_id=${gid}"`);
120+
});
121+
122+
test('delete user via rendered trash icon link', async ({ playwright }) => {
123+
const gid = await createGroup(playwright, cookies, 1);
124+
const username = uniqueName('deluiusr');
125+
const uid = await createUser(playwright, cookies, gid, { username });
126+
127+
// Fetch the user listing page
128+
const { body } = await authGet(playwright, `${BASE}/group_users.cgi?nt_group_id=${gid}`, cookies);
129+
130+
// Extract the actual delete link
131+
const href = extractUserDeleteHref(body, uid);
132+
expect(href, 'trash icon link should exist for the user').toBeTruthy();
133+
expect(href, 'delete link must include csrf_token').toContain('csrf_token');
134+
135+
// Follow the link
136+
const { body: afterBody } = await authGet(playwright, `${BASE}/${href}`, cookies);
137+
expect(afterBody).not.toContain('CSRF validation failed');
138+
139+
// User should be gone
140+
const { body: listBody } = await authGet(playwright, `${BASE}/group_users.cgi?nt_group_id=${gid}`, cookies);
141+
expect(listBody).not.toContain(username);
142+
143+
// Cleanup
144+
await deleteGroup(playwright, cookies, 1, gid);
145+
});
146+
147+
test('delete nameserver via rendered trash icon link', async ({ playwright }) => {
148+
// Create nameserver in root group (gid=1) which has usable nameservers
149+
const nsName = uniqueNsName('deluins') + '.example.com';
150+
const nsid = await createNameserver(playwright, cookies, 1, { name: nsName });
151+
152+
// Fetch the nameserver listing page
153+
const { body } = await authGet(playwright, `${BASE}/group_nameservers.cgi?nt_group_id=1`, cookies);
154+
155+
// Extract the actual delete link
156+
const href = extractNameserverDeleteHref(body, nsid);
157+
expect(href, 'trash icon link should exist for the nameserver').toBeTruthy();
158+
expect(href, 'delete link must include csrf_token').toContain('csrf_token');
159+
160+
// Follow the link
161+
const { body: afterBody } = await authGet(playwright, `${BASE}/${href}`, cookies);
162+
expect(afterBody).not.toContain('CSRF validation failed');
163+
164+
// Nameserver should be gone
165+
const { body: listBody } = await authGet(playwright, `${BASE}/group_nameservers.cgi?nt_group_id=1`, cookies);
166+
expect(listBody).not.toContain(nsName);
167+
});
168+
169+
test('delete zone via rendered trash icon link', async ({ playwright }) => {
170+
const gid = await createGroup(playwright, cookies, 1);
171+
const zoneName = `${uniqueName('deluizn')}.test`;
172+
const zid = await createZone(playwright, cookies, gid, zoneName);
173+
174+
// Fetch the zone listing page
175+
const { body } = await authGet(playwright, `${BASE}/group_zones.cgi?nt_group_id=${gid}`, cookies);
176+
177+
// Extract the actual delete link
178+
const href = extractZoneDeleteHref(body, zid);
179+
expect(href, 'trash icon link should exist for the zone').toBeTruthy();
180+
expect(href, 'delete link must include csrf_token').toContain('csrf_token');
181+
182+
// Follow the link
183+
const { body: afterBody } = await authGet(playwright, `${BASE}/${href}`, cookies);
184+
expect(afterBody).not.toContain('CSRF validation failed');
185+
186+
// Zone should be gone
187+
const { body: listBody } = await authGet(playwright, `${BASE}/group_zones.cgi?nt_group_id=${gid}`, cookies);
188+
expect(listBody).not.toContain(zoneName);
189+
190+
// Cleanup
191+
await deleteGroup(playwright, cookies, 1, gid);
192+
});
193+
194+
test('delete record via rendered trash form submit', async ({ playwright }) => {
195+
const gid = await createGroup(playwright, cookies, 1);
196+
const zoneName = `${uniqueName('deluirr')}.test`;
197+
const zid = await createZone(playwright, cookies, gid, zoneName);
198+
const rrid = await createRecord(playwright, cookies, gid, zid, {
199+
name: 'deltest', type: 'A', address: '10.0.0.99',
200+
});
201+
202+
// Fetch the zone detail page (which lists records)
203+
const { body } = await authGet(playwright,
204+
`${BASE}/zone.cgi?nt_group_id=${gid}&nt_zone_id=${zid}`, cookies);
205+
206+
// Extract the delete form for this record
207+
const form = extractRecordDeleteForm(body, rrid);
208+
expect(form, 'delete form should exist for the record').toBeTruthy();
209+
expect(form!.fields, 'delete form must include csrf_token').toHaveProperty('csrf_token');
210+
expect(form!.fields['csrf_token']).toBeTruthy();
211+
212+
// Submit the form exactly as the browser would
213+
const formData = Object.entries(form!.fields).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&');
214+
const { body: afterBody } = await authPost(playwright,
215+
`${BASE}/${form!.action}`, cookies, formData);
216+
217+
expect(afterBody).not.toContain('CSRF validation failed');
218+
219+
// Record should be gone
220+
const { body: listBody } = await authGet(playwright,
221+
`${BASE}/zone.cgi?nt_group_id=${gid}&nt_zone_id=${zid}`, cookies);
222+
expect(listBody).not.toContain(`nt_zone_record_id=${rrid}`);
223+
224+
// Cleanup
225+
await deleteZone(playwright, cookies, gid, zid);
226+
await deleteGroup(playwright, cookies, 1, gid);
227+
});
228+
});

0 commit comments

Comments
 (0)