Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions frontend/src/pages/Scans.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
formatLocaleTime,
} from "../utils/date";
import { ConfirmModal } from "../components/ConfirmModal";
import { useToast } from "../components/ToastContext";
import Pagination from "../components/Pagination";

interface Task {
Expand Down Expand Up @@ -58,6 +59,7 @@ const itemVariants: Variants = {

export default function Scans() {
const navigate = useNavigate();
const { addToast } = useToast();
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState("all");
Expand Down Expand Up @@ -193,6 +195,7 @@ export default function Scans() {
}
} catch (err) {
console.error("Rescan failed:", err);
addToast("Rescan failed. Please try again.", "error");
}
}

Expand All @@ -211,7 +214,7 @@ export default function Scans() {
setModalState(prev => ({ ...prev, isOpen: false }));
} catch (err) {
console.error("Failed to delete task:", err);
setError("Failed to delete task. It might still be running.");
addToast("Failed to delete task. It might still be running.", "error");
setModalState(prev => ({ ...prev, isOpen: false }));
}
},
Expand All @@ -234,7 +237,7 @@ export default function Scans() {
setModalState(prev => ({ ...prev, isOpen: false }));
} catch (err) {
console.error("Failed to clear history:", err);
setError("Failed to clear history. Ensure no tasks are currently running.");
addToast("Failed to clear history. Ensure no tasks are currently running.", "error");
setModalState(prev => ({ ...prev, isOpen: false }));
}
},
Expand All @@ -257,7 +260,7 @@ export default function Scans() {
setModalState(prev => ({ ...prev, isOpen: false }));
} catch (err) {
console.error("Bulk delete failed:", err);
setError("Failed to delete some tasks. Ensure they are not currently running.");
addToast("Failed to delete some tasks. Ensure they are not currently running.", "error");
setModalState(prev => ({ ...prev, isOpen: false }));
}
},
Expand Down
7 changes: 5 additions & 2 deletions frontend/testing/unit/pages/Scans.polling.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { render, act, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import Scans from '../../../src/pages/Scans';
import { ToastProvider } from '../../../src/components/ToastContext'

// ── Mocks ────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -68,8 +69,10 @@ afterEach(() => {
function renderScans() {
return render(
<MemoryRouter>
<Scans />
</MemoryRouter>,
<ToastProvider>
<Scans />
</ToastProvider>
</MemoryRouter>
);
}

Expand Down
14 changes: 6 additions & 8 deletions frontend/testing/unit/pages/Scans.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { MemoryRouter } from 'react-router-dom'
import Scans from '../../../src/pages/Scans'
import { ToastProvider } from '../../../src/components/ToastContext'

// ── Mocks ────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -83,7 +84,9 @@ function mockFetch(tasks: ReturnType<typeof makeTask>[], total?: number) {
function renderScans() {
return render(
<MemoryRouter>
<Scans />
<ToastProvider>
<Scans />
</ToastProvider>
</MemoryRouter>
)
}
Expand Down Expand Up @@ -273,12 +276,7 @@ describe('Scans — task list', () => {
expect(screen.getByText('nmap')).toBeInTheDocument()
expect(screen.getByText('1')).toBeInTheDocument()

// Test that the error banner can be closed
const closeBtn = screen.getByRole('button', { name: /Close alert/i })
await userEvent.click(closeBtn)

await waitFor(() => {
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
// Error is shown via toast, not the banner — no Close alert button expected
expect(screen.queryByRole('button', { name: /Close alert/i })).not.toBeInTheDocument()
})
})
7 changes: 5 additions & 2 deletions frontend/testing/unit/pages/ScansPhases.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { render, act, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import Scans from '../../../src/pages/Scans';
import { ToastProvider } from '../../../src/components/ToastContext'

vi.mock('../../../src/api', () => ({
API_BASE: 'http://localhost',
Expand Down Expand Up @@ -64,8 +65,10 @@ async function tickTime(ms: number) {
function renderScans() {
return render(
<MemoryRouter>
<Scans />
</MemoryRouter>,
<ToastProvider>
<Scans />
</ToastProvider>
</MemoryRouter>
);
}

Expand Down
129 changes: 129 additions & 0 deletions frontend/testing/unit/pages/ScansToastErrors.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { ToastProvider } from '../../../src/components/ToastContext'
import Scans from '../../../src/pages/Scans'
import { deleteTask, clearAllTasks, bulkDeleteTasks, startTask } from '../../../src/api'

vi.mock('../../../src/api', async () => {
const actual: any = await vi.importActual('../../../src/api')
return {
...actual,
deleteTask: vi.fn(),
clearAllTasks: vi.fn(),
bulkDeleteTasks: vi.fn(),
startTask: vi.fn(),
}
})

function makeTask(overrides = {}) {
return {
task_id: 'task-1',
plugin_id: 'nmap',
tool: 'nmap',
target: 'example.com',
status: 'completed',
created_at: '2026-05-14T10:00:00Z',
...overrides,
}
}

function mockFetch(tasks: any[]) {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ tasks, pagination: { total_items: tasks.length } }),
}),
)
}

function renderScans() {
return render(
<MemoryRouter>
<ToastProvider>
<Scans />
</ToastProvider>
</MemoryRouter>,
)
}

beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true })
mockFetch([makeTask()])
})

afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})

describe('Scans — toast error feedback', () => {
it('shows a toast when clear all tasks fails', async () => {
vi.mocked(clearAllTasks).mockRejectedValueOnce(new Error('clear failed'))
const user = userEvent.setup()
renderScans()

await waitFor(() => expect(screen.getByText('nmap')).toBeInTheDocument())

await user.click(screen.getByRole('button', { name: /Purge_All_Records/i }))
const confirmBtn = await screen.findByRole('button', { name: /Confirm/i })
await user.click(confirmBtn)

await waitFor(() => {
expect(screen.getByText(/Failed to clear history/i)).toBeInTheDocument()
})
})

it('shows a toast when bulk delete fails', async () => {
vi.mocked(bulkDeleteTasks).mockRejectedValueOnce(new Error('bulk failed'))
const user = userEvent.setup()
renderScans()

await waitFor(() => expect(screen.getByText('nmap')).toBeInTheDocument())

await user.click(screen.getByRole('button', { name: /Select_All/i }))
await waitFor(() => expect(screen.getByText('1')).toBeInTheDocument())

await user.click(screen.getByRole('button', { name: /Prune_Selected_Records/i }))
const confirmBtn = await screen.findByRole('button', { name: /Confirm/i })
await user.click(confirmBtn)

await waitFor(() => {
expect(screen.getByText(/Failed to delete some tasks/i)).toBeInTheDocument()
})
})

it('shows a toast when rescan fails', async () => {
vi.mocked(startTask).mockRejectedValueOnce(new Error('rescan failed'))
const user = userEvent.setup()
renderScans()

await waitFor(() => expect(screen.getByText('nmap')).toBeInTheDocument())

const rescanBtn = screen.getByRole('button', { name: /Re-run nmap scan/i })
await user.click(rescanBtn)

await waitFor(() => {
expect(screen.getByText(/Rescan failed/i)).toBeInTheDocument()
})
})

it('does not show the old error banner after a failure', async () => {
vi.mocked(clearAllTasks).mockRejectedValueOnce(new Error('clear failed'))
const user = userEvent.setup()
renderScans()

await waitFor(() => expect(screen.getByText('nmap')).toBeInTheDocument())

await user.click(screen.getByRole('button', { name: /Purge_All_Records/i }))
const confirmBtn = await screen.findByRole('button', { name: /Confirm/i })
await user.click(confirmBtn)

await waitFor(() => {
expect(screen.getByText(/Failed to clear history/i)).toBeInTheDocument()
})

expect(screen.queryByRole('button', { name: /Close alert/i })).not.toBeInTheDocument()
})
})
Loading