Files
Charon/tests/tasks/import-caddyfile.spec.ts
2026-03-04 18:34:49 +00:00

755 lines
27 KiB
TypeScript

/**
* Import Caddyfile - E2E Tests
*
* Tests for the Caddyfile import wizard functionality.
* Covers 18 test scenarios as defined in phase5-implementation.md.
*
* Test Categories:
* - Page Layout (2 tests): heading, wizard steps
* - File Upload (4 tests): dropzone, file selection, invalid file, file validation
* - Preview Step (4 tests): preview content, syntax validation, edit preview, warnings
* - Review Step (4 tests): server list, configuration details, select/deselect, validation
* - Import Execution (4 tests): import success, progress, error handling, partial import
*/
import { test, expect, loginUser } from '../fixtures/auth-fixtures';
import {
mockImportAPI,
mockImportPreview,
ImportPreview,
ImportSession,
IMPORT_SELECTORS,
} from '../utils/phase5-helpers';
import { waitForToast, waitForLoadingComplete, waitForAPIResponse } from '../utils/wait-helpers';
/**
* Selectors for the Import Caddyfile page
*/
const SELECTORS = {
// Page elements
pageTitle: 'h1',
dropzone: '[data-testid="import-dropzone"]',
banner: '[data-testid="import-banner"]',
reviewTable: '[data-testid="import-review-table"]',
uploadInput: 'input[type="file"]',
// Buttons
parseButton: 'button:has-text("Parse")',
nextButton: 'button:has-text("Next")',
importButton: 'button:has-text("Import")',
commitButton: 'button:has-text("Commit")',
cancelButton: 'button:has-text("Cancel")',
backButton: 'button:has-text("Back")',
// Text input
pasteTextarea: 'textarea',
// Review table elements
hostRow: 'tbody tr',
hostCheckbox: 'input[type="checkbox"]',
conflictIndicator: '.text-yellow-400',
newIndicator: '.text-green-400',
// Modals
successModal: '[data-testid="import-success-modal"]',
// Error display
errorMessage: '.bg-red-900, .bg-red-900\\/20',
warningMessage: '.bg-yellow-900, .bg-yellow-900\\/20',
// Source view
sourceToggle: 'text=Source Caddyfile Content',
sourceContent: 'pre.font-mono',
};
/**
* Mock Caddyfile content for testing
*/
const mockCaddyfile = `
example.com {
reverse_proxy localhost:3000
}
api.example.com {
reverse_proxy localhost:8080
}
`;
/**
* Mock preview response with valid hosts
*/
const mockPreviewSuccess: ImportPreview = {
session: {
id: 'test-session-123',
state: 'reviewing',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
preview: {
hosts: [
{ domain_names: 'example.com', forward_host: 'localhost', forward_port: 3000, forward_scheme: 'http' },
{ domain_names: 'api.example.com', forward_host: 'localhost', forward_port: 8080, forward_scheme: 'http' },
],
conflicts: [],
errors: [],
},
caddyfile_content: mockCaddyfile,
};
/**
* Mock preview response with conflicts
*/
const mockPreviewWithConflicts: ImportPreview = {
session: {
id: 'test-session-456',
state: 'reviewing',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
preview: {
hosts: [
{ domain_names: 'existing.example.com', forward_host: 'new-server', forward_port: 8080, forward_scheme: 'https' },
],
conflicts: ['existing.example.com'],
errors: [],
},
conflict_details: {
'existing.example.com': {
existing: {
forward_scheme: 'http',
forward_host: 'old-server',
forward_port: 80,
ssl_forced: false,
websocket: false,
enabled: true,
},
imported: {
forward_scheme: 'https',
forward_host: 'new-server',
forward_port: 8080,
ssl_forced: true,
websocket: true,
},
},
},
};
/**
* Mock preview response with warnings/errors
*/
const mockPreviewWithWarnings: ImportPreview = {
session: {
id: 'test-session-789',
state: 'reviewing',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
preview: {
hosts: [
{ domain_names: 'valid.example.com', forward_host: 'server', forward_port: 8080, forward_scheme: 'http' },
],
conflicts: [],
errors: ['Line 10: Invalid directive "invalid_directive"', 'Line 15: Unsupported matcher syntax'],
},
};
/**
* Helper to set up all import API mocks with the specified preview response
* Handles the full flow: initial status (no session) → upload → status (with session) → preview
*/
async function setupImportMocks(
page: import('@playwright/test').Page,
preview: ImportPreview,
options?: { uploadError?: boolean; commitError?: boolean }
) {
let hasSession = false;
// Mock status endpoint - initially no session, then has session after upload
await page.route('**/api/v1/import/status', async (route) => {
if (hasSession) {
await route.fulfill({
status: 200,
json: { has_pending: true, session: preview.session },
});
} else {
await route.fulfill({
status: 200,
json: { has_pending: false },
});
}
});
// Mock upload endpoint - sets hasSession to true on success
await page.route('**/api/v1/import/upload', async (route) => {
if (options?.uploadError) {
await route.fulfill({ status: 400, json: { error: 'Invalid Caddyfile syntax' } });
} else {
hasSession = true;
await route.fulfill({ status: 200, json: preview });
}
});
// Mock preview endpoint
await page.route('**/api/v1/import/preview', async (route) => {
await route.fulfill({ status: 200, json: preview });
});
// Mock commit endpoint
await page.route('**/api/v1/import/commit', async (route) => {
if (options?.commitError) {
await route.fulfill({ status: 500, json: { error: 'Commit failed' } });
} else {
await route.fulfill({ status: 200, json: { created: preview.preview.hosts.length, updated: 0, skipped: 0, errors: [] } });
}
});
// Mock cancel endpoint
await page.route('**/api/v1/import/cancel', async (route) => {
hasSession = false;
await route.fulfill({ status: 200, json: {} });
});
// Mock backups endpoint for pre-import backup
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'POST') {
await route.fulfill({
status: 201,
json: { filename: 'pre-import-backup.tar.gz', size: 1000, time: new Date().toISOString() },
});
} else {
await route.continue();
}
});
}
test.describe('Import Caddyfile - Wizard', () => {
// =========================================================================
// Page Layout Tests (2 tests)
// =========================================================================
test.describe('Page Layout', () => {
test('should display import page with correct heading', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
await page.goto('/tasks/import/caddyfile', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
await expect(page.locator(SELECTORS.pageTitle)).toContainText(/import/i);
});
test('should show upload section with wizard steps', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
await page.goto('/tasks/import/caddyfile', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
// Verify upload section is visible
const dropzone = page.locator(SELECTORS.dropzone);
await expect(dropzone).toBeVisible();
// Verify paste textarea is visible
const textarea = page.locator(SELECTORS.pasteTextarea);
await expect(textarea).toBeVisible();
// Verify parse button exists
const parseButton = page.getByRole('button', { name: /parse|review/i });
await expect(parseButton).toBeVisible();
});
});
// =========================================================================
// File Upload Tests (4 tests)
// =========================================================================
test.describe('File Upload', () => {
test('should display file upload dropzone', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
await page.goto('/tasks/import/caddyfile', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
// Verify dropzone/file input is present
const dropzone = page.locator(SELECTORS.dropzone);
await expect(dropzone).toBeVisible();
// Verify it accepts proper file types
const fileInput = page.locator(SELECTORS.uploadInput);
await expect(fileInput).toHaveAttribute('accept', /.caddyfile|.txt|text\/plain/);
});
test('should accept valid Caddyfile via file upload', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
await page.goto('/tasks/import/caddyfile', { waitUntil: 'domcontentloaded' });
// Mock import API
await page.route('**/api/v1/import/upload', async (route) => {
await route.fulfill({ status: 200, json: mockPreviewSuccess });
});
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'POST') {
await route.fulfill({
status: 201,
json: { filename: 'pre-import-backup.tar.gz', size: 1000, time: new Date().toISOString() },
});
} else {
await route.continue();
}
});
await page.goto('/tasks/import/caddyfile', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
// Upload file
const fileInput = page.locator(SELECTORS.uploadInput);
await fileInput.setInputFiles({
name: 'Caddyfile',
mimeType: 'text/plain',
buffer: Buffer.from(mockCaddyfile),
});
// The textarea should now contain the file content
const textarea = page.locator(SELECTORS.pasteTextarea);
await expect(textarea).toHaveValue(/^[\s\S]*example\.com[\s\S]*$/);
});
test('should accept valid Caddyfile via paste', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
// Mock all import API endpoints using the helper
await setupImportMocks(page, mockPreviewSuccess);
await page.goto('/tasks/import/caddyfile', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
// Paste content into textarea
const textarea = page.locator(SELECTORS.pasteTextarea);
await textarea.fill(mockCaddyfile);
// Verify content is in textarea
await expect(textarea).toHaveValue(/^[\s\S]*example\.com[\s\S]*$/);
// Click parse/review button
const parseButton = page.getByRole('button', { name: /parse|review/i });
await parseButton.click();
// Should show review table after API completes
await expect(page.locator(SELECTORS.reviewTable)).toBeVisible({ timeout: 10000 });
});
test('should show error for empty content submission', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
await page.goto('/tasks/import/caddyfile', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
// Ensure textarea is empty
const textarea = page.locator(SELECTORS.pasteTextarea);
await textarea.fill('');
// The parse button should be disabled when content is empty
const parseButton = page.getByRole('button', { name: /parse|review/i });
await expect(parseButton).toBeDisabled();
});
});
// =========================================================================
// Preview Step Tests (4 tests)
// =========================================================================
test.describe('Preview Step', () => {
test('should show parsed hosts from Caddyfile', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
// Use the complete mock helper to avoid missing endpoints
await setupImportMocks(page, mockPreviewSuccess);
await page.goto('/tasks/import/caddyfile', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
// Paste content and submit
await page.locator(SELECTORS.pasteTextarea).fill(mockCaddyfile);
await page.getByRole('button', { name: /parse|review/i }).click();
// Wait for the review table to appear
await expect(page.locator(SELECTORS.reviewTable)).toBeVisible({ timeout: 10000 });
// Verify both hosts are shown
await expect(page.getByText('example.com', { exact: true })).toBeVisible();
await expect(page.getByText('api.example.com', { exact: true })).toBeVisible();
});
test('should show validation errors for invalid Caddyfile syntax', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
// Mock import API with error
await page.route('**/api/v1/import/upload', async (route) => {
await route.fulfill({
status: 400,
json: { error: 'Invalid Caddyfile syntax at line 5' },
});
});
await page.goto('/tasks/import/caddyfile', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
// Paste invalid content
await page.locator(SELECTORS.pasteTextarea).fill('invalid { broken syntax');
await page.getByRole('button', { name: /parse|review/i }).click();
// Should show error message
await expect(page.locator(SELECTORS.errorMessage)).toBeVisible({ timeout: 5000 });
});
test('should display source Caddyfile content in preview', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
// Set up all import mocks
await setupImportMocks(page, mockPreviewSuccess);
await page.goto('/tasks/import/caddyfile', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
// Upload and parse
await page.locator(SELECTORS.pasteTextarea).fill(mockCaddyfile);
await page.getByRole('button', { name: /parse|review/i }).click();
// Wait for review table to appear after API completes
await expect(page.locator(SELECTORS.reviewTable)).toBeVisible({ timeout: 10000 });
// Click to show source content
const sourceToggle = page.locator(SELECTORS.sourceToggle);
if (await sourceToggle.isVisible()) {
await sourceToggle.click();
// Should show the source content
const sourceContent = page.locator('pre');
await expect(sourceContent).toContainText('reverse_proxy');
}
});
test('should show warnings for parsing issues', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
// Set up import mocks with warnings response
await setupImportMocks(page, mockPreviewWithWarnings);
await page.goto('/tasks/import/caddyfile', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
// Paste content with issues
await page.locator(SELECTORS.pasteTextarea).fill('test { invalid }');
await page.getByRole('button', { name: /parse|review/i }).click();
// Should show warning messages - check for error display in review table
await expect(page.locator(SELECTORS.reviewTable)).toBeVisible({ timeout: 5000 });
await expect(page.getByText('Line 10: Invalid directive').first()).toBeVisible();
});
});
// =========================================================================
// Review Step Tests (4 tests)
// =========================================================================
test.describe('Review Step', () => {
test('should display server list with configuration details', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
// Set up all import mocks
await setupImportMocks(page, mockPreviewSuccess);
await page.goto('/tasks/import/caddyfile', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
// Parse content
await page.locator(SELECTORS.pasteTextarea).fill(mockCaddyfile);
await page.getByRole('button', { name: /parse|review/i }).click();
// Verify review table is displayed
const reviewTable = page.locator(SELECTORS.reviewTable);
await expect(reviewTable).toBeVisible({ timeout: 10000 });
// Verify domain names are shown
await expect(page.getByText('example.com', { exact: true })).toBeVisible();
await expect(page.getByText('api.example.com', { exact: true })).toBeVisible();
// Verify "New" status indicators for non-conflicting hosts
await expect(page.locator(SELECTORS.newIndicator)).toHaveCount(2);
});
test('should highlight conflicts with existing hosts', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
// Set up import mocks with conflicts response
await setupImportMocks(page, mockPreviewWithConflicts);
await page.goto('/tasks/import/caddyfile', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
// Parse content
await page.locator(SELECTORS.pasteTextarea).fill('existing.example.com { reverse_proxy new-server:8080 }');
await page.getByRole('button', { name: /parse|review/i }).click();
// Verify conflict indicator is shown
await expect(page.locator(SELECTORS.reviewTable)).toBeVisible({ timeout: 10000 });
await expect(page.getByText('Conflict', { exact: true })).toBeVisible();
});
test('should allow conflict resolution selection', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
// Set up import mocks with conflicts response
await setupImportMocks(page, mockPreviewWithConflicts);
await page.goto('/tasks/import/caddyfile', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
// Parse content
await page.locator(SELECTORS.pasteTextarea).fill('existing.example.com { reverse_proxy new-server:8080 }');
await page.getByRole('button', { name: /parse|review/i }).click();
// Wait for review table to appear after API completes
await expect(page.locator(SELECTORS.reviewTable)).toBeVisible({ timeout: 10000 });
// Verify resolution dropdown exists
const resolutionSelect = page.locator('select').first();
await expect(resolutionSelect).toBeVisible();
// Select "overwrite" option
await resolutionSelect.selectOption('overwrite');
await expect(resolutionSelect).toHaveValue('overwrite');
});
test('should require name for each host before commit', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
// Set up all import mocks
await setupImportMocks(page, mockPreviewSuccess);
await page.goto('/tasks/import/caddyfile', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
// Parse content
await page.locator(SELECTORS.pasteTextarea).fill(mockCaddyfile);
await page.getByRole('button', { name: /parse|review/i }).click();
// Wait for review table
await expect(page.locator(SELECTORS.reviewTable)).toBeVisible({ timeout: 10000 });
// Verify name inputs are present
const nameInputs = page.locator('input[type="text"]');
await expect(nameInputs).toHaveCount(2);
// Clear the first name input
await nameInputs.first().clear();
// Try to commit
await page.locator(SELECTORS.commitButton).click();
// Should show validation error
await expect(page.locator(SELECTORS.errorMessage)).toBeVisible({ timeout: 5000 });
});
});
// =========================================================================
// Import Execution Tests (4 tests)
// =========================================================================
test.describe('Import Execution', () => {
test('should commit import successfully', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
let commitCalled = false;
// Set up all import mocks
await setupImportMocks(page, mockPreviewSuccess);
// Override commit to track if called
await page.route('**/api/v1/import/commit', async (route) => {
commitCalled = true;
await route.fulfill({
status: 200,
json: { created: 2, updated: 0, skipped: 0, errors: [] },
});
});
await page.goto('/tasks/import/caddyfile', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
// Parse content
await page.locator(SELECTORS.pasteTextarea).fill(mockCaddyfile);
await page.getByRole('button', { name: /parse|review/i }).click();
// Wait for review table to appear
await expect(page.locator(SELECTORS.reviewTable)).toBeVisible({ timeout: 10000 });
// Click commit button
await page.locator(SELECTORS.commitButton).click();
// Success modal should appear
await expect(page.locator(SELECTORS.successModal)).toBeVisible({ timeout: 10000 });
// Verify commit was called
expect(commitCalled).toBe(true);
});
test('should show progress during import', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
// Set up all import mocks
await setupImportMocks(page, mockPreviewSuccess);
// Override commit with delay to simulate slow operation
await page.route('**/api/v1/import/commit', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 500));
await route.fulfill({
status: 200,
json: { created: 2, updated: 0, skipped: 0, errors: [] },
});
});
await page.goto('/tasks/import/caddyfile', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
// Parse and go to review
await page.locator(SELECTORS.pasteTextarea).fill(mockCaddyfile);
await page.getByRole('button', { name: /parse|review/i }).click();
// Wait for review table
await expect(page.locator(SELECTORS.reviewTable)).toBeVisible({ timeout: 10000 });
// Click commit and verify button shows loading state
const commitButton = page.locator(SELECTORS.commitButton);
await commitButton.click();
// Button should be disabled or show loading text during commit
await expect(commitButton).toBeDisabled();
// Wait for commit to complete
await waitForAPIResponse(page, '/api/v1/import/commit', { status: 200 });
});
test('should handle import errors gracefully', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
// Set up import mocks with commit error
await setupImportMocks(page, mockPreviewSuccess, { commitError: true });
await page.goto('/tasks/import/caddyfile', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
// Parse and go to review
await page.locator(SELECTORS.pasteTextarea).fill(mockCaddyfile);
await page.getByRole('button', { name: /parse|review/i }).click();
// Wait for review table
await expect(page.locator(SELECTORS.reviewTable)).toBeVisible({ timeout: 10000 });
// Click commit
await page.locator(SELECTORS.commitButton).click();
// Should show error message
await expect(page.locator(SELECTORS.errorMessage)).toBeVisible({ timeout: 5000 });
});
test('should handle partial import with some failures', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
// Set up all import mocks
await setupImportMocks(page, mockPreviewSuccess);
// Override commit to return partial success with errors
await page.route('**/api/v1/import/commit', async (route) => {
await route.fulfill({
status: 200,
json: {
created: 1,
updated: 0,
skipped: 0,
errors: ['Failed to import api.example.com: upstream unreachable'],
},
});
});
await page.goto('/tasks/import/caddyfile', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
// Parse and go to review
await page.locator(SELECTORS.pasteTextarea).fill(mockCaddyfile);
await page.getByRole('button', { name: /parse|review/i }).click();
// Wait for review table to appear
await expect(page.locator(SELECTORS.reviewTable)).toBeVisible({ timeout: 10000 });
// Click commit
await page.locator(SELECTORS.commitButton).click();
// Success modal should appear (partial success is still success)
await expect(page.locator(SELECTORS.successModal)).toBeVisible({ timeout: 10000 });
// The modal should show the partial results
// Check for error indicator in success modal
await expect(page.getByText('1 error encountered')).toBeVisible();
});
});
// =========================================================================
// Session Management Tests (2 additional tests)
// =========================================================================
test.describe('Session Management', () => {
test('should show import banner when session exists', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
// Mock status API to return existing session
await page.route('**/api/v1/import/status', async (route) => {
await route.fulfill({
status: 200,
json: {
has_pending: true,
session: {
id: 'existing-session',
state: 'reviewing',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
},
});
});
await page.route('**/api/v1/import/preview', async (route) => {
await route.fulfill({ status: 200, json: mockPreviewSuccess });
});
await page.goto('/tasks/import/caddyfile', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
// Import banner should be visible
await expect(page.locator(SELECTORS.banner)).toBeVisible();
});
test('should allow canceling import session', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
let cancelCalled = false;
// Set up all import mocks
await setupImportMocks(page, mockPreviewSuccess);
// Override cancel to track if called
await page.route('**/api/v1/import/cancel', async (route) => {
cancelCalled = true;
await route.fulfill({ status: 204 });
});
await page.goto('/tasks/import/caddyfile', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
// Parse and go to review
await page.locator(SELECTORS.pasteTextarea).fill(mockCaddyfile);
await page.getByRole('button', { name: /parse|review/i }).click();
// Wait for review table
await expect(page.locator(SELECTORS.reviewTable)).toBeVisible({ timeout: 10000 });
// Handle browser confirm dialog (the component uses confirm()) - must register BEFORE clicking
page.on('dialog', async (dialog) => {
await dialog.accept();
});
// Click back/cancel button and confirm in dialog
await page.locator(SELECTORS.backButton).click();
// Verify cancel was called or review table is hidden
await expect(page.locator(SELECTORS.reviewTable)).not.toBeVisible({ timeout: 5000 });
});
});
});