@@ -192,3 +194,101 @@ export default function Sidebar() {
)
}
+
+function OfflineQueueIndicator({ isExpanded }: { isExpanded: boolean }) {
+ const { isOnline, pendingCount, queue, retry, retryAll, remove } = useOfflineQueue()
+ const [showDropdown, setShowDropdown] = useState(false)
+
+ if (isOnline && pendingCount === 0) return null
+
+ const handleRetryAll = (e: React.MouseEvent) => {
+ e.stopPropagation()
+ if (window.confirm(`Replay ${pendingCount} pending action(s)? This may create duplicate tasks or workflows.`)) {
+ retryAll()
+ }
+ }
+
+ const handleRetry = (e: React.MouseEvent, id: string, label?: string) => {
+ e.stopPropagation()
+ if (window.confirm(`Replay "${label || 'action'}"? This may create a duplicate task or workflow.`)) {
+ retry(id)
+ }
+ }
+
+ return (
+
+
+
+ {showDropdown && pendingCount > 0 && (
+
e.stopPropagation()}
+ >
+
+ {queue.map((action) => (
+
+
{action.label || action.url}
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+ )
+}
diff --git a/frontend/src/services/offlineQueue.ts b/frontend/src/services/offlineQueue.ts
new file mode 100644
index 00000000..2ccfd1f1
--- /dev/null
+++ b/frontend/src/services/offlineQueue.ts
@@ -0,0 +1,230 @@
+const STORAGE_KEY = 'offline-queue'
+const MAX_QUEUE_SIZE = 50
+const ACTION_TTL_MS = 86_400_000 // 24 hours
+
+export const SAFE_ACTION_TYPES: ActionType[] = ['createWorkflow', 'updateWorkflow']
+
+export type ActionType = 'createWorkflow' | 'updateWorkflow'
+
+export interface QueuedAction {
+ id: string
+ url: string
+ method: string
+ headers?: Record
+ body?: string
+ timestamp: number
+ retryCount: number
+ maxRetries: number
+ label?: string
+ actionType?: ActionType
+}
+
+type Listener = () => void
+
+let queue: QueuedAction[] = []
+let listeners: Set = new Set()
+let autoReplayEnabled = false
+let storageAvailable = true
+
+function load(): QueuedAction[] {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY)
+ const parsed: QueuedAction[] = raw ? JSON.parse(raw) : []
+ const now = Date.now()
+ const fresh = parsed.filter((a) => now - a.timestamp < ACTION_TTL_MS)
+ if (fresh.length !== parsed.length) {
+ try { localStorage.setItem(STORAGE_KEY, JSON.stringify(fresh)) } catch {}
+ }
+ return fresh
+ } catch {
+ return []
+ }
+}
+
+function persist(): void {
+ if (!storageAvailable) return
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(queue))
+ } catch {
+ storageAvailable = false
+ // continue with in-memory queue only
+ }
+}
+
+function notify(): void {
+ listeners.forEach((fn) => fn())
+}
+
+function addUniqueId(): string {
+ return Math.random().toString(36).substring(2, 11)
+}
+
+export function getQueue(): QueuedAction[] {
+ return queue
+}
+
+export function enqueue(action: Omit): QueuedAction {
+ if (queue.length >= MAX_QUEUE_SIZE) throw new Error('Queue is full')
+ const item: QueuedAction = {
+ ...action,
+ id: addUniqueId(),
+ timestamp: Date.now(),
+ retryCount: 0,
+ }
+ queue.push(item)
+ persist()
+ notify()
+ return item
+}
+
+export function remove(id: string): void {
+ queue = queue.filter((a) => a.id !== id)
+ persist()
+ notify()
+}
+
+export function clear(): void {
+ queue = []
+ persist()
+ notify()
+}
+
+type ReplayResult = 'ok' | 'gone' | 'fail' | 'conflict'
+
+export function retry(id: string): Promise {
+ const idx = queue.findIndex((a) => a.id === id)
+ if (idx === -1) return Promise.resolve(false)
+ const action = queue[idx]
+ return replayAction(action).then((result) => {
+ if (result === 'ok') {
+ remove(id)
+ return true
+ }
+ if (result === 'gone' || result === 'conflict' || action.retryCount >= action.maxRetries) {
+ remove(id)
+ return false
+ }
+ queue[idx] = { ...action, retryCount: action.retryCount + 1 }
+ persist()
+ notify()
+ return false
+ })
+}
+
+function isSafeActionType(actionType?: ActionType): boolean {
+ return actionType ? SAFE_ACTION_TYPES.includes(actionType) : false
+}
+
+export async function retryAll(): Promise {
+ const ids = [...queue.map((a) => a.id)]
+ let success = 0
+ for (const id of ids) {
+ const ok = await retry(id).catch(() => false)
+ if (ok) success++
+ }
+ return success
+}
+
+/**
+ * Called when the browser comes back online.
+ * Does NOT auto-replay unless autoReplayEnabled is explicitly set.
+ * Even then, only actions with a safe actionType are replayed.
+ */
+export function onReconnect(): Promise {
+ if (!autoReplayEnabled) return Promise.resolve(0)
+ const safeIds = queue.filter((a) => isSafeActionType(a.actionType)).map((a) => a.id)
+ if (safeIds.length === 0) return Promise.resolve(0)
+ return retryAllFiltered(safeIds)
+}
+
+async function retryAllFiltered(ids: string[]): Promise {
+ let success = 0
+ for (const id of ids) {
+ const ok = await retry(id).catch(() => false)
+ if (ok) success++
+ }
+ return success
+}
+
+export function isRetryable(method: string): boolean {
+ return ['POST', 'PATCH', 'PUT', 'DELETE'].includes(method.toUpperCase())
+}
+
+export function subscribe(fn: Listener): () => void {
+ listeners.add(fn)
+ return () => {
+ listeners.delete(fn)
+ }
+}
+
+export function setAutoReplay(enabled: boolean): void {
+ autoReplayEnabled = enabled
+}
+
+export function isOnline(): boolean {
+ return typeof navigator !== 'undefined' ? navigator.onLine : true
+}
+
+export function getAutoReplay(): boolean {
+ return autoReplayEnabled
+}
+
+async function conflictCheck(action: QueuedAction): Promise<'no-conflict' | 'conflict' | 'gone'> {
+ if (!action.actionType) return 'no-conflict'
+
+ try {
+ const getOptions: RequestInit = { method: 'GET', headers: action.headers }
+ switch (action.actionType) {
+ case 'updateWorkflow': {
+ const res = await fetch(action.url, getOptions)
+ if (res.status === 404 || res.status === 410) return 'gone'
+ return 'no-conflict'
+ }
+ case 'createWorkflow': {
+ const listUrl = action.url.replace(/\/workflows(\/.*)?$/, '/workflows')
+ const res = await fetch(listUrl, getOptions)
+ if (!res.ok) return 'no-conflict'
+ const body = action.body ? JSON.parse(action.body) : null
+ if (!body?.name) return 'no-conflict'
+ const data = await res.json()
+ const workflows = Array.isArray(data) ? data : data.workflows
+ const exists = Array.isArray(workflows) && workflows.some((w: any) => w.name === body.name)
+ return exists ? 'conflict' : 'no-conflict'
+ }
+ default:
+ return 'no-conflict'
+ }
+ } catch {
+ return 'no-conflict'
+ }
+}
+
+function replayAction(action: QueuedAction): Promise {
+ if (action.actionType && !isSafeActionType(action.actionType)) {
+ return Promise.resolve('gone' as const)
+ }
+ if (action.actionType) {
+ return conflictCheck(action).then((check) => {
+ if (check !== 'no-conflict') return check
+ return doFetch(action)
+ })
+ }
+ return doFetch(action)
+}
+
+function doFetch(action: QueuedAction): Promise {
+ const { url, method, headers, body } = action
+ return fetch(url, {
+ method,
+ headers: { 'Content-Type': 'application/json', ...headers },
+ body,
+ })
+ .then((res) => {
+ if (res.ok) return 'ok' as const
+ if (res.status === 404 || res.status === 410) return 'gone' as const
+ return 'fail' as const
+ })
+ .catch(() => 'fail' as const)
+}
+
+queue = load()
diff --git a/frontend/testing/unit/api.offline.test.ts b/frontend/testing/unit/api.offline.test.ts
new file mode 100644
index 00000000..76cf0972
--- /dev/null
+++ b/frontend/testing/unit/api.offline.test.ts
@@ -0,0 +1,216 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import * as offlineQueue from '../../src/services/offlineQueue'
+import { OfflineQueueError, setStoredApiKey } from '../../src/api'
+
+// The api module uses the offlineQueue service directly.
+// We test that calling retryable API functions while offline enqueues the action.
+// We import the functions dynamically to avoid top-level side effects.
+async function getApi() {
+ return await import('../../src/api')
+}
+
+const API_KEY_STORAGE_KEY = 'secuscan_api_key'
+
+describe('API offline integration', () => {
+ beforeEach(() => {
+ offlineQueue.clear()
+ Object.defineProperty(navigator, 'onLine', {
+ configurable: true,
+ value: true,
+ })
+ })
+
+ afterEach(() => {
+ offlineQueue.clear()
+ try {
+ localStorage.removeItem(API_KEY_STORAGE_KEY)
+ } catch {
+ // ignore
+ }
+ })
+
+ describe('retryable actions (safe/idempotent mutations only)', () => {
+ it('startTask does not enqueue when offline (non-idempotent)', async () => {
+ Object.defineProperty(navigator, 'onLine', { configurable: true, value: false })
+ const api = await getApi()
+
+ await expect(
+ api.startTask('test_plugin', { target: 'http://example.com' }, true),
+ ).rejects.toThrow()
+
+ expect(offlineQueue.getQueue()).toHaveLength(0)
+ })
+
+ it('createWorkflow enqueues when offline', async () => {
+ Object.defineProperty(navigator, 'onLine', { configurable: true, value: false })
+ const api = await getApi()
+
+ await expect(
+ api.createWorkflow({ name: 'test', enabled: true, steps: [] }),
+ ).rejects.toThrow(OfflineQueueError)
+
+ expect(offlineQueue.getQueue()).toHaveLength(1)
+ })
+
+ it('updateWorkflow enqueues when offline', async () => {
+ Object.defineProperty(navigator, 'onLine', { configurable: true, value: false })
+ const api = await getApi()
+
+ await expect(api.updateWorkflow('wf-1', { name: 'new' })).rejects.toThrow(OfflineQueueError)
+
+ expect(offlineQueue.getQueue()).toHaveLength(1)
+ })
+ })
+
+ describe('non-retryable actions (reads + destructive mutations)', () => {
+ it('getHealth does not enqueue when offline', async () => {
+ Object.defineProperty(navigator, 'onLine', { configurable: true, value: false })
+ const api = await getApi()
+
+ await expect(api.getHealth()).rejects.toThrow()
+ expect(offlineQueue.getQueue()).toHaveLength(0)
+ })
+
+ it('listPlugins does not enqueue when offline', async () => {
+ Object.defineProperty(navigator, 'onLine', { configurable: true, value: false })
+ const api = await getApi()
+
+ await expect(api.listPlugins()).rejects.toThrow()
+ expect(offlineQueue.getQueue()).toHaveLength(0)
+ })
+
+ it('getFindings does not enqueue when offline', async () => {
+ Object.defineProperty(navigator, 'onLine', { configurable: true, value: false })
+ const api = await getApi()
+
+ await expect(api.getFindings()).rejects.toThrow()
+ expect(offlineQueue.getQueue()).toHaveLength(0)
+ })
+
+ it('getTasks does not enqueue when offline', async () => {
+ Object.defineProperty(navigator, 'onLine', { configurable: true, value: false })
+ const api = await getApi()
+
+ await expect(api.getTasks()).rejects.toThrow()
+ expect(offlineQueue.getQueue()).toHaveLength(0)
+ })
+
+ it('deleteTask does not enqueue when offline (destructive)', async () => {
+ Object.defineProperty(navigator, 'onLine', { configurable: true, value: false })
+ const api = await getApi()
+
+ await expect(api.deleteTask('task-123')).rejects.toThrow()
+ expect(offlineQueue.getQueue()).toHaveLength(0)
+ })
+
+ it('cancelTask does not enqueue when offline (non-idempotent)', async () => {
+ Object.defineProperty(navigator, 'onLine', { configurable: true, value: false })
+ const api = await getApi()
+
+ await expect(api.cancelTask('task-123')).rejects.toThrow()
+ expect(offlineQueue.getQueue()).toHaveLength(0)
+ })
+
+ it('bulkDeleteTasks does not enqueue when offline (destructive)', async () => {
+ Object.defineProperty(navigator, 'onLine', { configurable: true, value: false })
+ const api = await getApi()
+
+ await expect(api.bulkDeleteTasks(['a', 'b'])).rejects.toThrow()
+ expect(offlineQueue.getQueue()).toHaveLength(0)
+ })
+
+ it('clearAllTasks does not enqueue when offline (destructive)', async () => {
+ Object.defineProperty(navigator, 'onLine', { configurable: true, value: false })
+ const api = await getApi()
+
+ await expect(api.clearAllTasks()).rejects.toThrow()
+ expect(offlineQueue.getQueue()).toHaveLength(0)
+ })
+
+ it('deleteWorkflow does not enqueue when offline (destructive)', async () => {
+ Object.defineProperty(navigator, 'onLine', { configurable: true, value: false })
+ const api = await getApi()
+
+ await expect(api.deleteWorkflow('wf-1')).rejects.toThrow()
+ expect(offlineQueue.getQueue()).toHaveLength(0)
+ })
+
+ it('runWorkflow does not enqueue when offline (non-idempotent)', async () => {
+ Object.defineProperty(navigator, 'onLine', { configurable: true, value: false })
+ const api = await getApi()
+
+ await expect(api.runWorkflow('wf-1')).rejects.toThrow()
+ expect(offlineQueue.getQueue()).toHaveLength(0)
+ })
+ })
+
+ describe('replay includes auth headers', () => {
+ beforeEach(() => {
+ setStoredApiKey('test-api-key-999')
+ })
+
+ it('stores X-Api-Key in queued action when offline', async () => {
+ Object.defineProperty(navigator, 'onLine', { configurable: true, value: false })
+ const api = await getApi()
+
+ await expect(
+ api.createWorkflow({ name: 'auth-test', enabled: true, steps: [] }),
+ ).rejects.toThrow(OfflineQueueError)
+
+ const queue = offlineQueue.getQueue()
+ expect(queue).toHaveLength(1)
+ expect(queue[0].headers?.['X-Api-Key']).toBe('test-api-key-999')
+ })
+
+ it('replays with X-Api-Key header included', async () => {
+ Object.defineProperty(navigator, 'onLine', { configurable: true, value: false })
+ const api = await getApi()
+
+ await expect(
+ api.createWorkflow({ name: 'replay-auth', enabled: true, steps: [] }),
+ ).rejects.toThrow(OfflineQueueError)
+
+ const [queued] = offlineQueue.getQueue()
+
+ Object.defineProperty(navigator, 'onLine', { configurable: true, value: true })
+ global.fetch = vi.fn().mockResolvedValue({ ok: true })
+
+ const success = await offlineQueue.retry(queued.id)
+ expect(success).toBe(true)
+
+ const calls = (global.fetch as ReturnType).mock.calls
+ expect(calls.length).toBeGreaterThanOrEqual(1)
+ for (const [, opts] of calls) {
+ expect(opts?.headers?.['X-Api-Key']).toBe('test-api-key-999')
+ }
+ })
+ })
+
+ describe('online behavior unchanged', () => {
+ it('retryable actions still call fetch when online', async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({ task_id: 'abc', status: 'running', created_at: 'now', stream_url: '/stream' }),
+ })
+ Object.defineProperty(navigator, 'onLine', { configurable: true, value: true })
+ const api = await getApi()
+
+ const result = await api.startTask('test_plugin', {}, false)
+ expect(result).toEqual({ task_id: 'abc', status: 'running', created_at: 'now', stream_url: '/stream' })
+ expect(offlineQueue.getQueue()).toHaveLength(0)
+ })
+
+ it('non-retryable actions call fetch when online', async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({ status: 'ok' }),
+ })
+ Object.defineProperty(navigator, 'onLine', { configurable: true, value: true })
+ const api = await getApi()
+
+ const result = await api.getHealth()
+ expect(result).toEqual({ status: 'ok' })
+ expect(offlineQueue.getQueue()).toHaveLength(0)
+ })
+ })
+})
diff --git a/frontend/testing/unit/components/OfflineQueueContext.test.tsx b/frontend/testing/unit/components/OfflineQueueContext.test.tsx
new file mode 100644
index 00000000..d1a7798c
--- /dev/null
+++ b/frontend/testing/unit/components/OfflineQueueContext.test.tsx
@@ -0,0 +1,115 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { render, screen, act } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { OfflineQueueProvider, useOfflineQueue } from '../../../src/components/OfflineQueueContext'
+import * as offlineQueue from '../../../src/services/offlineQueue'
+
+function TestConsumer() {
+ const { isOnline, pendingCount, queue, enqueue, retryAll, remove, clear } = useOfflineQueue()
+ return (
+
+ {String(isOnline)}
+ {pendingCount}
+ {queue.length}
+
+
+
+
+ {queue.map((a) => (
+ {a.label}
+ ))}
+
+ )
+}
+
+describe('OfflineQueueContext', () => {
+ beforeEach(() => {
+ offlineQueue.clear()
+ Object.defineProperty(navigator, 'onLine', { configurable: true, value: true })
+ })
+
+ afterEach(() => {
+ offlineQueue.clear()
+ })
+
+ function renderProvider() {
+ return render(
+
+
+ ,
+ )
+ }
+
+ it('provides online status', () => {
+ renderProvider()
+ expect(screen.getByTestId('online')).toHaveTextContent('true')
+ })
+
+ it('shows online status when navigator.onLine changes', () => {
+ renderProvider()
+ act(() => {
+ window.dispatchEvent(new Event('offline'))
+ })
+ expect(screen.getByTestId('online')).toHaveTextContent('false')
+
+ act(() => {
+ window.dispatchEvent(new Event('online'))
+ })
+ expect(screen.getByTestId('online')).toHaveTextContent('true')
+ })
+
+ it('enqueue adds to queue and updates pending count', async () => {
+ const user = userEvent.setup()
+ renderProvider()
+
+ await user.click(screen.getByTestId('enqueue'))
+
+ expect(screen.getByTestId('pending')).toHaveTextContent('1')
+ expect(screen.getByTestId('queue-length')).toHaveTextContent('1')
+ expect(screen.getAllByTestId('queue-item')).toHaveLength(1)
+ })
+
+ it('remove takes an item out of the queue', async () => {
+ const user = userEvent.setup()
+ renderProvider()
+
+ await user.click(screen.getByTestId('enqueue'))
+ expect(screen.getByTestId('pending')).toHaveTextContent('1')
+
+ await user.click(screen.getByTestId('remove'))
+ expect(screen.getByTestId('pending')).toHaveTextContent('0')
+ })
+
+ it('clear empties the queue', async () => {
+ const user = userEvent.setup()
+ renderProvider()
+
+ await user.click(screen.getByTestId('enqueue'))
+ await user.click(screen.getByTestId('enqueue'))
+ expect(screen.getByTestId('pending')).toHaveTextContent('2')
+
+ await user.click(screen.getByTestId('clear'))
+ expect(screen.getByTestId('pending')).toHaveTextContent('0')
+ })
+
+ it('retryAll replays queue items', async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: true })
+ const user = userEvent.setup()
+ renderProvider()
+
+ await user.click(screen.getByTestId('enqueue'))
+ await user.click(screen.getByTestId('enqueue'))
+ expect(screen.getByTestId('pending')).toHaveTextContent('2')
+
+ await user.click(screen.getByTestId('retry-all'))
+ expect(screen.getByTestId('pending')).toHaveTextContent('0')
+ })
+
+ it('throws error when used outside provider', () => {
+ expect(() => render()).toThrow(
+ 'useOfflineQueue must be used within OfflineQueueProvider',
+ )
+ })
+})
diff --git a/frontend/testing/unit/minimal.test.ts b/frontend/testing/unit/minimal.test.ts
new file mode 100644
index 00000000..809eddee
--- /dev/null
+++ b/frontend/testing/unit/minimal.test.ts
@@ -0,0 +1,22 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+
+describe('minimal polling test', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('setInterval fires with fake timers', async () => {
+ const fn = vi.fn()
+ setInterval(fn, 50)
+
+ vi.advanceTimersByTime(50)
+ expect(fn).toHaveBeenCalledTimes(1)
+
+ vi.advanceTimersByTime(50)
+ expect(fn).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/frontend/testing/unit/pages/ToolConfigDynamic.test.tsx b/frontend/testing/unit/pages/ToolConfigDynamic.test.tsx
index 6cd5a1da..03bfb13c 100644
--- a/frontend/testing/unit/pages/ToolConfigDynamic.test.tsx
+++ b/frontend/testing/unit/pages/ToolConfigDynamic.test.tsx
@@ -108,6 +108,11 @@ describe('ToolConfig dynamic schema flow', () => {
}),
true,
'quick',
+ expect.objectContaining({
+ scan_profile: 'standard',
+ validation_mode: 'proof',
+ evidence_level: 'standard',
+ }),
)
})
})
diff --git a/frontend/testing/unit/pages/ToolConfigTimeout.test.tsx b/frontend/testing/unit/pages/ToolConfigTimeout.test.tsx
index 4c0b2213..3318dac3 100644
--- a/frontend/testing/unit/pages/ToolConfigTimeout.test.tsx
+++ b/frontend/testing/unit/pages/ToolConfigTimeout.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from '@testing-library/react'
+import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import ToolConfig from '../../../src/pages/ToolConfig'
@@ -16,6 +16,9 @@ vi.mock('../../../src/api', () => ({
getPluginSchema: vi.fn(),
startTask: vi.fn(),
getSettings: vi.fn(),
+ listTargetPolicies: vi.fn().mockResolvedValue([]),
+ listCredentialProfiles: vi.fn().mockResolvedValue([]),
+ listSessionProfiles: vi.fn().mockResolvedValue([]),
}))
describe('ToolConfig timeout control', () => {
@@ -74,7 +77,9 @@ describe('ToolConfig timeout control', () => {
const input = await screen.findByLabelText(/Max Scan Time/i)
// min from field.validation
expect(input).toHaveAttribute('min', '30')
- // max is min(field.validation.max, server default_timeout)
- expect(input).toHaveAttribute('max', '600')
+ // max is min(field.validation.max, server default_timeout), wait for serverLimits
+ await waitFor(() => {
+ expect(input).toHaveAttribute('max', '600')
+ })
})
})
diff --git a/frontend/testing/unit/pages/Workflows.test.tsx b/frontend/testing/unit/pages/Workflows.test.tsx
index 7c304da8..54628a33 100644
--- a/frontend/testing/unit/pages/Workflows.test.tsx
+++ b/frontend/testing/unit/pages/Workflows.test.tsx
@@ -130,7 +130,15 @@ describe('Workflows — create action', () => {
name: 'Nightly Scan',
schedule_seconds: 7200,
enabled: true,
- steps: [{ plugin_id: '', inputs: {} }],
+ steps: [{
+ plugin_id: '',
+ inputs: {},
+ execution_context: {
+ scan_profile: 'standard',
+ validation_mode: 'proof',
+ evidence_level: 'standard',
+ },
+ }],
})
})
})
diff --git a/frontend/testing/unit/services/offlineQueue.test.ts b/frontend/testing/unit/services/offlineQueue.test.ts
new file mode 100644
index 00000000..d6d3cdaa
--- /dev/null
+++ b/frontend/testing/unit/services/offlineQueue.test.ts
@@ -0,0 +1,483 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import * as offlineQueue from '../../../src/services/offlineQueue'
+
+function createMockLocalStorage() {
+ const store = new Map()
+ return {
+ getItem: vi.fn((key: string) => store.get(key) ?? null),
+ setItem: vi.fn((key: string, value: string) => { store.set(key, value) }),
+ removeItem: vi.fn((key: string) => { store.delete(key) }),
+ clear: vi.fn(() => { store.clear() }),
+ key: vi.fn((i: number) => Array.from(store.keys())[i] ?? null),
+ get length() { return store.size },
+ }
+}
+
+function mockNavigatorOnline(onLine: boolean) {
+ Object.defineProperty(navigator, 'onLine', {
+ configurable: true,
+ value: onLine,
+ })
+}
+
+describe('offlineQueue', () => {
+ let mockStorage: ReturnType
+
+ beforeEach(() => {
+ mockStorage = createMockLocalStorage()
+ Object.defineProperty(window, 'localStorage', {
+ value: mockStorage,
+ configurable: true,
+ writable: true,
+ })
+ mockNavigatorOnline(true)
+ offlineQueue.setAutoReplay(false)
+ offlineQueue.clear()
+ vi.clearAllMocks()
+ })
+
+ afterEach(() => {
+ offlineQueue.clear()
+ })
+
+ describe('isRetryable', () => {
+ it('returns true for POST, PATCH, PUT, DELETE', () => {
+ expect(offlineQueue.isRetryable('POST')).toBe(true)
+ expect(offlineQueue.isRetryable('PATCH')).toBe(true)
+ expect(offlineQueue.isRetryable('PUT')).toBe(true)
+ expect(offlineQueue.isRetryable('DELETE')).toBe(true)
+ })
+
+ it('returns false for GET and HEAD', () => {
+ expect(offlineQueue.isRetryable('GET')).toBe(false)
+ expect(offlineQueue.isRetryable('HEAD')).toBe(false)
+ })
+ })
+
+ describe('enqueue / getQueue', () => {
+ it('adds an action to the queue', () => {
+ const action = offlineQueue.enqueue({
+ url: '/api/v1/task/start',
+ method: 'POST',
+ body: '{"foo":"bar"}',
+ maxRetries: 3,
+ label: 'Start Scan',
+ })
+
+ expect(action.id).toBeTruthy()
+ expect(action.url).toBe('/api/v1/task/start')
+ expect(action.method).toBe('POST')
+ expect(action.retryCount).toBe(0)
+ expect(action.timestamp).toBeGreaterThan(0)
+
+ const queue = offlineQueue.getQueue()
+ expect(queue).toHaveLength(1)
+ expect(queue[0].id).toBe(action.id)
+ })
+
+ it('persists to localStorage', () => {
+ offlineQueue.enqueue({
+ url: '/api/v1/task/abc',
+ method: 'DELETE',
+ maxRetries: 3,
+ label: 'Delete Task',
+ })
+
+ const saved = mockStorage.getItem('offline-queue')
+ expect(saved).toBeTruthy()
+ const parsed = JSON.parse(saved!)
+ expect(parsed).toHaveLength(1)
+ expect(parsed[0].method).toBe('DELETE')
+ })
+
+ it('queues multiple actions', () => {
+ offlineQueue.enqueue({ url: '/a', method: 'POST', maxRetries: 3 })
+ offlineQueue.enqueue({ url: '/b', method: 'DELETE', maxRetries: 3 })
+ expect(offlineQueue.getQueue()).toHaveLength(2)
+ })
+ })
+
+ describe('remove', () => {
+ it('removes an action by id', () => {
+ const a = offlineQueue.enqueue({ url: '/a', method: 'POST', maxRetries: 3 })
+ offlineQueue.enqueue({ url: '/b', method: 'DELETE', maxRetries: 3 })
+
+ offlineQueue.remove(a.id)
+
+ expect(offlineQueue.getQueue()).toHaveLength(1)
+ expect(offlineQueue.getQueue()[0].url).toBe('/b')
+ })
+
+ it('does nothing for unknown id', () => {
+ offlineQueue.enqueue({ url: '/a', method: 'POST', maxRetries: 3 })
+ offlineQueue.remove('nonexistent')
+ expect(offlineQueue.getQueue()).toHaveLength(1)
+ })
+ })
+
+ describe('clear', () => {
+ it('removes all actions', () => {
+ offlineQueue.enqueue({ url: '/a', method: 'POST', maxRetries: 3 })
+ offlineQueue.enqueue({ url: '/b', method: 'DELETE', maxRetries: 3 })
+ offlineQueue.clear()
+ expect(offlineQueue.getQueue()).toHaveLength(0)
+ })
+ })
+
+ describe('retry', () => {
+ it('replays a queued action and removes it on success', async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: true })
+
+ const action = offlineQueue.enqueue({
+ url: '/api/v1/task/start',
+ method: 'POST',
+ body: '{}',
+ maxRetries: 3,
+ label: 'Start Scan',
+ headers: { 'X-Custom': 'val' },
+ })
+
+ const ok = await offlineQueue.retry(action.id)
+ expect(ok).toBe(true)
+ expect(global.fetch).toHaveBeenCalledWith(
+ '/api/v1/task/start',
+ expect.objectContaining({
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-Custom': 'val' },
+ body: '{}',
+ }),
+ )
+ expect(offlineQueue.getQueue()).toHaveLength(0)
+ })
+
+ it('increments retryCount on failure and keeps item in queue', async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error('Network error'))
+
+ const action = offlineQueue.enqueue({
+ url: '/api/v1/task/abc',
+ method: 'DELETE',
+ maxRetries: 3,
+ })
+
+ const ok = await offlineQueue.retry(action.id)
+ expect(ok).toBe(false)
+
+ const remaining = offlineQueue.getQueue()
+ expect(remaining).toHaveLength(1)
+ expect(remaining[0].retryCount).toBe(1)
+ })
+
+ it('returns false for unknown id', async () => {
+ const result = await offlineQueue.retry('nonexistent')
+ expect(result).toBe(false)
+ })
+
+ it('removes action when server returns 404 (stale resource)', async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 404 })
+
+ const action = offlineQueue.enqueue({
+ url: '/api/v1/task/abc',
+ method: 'DELETE',
+ maxRetries: 3,
+ })
+
+ const ok = await offlineQueue.retry(action.id)
+ expect(ok).toBe(false)
+ expect(offlineQueue.getQueue()).toHaveLength(0)
+ })
+
+ it('removes action when server returns 410 (gone)', async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 410 })
+
+ const action = offlineQueue.enqueue({
+ url: '/api/v1/workflows/wf-1',
+ method: 'PATCH',
+ maxRetries: 3,
+ })
+
+ const ok = await offlineQueue.retry(action.id)
+ expect(ok).toBe(false)
+ expect(offlineQueue.getQueue()).toHaveLength(0)
+ })
+
+ it('removes action when maxRetries exceeded', async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error('Network error'))
+
+ const action = offlineQueue.enqueue({
+ url: '/api/v1/task/start',
+ method: 'POST',
+ maxRetries: 2,
+ })
+
+ await offlineQueue.retry(action.id)
+ expect(offlineQueue.getQueue()).toHaveLength(1)
+ expect(offlineQueue.getQueue()[0].retryCount).toBe(1)
+
+ await offlineQueue.retry(action.id)
+ expect(offlineQueue.getQueue()).toHaveLength(1)
+ expect(offlineQueue.getQueue()[0].retryCount).toBe(2)
+
+ await offlineQueue.retry(action.id)
+ expect(offlineQueue.getQueue()).toHaveLength(0)
+ })
+ })
+
+ describe('retryAll', () => {
+ it('replays all queued actions', async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: true })
+
+ offlineQueue.enqueue({ url: '/a', method: 'POST', body: '{}', maxRetries: 3 })
+ offlineQueue.enqueue({ url: '/b', method: 'DELETE', maxRetries: 3 })
+
+ const successCount = await offlineQueue.retryAll()
+ expect(successCount).toBe(2)
+ expect(offlineQueue.getQueue()).toHaveLength(0)
+ })
+
+ it('reports partial success on mixed results', async () => {
+ let callCount = 0
+ global.fetch = vi.fn().mockImplementation(() => {
+ callCount++
+ if (callCount === 1) return Promise.resolve({ ok: true })
+ return Promise.reject(new Error('fail'))
+ })
+
+ offlineQueue.enqueue({ url: '/a', method: 'POST', maxRetries: 3 })
+ offlineQueue.enqueue({ url: '/b', method: 'DELETE', maxRetries: 3 })
+
+ const successCount = await offlineQueue.retryAll()
+ expect(successCount).toBe(1)
+ expect(offlineQueue.getQueue()).toHaveLength(1)
+ })
+ })
+
+ describe('subscribe', () => {
+ it('notifies listeners on queue changes', () => {
+ const listener = vi.fn()
+ offlineQueue.subscribe(listener)
+
+ offlineQueue.enqueue({ url: '/a', method: 'POST', maxRetries: 3 })
+
+ expect(listener).toHaveBeenCalledTimes(1)
+ })
+
+ it('returns an unsubscribe function', () => {
+ const listener = vi.fn()
+ const unsub = offlineQueue.subscribe(listener)
+
+ unsub()
+ offlineQueue.enqueue({ url: '/a', method: 'POST', maxRetries: 3 })
+
+ expect(listener).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('isOnline', () => {
+ it('returns navigator.onLine status', () => {
+ mockNavigatorOnline(true)
+ expect(offlineQueue.isOnline()).toBe(true)
+
+ mockNavigatorOnline(false)
+ expect(offlineQueue.isOnline()).toBe(false)
+ })
+ })
+
+ describe('conflict checks', () => {
+ it('removes updateWorkflow when resource is gone (GET 404)', async () => {
+ let callCount = 0
+ global.fetch = vi.fn().mockImplementation(() => {
+ callCount++
+ if (callCount === 1) return Promise.resolve({ ok: false, status: 404 })
+ return Promise.resolve({ ok: true })
+ })
+
+ const action = offlineQueue.enqueue({
+ url: '/api/v1/workflows/wf-1',
+ method: 'PATCH',
+ body: '{"name":"new"}',
+ maxRetries: 3,
+ actionType: 'updateWorkflow',
+ })
+
+ const ok = await offlineQueue.retry(action.id)
+ expect(ok).toBe(false)
+ expect(offlineQueue.getQueue()).toHaveLength(0)
+ expect(callCount).toBe(1)
+ })
+
+ it('removes createWorkflow when workflow name already exists (conflict)', async () => {
+ let callCount = 0
+ global.fetch = vi.fn().mockImplementation(() => {
+ callCount++
+ if (callCount === 1) return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({
+ workflows: [{ name: 'test' }],
+ total: 1,
+ }),
+ })
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({}) })
+ })
+
+ const action = offlineQueue.enqueue({
+ url: '/api/v1/workflows',
+ method: 'POST',
+ body: JSON.stringify({ name: 'test', enabled: true, steps: [] }),
+ maxRetries: 3,
+ actionType: 'createWorkflow',
+ })
+
+ const ok = await offlineQueue.retry(action.id)
+ expect(ok).toBe(false)
+ expect(offlineQueue.getQueue()).toHaveLength(0)
+ expect(callCount).toBe(1)
+ })
+
+ it('proceeds with createWorkflow when workflow name is unique (no conflict)', async () => {
+ let callCount = 0
+ global.fetch = vi.fn().mockImplementation(() => {
+ callCount++
+ if (callCount === 1) return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({
+ workflows: [{ name: 'other' }],
+ total: 1,
+ }),
+ })
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({ id: 'wf-2' }) })
+ })
+
+ const action = offlineQueue.enqueue({
+ url: '/api/v1/workflows',
+ method: 'POST',
+ body: JSON.stringify({ name: 'new-workflow', enabled: true, steps: [] }),
+ maxRetries: 3,
+ actionType: 'createWorkflow',
+ })
+
+ const ok = await offlineQueue.retry(action.id)
+ expect(ok).toBe(true)
+ expect(offlineQueue.getQueue()).toHaveLength(0)
+ expect(callCount).toBe(2)
+ })
+
+ it('skips conflict check when actionType is not set (backward compat)', async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: true })
+
+ const action = offlineQueue.enqueue({
+ url: '/api/v1/task/start',
+ method: 'POST',
+ body: '{}',
+ maxRetries: 3,
+ })
+
+ const ok = await offlineQueue.retry(action.id)
+ expect(ok).toBe(true)
+ expect(global.fetch).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('auto-replay is disabled', () => {
+ it('does not auto-replay on reconnect (default is false)', () => {
+ const retryAllSpy = vi.spyOn(offlineQueue, 'retryAll')
+ offlineQueue.enqueue({ url: '/a', method: 'POST', maxRetries: 3 })
+
+ window.dispatchEvent(new Event('online'))
+
+ expect(retryAllSpy).not.toHaveBeenCalled()
+ retryAllSpy.mockRestore()
+ })
+
+ it('setAutoReplay is preserved for backward compat', () => {
+ expect(() => offlineQueue.setAutoReplay(false)).not.toThrow()
+ expect(() => offlineQueue.setAutoReplay(true)).not.toThrow()
+ })
+ })
+
+ describe('queue safety boundaries', () => {
+ it('enforces max queue size and throws when full', () => {
+ for (let i = 0; i < 50; i++) {
+ offlineQueue.enqueue({ url: `/a/${i}`, method: 'POST', maxRetries: 3 })
+ }
+ expect(() => offlineQueue.enqueue({ url: '/overflow', method: 'POST', maxRetries: 3 })).toThrow('Queue is full')
+ expect(offlineQueue.getQueue()).toHaveLength(50)
+ })
+
+ it('drops stale entries older than TTL on load', () => {
+ const oldTimestamp = Date.now() - 86_400_001 // 24h + 1ms
+ const stale = [{ id: 'old', url: '/stale', method: 'POST', timestamp: oldTimestamp, retryCount: 0, maxRetries: 3 }]
+ mockStorage.getItem.mockReturnValue(JSON.stringify(stale))
+
+ const fresh = offlineQueue.enqueue({ url: '/fresh', method: 'POST', maxRetries: 3 })
+ expect(offlineQueue.getQueue()).toHaveLength(1)
+ expect(offlineQueue.getQueue()[0].id).toBe(fresh.id)
+ })
+
+ it('falls back to in-memory when localStorage fails on persist', () => {
+ mockStorage.setItem.mockImplementation(() => { throw new Error('QuotaExceeded') })
+ const action = offlineQueue.enqueue({ url: '/a', method: 'POST', maxRetries: 3 })
+ expect(action).not.toBeNull()
+ expect(offlineQueue.getQueue()).toHaveLength(1)
+ })
+ })
+
+ describe('onReconnect guard', () => {
+ it('does not replay when autoReplay is disabled', async () => {
+ offlineQueue.clear()
+ offlineQueue.setAutoReplay(false)
+ const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue({ ok: true } as Response)
+ offlineQueue.enqueue({ url: '/a', method: 'POST', maxRetries: 3, actionType: 'createWorkflow' })
+ offlineQueue.enqueue({ url: '/b', method: 'POST', maxRetries: 3, actionType: 'createWorkflow' })
+
+ const count = await offlineQueue.onReconnect()
+ expect(count).toBe(0)
+ expect(fetchSpy).not.toHaveBeenCalled()
+ fetchSpy.mockRestore()
+ })
+
+ it('only replays safe action types when autoReplay is enabled', async () => {
+ offlineQueue.setAutoReplay(true)
+ let callCount = 0
+ global.fetch = vi.fn().mockImplementation(() => {
+ callCount++
+ if (callCount === 1) return Promise.resolve({ ok: true, json: () => Promise.resolve({ workflows: [] }) })
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({}) })
+ })
+ offlineQueue.enqueue({ url: '/api/v1/workflows', method: 'POST', maxRetries: 3, actionType: 'createWorkflow' })
+ offlineQueue.enqueue({ url: '/unsafe', method: 'DELETE', maxRetries: 3, actionType: undefined })
+
+ const count = await offlineQueue.onReconnect()
+ expect(count).toBe(1)
+ expect(callCount).toBe(2) // 1 conflict check GET + 1 actual POST
+ })
+
+ it('returns 0 and replays nothing when autoReplay is enabled but queue has no safe action types', async () => {
+ offlineQueue.setAutoReplay(true)
+ global.fetch = vi.fn().mockResolvedValue({ ok: true })
+ offlineQueue.enqueue({ url: '/unsafe', method: 'DELETE', maxRetries: 3, actionType: undefined })
+
+ const count = await offlineQueue.onReconnect()
+ expect(count).toBe(0)
+ expect(global.fetch).not.toHaveBeenCalled()
+ })
+
+ it('replays nothing when queue is empty', async () => {
+ offlineQueue.setAutoReplay(true)
+ const count = await offlineQueue.onReconnect()
+ expect(count).toBe(0)
+ })
+ })
+
+ describe('SAFE_ACTION_TYPES', () => {
+ it('only includes createWorkflow and updateWorkflow', () => {
+ expect(offlineQueue.SAFE_ACTION_TYPES).toEqual(['createWorkflow', 'updateWorkflow'])
+ expect(offlineQueue.SAFE_ACTION_TYPES).not.toContain('startTask')
+ expect(offlineQueue.SAFE_ACTION_TYPES).not.toContain('deleteTask')
+ expect(offlineQueue.SAFE_ACTION_TYPES).not.toContain('cancelTask')
+ expect(offlineQueue.SAFE_ACTION_TYPES).not.toContain('clearAllTasks')
+ expect(offlineQueue.SAFE_ACTION_TYPES).not.toContain('runWorkflow')
+ expect(offlineQueue.SAFE_ACTION_TYPES).not.toContain('deleteWorkflow')
+ expect(offlineQueue.SAFE_ACTION_TYPES).not.toContain('bulkDeleteTasks')
+ })
+ })
+})