diff --git a/frontend/src/api/__tests__/import.test.ts b/frontend/src/api/__tests__/import.test.ts index b8fa3078..60cccf04 100644 --- a/frontend/src/api/__tests__/import.test.ts +++ b/frontend/src/api/__tests__/import.test.ts @@ -78,11 +78,20 @@ describe('import API', () => { mockedDelete.mockResolvedValue({}); await cancelImport(sessionUUID); + + expect(client.delete).toHaveBeenCalledTimes(1); expect(client.delete).toHaveBeenCalledWith('/import/cancel', { params: { session_uuid: sessionUUID, }, }); + + const [, requestConfig] = mockedDelete.mock.calls[0]; + expect(requestConfig).toEqual({ + params: { + session_uuid: sessionUUID, + }, + }); }); it('forwards commitImport errors', async () => { diff --git a/frontend/src/api/__tests__/jsonImport.test.ts b/frontend/src/api/__tests__/jsonImport.test.ts index 355ff09a..ca41af35 100644 --- a/frontend/src/api/__tests__/jsonImport.test.ts +++ b/frontend/src/api/__tests__/jsonImport.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { cancelJSONImport } from '../jsonImport'; +import { uploadJSONExport, commitJSONImport, cancelJSONImport } from '../jsonImport'; import client from '../client'; vi.mock('../client', () => ({ @@ -26,6 +26,67 @@ describe('jsonImport API', () => { }); }); + it('uploadJSONExport posts upload endpoint with content payload', async () => { + const content = '{"proxy_hosts":[]}'; + const mockResponse = { + session: { + id: 'json-session-456', + state: 'reviewing', + source: 'json', + }, + preview: { + hosts: [], + conflicts: [], + errors: [], + }, + conflict_details: {}, + }; + + mockedPost.mockResolvedValue({ data: mockResponse }); + + const result = await uploadJSONExport(content); + + expect(client.post).toHaveBeenCalledWith('/import/json/upload', { content }); + expect(result).toEqual(mockResponse); + }); + + it('commitJSONImport posts commit endpoint with session_uuid, resolutions, and names body', async () => { + const sessionUUID = 'json-session-789'; + const resolutions = { 'json.example.com': 'replace' }; + const names = { 'json.example.com': 'JSON Example' }; + const mockResponse = { + created: 1, + updated: 1, + skipped: 0, + errors: [], + }; + + mockedPost.mockResolvedValue({ data: mockResponse }); + + const result = await commitJSONImport(sessionUUID, resolutions, names); + + expect(client.post).toHaveBeenCalledWith('/import/json/commit', { + session_uuid: sessionUUID, + resolutions, + names, + }); + expect(result).toEqual(mockResponse); + }); + + it('forwards uploadJSONExport errors', async () => { + const error = new Error('upload failed'); + mockedPost.mockRejectedValue(error); + + await expect(uploadJSONExport('{"proxy_hosts":[]}')).rejects.toBe(error); + }); + + it('forwards commitJSONImport errors', async () => { + const error = new Error('commit failed'); + mockedPost.mockRejectedValue(error); + + await expect(commitJSONImport('json-session-123', {}, {})).rejects.toBe(error); + }); + it('forwards cancelJSONImport errors', async () => { const error = new Error('cancel failed'); mockedPost.mockRejectedValue(error); diff --git a/frontend/src/api/__tests__/npmImport.test.ts b/frontend/src/api/__tests__/npmImport.test.ts index 5599d85d..0eebf1f7 100644 --- a/frontend/src/api/__tests__/npmImport.test.ts +++ b/frontend/src/api/__tests__/npmImport.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { cancelNPMImport } from '../npmImport'; +import { uploadNPMExport, commitNPMImport, cancelNPMImport } from '../npmImport'; import client from '../client'; vi.mock('../client', () => ({ @@ -26,6 +26,67 @@ describe('npmImport API', () => { }); }); + it('uploadNPMExport posts upload endpoint with content payload', async () => { + const content = '{"proxy_hosts":[]}'; + const mockResponse = { + session: { + id: 'npm-session-456', + state: 'reviewing', + source: 'npm', + }, + preview: { + hosts: [], + conflicts: [], + errors: [], + }, + conflict_details: {}, + }; + + mockedPost.mockResolvedValue({ data: mockResponse }); + + const result = await uploadNPMExport(content); + + expect(client.post).toHaveBeenCalledWith('/import/npm/upload', { content }); + expect(result).toEqual(mockResponse); + }); + + it('commitNPMImport posts commit endpoint with session_uuid, resolutions, and names body', async () => { + const sessionUUID = 'npm-session-789'; + const resolutions = { 'npm.example.com': 'replace' }; + const names = { 'npm.example.com': 'NPM Example' }; + const mockResponse = { + created: 1, + updated: 1, + skipped: 0, + errors: [], + }; + + mockedPost.mockResolvedValue({ data: mockResponse }); + + const result = await commitNPMImport(sessionUUID, resolutions, names); + + expect(client.post).toHaveBeenCalledWith('/import/npm/commit', { + session_uuid: sessionUUID, + resolutions, + names, + }); + expect(result).toEqual(mockResponse); + }); + + it('forwards uploadNPMExport errors', async () => { + const error = new Error('upload failed'); + mockedPost.mockRejectedValue(error); + + await expect(uploadNPMExport('{"proxy_hosts":[]}')).rejects.toBe(error); + }); + + it('forwards commitNPMImport errors', async () => { + const error = new Error('commit failed'); + mockedPost.mockRejectedValue(error); + + await expect(commitNPMImport('npm-session-123', {}, {})).rejects.toBe(error); + }); + it('forwards cancelNPMImport errors', async () => { const error = new Error('cancel failed'); mockedPost.mockRejectedValue(error); diff --git a/frontend/src/hooks/__tests__/useJSONImport.test.tsx b/frontend/src/hooks/__tests__/useJSONImport.test.tsx index 9e04f484..445939a2 100644 --- a/frontend/src/hooks/__tests__/useJSONImport.test.tsx +++ b/frontend/src/hooks/__tests__/useJSONImport.test.tsx @@ -29,6 +29,86 @@ describe('useJSONImport', () => { vi.clearAllMocks() }) + it('sets preview and sessionId after successful upload', async () => { + const uploadResponse = { + session: { + id: 'json-session-upload', + state: 'reviewing', + source: 'json', + }, + preview: { + hosts: [], + conflicts: [], + errors: [], + }, + conflict_details: {}, + } + + vi.mocked(api.uploadJSONExport).mockResolvedValue(uploadResponse) + + const { result } = renderHook(() => useJSONImport(), { wrapper: createWrapper() }) + + await act(async () => { + await result.current.upload('{"proxy_hosts":[]}') + }) + + await waitFor(() => { + expect(result.current.sessionId).toBe('json-session-upload') + expect(result.current.preview).toEqual(uploadResponse) + }) + }) + + it('commits active session and clears preview/session state', async () => { + const uploadResponse = { + session: { + id: 'json-session-commit', + state: 'reviewing', + source: 'json', + }, + preview: { + hosts: [], + conflicts: [], + errors: [], + }, + conflict_details: {}, + } + const commitResponse = { + created: 1, + updated: 0, + skipped: 0, + errors: [], + } + + vi.mocked(api.uploadJSONExport).mockResolvedValue(uploadResponse) + vi.mocked(api.commitJSONImport).mockResolvedValue(commitResponse) + + const { result } = renderHook(() => useJSONImport(), { wrapper: createWrapper() }) + + await act(async () => { + await result.current.upload('{"proxy_hosts":[]}') + }) + + await waitFor(() => { + expect(result.current.sessionId).toBe('json-session-commit') + }) + + await act(async () => { + await result.current.commit({ 'json.example.com': 'replace' }, { 'json.example.com': 'JSON Example' }) + }) + + expect(api.commitJSONImport).toHaveBeenCalledWith( + 'json-session-commit', + { 'json.example.com': 'replace' }, + { 'json.example.com': 'JSON Example' } + ) + + await waitFor(() => { + expect(result.current.sessionId).toBeNull() + expect(result.current.preview).toBeNull() + expect(result.current.commitResult).toEqual(commitResponse) + }) + }) + it('passes active session UUID to cancelJSONImport', async () => { const sessionId = 'json-session-123' vi.mocked(api.uploadJSONExport).mockResolvedValue({ @@ -66,4 +146,45 @@ describe('useJSONImport', () => { expect(result.current.sessionId).toBeNull() }) }) + + it('returns No active session and skips cancel API call when session is missing', async () => { + const { result } = renderHook(() => useJSONImport(), { wrapper: createWrapper() }) + + await expect(result.current.cancel()).rejects.toThrow('No active session') + expect(api.cancelJSONImport).not.toHaveBeenCalled() + }) + + it('exposes commit error and preserves session on commit failure', async () => { + const uploadResponse = { + session: { + id: 'json-session-error', + state: 'reviewing', + source: 'json', + }, + preview: { + hosts: [], + conflicts: [], + errors: [], + }, + conflict_details: {}, + } + const commitError = new Error('404 Not Found') + + vi.mocked(api.uploadJSONExport).mockResolvedValue(uploadResponse) + vi.mocked(api.commitJSONImport).mockRejectedValue(commitError) + + const { result } = renderHook(() => useJSONImport(), { wrapper: createWrapper() }) + + await act(async () => { + await result.current.upload('{"proxy_hosts":[]}') + }) + + await expect(result.current.commit({}, {})).rejects.toBe(commitError) + + await waitFor(() => { + expect(result.current.commitError).toBe(commitError) + expect(result.current.sessionId).toBe('json-session-error') + expect(result.current.preview).not.toBeNull() + }) + }) }) diff --git a/frontend/src/hooks/__tests__/useNPMImport.test.tsx b/frontend/src/hooks/__tests__/useNPMImport.test.tsx index ed760259..ff9a330e 100644 --- a/frontend/src/hooks/__tests__/useNPMImport.test.tsx +++ b/frontend/src/hooks/__tests__/useNPMImport.test.tsx @@ -29,6 +29,86 @@ describe('useNPMImport', () => { vi.clearAllMocks() }) + it('sets preview and sessionId after successful upload', async () => { + const uploadResponse = { + session: { + id: 'npm-session-upload', + state: 'reviewing', + source: 'npm', + }, + preview: { + hosts: [], + conflicts: [], + errors: [], + }, + conflict_details: {}, + } + + vi.mocked(api.uploadNPMExport).mockResolvedValue(uploadResponse) + + const { result } = renderHook(() => useNPMImport(), { wrapper: createWrapper() }) + + await act(async () => { + await result.current.upload('{"proxy_hosts":[]}') + }) + + await waitFor(() => { + expect(result.current.sessionId).toBe('npm-session-upload') + expect(result.current.preview).toEqual(uploadResponse) + }) + }) + + it('commits active session and clears preview/session state', async () => { + const uploadResponse = { + session: { + id: 'npm-session-commit', + state: 'reviewing', + source: 'npm', + }, + preview: { + hosts: [], + conflicts: [], + errors: [], + }, + conflict_details: {}, + } + const commitResponse = { + created: 1, + updated: 0, + skipped: 0, + errors: [], + } + + vi.mocked(api.uploadNPMExport).mockResolvedValue(uploadResponse) + vi.mocked(api.commitNPMImport).mockResolvedValue(commitResponse) + + const { result } = renderHook(() => useNPMImport(), { wrapper: createWrapper() }) + + await act(async () => { + await result.current.upload('{"proxy_hosts":[]}') + }) + + await waitFor(() => { + expect(result.current.sessionId).toBe('npm-session-commit') + }) + + await act(async () => { + await result.current.commit({ 'npm.example.com': 'replace' }, { 'npm.example.com': 'NPM Example' }) + }) + + expect(api.commitNPMImport).toHaveBeenCalledWith( + 'npm-session-commit', + { 'npm.example.com': 'replace' }, + { 'npm.example.com': 'NPM Example' } + ) + + await waitFor(() => { + expect(result.current.sessionId).toBeNull() + expect(result.current.preview).toBeNull() + expect(result.current.commitResult).toEqual(commitResponse) + }) + }) + it('passes active session UUID to cancelNPMImport', async () => { const sessionId = 'npm-session-123' vi.mocked(api.uploadNPMExport).mockResolvedValue({ @@ -66,4 +146,45 @@ describe('useNPMImport', () => { expect(result.current.sessionId).toBeNull() }) }) + + it('returns No active session and skips cancel API call when session is missing', async () => { + const { result } = renderHook(() => useNPMImport(), { wrapper: createWrapper() }) + + await expect(result.current.cancel()).rejects.toThrow('No active session') + expect(api.cancelNPMImport).not.toHaveBeenCalled() + }) + + it('exposes commit error and preserves session on commit failure', async () => { + const uploadResponse = { + session: { + id: 'npm-session-error', + state: 'reviewing', + source: 'npm', + }, + preview: { + hosts: [], + conflicts: [], + errors: [], + }, + conflict_details: {}, + } + const commitError = new Error('404 Not Found') + + vi.mocked(api.uploadNPMExport).mockResolvedValue(uploadResponse) + vi.mocked(api.commitNPMImport).mockRejectedValue(commitError) + + const { result } = renderHook(() => useNPMImport(), { wrapper: createWrapper() }) + + await act(async () => { + await result.current.upload('{"proxy_hosts":[]}') + }) + + await expect(result.current.commit({}, {})).rejects.toBe(commitError) + + await waitFor(() => { + expect(result.current.commitError).toBe(commitError) + expect(result.current.sessionId).toBe('npm-session-error') + expect(result.current.preview).not.toBeNull() + }) + }) }) diff --git a/tests/integration/import-save-route-regression.spec.ts b/tests/integration/import-save-route-regression.spec.ts new file mode 100644 index 00000000..67f7c3d1 --- /dev/null +++ b/tests/integration/import-save-route-regression.spec.ts @@ -0,0 +1,299 @@ +import { test, expect, loginUser } from '../fixtures/auth-fixtures'; +import { getStorageStateAuthHeaders } from '../utils/api-helpers'; + +type SessionResponse = { + session?: { + id?: string; + }; +}; + +const SAMPLE_CADDYFILE = `example.com { + reverse_proxy localhost:8080 +}`; + +const SAMPLE_NPM_OR_JSON_EXPORT = JSON.stringify( + { + proxy_hosts: [ + { + domain_names: ['route-regression.example.test'], + forward_host: 'localhost', + forward_port: 8080, + forward_scheme: 'http', + }, + ], + access_lists: [], + certificates: [], + }, + null, + 2 +); + +function expectPredictableRouteMiss(status: number): void { + expect([404, 405]).toContain(status); +} + +function expectCanonicalNon404(status: number): void { + expect(status).not.toBe(404); + expect(status).toBeLessThan(500); +} + +async function readSessionId(response: import('@playwright/test').APIResponse): Promise { + const data = (await response.json()) as SessionResponse; + const sessionId = data?.session?.id; + expect(sessionId).toBeTruthy(); + return sessionId as string; +} + +test.describe('Import/Save Route Regression Coverage', () => { + test('Caddy import flow stages use canonical routes and reject route drift', async ({ page, adminUser }) => { + await loginUser(page, adminUser); + const headers = getStorageStateAuthHeaders(); + + await test.step('Open Caddy import page and validate route-negative probes', async () => { + await page.goto('/tasks/import/caddyfile', { waitUntil: 'domcontentloaded' }); + await expect(page.getByRole('heading', { level: 1 })).toContainText(/import/i); + await expect(page.getByRole('button', { name: /parse|review/i })).toBeVisible(); + + const wrongStatusMethod = await page.request.post('/api/v1/import/status', { + headers, + data: {}, + }); + expectPredictableRouteMiss(wrongStatusMethod.status()); + + const wrongUploadMethod = await page.request.get('/api/v1/import/upload', { headers }); + expectPredictableRouteMiss(wrongUploadMethod.status()); + + const wrongCancelMethod = await page.request.post('/api/v1/import/cancel', { + headers, + data: {}, + }); + expectPredictableRouteMiss(wrongCancelMethod.status()); + }); + + await test.step('Run canonical Caddy import status/upload/cancel path', async () => { + const statusResponse = await page.request.get('/api/v1/import/status', { headers }); + expectCanonicalNon404(statusResponse.status()); + + const uploadForCancel = await page.request.post('/api/v1/import/upload', { + headers, + data: { content: SAMPLE_CADDYFILE }, + }); + expectCanonicalNon404(uploadForCancel.status()); + const cancelSessionId = await readSessionId(uploadForCancel); + + const cancelResponse = await page.request.delete('/api/v1/import/cancel', { + headers, + params: { session_uuid: cancelSessionId }, + }); + expectCanonicalNon404(cancelResponse.status()); + }); + + await test.step('Run canonical Caddy preview/backup-before-commit/commit/post-state path', async () => { + const uploadForCommit = await page.request.post('/api/v1/import/upload', { + headers, + data: { content: SAMPLE_CADDYFILE }, + }); + expectCanonicalNon404(uploadForCommit.status()); + const commitSessionId = await readSessionId(uploadForCommit); + + const previewResponse = await page.request.get('/api/v1/import/preview', { headers }); + expectCanonicalNon404(previewResponse.status()); + + const backupBeforeCommit = await page.request.post('/api/v1/backups', { + headers, + data: {}, + }); + expectCanonicalNon404(backupBeforeCommit.status()); + + const commitResponse = await page.request.post('/api/v1/import/commit', { + headers, + data: { + session_uuid: commitSessionId, + resolutions: {}, + names: {}, + }, + }); + expectCanonicalNon404(commitResponse.status()); + + const postState = await page.request.get('/api/v1/import/status', { headers }); + expectCanonicalNon404(postState.status()); + }); + }); + + test('NPM and JSON import critical routes pass canonical methods and reject drift', async ({ page, adminUser }) => { + await loginUser(page, adminUser); + const headers = getStorageStateAuthHeaders(); + + await test.step('NPM import upload/commit/cancel with route-mismatch checks', async () => { + await page.goto('/tasks/import/npm', { waitUntil: 'domcontentloaded' }); + await expect(page.getByRole('heading').filter({ hasText: /npm/i }).first()).toBeVisible(); + await expect(page.getByRole('button', { name: /upload\s*&\s*preview/i })).toBeVisible(); + + const npmWrongMethod = await page.request.get('/api/v1/import/npm/upload', { headers }); + expectPredictableRouteMiss(npmWrongMethod.status()); + + const npmCancelWrongPath = await page.request.post('/api/v1/import/npm/cancel-session', { + headers, + data: {}, + }); + expectPredictableRouteMiss(npmCancelWrongPath.status()); + + const npmUploadForCancel = await page.request.post('/api/v1/import/npm/upload', { + headers, + data: { content: SAMPLE_NPM_OR_JSON_EXPORT }, + }); + expectCanonicalNon404(npmUploadForCancel.status()); + const npmCancelSession = await readSessionId(npmUploadForCancel); + + const npmCancel = await page.request.post('/api/v1/import/npm/cancel', { + headers, + data: { session_uuid: npmCancelSession }, + }); + expectCanonicalNon404(npmCancel.status()); + + const npmUploadForCommit = await page.request.post('/api/v1/import/npm/upload', { + headers, + data: { content: SAMPLE_NPM_OR_JSON_EXPORT }, + }); + expectCanonicalNon404(npmUploadForCommit.status()); + const npmCommitSession = await readSessionId(npmUploadForCommit); + + const npmCommit = await page.request.post('/api/v1/import/npm/commit', { + headers, + data: { + session_uuid: npmCommitSession, + resolutions: {}, + names: {}, + }, + }); + expectCanonicalNon404(npmCommit.status()); + }); + + await test.step('JSON import upload/commit/cancel with route-mismatch checks', async () => { + await page.goto('/tasks/import/json', { waitUntil: 'domcontentloaded' }); + await expect(page.getByRole('heading').filter({ hasText: /json/i }).first()).toBeVisible(); + await expect(page.getByRole('button', { name: /upload\s*&\s*preview/i })).toBeVisible(); + + const jsonWrongMethod = await page.request.get('/api/v1/import/json/upload', { headers }); + expectPredictableRouteMiss(jsonWrongMethod.status()); + + const jsonCommitWrongPath = await page.request.post('/api/v1/import/json/commit-now', { + headers, + data: {}, + }); + expectPredictableRouteMiss(jsonCommitWrongPath.status()); + + const jsonUploadForCancel = await page.request.post('/api/v1/import/json/upload', { + headers, + data: { content: SAMPLE_NPM_OR_JSON_EXPORT }, + }); + expectCanonicalNon404(jsonUploadForCancel.status()); + const jsonCancelSession = await readSessionId(jsonUploadForCancel); + + const jsonCancel = await page.request.post('/api/v1/import/json/cancel', { + headers, + data: { session_uuid: jsonCancelSession }, + }); + expectCanonicalNon404(jsonCancel.status()); + + const jsonUploadForCommit = await page.request.post('/api/v1/import/json/upload', { + headers, + data: { content: SAMPLE_NPM_OR_JSON_EXPORT }, + }); + expectCanonicalNon404(jsonUploadForCommit.status()); + const jsonCommitSession = await readSessionId(jsonUploadForCommit); + + const jsonCommit = await page.request.post('/api/v1/import/json/commit', { + headers, + data: { + session_uuid: jsonCommitSession, + resolutions: {}, + names: {}, + }, + }); + expectCanonicalNon404(jsonCommit.status()); + }); + }); + + test('Save flow routes for settings and proxy-host paths detect 404 regressions', async ({ page, adminUser }) => { + await loginUser(page, adminUser); + const headers = getStorageStateAuthHeaders(); + let createdProxyUUID = ''; + + await test.step('System settings save path succeeds on canonical route', async () => { + await page.goto('/settings/system', { waitUntil: 'domcontentloaded' }); + await expect(page.getByRole('heading', { name: /system settings/i })).toBeVisible(); + + const saveButton = page.getByRole('button', { name: /save settings/i }).first(); + await expect(saveButton).toBeEnabled(); + + const saveResponsePromise = page.waitForResponse( + (response) => + response.url().includes('/api/v1/settings') && + ['POST', 'PATCH'].includes(response.request().method()) + ); + + await saveButton.click(); + const saveResponse = await saveResponsePromise; + expectCanonicalNon404(saveResponse.status()); + + const wrongSettingsMethod = await page.request.delete('/api/v1/settings', { headers }); + expectPredictableRouteMiss(wrongSettingsMethod.status()); + }); + + await test.step('Proxy-host save path succeeds on canonical route and rejects wrong method/path', async () => { + const unique = `${Date.now()}-${Math.floor(Math.random() * 1000)}`; + const createResponse = await page.request.post('/api/v1/proxy-hosts', { + headers, + data: { + name: `PR3 Route Regression ${unique}`, + domain_names: `pr3-route-${unique}.example.test`, + forward_host: 'localhost', + forward_port: 8080, + forward_scheme: 'http', + websocket_support: false, + enabled: true, + }, + }); + expectCanonicalNon404(createResponse.status()); + expect([200, 201]).toContain(createResponse.status()); + + const created = (await createResponse.json()) as { uuid?: string }; + createdProxyUUID = created.uuid || ''; + expect(createdProxyUUID).toBeTruthy(); + + const updateResponse = await page.request.put(`/api/v1/proxy-hosts/${createdProxyUUID}`, { + headers, + data: { + name: `PR3 Route Regression Updated ${unique}`, + domain_names: `pr3-route-${unique}.example.test`, + forward_host: 'localhost', + forward_port: 8081, + forward_scheme: 'http', + websocket_support: false, + enabled: true, + }, + }); + expectCanonicalNon404(updateResponse.status()); + + const wrongProxyMethod = await page.request.post(`/api/v1/proxy-hosts/${createdProxyUUID}`, { + headers, + data: {}, + }); + expectPredictableRouteMiss(wrongProxyMethod.status()); + + const wrongProxyPath = await page.request.put('/api/v1/proxy-host', { + headers, + data: {}, + }); + expectPredictableRouteMiss(wrongProxyPath.status()); + }); + + if (createdProxyUUID) { + await test.step('Cleanup created proxy host', async () => { + const cleanup = await page.request.delete(`/api/v1/proxy-hosts/${createdProxyUUID}`, { headers }); + expectCanonicalNon404(cleanup.status()); + }); + } + }); +});