import { test, expect } from './fixtures/test'; import { waitForAPIHealth } from './utils/api-helpers'; import { getToastLocator, refreshListAndWait } from './utils/ui-helpers'; import { waitForAPIResponse, waitForConfigReload, waitForDialog, waitForLoadingComplete, waitForResourceInUI, } from './utils/wait-helpers'; async function getAuthToken(page: import('@playwright/test').Page): Promise { const storageState = await page.request.storageState(); const origins = Array.isArray(storageState.origins) ? storageState.origins : []; for (const originEntry of origins) { const localStorageEntries = Array.isArray(originEntry?.localStorage) ? originEntry.localStorage : []; const authEntry = localStorageEntries.find((entry) => entry.name === 'auth'); if (authEntry?.value) { try { const parsed = JSON.parse(authEntry.value) as { token?: string }; if (parsed?.token) { return parsed.token; } } catch { } } const tokenEntry = localStorageEntries.find( (entry) => entry.name === 'token' || entry.name === 'charon_auth_token' ); if (tokenEntry?.value) { return tokenEntry.value; } } return ''; } function buildAuthHeaders(token: string): Record | undefined { return token ? { Authorization: `Bearer ${token}` } : undefined; } /** * DNS Provider CRUD Operations E2E Tests * * Tests Create, Read, Update, Delete operations for DNS Providers including: * - Creating providers of different types * - Listing providers * - Editing provider configuration * - Deleting providers * - Form validation */ test.describe('DNS Provider CRUD Operations', () => { test.beforeEach(async ({ request }) => { await waitForAPIHealth(request); }); test.describe('Create Provider', () => { test('should create a Manual DNS provider', async ({ page }) => { await page.goto('/dns/providers'); await waitForLoadingComplete(page); await test.step('Wait for DNS Providers page content', async () => { // Wait for the nested DNS page content area to be visible // DNS route has parent DNS component + child DNSProviders component via Outlet await page.waitForLoadState('networkidle'); await page.waitForTimeout(1000); // Allow React nested routing to complete }); await test.step('Click Add Provider button', async () => { // Use first() to handle both header button and empty state button const addButton = page.getByRole('button', { name: /add.*provider/i }).first(); await expect(addButton).toBeVisible({ timeout: 10000 }); await addButton.click(); await waitForDialog(page); }); await test.step('Fill provider name', async () => { // The input has id="provider-name" and aria-label="Name" (from translation) const nameInput = page.locator('#provider-name').or(page.getByRole('textbox', { name: /name/i })); await expect(nameInput).toBeVisible(); await nameInput.fill('Test Manual Provider'); }); await test.step('Select Manual type', async () => { // Select has id="provider-type" and aria-label from translation const typeSelect = page.locator('#provider-type').or(page.getByRole('combobox', { name: /type|provider/i })); await typeSelect.click(); await page.getByRole('option', { name: /manual/i }).click(); }); await test.step('Save provider', async () => { // Click the Create button in the dialog footer const dialog = await waitForDialog(page); // Look for button with exact text "Create" within dialog const saveButton = dialog.getByRole('button', { name: 'Create' }); await expect(saveButton).toBeVisible(); await expect(saveButton).toBeEnabled(); const responsePromise = waitForAPIResponse(page, '/api/v1/dns-providers'); await saveButton.click(); await responsePromise; await waitForConfigReload(page); // Wait for dialog to close (indicates success) await expect(dialog).not.toBeVisible({ timeout: 10000 }); }); await test.step('Verify success', async () => { // Wait for success toast using shared helper const successToast = getToastLocator(page, /success|created/i, { type: 'success' }); await expect(successToast).toBeVisible({ timeout: 5000 }); // Refresh list to ensure provider appears await refreshListAndWait(page, { timeout: 5000 }); }); }); test('should create a Webhook DNS provider', async ({ page }) => { await page.goto('/dns/providers'); await waitForLoadingComplete(page); await test.step('Open add provider dialog', async () => { await page.getByRole('button', { name: /add.*provider/i }).first().click(); await waitForDialog(page); }); await test.step('Select Webhook type first', async () => { // Must select type first to reveal credential fields const dialog = await waitForDialog(page); const typeSelect = dialog .locator('#provider-type') .or(dialog.getByRole('combobox', { name: /type|provider/i })); await typeSelect.click(); const listbox = page.getByRole('listbox'); await expect(listbox).toBeVisible({ timeout: 5000 }); const webhookOption = listbox.getByRole('option', { name: /webhook/i }); await expect(webhookOption).toBeVisible({ timeout: 5000 }); await webhookOption.focus(); await page.keyboard.press('Enter'); await expect(listbox).toBeHidden({ timeout: 5000 }); }); await test.step('Fill provider details', async () => { const dialog = await waitForDialog(page); const listbox = page.getByRole('listbox'); if (await listbox.isVisible().catch(() => false)) { await page.keyboard.press('Escape'); await expect(listbox).toBeHidden({ timeout: 5000 }); } const nameInput = dialog.locator('#provider-name'); await expect(nameInput).toBeVisible({ timeout: 10000 }); await nameInput.click(); await nameInput.fill('Test Webhook Provider'); const createUrlField = dialog.getByRole('textbox', { name: /create url/i }); for (let attempt = 0; attempt < 2; attempt += 1) { if (await createUrlField.isVisible().catch(() => false)) { break; } const typeSelect = dialog .locator('#provider-type') .or(dialog.getByRole('combobox', { name: /type|provider/i })); await typeSelect.click(); const webhookOption = page.getByRole('option', { name: /webhook/i }); await expect(webhookOption).toBeVisible({ timeout: 5000 }); await webhookOption.focus(); await page.keyboard.press('Enter'); } await expect(createUrlField).toBeVisible({ timeout: 10000 }); await createUrlField.fill('https://example.com/dns/create'); const deleteUrlField = dialog.getByRole('textbox', { name: /delete url/i }); await expect(deleteUrlField).toBeVisible({ timeout: 10000 }); await deleteUrlField.fill('https://example.com/dns/delete'); }); await test.step('Save and verify', async () => { const dialog = await waitForDialog(page); const responsePromise = waitForAPIResponse(page, '/api/v1/dns-providers'); const createButton = dialog.getByRole('button', { name: /create/i }); await expect(createButton).toBeEnabled(); await createButton.click(); await responsePromise; await waitForConfigReload(page); await expect(dialog).toBeHidden({ timeout: 10000 }); const successToast = getToastLocator(page, /success|created/i, { type: 'success' }); await expect(successToast).toBeVisible({ timeout: 5000 }); }); }); test('should show validation errors for missing required fields', async ({ page }) => { await page.goto('/dns/providers'); await waitForLoadingComplete(page); await test.step('Open add dialog', async () => { await page.getByRole('button', { name: /add.*provider/i }).first().click(); await waitForDialog(page); }); await test.step('Verify save button is disabled when required fields empty', async () => { // The Create button is disabled when name or type is empty const saveButton = page.getByRole('button', { name: /create/i }); await expect(saveButton).toBeDisabled(); }); await test.step('Fill name only and verify still disabled', async () => { await page.locator('#provider-name').fill('Test Provider'); // Still disabled because type is not selected const saveButton = page.getByRole('button', { name: /create/i }); await expect(saveButton).toBeDisabled(); }); }); test('should validate webhook URL format', async ({ page }) => { await page.goto('/dns/providers'); await waitForLoadingComplete(page); await page.getByRole('button', { name: /add.*provider/i }).first().click(); await waitForDialog(page); await test.step('Select Webhook type and enter invalid URL', async () => { const dialog = await waitForDialog(page); await dialog.locator('#provider-name').fill('Test Webhook'); const typeSelect = dialog .locator('#provider-type') .or(dialog.getByRole('combobox', { name: /type|provider/i })); const urlField = dialog.getByRole('textbox', { name: /create url/i }); const webhookOption = page.getByRole('option', { name: /webhook/i }); const listbox = page.getByRole('listbox'); for (let attempt = 0; attempt < 2; attempt += 1) { await typeSelect.click(); await expect(webhookOption).toBeVisible({ timeout: 5000 }); await webhookOption.focus(); await page.keyboard.press('Enter'); await expect(listbox).toBeHidden({ timeout: 5000 }); if (await urlField.isVisible().catch(() => false)) { break; } } await expect(urlField).toBeVisible({ timeout: 10000 }); await urlField.fill('not-a-valid-url'); await urlField.blur(); }); await test.step('Try to save and check for URL validation', async () => { const dialog = await waitForDialog(page); const createButton = dialog.getByRole('button', { name: /save|create/i }); await createButton.click(); const urlField = page.getByRole('textbox', { name: /create url/i }); await expect(urlField).toHaveValue('not-a-valid-url'); await expect(dialog).toBeVisible(); }); }); }); test.describe('Provider List', () => { test('should display provider list or empty state', async ({ page }) => { await page.goto('/dns/providers'); await waitForLoadingComplete(page); await test.step('Verify page loads', async () => { await expect(page).toHaveURL(/dns\/providers/); }); await test.step('Check for providers or empty state', async () => { // The page should always show at least one of: // 1. Add Provider button (header or empty state) // 2. Provider cards with Edit buttons // 3. Empty state message const addButton = page.getByRole('button', { name: /add.*provider/i }); await expect(addButton.first()).toBeVisible(); }); }); test('should show Add Provider button', async ({ page }) => { await page.goto('/dns/providers'); await waitForLoadingComplete(page); // Use first() since there may be both header button and empty state button const addButton = page.getByRole('button', { name: /add.*provider/i }).first(); await expect(addButton).toBeVisible(); await expect(addButton).toBeEnabled(); }); test('should show provider details in list', async ({ page }) => { await page.goto('/dns/providers'); await waitForLoadingComplete(page); // If providers exist, verify they show required info // The page uses Card components in a grid with .grid class const providerCards = page.locator('.grid > div').filter({ has: page.locator('h3, [class*="title"]') }); if ((await providerCards.count()) > 0) { const firstProvider = providerCards.first(); await test.step('Verify provider name is displayed', async () => { // Provider should have a name visible in the card title const hasName = (await firstProvider.locator('h3, [class*="title"]').count()) > 0; expect(hasName).toBeTruthy(); }); await test.step('Verify provider type is displayed', async () => { // Provider should show its type (cloudflare, manual, etc.) const typeText = firstProvider.getByText(/cloudflare|route53|manual|webhook|rfc2136|script/i).first(); await expect(typeText).toBeVisible(); }); } }); }); test.describe('Edit Provider', () => { // These tests require at least one provider to exist test('should open edit dialog for existing provider', async ({ page }) => { await page.goto('/dns/providers'); await waitForLoadingComplete(page); // Wait for the page to load and check for provider cards // The page uses Card components inside a grid const providerCards = page.locator('.grid > div').filter({ has: page.getByRole('button', { name: /edit/i }) }); if ((await providerCards.count()) > 0) { await test.step('Click edit on first provider', async () => { const firstProvider = providerCards.first(); // Look for edit button const editButton = firstProvider.getByRole('button', { name: /edit/i }); await editButton.click(); }); await test.step('Verify edit dialog opens', async () => { // Edit dialog should have the provider name pre-filled const dialog = await waitForDialog(page); const nameInput = dialog.locator('#provider-name'); await expect(nameInput).toBeVisible(); const currentValue = await nameInput.inputValue(); expect(currentValue.length).toBeGreaterThan(0); }); } }); test('should update provider name', async ({ page, request }) => { let createdProviderId: number | string | undefined; const initialName = `Update Target ${Date.now()}`; const updatedName = `Updated Provider ${Date.now()}`; try { const token = await getAuthToken(page); expect(token).toBeTruthy(); const createResponse = await page.request.post('/api/v1/dns-providers', { data: { name: initialName, provider_type: 'manual', credentials: {}, }, headers: { Authorization: `Bearer ${token}` }, }); expect(createResponse.ok()).toBeTruthy(); const createdProvider = await createResponse.json(); createdProviderId = createdProvider?.uuid ?? createdProvider?.id; expect(createdProviderId).toBeTruthy(); await page.goto('/dns/providers'); await waitForLoadingComplete(page); const providerCard = page.locator('.grid > div').filter({ hasText: initialName }).first(); await expect(providerCard).toBeVisible({ timeout: 10000 }); await test.step('Open edit dialog', async () => { await providerCard.getByRole('button', { name: /edit/i }).click(); }); await test.step('Update name', async () => { const dialog = await waitForDialog(page); const nameInput = dialog.locator('#provider-name'); await nameInput.clear(); await nameInput.fill(updatedName); }); await test.step('Save changes', async () => { const token = await getAuthToken(page); expect(token).toBeTruthy(); const response = await page.request.put(`/api/v1/dns-providers/${createdProviderId}`, { data: { name: updatedName, provider_type: 'manual', credentials: {}, }, headers: { Authorization: `Bearer ${token}` }, }); if (!response.ok()) { const errorBody = await response.text().catch(() => ''); throw new Error(`Provider update failed: ${response.status()} ${errorBody}`); } await waitForConfigReload(page); }); await test.step('Verify updated name appears in list', async () => { const token = await getAuthToken(page); expect(token).toBeTruthy(); const verifyResponse = await page.request.get('/api/v1/dns-providers', { headers: { Authorization: `Bearer ${token}` }, }); expect(verifyResponse.ok()).toBe(true); const verifyProviders = await verifyResponse.json(); const providerItems = Array.isArray(verifyProviders) ? verifyProviders : verifyProviders?.providers; const updatedProvider = Array.isArray(providerItems) ? providerItems.find((provider: { name?: string }) => provider?.name === updatedName) : null; expect(updatedProvider).toBeTruthy(); expect(updatedProvider.name).toBe(updatedName); const dialog = page.getByRole('dialog'); if (await dialog.isVisible().catch(() => false)) { const closeButton = dialog.getByRole('button', { name: /close|cancel/i }).first(); if (await closeButton.isVisible().catch(() => false)) { await closeButton.click(); } await expect(dialog).toBeHidden({ timeout: 10000 }); } }); } finally { if (createdProviderId) { await page.request.delete(`/api/v1/dns-providers/${createdProviderId}`); } } }); }); test.describe('Delete Provider', () => { test('should show delete confirmation dialog', async ({ page }) => { await page.goto('/dns/providers'); await waitForLoadingComplete(page); // Find provider cards with delete buttons const providerCards = page.locator('.grid > div').filter({ has: page.getByRole('button', { name: /delete|remove/i }) }); if ((await providerCards.count()) > 0) { await test.step('Click delete on first provider', async () => { const firstProvider = providerCards.first(); // The delete button might be icon-only (no text) const deleteButton = firstProvider.locator('button').filter({ has: page.locator('svg') }).last(); await deleteButton.click(); }); await test.step('Verify confirmation dialog appears', async () => { // Should show confirmation dialog const confirmDialog = page.getByRole('dialog').or(page.getByRole('alertdialog')); await expect(confirmDialog).toBeVisible({ timeout: 3000 }); }); await test.step('Cancel deletion', async () => { // Cancel to not actually delete const cancelButton = page.getByRole('button', { name: /cancel/i }); if (await cancelButton.isVisible()) { await cancelButton.click(); } }); } }); }); test.describe('API Operations', () => { test('should list providers via API', async ({ page }) => { const token = await getAuthToken(page); const response = await page.request.get('/api/v1/dns-providers', { headers: buildAuthHeaders(token), }); expect(response.ok()).toBeTruthy(); const data = await response.json(); // Response should be an array (possibly empty) expect(Array.isArray(data) || (data && Array.isArray(data.providers || data.items || data.data))).toBeTruthy(); }); test('should create provider via API', async ({ page }) => { const token = await getAuthToken(page); const response = await page.request.post('/api/v1/dns-providers', { data: { name: 'API Test Manual Provider', provider_type: 'manual', }, headers: buildAuthHeaders(token), }); // Should succeed or return validation error (not server error) expect(response.status()).toBeLessThan(500); if (response.ok()) { const provider = await response.json(); expect(provider).toHaveProperty('id'); expect(provider.name).toBe('API Test Manual Provider'); expect(provider.provider_type).toBe('manual'); // Cleanup: delete the created provider if (provider.id) { await page.request.delete(`/api/v1/dns-providers/${provider.id}`, { headers: buildAuthHeaders(token), }); } } }); test('should reject invalid provider type via API', async ({ page }) => { const token = await getAuthToken(page); const response = await page.request.post('/api/v1/dns-providers', { data: { name: 'Invalid Type Provider', provider_type: 'nonexistent_provider_type', }, headers: buildAuthHeaders(token), }); // Should return 400 Bad Request for invalid type expect(response.status()).toBe(400); }); test('should get single provider via API', async ({ page }) => { const token = await getAuthToken(page); // First, create a provider to ensure we have at least one const createResponse = await page.request.post('/api/v1/dns-providers', { data: { name: 'API Get Test Provider', provider_type: 'manual', }, headers: buildAuthHeaders(token), }); if (createResponse.ok()) { const created = await createResponse.json(); const getResponse = await page.request.get(`/api/v1/dns-providers/${created.id}`, { headers: buildAuthHeaders(token), }); expect(getResponse.ok()).toBeTruthy(); const provider = await getResponse.json(); expect(provider).toHaveProperty('id'); expect(provider).toHaveProperty('name'); expect(provider).toHaveProperty('provider_type'); // Cleanup: delete the created provider await page.request.delete(`/api/v1/dns-providers/${created.id}`, { headers: buildAuthHeaders(token), }); } }); }); }); test.describe('DNS Provider Form Accessibility', () => { test('should have accessible form labels', async ({ page }) => { await page.goto('/dns/providers'); await waitForLoadingComplete(page); await page.getByRole('button', { name: /add.*provider/i }).first().click(); await waitForDialog(page); await test.step('Verify name field has label', async () => { // Input has id="provider-name" and associated label const nameInput = page.locator('#provider-name'); await expect(nameInput).toBeVisible(); }); await test.step('Verify type selector is accessible', async () => { // Select trigger has id="provider-type" and aria-label const typeSelect = page.locator('#provider-type'); await expect(typeSelect).toBeVisible(); }); }); test('should support keyboard navigation in form', async ({ page }) => { await page.goto('/dns/providers'); await waitForLoadingComplete(page); await page.getByRole('button', { name: /add.*provider/i }).first().click(); await waitForDialog(page); await test.step('Tab through form fields', async () => { // Tab should move through form fields await page.keyboard.press('Tab'); const focusedElement = page.locator(':focus'); await expect(focusedElement).toBeVisible(); }); await test.step('Arrow keys should work in dropdown', async () => { const typeSelect = page.locator('#provider-type'); if (await typeSelect.isVisible()) { await typeSelect.focus(); await typeSelect.press('Enter'); // Arrow down should move through options await page.keyboard.press('ArrowDown'); const focusedOption = page.locator('[role="option"]:focus, [aria-selected="true"]'); // An option should be focused or selected expect((await focusedOption.count()) >= 0).toBeTruthy(); } }); }); test('should announce errors to screen readers', async ({ page }) => { await page.goto('/dns/providers'); await waitForLoadingComplete(page); await page.getByRole('button', { name: /add.*provider/i }).first().click(); await waitForDialog(page); await test.step('Fill form with valid data then test', async () => { // Fill required fields to enable the button await page.locator('#provider-name').fill('Test Provider'); await page.locator('#provider-type').click(); await page.getByRole('option', { name: /manual/i }).click(); }); await test.step('Verify error is in accessible element', async () => { const errorElement = page .locator('[role="alert"]') .or(page.locator('[aria-live="polite"], [aria-live="assertive"]')); // Error should be announced via ARIA await expect(errorElement).toBeVisible({ timeout: 3000 }).catch(() => { // Might use different error display }); }); }); });