Files
Charon/tests/core/caddy-import/caddy-import-cross-browser.spec.ts
2026-03-04 18:34:49 +00:00

517 lines
17 KiB
TypeScript

/**
* Caddy Import - Cross-Browser E2E Tests
*
* Runs core Caddyfile import scenarios against Chromium, Firefox, and WebKit
* to prevent browser-specific regressions (GitHub Issue #567).
*
* EXECUTION:
* npx playwright test tests/tasks/caddy-import-cross-browser.spec.ts --project=chromium --project=firefox --project=webkit
*
* SCOPE:
* - Tests UI/UX on management interface (port 8080)
* - API request/response validation
* - Session state management
* - Conflict resolution flow
*
* NOTE: Does NOT test middleware enforcement (WAF, ACL, Rate Limiting).
* Those are verified in backend/integration/ tests.
*/
import { test, expect, type TestUser } from '../../fixtures/auth-fixtures';
import { Page } from '@playwright/test';
import { ensureImportUiPreconditions, resetImportSession } from './import-page-helpers';
/**
* Mock Caddyfile content for testing
*/
const VALID_CADDYFILE = `
example.com {
reverse_proxy localhost:3000
}
api.example.com {
reverse_proxy localhost:8080
}
`;
const INVALID_CADDYFILE = `
invalid syntax {
missing_directive
`;
const SINGLE_HOST_CADDYFILE = `
test.example.com {
reverse_proxy 192.168.1.100:8080
}
`;
/**
* Helper to set up all import API mocks with the specified behavior
*/
async function setupImportMocks(
page: Page,
options: {
uploadSuccess?: boolean;
previewHosts?: any[];
conflicts?: string[];
commitSuccess?: boolean;
} = {}
) {
const {
uploadSuccess = true,
previewHosts = [
{ 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 = [],
commitSuccess = true,
} = options;
let hasSession = false;
// Mock status endpoint
await page.route('**/api/v1/import/status', async (route) => {
if (hasSession) {
await route.fulfill({
status: 200,
json: {
has_pending: true,
session: {
id: 'test-session-cross-browser',
state: 'reviewing',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
},
});
} else {
await route.fulfill({
status: 200,
json: { has_pending: false },
});
}
});
// Mock upload endpoint
await page.route('**/api/v1/import/upload', async (route) => {
if (uploadSuccess) {
hasSession = true;
await route.fulfill({
status: 200,
json: {
session: {
id: 'test-session-cross-browser',
state: 'transient',
source_file: '/imports/uploads/test-session-cross-browser.caddyfile',
},
preview: {
hosts: previewHosts,
conflicts: conflicts,
warnings: [],
},
caddyfile_content: VALID_CADDYFILE,
conflict_details: {},
},
});
} else {
await route.fulfill({
status: 400,
json: { error: 'Invalid Caddyfile syntax at line 2: unexpected token' },
});
}
});
// Mock preview endpoint
await page.route('**/api/v1/import/preview', async (route) => {
await route.fulfill({
status: 200,
json: {
session: {
id: 'test-session-cross-browser',
state: 'reviewing',
},
preview: {
hosts: previewHosts,
conflicts: conflicts,
warnings: [],
},
caddyfile_content: VALID_CADDYFILE,
},
});
});
// Mock commit endpoint
await page.route('**/api/v1/import/commit', async (route) => {
if (commitSuccess) {
await route.fulfill({
status: 200,
json: {
created: previewHosts.length,
updated: 0,
skipped: 0,
errors: [],
},
});
} else {
await route.fulfill({
status: 500,
json: { error: 'Failed to commit import' },
});
}
});
// Mock cancel endpoint — pattern ends with * to match DELETE ?session_uuid=... query param
await page.route('**/api/v1/import/cancel*', async (route) => {
hasSession = false;
await route.fulfill({ status: 204 });
});
// Mock backups endpoint (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();
}
});
}
async function gotoImportPageWithAuthRecovery(page: Page, adminUser: TestUser): Promise<void> {
await expect(async () => {
await ensureImportUiPreconditions(page, adminUser);
}).toPass({ timeout: 15000 });
}
test.describe('Caddy Import - Cross-Browser @cross-browser', () => {
test.beforeEach(async ({ page, adminUser }) => {
await resetImportSession(page);
await ensureImportUiPreconditions(page, adminUser);
});
test.afterEach(async ({ page }) => {
await resetImportSession(page);
});
/**
* TEST 1: Parse valid Caddyfile across all browsers
* Verifies basic import flow works identically in Chromium, Firefox, and WebKit
*/
test('should parse valid Caddyfile in all browsers', async ({ page, browserName, adminUser }) => {
await setupImportMocks(page);
await test.step(`[${browserName}] Navigate to import page`, async () => {
await gotoImportPageWithAuthRecovery(page, adminUser);
await expect(page.locator('h1')).toContainText(/import/i);
});
await test.step(`[${browserName}] Paste Caddyfile content`, async () => {
const textarea = page.locator('textarea');
await textarea.fill(VALID_CADDYFILE);
await expect(textarea).toHaveValue(/^[\s\S]*example\.com[\s\S]*$/);
});
let requestSent = false;
await test.step(`[${browserName}] Monitor API request and click Parse`, async () => {
// Register listener BEFORE clicking (critical for Firefox)
const uploadPromise = page.waitForResponse(
(r) => r.url().includes('/api/v1/import/upload'),
{ timeout: 10000 }
);
const parseButton = page.getByRole('button', { name: /parse|review/i });
await expect(parseButton).toBeVisible();
await expect(parseButton).toBeEnabled();
await parseButton.click();
const response = await uploadPromise;
requestSent = response.ok();
expect(requestSent).toBeTruthy();
const body = await response.json();
expect(body.session).toBeDefined();
expect(body.preview.hosts).toHaveLength(2);
});
await test.step(`[${browserName}] Verify review table appears`, async () => {
expect(requestSent).toBeTruthy();
const reviewTable = page.locator('[data-testid="import-review-table"]');
await expect(reviewTable).toBeVisible({ timeout: 10000 });
// Verify both hosts are displayed
await expect(page.getByText('example.com', { exact: true })).toBeVisible();
await expect(page.getByText('api.example.com', { exact: true })).toBeVisible();
});
});
/**
* TEST 2: Handle syntax errors across all browsers
* Verifies error handling works consistently
*/
test('should show error for invalid Caddyfile syntax in all browsers', async ({ page, browserName, adminUser }) => {
await setupImportMocks(page, { uploadSuccess: false });
await test.step(`[${browserName}] Navigate to import page`, async () => {
await gotoImportPageWithAuthRecovery(page, adminUser);
});
await test.step(`[${browserName}] Paste invalid content and parse`, async () => {
const textarea = page.locator('textarea');
await textarea.fill(INVALID_CADDYFILE);
const parseButton = page.getByRole('button', { name: /parse|review/i });
await parseButton.click();
});
await test.step(`[${browserName}] Verify error message displayed`, async () => {
// Look for error indicators (toast, banner, or inline error)
const errorLocator = page.locator('.bg-red-900, .bg-red-900\\/20, [role="alert"]').filter({
hasText: /invalid|syntax|error/i,
});
await expect(errorLocator.first()).toBeVisible({ timeout: 5000 });
});
});
/**
* TEST 3: Multi-file import flow across all browsers
* Tests the multi-file import modal and API interaction
*/
test('should handle multi-file import in all browsers', async ({ page, browserName, adminUser }) => {
await test.step(`[${browserName}] Navigate to import page`, async () => {
await gotoImportPageWithAuthRecovery(page, adminUser);
});
await test.step(`[${browserName}] Set up multi-file API mocks`, async () => {
// Mock multi-file upload endpoint
await page.route('**/api/v1/import/upload-multi', async (route) => {
await route.fulfill({
status: 200,
json: {
session: {
id: 'multi-file-session',
state: 'transient',
},
preview: {
hosts: [
{ domain_names: 'site1.com', forward_host: 'backend1', forward_port: 3000, forward_scheme: 'http' },
{ domain_names: 'site2.com', forward_host: 'backend2', forward_port: 3001, forward_scheme: 'http' },
],
conflicts: [],
warnings: [],
},
},
});
});
});
await test.step(`[${browserName}] Open multi-file modal`, async () => {
// Look for multi-file import button/link
const multiFileButton = page.getByRole('button', { name: /multi.*file|import.*sites/i });
if (await multiFileButton.isVisible()) {
await multiFileButton.click();
// Modal should appear
const modal = page.locator('[role="dialog"]').or(page.locator('.modal'));
await expect(modal).toBeVisible({ timeout: 5000 });
} else {
// Multi-file import button not found - feature may not be available
}
});
});
/**
* TEST 4: Conflict resolution flow across all browsers
* Creates a host, then imports a conflicting host to verify conflict handling
*/
test('should handle conflict resolution in all browsers', async ({ page, browserName, adminUser }) => {
await setupImportMocks(page, {
previewHosts: [
{ domain_names: 'existing.example.com', forward_host: 'new-server', forward_port: 8080, forward_scheme: 'https' },
],
conflicts: ['existing.example.com'],
});
// Mock conflict details (overrides the preview route from setupImportMocks)
await page.route('**/api/v1/import/preview', async (route) => {
await route.fulfill({
status: 200,
json: {
session: { id: 'conflict-session', state: 'reviewing' },
preview: {
hosts: [
{ domain_names: 'existing.example.com', forward_host: 'new-server', forward_port: 8080, forward_scheme: 'https' },
],
conflicts: ['existing.example.com'],
warnings: [],
},
conflict_details: {
'existing.example.com': {
existing: {
forward_scheme: 'http',
forward_host: 'old-server',
forward_port: 80,
},
imported: {
forward_scheme: 'https',
forward_host: 'new-server',
forward_port: 8080,
},
},
},
},
});
});
await test.step(`[${browserName}] Navigate to import page`, async () => {
await gotoImportPageWithAuthRecovery(page, adminUser);
});
await test.step(`[${browserName}] Parse conflicting Caddyfile`, async () => {
const textarea = page.locator('textarea');
await textarea.fill('existing.example.com { reverse_proxy new-server:8080 }');
const parseButton = page.getByRole('button', { name: /parse|review/i });
await parseButton.click();
});
await test.step(`[${browserName}] Verify conflict indicator and resolution options`, async () => {
const reviewTable = page.locator('[data-testid="import-review-table"]');
await expect(reviewTable).toBeVisible({ timeout: 10000 });
// Look for conflict indicator
const conflictIndicator = page.getByText('Conflict', { exact: true }).or(page.locator('.text-yellow-400'));
await expect(conflictIndicator).toBeVisible();
// Check for resolution dropdown/select
const resolutionSelect = page.locator('select').first();
if (await resolutionSelect.isVisible()) {
await expect(resolutionSelect).toBeVisible();
// Verify options exist
const options = await resolutionSelect.locator('option').count();
expect(options).toBeGreaterThan(1); // Should have multiple resolution strategies
}
});
});
/**
* TEST 5: Session resume across all browsers
* Verifies that starting an import, navigating away, and returning shows the session
*/
test('should resume import session in all browsers', async ({ page, browserName, adminUser }) => {
await setupImportMocks(page, {
previewHosts: [
{ domain_names: 'test.example.com', forward_host: 'localhost', forward_port: 3000, forward_scheme: 'http' },
],
});
await test.step(`[${browserName}] Navigate to import page`, async () => {
await gotoImportPageWithAuthRecovery(page, adminUser);
});
await test.step(`[${browserName}] Start import session`, async () => {
const textarea = page.locator('textarea');
await textarea.fill(SINGLE_HOST_CADDYFILE);
const parseButton = page.getByRole('button', { name: /parse|review/i });
await parseButton.click();
// Wait for review table
await expect(page.locator('[data-testid="import-review-table"]')).toBeVisible({ timeout: 10000 });
});
await test.step(`[${browserName}] Navigate away`, async () => {
await page.goto('/proxy-hosts');
await expect(page.locator('h1')).toContainText(/proxy.*hosts?/i, { timeout: 5000 });
});
await test.step(`[${browserName}] Return to import page and verify session banner`, async () => {
// Mock status to return existing session
await page.route('**/api/v1/import/status', async (route) => {
await route.fulfill({
status: 200,
json: {
has_pending: true,
session: {
id: 'test-session-cross-browser',
state: 'reviewing',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
},
});
});
await page.goto('/tasks/import/caddyfile', { waitUntil: 'domcontentloaded' });
// Should show banner or button to resume
const banner = page.locator('[data-testid="import-banner"]').or(page.getByText(/pending|resume|continue/i));
await expect(banner.first()).toBeVisible({ timeout: 5000 });
});
});
/**
* TEST 6: Cancel import session across all browsers
* Verifies session cancellation clears state correctly
*/
test('should cancel import session in all browsers', async ({ page, browserName, adminUser }) => {
await setupImportMocks(page, {
previewHosts: [
{ domain_names: 'test.example.com', forward_host: 'localhost', forward_port: 3000, forward_scheme: 'http' },
],
});
await test.step(`[${browserName}] Navigate to import page`, async () => {
await gotoImportPageWithAuthRecovery(page, adminUser);
});
await test.step(`[${browserName}] Start import session`, async () => {
const textarea = page.locator('textarea');
await textarea.fill(SINGLE_HOST_CADDYFILE);
const parseButton = page.getByRole('button', { name: /parse|review/i });
await parseButton.click();
// Wait for review table
await expect(page.locator('[data-testid="import-review-table"]')).toBeVisible({ timeout: 10000 });
});
let cancelRequested = false;
await test.step(`[${browserName}] Cancel import`, async () => {
// Handle browser confirm dialog
page.on('dialog', async (dialog) => {
await dialog.accept();
});
// Monitor cancel API call
page.on('request', (req) => {
if (req.url().includes('/api/v1/import/cancel')) {
cancelRequested = true;
}
});
// Click back/cancel button (use first match to avoid strict mode violation)
const backButton = page.getByRole('button', { name: /back|cancel/i }).first();
if (await backButton.isVisible()) {
await backButton.click();
}
});
await test.step(`[${browserName}] Verify session cleared`, async () => {
// Review table should disappear
const reviewTable = page.locator('[data-testid="import-review-table"]');
await expect(reviewTable).not.toBeVisible({ timeout: 5000 });
// Upload section should be visible again
const textarea = page.locator('textarea');
await expect(textarea).toBeVisible();
});
});
});