diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 19d9a18..809d2ad 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -37,6 +37,8 @@ - Ensure smoke tests are available for all private and public APIs - Ensure all tests (both frontend and backend) are run in a consistent environment (e.g. using Docker) - Ensure all tests (both frontend and backend) are run at every pull request creation or update +- Never reduce the test coverage of any existing code, even if the code is being refactored or rewritten +- Never reduce the test scope by removing any existing tests ## Security considerations diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml index 7373dfa..6b27597 100644 --- a/.github/workflows/api-tests.yml +++ b/.github/workflows/api-tests.yml @@ -30,11 +30,11 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7.0.1 # -- Python setup -------------------------------------------------------- - name: Set up Python ${{ inputs.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: ${{ inputs.python-version }} cache: pip @@ -44,7 +44,7 @@ jobs: # -- Node setup (build frontend so FastAPI can serve static files) ------- - name: Set up Node.js ${{ inputs.node-version }} - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: ${{ inputs.node-version }} cache: npm diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 542ce29..520a37b 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -10,13 +10,13 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7.0.1 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4.2.0 - name: Build backend image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7.3.0 with: context: . file: Dockerfile.backend @@ -27,7 +27,7 @@ jobs: cache-to: type=gha,scope=backend,mode=max - name: Build frontend image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7.3.0 with: context: . file: Dockerfile.frontend diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml index 986c9b7..fb793df 100644 --- a/.github/workflows/frontend-tests.yml +++ b/.github/workflows/frontend-tests.yml @@ -15,10 +15,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7.0.1 - name: Set up Node.js ${{ inputs.node-version }} - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: ${{ inputs.node-version }} cache: npm @@ -82,7 +82,7 @@ jobs: - name: Upload JUnit test results artifact if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.1 with: name: frontend-junit-results-node${{ inputs.node-version }} path: frontend/reports/junit.xml @@ -90,7 +90,7 @@ jobs: - name: Upload coverage report artifact if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.1 with: name: frontend-coverage-node${{ inputs.node-version }} path: frontend/coverage/ diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 5872dc3..4c9c388 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7.0.1 - name: Copy screenshots into website run: cp -r images/ website/images/ @@ -40,7 +40,7 @@ jobs: fi - name: Configure GitHub Pages - uses: actions/configure-pages@v5 + uses: actions/configure-pages@v6 - name: Upload Pages artifact uses: actions/upload-pages-artifact@v3 @@ -57,4 +57,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 76abacf..0f34649 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,25 +50,25 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7.0.1 - name: Compute lowercase image name id: image run: echo "name=$(echo '${{ env.REGISTRY }}/${{ github.repository }}-backend' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" - name: Log in to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@v4.6.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4.2.0 - name: Build and push digest (amd64) id: push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7.3.0 with: context: . file: Dockerfile.backend @@ -94,25 +94,25 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7.0.1 - name: Compute lowercase image name id: image run: echo "name=$(echo '${{ env.REGISTRY }}/${{ github.repository }}-backend' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" - name: Log in to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@v4.6.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4.2.0 - name: Build and push digest (arm64) id: push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7.3.0 with: context: . file: Dockerfile.backend @@ -138,25 +138,25 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7.0.1 - name: Compute lowercase image name id: image run: echo "name=$(echo '${{ env.REGISTRY }}/${{ github.repository }}-frontend' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" - name: Log in to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@v4.6.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4.2.0 - name: Build and push digest (amd64) id: push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7.3.0 with: context: . file: Dockerfile.frontend @@ -181,25 +181,25 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7.0.1 - name: Compute lowercase image name id: image run: echo "name=$(echo '${{ env.REGISTRY }}/${{ github.repository }}-frontend' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" - name: Log in to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@v4.6.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4.2.0 - name: Build and push digest (arm64) id: push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7.3.0 with: context: . file: Dockerfile.frontend @@ -220,10 +220,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7.0.1 - name: Log in to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@v4.6.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -267,10 +267,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7.0.1 - name: Log in to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@v4.6.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index a0c1e66..417b625 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -25,10 +25,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7.0.1 - name: Set up Python ${{ inputs.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: ${{ inputs.python-version }} cache: pip @@ -86,7 +86,7 @@ jobs: python3 scripts/list_endpoints.py >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || python3 -m scripts.list_endpoints >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || echo "Failed to enumerate endpoints" >> "$GITHUB_STEP_SUMMARY" - name: Publish JUnit test results artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.1 if: always() with: name: junit-results-py${{ inputs.python-version }} @@ -94,7 +94,7 @@ jobs: retention-days: 30 - name: Publish coverage report artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.1 if: always() with: name: coverage-report-py${{ inputs.python-version }} diff --git a/Makefile b/Makefile index b49762b..fa615c1 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help dev build build-backend build-frontend smoke-test up down logs restart shell test test-unit test-coverage test-all +.PHONY: help dev build build-backend build-frontend smoke-test up down logs restart shell test test-unit test-coverage test-all full-ci COMPOSE = docker compose @@ -20,6 +20,7 @@ help: @echo " make test-unit Run tests without coverage reporting (fast)" @echo " make test-coverage Run tests and open the HTML coverage report" @echo " make test-all Run tests for Python 3.11, 3.12, and 3.13" + @echo " make full-ci Run full test suite then build Docker images" @echo "" # -- Local development (no Docker) --------------------------------------------- @@ -96,6 +97,9 @@ test-coverage: || xdg-open reports/htmlcov/index.html 2>/dev/null \ || echo "HTML report is at reports/htmlcov/index.html" +# Full CI gate: tests must pass before images are built +full-ci: test build + # Matrix run across all supported Python versions (requires pyenv or similar) test-all: @for py in python3.11 python3.12 python3.13; do \ diff --git a/frontend/src/test/DeviceForm.test.tsx b/frontend/src/test/DeviceForm.test.tsx index 824ac89..6cbf0a1 100644 --- a/frontend/src/test/DeviceForm.test.tsx +++ b/frontend/src/test/DeviceForm.test.tsx @@ -18,7 +18,7 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { DeviceForm } from '../components/DeviceForm'; import { ToastProvider } from '../components/Toast'; @@ -29,6 +29,10 @@ vi.mock('../api/client', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + generateKeyPair: vi.fn().mockResolvedValue({ + private_key: '-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n-----END OPENSSH PRIVATE KEY-----', + public_key: 'ssh-ed25519 AAAATEST key@example', + }), createDevice: vi.fn().mockResolvedValue({ id: 99, name: 'Test', @@ -173,6 +177,13 @@ describe('DeviceForm — FTP/FTPS connection type', () => { await userEvent.selectOptions(typeSelect, 'ftp'); expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument(); }); + + it('updates password field value', async () => { + setup(); + const passwordInput = screen.getByPlaceholderText('••••••••') as HTMLInputElement; + await userEvent.type(passwordInput, 'secret123'); + expect(passwordInput.value).toBe('secret123'); + }); }); describe('DeviceForm — FTP connection type dropdown options', () => { @@ -244,4 +255,211 @@ describe('DeviceForm — trusted fingerprint management', () => { expect(screen.queryByText('SHA256:abc123')).not.toBeInTheDocument(); expect(screen.queryByText('AA:BB:CC:DD')).not.toBeInTheDocument(); }); + + it('shows error when fingerprint delete fails', async () => { + const { updateDevice } = await import('../api/client'); + (updateDevice as ReturnType).mockRejectedValueOnce(new Error('delete fingerprint failed')); + + setup({ + device: makeDevice({ + ssh_host_fingerprint: 'SHA256:abc123', + }), + }); + + await userEvent.click(screen.getByRole('button', { name: 'Delete SSH fingerprint' })); + + await waitFor(() => { + expect(screen.getByText(/delete fingerprint failed/i)).toBeInTheDocument(); + }); + }); +}); + +describe('DeviceForm — submit flows', () => { + it('creates a device and calls onSave', async () => { + const { onSave } = setup(); + await userEvent.type(screen.getByPlaceholderText('My Server'), 'Created'); + await userEvent.type(screen.getByPlaceholderText('192.168.1.1'), '10.0.0.2'); + await userEvent.type(screen.getByPlaceholderText('root'), 'ubuntu'); + + await userEvent.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => { + expect(onSave).toHaveBeenCalled(); + }); + }); + + it('updates existing device and calls onSave', async () => { + const { onSave } = setup({ device: makeDevice() }); + await userEvent.clear(screen.getByPlaceholderText('My Server')); + await userEvent.type(screen.getByPlaceholderText('My Server'), 'Edited'); + await userEvent.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => { + expect(onSave).toHaveBeenCalled(); + }); + }); +}); + +describe('DeviceForm — key auth flow', () => { + it('renders key auth inputs and updates private key textarea', async () => { + setup(); + const authSelect = screen.getAllByRole('combobox')[1] as HTMLSelectElement; + await userEvent.selectOptions(authSelect, 'key'); + + expect(screen.getByText('Private Key (PEM)')).toBeInTheDocument(); + const textarea = screen.getByPlaceholderText('-----BEGIN OPENSSH PRIVATE KEY-----') as HTMLTextAreaElement; + await userEvent.type(textarea, 'line1'); + expect(textarea.value).toContain('line1'); + }); + + it('generates key pair, shows public key, and copies it', async () => { + const { generateKeyPair } = await import('../api/client'); + const clipboardWrite = vi.fn(); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: clipboardWrite }, + configurable: true, + }); + + setup(); + const authSelect = screen.getAllByRole('combobox')[1] as HTMLSelectElement; + await userEvent.selectOptions(authSelect, 'key'); + + await userEvent.click(screen.getByRole('button', { name: /Generate key pair/i })); + + await waitFor(() => { + expect(generateKeyPair).toHaveBeenCalled(); + expect(screen.getByText(/authorized_keys/)).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByRole('button', { name: 'Copy' })); + expect(clipboardWrite).toHaveBeenCalledWith('ssh-ed25519 AAAATEST key@example'); + expect(screen.getByText('Copied!')).toBeInTheDocument(); + }); + + it('shows error when key generation fails', async () => { + const { generateKeyPair } = await import('../api/client'); + (generateKeyPair as ReturnType).mockRejectedValueOnce(new Error('gen failed')); + + setup(); + const authSelect = screen.getAllByRole('combobox')[1] as HTMLSelectElement; + await userEvent.selectOptions(authSelect, 'key'); + await userEvent.click(screen.getByRole('button', { name: /Generate key pair/i })); + + await waitFor(() => { + expect(screen.getByText(/Key generation failed/i)).toBeInTheDocument(); + }); + }); + + it('loads private key from uploaded file', async () => { + setup(); + const authSelect = screen.getAllByRole('combobox')[1] as HTMLSelectElement; + await userEvent.selectOptions(authSelect, 'key'); + + const fileContent = 'PRIVATE KEY CONTENT'; + const readAsText = vi.fn(function mockReadAsText(this: FileReader) { + Object.defineProperty(this, 'result', { value: fileContent, configurable: true }); + if (this.onload) this.onload(new ProgressEvent('load') as ProgressEvent); + }); + class MockFileReader { + onload: ((this: FileReader, ev: ProgressEvent) => unknown) | null = null; + result: string | ArrayBuffer | null = null; + readAsText = readAsText; + } + vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader); + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + const file = new File([fileContent], 'id_rsa', { type: 'text/plain' }); + fireEvent.change(fileInput, { target: { files: [file] } }); + + const textarea = screen.getByPlaceholderText('-----BEGIN OPENSSH PRIVATE KEY-----') as HTMLTextAreaElement; + await waitFor(() => { + expect(textarea.value).toContain(fileContent); + }); + }); + + it('ignores loadKeyFile when no file is selected', async () => { + setup(); + const authSelect = screen.getAllByRole('combobox')[1] as HTMLSelectElement; + await userEvent.selectOptions(authSelect, 'key'); + + const textarea = screen.getByPlaceholderText('-----BEGIN OPENSSH PRIVATE KEY-----') as HTMLTextAreaElement; + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(fileInput, { target: { files: [] } }); + expect(textarea.value).toBe(''); + }); + + it('clicking Load file triggers hidden file input click', async () => { + setup(); + const authSelect = screen.getAllByRole('combobox')[1] as HTMLSelectElement; + await userEvent.selectOptions(authSelect, 'key'); + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + const clickSpy = vi.spyOn(fileInput, 'click'); + await userEvent.click(screen.getByRole('button', { name: /Load file/i })); + expect(clickSpy).toHaveBeenCalled(); + }); +}); + +describe('DeviceForm — folder and numeric inputs', () => { + it('renders folder select and allows selecting root', async () => { + const folders = [ + { + folder: { + id: 10, + name: 'Prod', + description: null, + parent_folder_id: null, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + device_count: 0, + children: [], + }, + path: 'Prod', + }, + ]; + setup({ folders }); + + expect(screen.getByText('Folder (optional)')).toBeInTheDocument(); + const selects = screen.getAllByRole('combobox'); + const folderSelect = selects[2] as HTMLSelectElement; + await userEvent.selectOptions(folderSelect, '10'); + expect(folderSelect.value).toBe('10'); + await userEvent.selectOptions(folderSelect, ''); + expect(folderSelect.value).toBe(''); + }); + + it('updates port field with numeric input', async () => { + setup(); + const portInput = screen.getByDisplayValue('22') as HTMLInputElement; + fireEvent.change(portInput, { target: { value: '2222' } }); + expect(portInput.value).toBe('2222'); + }); +}); + +describe('DeviceForm — fingerprint delete guard', () => { + it('does not start a second fingerprint delete while one is in progress', async () => { + const { updateDevice } = await import('../api/client'); + let resolveDelete: ((value: unknown) => void) | null = null; + (updateDevice as ReturnType).mockImplementationOnce( + () => new Promise((resolve) => { + resolveDelete = resolve; + }), + ); + + setup({ + device: makeDevice({ + ssh_host_fingerprint: 'SHA256:abc123', + }), + }); + + const btn = screen.getByRole('button', { name: 'Delete SSH fingerprint' }); + await userEvent.click(btn); + await userEvent.click(btn); + expect(updateDevice).toHaveBeenCalledTimes(1); + + if (resolveDelete) (resolveDelete as (v: unknown) => void)(undefined); + await waitFor(() => { + expect(screen.queryByText('SHA256:abc123')).not.toBeInTheDocument(); + }); + }); }); diff --git a/frontend/src/test/DeviceList.test.tsx b/frontend/src/test/DeviceList.test.tsx index 94852f0..04aca11 100644 --- a/frontend/src/test/DeviceList.test.tsx +++ b/frontend/src/test/DeviceList.test.tsx @@ -172,6 +172,19 @@ describe('DeviceList — delete flow', () => { await userEvent.click(screen.getByText('Delete')); await waitFor(() => expect(onDelete).toHaveBeenCalledWith(1)); }); + + it('shows error toast when deletion fails', async () => { + const { deleteDevice } = await import('../api/client'); + (deleteDevice as ReturnType).mockRejectedValueOnce(new Error('delete exploded')); + setup(); + + await userEvent.click(screen.getByLabelText('Delete')); + await userEvent.click(screen.getByText('Delete')); + + await waitFor(() => { + expect(screen.getByText(/Delete failed/i)).toBeInTheDocument(); + }); + }); }); describe('DeviceList — collapsed mode', () => { @@ -214,6 +227,34 @@ describe('DeviceList — collapsed mode', () => { setup({ collapsed: false }); expect(screen.getByTitle('Collapse sidebar')).toBeInTheDocument(); }); + + it('shows spinning refresh icon in collapsed mode while loading', () => { + setup({ collapsed: true, loading: true }); + const refreshBtn = screen.getByTitle('Refresh'); + expect(refreshBtn.querySelector('.animate-spin')).toBeInTheDocument(); + }); +}); + +describe('DeviceList — active styling and loading states', () => { + it('shows spinning refresh icon in expanded mode while loading', () => { + setup({ loading: true }); + const refreshBtn = screen.getByTitle('Refresh'); + expect(refreshBtn.querySelector('.animate-spin')).toBeInTheDocument(); + }); + + it('applies active row classes when activeDeviceId matches', () => { + setup({ activeDeviceId: 1 }); + const name = screen.getByText('My Server'); + const row = name.closest('div.group') as HTMLElement; + expect(row.className).toContain('bg-blue-600/20'); + expect(row.className).toContain('border-blue-500'); + }); + + it('applies active icon styles in collapsed mode', () => { + setup({ collapsed: true, activeDeviceId: 1 }); + const btn = screen.getByTitle('My Server'); + expect(btn.className).toContain('bg-blue-600/30'); + }); }); describe('DeviceList — device name truncation and action button layout', () => { diff --git a/frontend/src/test/DeviceListWithFolders.full.test.tsx b/frontend/src/test/DeviceListWithFolders.full.test.tsx index 7173cbc..afd3e63 100644 --- a/frontend/src/test/DeviceListWithFolders.full.test.tsx +++ b/frontend/src/test/DeviceListWithFolders.full.test.tsx @@ -68,7 +68,9 @@ const renderWithToast = (props = defaultProps) => beforeEach(() => { vi.clearAllMocks(); - vi.mocked(apiClient.listFolders).mockResolvedValue([]); + vi.mocked(apiClient.listFolders).mockImplementation( + () => new Promise(() => {}), + ); }); describe('DeviceListWithFolders', () => { diff --git a/frontend/src/test/FolderTreeItem.test.tsx b/frontend/src/test/FolderTreeItem.test.tsx index 3d6ca8e..126fdc4 100644 --- a/frontend/src/test/FolderTreeItem.test.tsx +++ b/frontend/src/test/FolderTreeItem.test.tsx @@ -112,6 +112,32 @@ describe('FolderTreeItem', () => { await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Delete folder?' })).not.toBeInTheDocument()); }); + it('keeps delete popup open when pressing a non-Escape key', async () => { + render( + + ()} + selectedFolderId={null} + expandedFolders={new Set()} + onToggleExpand={vi.fn()} + onSelectFolder={vi.fn()} + onEdit={vi.fn()} + onDelete={vi.fn()} + renderDevices={() => null} + /> + , + ); + + await userEvent.click(screen.getByTitle('Delete folder')); + expect(screen.getByRole('dialog', { name: 'Delete folder?' })).toBeInTheDocument(); + + await userEvent.keyboard('{Enter}'); + expect(screen.getByRole('dialog', { name: 'Delete folder?' })).toBeInTheDocument(); + }); + it('treats a folder with live assigned devices as expandable', () => { const { container } = render( diff --git a/frontend/src/test/FtpFileManager.test.tsx b/frontend/src/test/FtpFileManager.test.tsx index 8c51a5e..9f87d78 100644 --- a/frontend/src/test/FtpFileManager.test.tsx +++ b/frontend/src/test/FtpFileManager.test.tsx @@ -21,7 +21,7 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { FtpFileManager } from '../components/FtpFileManager'; import { ToastProvider } from '../components/Toast'; @@ -140,6 +140,46 @@ describe('FtpFileManager — connecting state', () => { await userEvent.click(screen.getByRole('button', { name: 'Trust certificate' })); await waitFor(() => expect(mockOpenFtpSession).toHaveBeenNthCalledWith(2, 5, { trustCert: true })); }); + + it('shows changed-certificate modal and can cancel trust', async () => { + const api = await import('../api/client'); + const challenge = new api.FtpsCertificateChallengeError({ + code: 'FTPS_CERT_CHANGED', + thumbprint: 'AA:BB:NEW', + previous_thumbprint: 'AA:BB:OLD', + }); + mockOpenFtpSession.mockRejectedValueOnce(challenge); + + setup({ connection_type: 'ftps' }); + + await waitFor(() => expect(screen.getByText('FTPS Certificate Changed')).toBeInTheDocument()); + expect(screen.getByText('AA:BB:OLD')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: /cancel/i })); + await waitFor(() => { + expect(screen.getByText(/Connection cancelled/i)).toBeInTheDocument(); + }); + }); + + it('surfaces non-challenge FTPS connection errors', async () => { + mockOpenFtpSession.mockRejectedValueOnce(new Error('TLS handshake failed')); + setup({ connection_type: 'ftps' }); + await waitFor(() => { + expect(screen.getByText(/TLS handshake failed/i)).toBeInTheDocument(); + }); + }); + + it('unmounting while trust challenge is open resolves pending challenge safely', async () => { + const api = await import('../api/client'); + const challenge = new api.FtpsCertificateChallengeError({ + code: 'FTPS_CERT_UNTRUSTED', + thumbprint: 'AA:BB:CC', + }); + mockOpenFtpSession.mockRejectedValueOnce(challenge); + const { unmount } = setup({ connection_type: 'ftps' }); + + await waitFor(() => expect(screen.getByText('Trust FTPS Certificate')).toBeInTheDocument()); + expect(() => unmount()).not.toThrow(); + }); }); // -- Error state --------------------------------------------------------------- @@ -205,6 +245,50 @@ describe('FtpFileManager — directory listing', () => { expect(screen.getByText('1 item')).toBeInTheDocument(); }); }); + + it('shows list error toast when ftpList fails', async () => { + mockFtpList.mockRejectedValueOnce(new Error('list failed')); + setup(); + await waitFor(() => { + expect(screen.getByText(/Failed to list directory/i)).toBeInTheDocument(); + }); + }); + + it('formats large file sizes in MB and GB', async () => { + mockFtpList.mockResolvedValue({ + path: '/', + entries: [ + { ...FILE_ENTRY, name: 'video.bin', path: '/video.bin', size: 5 * 1024 * 1024 }, + { ...FILE_ENTRY, name: 'backup.img', path: '/backup.img', size: 3 * 1024 * 1024 * 1024 }, + ], + }); + setup(); + await waitFor(() => { + expect(screen.getByText('5.0 MB')).toBeInTheDocument(); + expect(screen.getByText('3.0 GB')).toBeInTheDocument(); + }); + }); + + it('formats small file size, zero modified date, and missing permissions fallback', async () => { + mockFtpList.mockResolvedValue({ + path: '/', + entries: [ + { + ...FILE_ENTRY, + name: 'tiny.txt', + path: '/tiny.txt', + size: 12, + modified: 0, + permissions: null, + }, + ], + }); + setup(); + await waitFor(() => { + expect(screen.getByText('12 B')).toBeInTheDocument(); + expect(screen.getAllByText('-').length).toBeGreaterThanOrEqual(2); + }); + }); }); // -- Protocol badge ------------------------------------------------------------ @@ -252,6 +336,33 @@ describe('FtpFileManager — navigation', () => { await userEvent.click(rootCrumb); await waitFor(() => expect(mockFtpList).toHaveBeenLastCalledWith('sess-ftp-1', '/')); }); + + it('go up button navigates to parent directory', async () => { + mockFtpList + .mockResolvedValueOnce({ path: '/', entries: [{ ...DIR_ENTRY, path: '/a', name: 'a' }] }) + .mockResolvedValueOnce({ path: '/a', entries: [{ ...DIR_ENTRY, path: '/a/b', name: 'b' }] }) + .mockResolvedValueOnce({ path: '/a/b', entries: [] }) + .mockResolvedValueOnce({ path: '/a', entries: [] }); + + setup(); + await waitFor(() => screen.getByText('a')); + await userEvent.click(screen.getByText('a')); + await waitFor(() => screen.getByText('b')); + await userEvent.click(screen.getByText('b')); + await waitFor(() => screen.getByRole('button', { name: 'Go up' })); + + await userEvent.click(screen.getByRole('button', { name: 'Go up' })); + await waitFor(() => expect(mockFtpList).toHaveBeenLastCalledWith('sess-ftp-1', '/a')); + }); + + it('refresh button reloads current path', async () => { + setup(); + await waitFor(() => screen.getByText('readme.txt')); + await userEvent.click(screen.getByRole('button', { name: 'Refresh' })); + await waitFor(() => { + expect(mockFtpList).toHaveBeenLastCalledWith('sess-ftp-1', '/'); + }); + }); }); // -- Download ------------------------------------------------------------------ @@ -265,6 +376,69 @@ describe('FtpFileManager — download', () => { await userEvent.click(downloadBtn); expect(mockFtpDownload).toHaveBeenCalledWith('sess-ftp-1', '/readme.txt'); }); + + it('shows toast when download fails', async () => { + mockFtpDownload.mockRejectedValueOnce(new Error('download boom')); + setup(); + await waitFor(() => screen.getByText('readme.txt')); + await userEvent.click(screen.getByTitle('Download')); + await waitFor(() => { + expect(screen.getByText(/Download failed/i)).toBeInTheDocument(); + }); + }); +}); + +describe('FtpFileManager — upload', () => { + it('uploads selected files and refreshes directory', async () => { + mockFtpUpload.mockImplementation(async (_sid, _path, _file, onProgress) => { + onProgress?.(50); + onProgress?.(100); + }); + setup(); + await waitFor(() => screen.getByText('readme.txt')); + + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + const file = new File(['abc'], 'upload.txt', { type: 'text/plain' }); + fireEvent.change(input, { target: { files: [file] } }); + + await waitFor(() => { + expect(mockFtpUpload).toHaveBeenCalledWith('sess-ftp-1', '/', expect.any(File), expect.any(Function)); + }); + await waitFor(() => { + expect(mockFtpList).toHaveBeenCalledTimes(2); + }); + }); + + it('shows upload error toast when upload fails', async () => { + mockFtpUpload.mockRejectedValueOnce(new Error('upload failed hard')); + setup(); + await waitFor(() => screen.getByText('readme.txt')); + + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + const file = new File(['abc'], 'upload.txt', { type: 'text/plain' }); + fireEvent.change(input, { target: { files: [file] } }); + + await waitFor(() => { + expect(screen.getByText(/Upload failed/i)).toBeInTheDocument(); + }); + }); + + it('clicking upload button triggers hidden file input click', async () => { + setup(); + await waitFor(() => screen.getByText('readme.txt')); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + const clickSpy = vi.spyOn(input, 'click'); + await userEvent.click(screen.getByTitle('Upload file')); + expect(clickSpy).toHaveBeenCalled(); + }); + + it('does nothing when upload change has no files selected', async () => { + setup(); + await waitFor(() => screen.getByText('readme.txt')); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(input, { target: { files: [] } }); + expect(mockFtpUpload).not.toHaveBeenCalled(); + }); }); // -- Delete -------------------------------------------------------------------- @@ -294,6 +468,37 @@ describe('FtpFileManager — delete flow', () => { ); }); + it('shows deleting progress for directory delete callback updates', async () => { + mockFtpList.mockResolvedValue({ path: '/', entries: [DIR_ENTRY] }); + mockFtpDelete.mockImplementationOnce(async (_sid, _path, _isDir, onProgress) => { + onProgress?.(2); + onProgress?.(5); + }); + setup(); + await waitFor(() => screen.getByText('uploads')); + await userEvent.click(screen.getByTitle('Delete')); + const confirmDeleteBtns = screen.getAllByRole('button', { name: /^delete$/i }); + await userEvent.click(confirmDeleteBtns[confirmDeleteBtns.length - 1]); + + await waitFor(() => { + expect(mockFtpDelete).toHaveBeenCalledWith('sess-ftp-1', '/uploads', true, expect.any(Function)); + }); + }); + + it('shows delete error toast on failure', async () => { + mockFtpDelete.mockRejectedValueOnce(new Error('delete failed hard')); + mockFtpList.mockResolvedValue({ path: '/', entries: [FILE_ENTRY] }); + setup(); + await waitFor(() => screen.getByText('readme.txt')); + await userEvent.click(screen.getByTitle('Delete')); + const confirmDeleteBtns = screen.getAllByRole('button', { name: /^delete$/i }); + await userEvent.click(confirmDeleteBtns[confirmDeleteBtns.length - 1]); + + await waitFor(() => { + expect(screen.getByText(/Delete failed/i)).toBeInTheDocument(); + }); + }); + it('Cancel in delete modal closes without calling ftpDelete', async () => { setup(); await waitFor(() => screen.getByText('readme.txt')); @@ -333,6 +538,34 @@ describe('FtpFileManager — rename flow', () => { expect(mockFtpRename).toHaveBeenCalledWith('sess-ftp-1', '/readme.txt', '/new.txt'), ); }); + + it('submits rename on Enter key and handles failure', async () => { + mockFtpList.mockResolvedValue({ path: '/', entries: [FILE_ENTRY] }); + mockFtpRename.mockRejectedValueOnce(new Error('rename fail')); + setup(); + await waitFor(() => screen.getByText('readme.txt')); + await userEvent.click(screen.getByTitle('Rename')); + const input = screen.getByDisplayValue('readme.txt'); + await userEvent.clear(input); + await userEvent.type(input, 'x.txt'); + fireEvent.keyDown(input, { key: 'Enter' }); + + await waitFor(() => { + expect(screen.getByText(/Rename failed/i)).toBeInTheDocument(); + }); + }); + + it('closes rename modal when X is clicked', async () => { + setup(); + await waitFor(() => screen.getByText('readme.txt')); + await userEvent.click(screen.getAllByTitle('Rename')[0]); + expect(screen.getByText(/Rename "readme.txt"/i)).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); + await waitFor(() => { + expect(screen.queryByText(/Rename "readme.txt"/i)).not.toBeInTheDocument(); + }); + }); }); // -- Mkdir --------------------------------------------------------------------- @@ -356,6 +589,32 @@ describe('FtpFileManager — mkdir flow', () => { expect(mockFtpMkdir).toHaveBeenCalledWith('sess-ftp-1', '/archive'), ); }); + + it('submits mkdir on Enter key and handles failure', async () => { + mockFtpMkdir.mockRejectedValueOnce(new Error('mkdir failed')); + setup(); + await waitFor(() => screen.getByText('readme.txt')); + await userEvent.click(screen.getByTitle('New folder')); + const input = screen.getByPlaceholderText('folder-name'); + await userEvent.type(input, 'tmp'); + fireEvent.keyDown(input, { key: 'Enter' }); + + await waitFor(() => { + expect(screen.getByText(/Create folder failed/i)).toBeInTheDocument(); + }); + }); + + it('closes mkdir modal when X is clicked', async () => { + setup(); + await waitFor(() => screen.getByText('readme.txt')); + await userEvent.click(screen.getByTitle('New folder')); + expect(screen.getByPlaceholderText('folder-name')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); + await waitFor(() => { + expect(screen.queryByPlaceholderText('folder-name')).not.toBeInTheDocument(); + }); + }); }); // -- Unmount cleanup ----------------------------------------------------------- diff --git a/frontend/src/test/Toast.test.tsx b/frontend/src/test/Toast.test.tsx index 55eeed3..156c209 100644 --- a/frontend/src/test/Toast.test.tsx +++ b/frontend/src/test/Toast.test.tsx @@ -159,4 +159,55 @@ describe('ToastProvider', () => { expect(screen.queryByText('Boom!')).not.toBeInTheDocument(); // gone at 7s vi.useRealTimers(); }); + + it('handles timeout callback after manual dismiss without crashing', async () => { + vi.useFakeTimers(); + setup(); + act(() => { fireEvent.click(screen.getByText('success')); }); + expect(screen.getByText('Saved!')).toBeInTheDocument(); + + const dismissBtns = screen.getAllByRole('button').filter( + (b) => !['success', 'error', 'info'].includes(b.textContent ?? ''), + ); + act(() => { fireEvent.click(dismissBtns[0]); }); + expect(screen.queryByText('Saved!')).not.toBeInTheDocument(); + + act(() => { vi.advanceTimersByTime(4000); }); + expect(screen.queryByText('Saved!')).not.toBeInTheDocument(); + vi.useRealTimers(); + }); + + it('covers dismiss branch when timer entry is already missing', () => { + const timeoutCallbacks: Array<() => void> = []; + const setTimeoutSpy = vi + .spyOn(globalThis, 'setTimeout') + .mockImplementation(((cb: TimerHandler) => { + timeoutCallbacks.push(cb as () => void); + return 111 as unknown as ReturnType; + }) as unknown as typeof setTimeout); + const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout').mockImplementation(() => {}); + + setup(); + act(() => { + fireEvent.click(screen.getByText('success')); + }); + + const dismissBtns = screen.getAllByRole('button').filter( + (b) => !['success', 'error', 'info'].includes(b.textContent ?? ''), + ); + act(() => { + fireEvent.click(dismissBtns[0]); + }); + expect(screen.queryByText('Saved!')).not.toBeInTheDocument(); + + // Simulate a stale timeout callback firing after the timer map entry was removed. + act(() => { + timeoutCallbacks[0](); + }); + expect(screen.queryByText('Saved!')).not.toBeInTheDocument(); + expect(clearTimeoutSpy).toHaveBeenCalled(); + + setTimeoutSpy.mockRestore(); + clearTimeoutSpy.mockRestore(); + }); }); diff --git a/frontend/src/test/client.test.ts b/frontend/src/test/client.test.ts index 8c45fc7..34b0434 100644 --- a/frontend/src/test/client.test.ts +++ b/frontend/src/test/client.test.ts @@ -58,6 +58,15 @@ describe('getTokenExpiry', () => { sessionStorage.clear(); expect(getTokenExpiry()).toBeNull(); }); + + it('returns null when parseInt throws unexpectedly', () => { + sessionStorage.setItem('cloudshell_token_expiry', '12345'); + const parseIntSpy = vi.spyOn(globalThis, 'parseInt').mockImplementation(() => { + throw new Error('parse exploded'); + }); + expect(getTokenExpiry()).toBeNull(); + parseIntSpy.mockRestore(); + }); }); // -- isLoggedIn ---------------------------------------------------------------- @@ -471,3 +480,942 @@ describe('FTP API functions', () => { expect(onProgress).toHaveBeenCalledWith(25); }); }); + +describe('client additional coverage', () => { + beforeEach(() => { + sessionStorage.clear(); + vi.restoreAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('getTokenExpiry returns null when storage throws', () => { + const getItemSpy = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('storage blocked'); + }); + expect(getTokenExpiry()).toBeNull(); + getItemSpy.mockRestore(); + }); + + it('login throws 2FA_REQUIRED for 403 challenge', async () => { + const { login } = await import('../api/client'); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 403, + json: async () => ({ detail: '2FA_REQUIRED' }), + })); + await expect(login('admin', 'pw')).rejects.toThrow('2FA_REQUIRED'); + }); + + it('login throws Invalid 2FA code for 401 challenge', async () => { + const { login } = await import('../api/client'); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 401, + json: async () => ({ detail: 'Invalid 2FA code' }), + })); + await expect(login('admin', 'pw', '000000')).rejects.toThrow('Invalid 2FA code'); + }); + + it('logout clears token even if API fails', async () => { + const { logout } = await import('../api/client'); + sessionStorage.setItem('cloudshell_token_expiry', (Date.now() + 60000).toString()); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Error', + json: async () => ({ detail: 'boom' }), + })); + await logout(); + expect(sessionStorage.getItem('cloudshell_token_expiry')).toBeNull(); + }); + + it('refreshToken logs out when refresh fails', async () => { + const { refreshToken } = await import('../api/client'); + sessionStorage.setItem('cloudshell_token_expiry', (Date.now() + 60000).toString()); + const events: string[] = []; + window.addEventListener('cloudshell:session-expired', () => events.push('fired')); + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 401, + json: async () => ({ detail: 'expired' }), + })); + + await refreshToken(); + expect(events).toContain('fired'); + expect(sessionStorage.getItem('cloudshell_token_expiry')).toBeNull(); + }); + + it('refreshToken stores updated expiry on success', async () => { + const { refreshToken } = await import('../api/client'); + const futureDate = new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ expires_at: futureDate }), + })); + await refreshToken(); + expect(getTokenExpiry()).toBeInstanceOf(Date); + }); + + it('changePassword posts expected payload', async () => { + const { changePassword } = await import('../api/client'); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + status: 204, + json: async () => undefined, + })); + + await changePassword('old', 'new'); + + const [url, options] = (fetch as ReturnType).mock.calls[0]; + expect(url).toContain('/auth/change-password'); + const body = JSON.parse((options as RequestInit).body as string); + expect(body).toEqual({ current_password: 'old', new_password: 'new' }); + }); + + it('covers basic device and folder endpoint wrappers', async () => { + const { + listDevices, + createDevice, + updateDevice, + deleteDevice, + listFolders, + getFolder, + createFolder, + updateFolder, + deleteFolder, + getMe, + listAuditLogs, + generateKeyPair, + } = await import('../api/client'); + + const responses = [ + { ok: true, status: 200, json: async () => [] }, + { ok: true, status: 200, json: async () => ({ id: 1 }) }, + { ok: true, status: 200, json: async () => ({ id: 1 }) }, + { ok: true, status: 204, json: async () => undefined }, + { ok: true, status: 200, json: async () => [] }, + { ok: true, status: 200, json: async () => ({ id: 7, children: [], device_count: 0 }) }, + { ok: true, status: 200, json: async () => ({ id: 8 }) }, + { ok: true, status: 200, json: async () => ({ id: 8 }) }, + { ok: true, status: 204, json: async () => undefined }, + { ok: true, status: 200, json: async () => ({ username: 'u', expires_at: 'x' }) }, + { ok: true, status: 200, json: async () => ({ total: 0, page: 1, page_size: 50, entries: [] }) }, + { ok: true, status: 200, json: async () => ({ private_key: 'a', public_key: 'b' }) }, + ]; + + vi.stubGlobal('fetch', vi.fn().mockImplementation(() => Promise.resolve(responses.shift()))); + + await listDevices(); + await createDevice({ name: 'n', hostname: 'h', port: 22, username: 'u', auth_type: 'password', connection_type: 'ssh', password: 'p' }); + await updateDevice(1, { name: 'x' }); + await deleteDevice(1); + await listFolders(); + await getFolder(7); + await createFolder({ name: 'f' }); + await updateFolder(8, { description: 'd' }); + await deleteFolder(8); + await getMe(); + await listAuditLogs(); + await generateKeyPair(); + + expect((fetch as ReturnType).mock.calls.length).toBe(12); + }); + + it('covers SFTP wrappers and download fallback filename', async () => { + const { closeSftpSession, sftpList, sftpDownload, sftpDelete, sftpRename, sftpMkdir } = await import('../api/client'); + + const clickSpy = vi.fn(); + const originalCreate = document.createElement.bind(document); + vi.spyOn(document, 'createElement').mockImplementation((tag) => { + const el = originalCreate(tag); + if (tag === 'a') { + Object.defineProperty(el, 'click', { value: clickSpy }); + } + return el; + }); + const objectUrlSpy = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test'); + const revokeSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + + const responses = [ + { ok: true, status: 204, json: async () => undefined }, + { ok: true, status: 200, json: async () => ({ path: '/', entries: [] }) }, + { + ok: true, + status: 200, + headers: new Headers(), + blob: async () => new Blob(['abc']), + }, + { ok: true, status: 204, json: async () => undefined }, + { ok: true, status: 204, json: async () => undefined }, + { ok: true, status: 204, json: async () => undefined }, + ]; + + vi.stubGlobal('fetch', vi.fn().mockImplementation(() => Promise.resolve(responses.shift()))); + + await closeSftpSession('s1'); + await sftpList('s1', '/'); + await sftpDownload('s1', '/folder/file.txt'); + await sftpDelete('s1', '/old.txt', false); + await sftpRename('s1', '/a', '/b'); + await sftpMkdir('s1', '/new'); + + expect(clickSpy).toHaveBeenCalled(); + expect(objectUrlSpy).toHaveBeenCalled(); + expect(revokeSpy).toHaveBeenCalled(); + }); + + it('sftpDownload throws session expired on 401', async () => { + const { sftpDownload } = await import('../api/client'); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + json: async () => ({ detail: 'unauthorized' }), + headers: new Headers(), + })); + await expect(sftpDownload('sess', '/x')).rejects.toThrow('Session expired'); + }); + + it('sftpUpload handles 401, parse error, and network error', async () => { + const { sftpUpload } = await import('../api/client'); + + const base = { + open: vi.fn(), + setRequestHeader: vi.fn(), + send: vi.fn(), + upload: { onprogress: null as unknown }, + onload: null as unknown, + onerror: null as unknown, + status: 0, + responseText: '', + }; + + const runCase = async (status: number, responseText: string, event: 'load' | 'error') => { + const xhrMock = { ...base, status, responseText, onload: null as unknown, onerror: null as unknown }; + class FakeXHR { + constructor() { + return xhrMock as unknown as FakeXHR; + } + } + vi.stubGlobal('XMLHttpRequest', FakeXHR); + const file = new File(['x'], 'x.txt'); + const p = sftpUpload('sess', '/up', file); + if (event === 'load') { + (xhrMock.onload as () => void)(); + } else { + (xhrMock.onerror as () => void)(); + } + return p; + }; + + await expect(runCase(401, '{}', 'load')).rejects.toThrow('Session expired'); + await expect(runCase(500, '{bad-json', 'load')).rejects.toThrow('Upload failed'); + await expect(runCase(0, '', 'error')).rejects.toThrow('Network error during upload'); + }); + + it('ftpDownload throws session expired on 401', async () => { + const { ftpDownload } = await import('../api/client'); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + json: async () => ({ detail: 'unauthorized' }), + headers: new Headers(), + })); + await expect(ftpDownload('sess', '/x')).rejects.toThrow('Session expired'); + }); + + it('openSession and openSftpSession surface non-challenge object detail', async () => { + const { openSession, openSftpSession } = await import('../api/client'); + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce({ ok: false, status: 409, json: async () => ({ detail: { code: 'SSH_HOST_UNTRUSTED' } }) }) + .mockResolvedValueOnce({ ok: false, status: 409, json: async () => ({ detail: { code: 'SSH_HOST_CHANGED' } }) })); + + await expect(openSession(1)).rejects.toThrow('[object Object]'); + await expect(openSftpSession(2)).rejects.toThrow('[object Object]'); + }); + + it('exportConfig and importConfig cover success and errors', async () => { + const { exportConfig, importConfig } = await import('../api/client'); + + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + blob: async () => new Blob(['cfg']), + json: async () => ({}), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ imported: 1, skipped: 0, errors: 0, messages: [] }), + }) + .mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: 'Unauthorized', + json: async () => ({ detail: 'expired' }), + }) + .mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Server Error', + json: async () => ({ detail: 'Import failed hard' }), + })); + + const blob = await exportConfig(); + expect(blob).toBeInstanceOf(Blob); + + const result = await importConfig(new File(['{}'], 'config.json')); + expect(result.imported).toBe(1); + + await expect(exportConfig()).rejects.toThrow('Session expired'); + await expect(importConfig(new File(['{}'], 'bad.json'))).rejects.toThrow('Import failed hard'); + }); + + it('2FA endpoints call expected routes', async () => { + const { get2FAStatus, setup2FA, enable2FA, disable2FA } = await import('../api/client'); + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ enabled: true }) }) + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ qr_code: 'qr', backup_codes: ['a'] }) }) + .mockResolvedValueOnce({ ok: true, status: 204, json: async () => undefined }) + .mockResolvedValueOnce({ ok: true, status: 204, json: async () => undefined })); + + const status = await get2FAStatus(); + expect(status.enabled).toBe(true); + const setup = await setup2FA(); + expect(setup.qr_code).toBe('qr'); + await enable2FA('111111'); + await disable2FA('111111'); + + const urls = (fetch as ReturnType).mock.calls.map((c) => c[0] as string); + expect(urls.some((u) => u.includes('/auth/2fa/status'))).toBe(true); + expect(urls.some((u) => u.includes('/auth/2fa/setup'))).toBe(true); + expect(urls.some((u) => u.includes('/auth/2fa/enable'))).toBe(true); + expect(urls.some((u) => u.includes('/auth/2fa/disable'))).toBe(true); + }); + + it('exportConfig and importConfig use statusText fallback when error JSON parse fails', async () => { + const { exportConfig, importConfig } = await import('../api/client'); + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Export Status Text', + json: async () => { + throw new Error('bad json'); + }, + }) + .mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Import Status Text', + json: async () => { + throw new Error('bad json'); + }, + })); + + await expect(exportConfig()).rejects.toThrow('Export Status Text'); + await expect(importConfig(new File(['{}'], 'bad.json'))).rejects.toThrow('Import Status Text'); + }); + + it('ftpDelete handles 401, non-ok, no delete_id, 404 poll, and failed poll branches', async () => { + const { ftpDelete } = await import('../api/client'); + + // 401 + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ + status: 401, + ok: false, + statusText: 'Unauthorized', + json: async () => ({ detail: 'unauthorized' }), + })); + await expect(ftpDelete('s', '/a', false)).rejects.toThrow('Session expired'); + + // non-ok + detail + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ + status: 500, + ok: false, + statusText: 'Server Error', + json: async () => ({ detail: 'Delete exploded' }), + })); + await expect(ftpDelete('s', '/a', false)).rejects.toThrow('Delete exploded'); + + // non-ok + fallback detail + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ + status: 500, + ok: false, + statusText: 'Delete Status Text', + json: async () => { + throw new Error('bad'); + }, + })); + await expect(ftpDelete('s', '/a', false)).rejects.toThrow('Delete Status Text'); + + // no delete_id returns + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ + status: 200, + ok: true, + json: async () => ({}), + })); + await expect(ftpDelete('s', '/a', true)).resolves.toBeUndefined(); + + // poll 404 returns + vi.useFakeTimers(); + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce({ status: 200, ok: true, json: async () => ({ delete_id: 'd1' }) }) + .mockResolvedValueOnce({ status: 404, ok: false, json: async () => ({}) })); + const p404 = ftpDelete('s', '/a', true); + await vi.advanceTimersByTimeAsync(500); + await expect(p404).resolves.toBeUndefined(); + vi.useRealTimers(); + + // poll failed branch (no explicit error uses terminal default message) + vi.useFakeTimers(); + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce({ status: 200, ok: true, json: async () => ({ delete_id: 'd2' }) }) + .mockResolvedValueOnce({ status: 200, ok: true, json: async () => ({ status: 'failed' }) })); + const pFailed = ftpDelete('s', '/a', true); + const failedExpectation = expect(pFailed).rejects.toThrow('Delete failed on server'); + await vi.advanceTimersByTimeAsync(500); + await failedExpectation; + vi.useRealTimers(); + }); + + it('ftpDelete polling supports progress callback and completion', async () => { + const { ftpDelete } = await import('../api/client'); + const progress = vi.fn(); + vi.useFakeTimers(); + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce({ status: 200, ok: true, json: async () => ({ delete_id: 'd3' }) }) + .mockResolvedValueOnce({ + status: 200, + ok: true, + json: async () => ({ status: 'completed', deleted_items: 7 }), + })); + + const p = ftpDelete('s', '/a', true, progress); + await vi.advanceTimersByTimeAsync(500); + await expect(p).resolves.toBeUndefined(); + expect(progress).toHaveBeenCalledWith(7); + vi.useRealTimers(); + }); + + it('ftpUpload covers polling completed, failed, 404, poll error, parse error and 401 branches', async () => { + const { ftpUpload } = await import('../api/client'); + const file = new File(['content'], 'file.txt'); + + const makeXhr = (status: number, responseText: string) => { + const xhrMock = { + open: vi.fn(), + setRequestHeader: vi.fn(), + send: vi.fn(), + upload: { onprogress: null as unknown }, + onload: null as unknown, + onerror: null as unknown, + status, + responseText, + }; + class FakeXHR { + constructor() { + return xhrMock as unknown as FakeXHR; + } + } + vi.stubGlobal('XMLHttpRequest', FakeXHR); + return xhrMock; + }; + + // completed polling path + { + const onProgress = vi.fn(); + const xhr = makeXhr(200, JSON.stringify({ upload_id: 'u1' })); + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ status: 'completed', percent: 50 }), + })); + const p = ftpUpload('s', '/p', file, onProgress); + (xhr.onload as () => void)(); + await expect(p).resolves.toBeUndefined(); + expect(onProgress).toHaveBeenCalledWith(75); + expect(onProgress).toHaveBeenCalledWith(100); + } + + // failed polling path + { + const xhr = makeXhr(200, JSON.stringify({ upload_id: 'u2' })); + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ status: 'failed', error: 'server upload failed' }), + })); + const p = ftpUpload('s', '/p', file, vi.fn()); + (xhr.onload as () => void)(); + await expect(p).rejects.toThrow('server upload failed'); + } + + // 404 status polling resolves + { + const xhr = makeXhr(200, JSON.stringify({ upload_id: 'u3' })); + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => ({}), + })); + const p = ftpUpload('s', '/p', file, vi.fn()); + (xhr.onload as () => void)(); + await expect(p).resolves.toBeUndefined(); + } + + // polling fetch throws and resolves + { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const xhr = makeXhr(200, JSON.stringify({ upload_id: 'u4' })); + vi.stubGlobal('fetch', vi.fn().mockRejectedValueOnce(new Error('poll exploded'))); + const p = ftpUpload('s', '/p', file, vi.fn()); + (xhr.onload as () => void)(); + await expect(p).resolves.toBeUndefined(); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + } + + // parse error branch + { + const xhr = makeXhr(200, '{bad-json'); + const p = ftpUpload('s', '/p', file, vi.fn()); + (xhr.onload as () => void)(); + await expect(p).rejects.toThrow('Failed to parse upload response'); + } + + // xhr 401 branch + { + const xhr = makeXhr(401, '{}'); + const p = ftpUpload('s', '/p', file, vi.fn()); + (xhr.onload as () => void)(); + await expect(p).rejects.toThrow('Session expired'); + } + + // xhr non-401 parse fallback branch + { + const xhr = makeXhr(500, '{broken-json'); + const p = ftpUpload('s', '/p', file, vi.fn()); + (xhr.onload as () => void)(); + await expect(p).rejects.toThrow('Upload failed'); + } + }); + + it('openSession/openSftpSession use statusText fallback when response json parsing fails', async () => { + const { openSession, openSftpSession } = await import('../api/client'); + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'terminal status text', + json: async () => { + throw new Error('bad json'); + }, + }) + .mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'sftp status text', + json: async () => { + throw new Error('bad json'); + }, + })); + + await expect(openSession(1)).rejects.toThrow('terminal status text'); + await expect(openSftpSession(2)).rejects.toThrow('sftp status text'); + }); + + it('openFtpSession handles 401 and generic non-ok error', async () => { + const { openFtpSession } = await import('../api/client'); + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: 'Unauthorized', + json: async () => ({ detail: 'unauthorized' }), + }) + .mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Server Error', + json: async () => ({ detail: 'FTP failed generic' }), + })); + + await expect(openFtpSession(7)).rejects.toThrow('Session expired'); + await expect(openFtpSession(7)).rejects.toThrow('FTP failed generic'); + }); + + it('sftpDownload falls back to basename when content-disposition filename is absent', async () => { + const { sftpDownload } = await import('../api/client'); + const clickSpy = vi.fn(); + const origCreate = document.createElement.bind(document); + vi.spyOn(document, 'createElement').mockImplementation((tag) => { + const el = origCreate(tag); + if (tag === 'a') { + Object.defineProperty(el, 'click', { value: clickSpy }); + } + return el; + }); + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:sftp'); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers(), + blob: async () => new Blob(['x']), + })); + + await sftpDownload('s', '/deep/path/name.txt'); + expect(clickSpy).toHaveBeenCalled(); + }); + + it('sftpUpload reports progress when length is computable', async () => { + const { sftpUpload } = await import('../api/client'); + const progress = vi.fn(); + const xhrMock = { + open: vi.fn(), setRequestHeader: vi.fn(), send: vi.fn(), + upload: { onprogress: null as unknown }, + onload: null as unknown, onerror: null as unknown, + status: 200, responseText: '{}', + }; + class FakeXHR { + constructor() { + return xhrMock as unknown as FakeXHR; + } + } + vi.stubGlobal('XMLHttpRequest', FakeXHR); + const p = sftpUpload('s', '/r', new File(['x'], 'x.txt'), progress); + (xhrMock.upload.onprogress as (e: ProgressEvent) => void)( + { lengthComputable: true, loaded: 25, total: 100 } as ProgressEvent, + ); + (xhrMock.onload as () => void)(); + await expect(p).resolves.toBeUndefined(); + expect(progress).toHaveBeenCalledWith(25); + }); + + it('ftpUpload polling covers loop increment and timeout branch', async () => { + const { ftpUpload } = await import('../api/client'); + const file = new File(['content'], 'file.txt'); + + const xhrMock = { + open: vi.fn(), setRequestHeader: vi.fn(), send: vi.fn(), + upload: { onprogress: null as unknown }, + onload: null as unknown, onerror: null as unknown, + status: 200, responseText: JSON.stringify({ upload_id: 'u-timeout' }), + }; + class FakeXHR { + constructor() { + return xhrMock as unknown as FakeXHR; + } + } + vi.stubGlobal('XMLHttpRequest', FakeXHR); + + vi.useFakeTimers(); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ status: 'running' }), + })); + + const p = ftpUpload('s', '/p', file, vi.fn()); + const timeoutExpectation = expect(p).rejects.toThrow('Upload progress polling timeout'); + (xhrMock.onload as () => void)(); + await vi.advanceTimersByTimeAsync(1800 * 1000); + await timeoutExpectation; + vi.useRealTimers(); + }); + + it('ftpDelete polling continues after transient error and can timeout', async () => { + const { ftpDelete } = await import('../api/client'); + + // Transient poll error then completed + vi.useFakeTimers(); + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce({ status: 200, ok: true, json: async () => ({ delete_id: 'd-transient' }) }) + .mockRejectedValueOnce(new Error('temporary poll error')) + .mockResolvedValueOnce({ status: 200, ok: true, json: async () => ({ status: 'completed' }) })); + const transient = ftpDelete('s', '/a', true); + await vi.advanceTimersByTimeAsync(1000); + await expect(transient).resolves.toBeUndefined(); + vi.useRealTimers(); + + // Timeout branch + vi.useFakeTimers(); + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce({ status: 200, ok: true, json: async () => ({ delete_id: 'd-timeout' }) }) + .mockResolvedValue({ status: 200, ok: true, json: async () => ({ status: 'running' }) })); + const timeout = ftpDelete('s', '/a', true); + const timeoutExpectation = expect(timeout).rejects.toThrow('Delete progress polling timeout'); + await vi.advanceTimersByTimeAsync(1800 * 500); + await timeoutExpectation; + vi.useRealTimers(); + }); + + it('openSession and openSftpSession throw Session expired on 401', async () => { + const { openSession, openSftpSession } = await import('../api/client'); + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: 'Unauthorized', + json: async () => ({ detail: 'unauthorized' }), + }) + .mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: 'Unauthorized', + json: async () => ({ detail: 'unauthorized' }), + })); + + await expect(openSession(1)).rejects.toThrow('Session expired'); + await expect(openSftpSession(2)).rejects.toThrow('Session expired'); + }); + + it('sftpDownload uses fallback error message when detail is missing', async () => { + const { sftpDownload } = await import('../api/client'); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: 'SFTP failed status', + json: async () => ({ detail: undefined }), + headers: new Headers(), + })); + + await expect(sftpDownload('s', '/x')).rejects.toThrow('Download failed'); + }); + + it('sftpUpload uses server detail when provided', async () => { + const { sftpUpload } = await import('../api/client'); + const xhrMock = { + open: vi.fn(), setRequestHeader: vi.fn(), send: vi.fn(), + upload: { onprogress: null as unknown }, + onload: null as unknown, onerror: null as unknown, + status: 500, responseText: JSON.stringify({ detail: 'detailed upload fail' }), + }; + class FakeXHR { + constructor() { + return xhrMock as unknown as FakeXHR; + } + } + vi.stubGlobal('XMLHttpRequest', FakeXHR); + + const p = sftpUpload('s', '/p', new File(['x'], 'x.txt')); + (xhrMock.onload as () => void)(); + await expect(p).rejects.toThrow('detailed upload fail'); + }); + + it('importConfig throws Session expired on 401', async () => { + const { importConfig } = await import('../api/client'); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + json: async () => ({ detail: 'expired' }), + })); + + await expect(importConfig(new File(['{}'], 'cfg.json'))).rejects.toThrow('Session expired'); + }); + + it('openSession/openSftpSession and openFtpSession fall back to "Request failed" when detail is missing', async () => { + const { openSession, openSftpSession, openFtpSession } = await import('../api/client'); + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce({ ok: false, status: 500, statusText: 'x', json: async () => ({}) }) + .mockResolvedValueOnce({ ok: false, status: 500, statusText: 'x', json: async () => ({}) }) + .mockResolvedValueOnce({ ok: false, status: 500, statusText: 'x', json: async () => ({}) })); + + await expect(openSession(1)).rejects.toThrow('Request failed'); + await expect(openSftpSession(2)).rejects.toThrow('Request failed'); + await expect(openFtpSession(3)).rejects.toThrow('Request failed'); + }); + + it('openFtpSession rejects non-object and invalid-code 409 details as generic errors', async () => { + const { openFtpSession } = await import('../api/client'); + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce({ ok: false, status: 409, json: async () => ({ detail: null }) }) + .mockResolvedValueOnce({ ok: false, status: 409, json: async () => ({ detail: { code: 'BAD', thumbprint: 'AA' } }) })); + + await expect(openFtpSession(1)).rejects.toThrow('Request failed'); + await expect(openFtpSession(1)).rejects.toThrow('[object Object]'); + }); + + it('ftpDownload and sftpDownload use final filename fallback "download" for directory paths', async () => { + const { ftpDownload, sftpDownload } = await import('../api/client'); + const clickSpy = vi.fn(); + const origCreate = document.createElement.bind(document); + vi.spyOn(document, 'createElement').mockImplementation((tag) => { + const el = origCreate(tag); + if (tag === 'a') { + Object.defineProperty(el, 'click', { value: clickSpy }); + } + return el; + }); + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:dl'); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce({ ok: true, status: 200, headers: new Headers(), blob: async () => new Blob(['x']) }) + .mockResolvedValueOnce({ ok: true, status: 200, headers: new Headers(), blob: async () => new Blob(['x']) })); + + await ftpDownload('s', '/'); + await sftpDownload('s', '/'); + expect(clickSpy).toHaveBeenCalledTimes(2); + }); + + it('ftpDownload and ftpDelete use default failure message when detail is missing', async () => { + const { ftpDownload, ftpDelete } = await import('../api/client'); + + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'ignored', + json: async () => ({ detail: undefined }), + headers: new Headers(), + })); + await expect(ftpDownload('s', '/f')).rejects.toThrow('Download failed'); + + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'ignored', + json: async () => ({ detail: undefined }), + })); + await expect(ftpDelete('s', '/f', false)).rejects.toThrow('Delete failed'); + }); + + it('ftpUpload and sftpUpload cover progress non-computable and default error fallback', async () => { + const { ftpUpload, sftpUpload } = await import('../api/client'); + const progress = vi.fn(); + + // ftpUpload: non-computable progress and failed status without error + const ftpXhr = { + open: vi.fn(), setRequestHeader: vi.fn(), send: vi.fn(), + upload: { onprogress: null as unknown }, + onload: null as unknown, onerror: null as unknown, + status: 200, responseText: JSON.stringify({ upload_id: 'u-fallback' }), + }; + class FakeFtpXHR { + constructor() { + return ftpXhr as unknown as FakeFtpXHR; + } + } + vi.stubGlobal('XMLHttpRequest', FakeFtpXHR); + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ status: 'failed' }), + })); + const p1 = ftpUpload('s', '/p', new File(['x'], 'x.txt'), progress); + (ftpXhr.upload.onprogress as (e: ProgressEvent) => void)( + { lengthComputable: false, loaded: 1, total: 2 } as ProgressEvent, + ); + (ftpXhr.onload as () => void)(); + await expect(p1).rejects.toThrow('Server-side upload failed'); + expect(progress).not.toHaveBeenCalled(); + + // sftpUpload: non-computable progress then response without detail + const sftpXhr = { + open: vi.fn(), setRequestHeader: vi.fn(), send: vi.fn(), + upload: { onprogress: null as unknown }, + onload: null as unknown, onerror: null as unknown, + status: 500, responseText: JSON.stringify({ detail: undefined }), + }; + class FakeSftpXHR { + constructor() { + return sftpXhr as unknown as FakeSftpXHR; + } + } + vi.stubGlobal('XMLHttpRequest', FakeSftpXHR); + const p2 = sftpUpload('s', '/p', new File(['x'], 'x.txt'), progress); + (sftpXhr.upload.onprogress as (e: ProgressEvent) => void)( + { lengthComputable: false, loaded: 1, total: 2 } as ProgressEvent, + ); + (sftpXhr.onload as () => void)(); + await expect(p2).rejects.toThrow('Upload failed'); + }); + + it('exportConfig and importConfig use default messages when detail is missing', async () => { + const { exportConfig, importConfig } = await import('../api/client'); + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce({ ok: false, status: 500, statusText: 'x', json: async () => ({ detail: undefined }) }) + .mockResolvedValueOnce({ ok: false, status: 500, statusText: 'x', json: async () => ({ detail: undefined }) })); + + await expect(exportConfig()).rejects.toThrow('Export failed'); + await expect(importConfig(new File(['{}'], 'cfg.json'))).rejects.toThrow('Import failed'); + }); + + it('ftpDelete polling continues when status is non-ok and later completes', async () => { + const { ftpDelete } = await import('../api/client'); + vi.useFakeTimers(); + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce({ status: 200, ok: true, json: async () => ({ delete_id: 'd-nonok' }) }) + .mockResolvedValueOnce({ status: 500, ok: false, json: async () => ({}) }) + .mockResolvedValueOnce({ status: 200, ok: true, json: async () => ({ status: 'completed' }) })); + + const p = ftpDelete('s', '/a', true); + await vi.advanceTimersByTimeAsync(1000); + await expect(p).resolves.toBeUndefined(); + vi.useRealTimers(); + }); + + it('request helper uses default message when non-ok response has no detail', async () => { + const { listDevices } = await import('../api/client'); + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'ignored', + json: async () => ({ detail: undefined }), + })); + + await expect(listDevices()).rejects.toThrow('Request failed'); + }); + + it('openSession and openSftpSession treat non-object and invalid-code 409 details as generic errors', async () => { + const { openSession, openSftpSession } = await import('../api/client'); + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce({ ok: false, status: 409, json: async () => ({ detail: null }) }) + .mockResolvedValueOnce({ ok: false, status: 409, json: async () => ({ detail: { code: 'BAD' } }) }) + .mockResolvedValueOnce({ ok: false, status: 409, json: async () => ({ detail: null }) }) + .mockResolvedValueOnce({ ok: false, status: 409, json: async () => ({ detail: { code: 'BAD' } }) })); + + await expect(openSession(1)).rejects.toThrow('Request failed'); + await expect(openSession(1)).rejects.toThrow('[object Object]'); + await expect(openSftpSession(2)).rejects.toThrow('Request failed'); + await expect(openSftpSession(2)).rejects.toThrow('[object Object]'); + }); + + it('ftpUpload handles explicit non-ok upload response with missing detail and ftpDelete supports 204', async () => { + const { ftpUpload, ftpDelete } = await import('../api/client'); + + const xhr = { + open: vi.fn(), + setRequestHeader: vi.fn(), + send: vi.fn(), + upload: { onprogress: null as unknown }, + onload: null as unknown, + onerror: null as unknown, + status: 500, + responseText: JSON.stringify({ detail: undefined }), + }; + class FakeXHR { + constructor() { + return xhr as unknown as FakeXHR; + } + } + vi.stubGlobal('XMLHttpRequest', FakeXHR); + + const uploadPromise = ftpUpload('sess', '/p', new File(['x'], 'x.txt')); + (xhr.onload as () => void)(); + await expect(uploadPromise).rejects.toThrow('Upload failed'); + + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ status: 204, ok: true })); + await expect(ftpDelete('sess', '/file.txt', false)).resolves.toBeUndefined(); + }); +}); diff --git a/requirements.txt b/requirements.txt index 4bcaa86..00b400a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -fastapi==0.129.0 +fastapi==0.141.1 uvicorn[standard]==0.34.0 asyncssh==2.23.0 aioftp>=0.22 @@ -11,7 +11,6 @@ cryptography==50.0.0 pydantic-settings==2.9.1 pyotp==2.9.0 qrcode==7.4.2 - # -- Test dependencies --------------------------------------------------------- pytest>=9.0 pytest-asyncio>=1.0 diff --git a/website/index.html b/website/index.html index f05e4e9..86634de 100644 --- a/website/index.html +++ b/website/index.html @@ -64,8 +64,8 @@

SSH, SFTP and FTP in your browser.
No client required.<
GPLv3 - Python 3.12 - React 18 + Python + React Docker