diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1b15244e..2aa5cff8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -145,6 +145,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -506,6 +507,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -554,6 +556,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -563,6 +566,7 @@ "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", "license": "MIT", + "peer": true, "dependencies": { "@emotion/memoize": "^0.9.0" } @@ -1606,6 +1610,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -1833,6 +1838,7 @@ "integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.21.0" } @@ -1863,6 +1869,7 @@ "integrity": "sha512-WeqMfGJLGuLCqHGYRGHxnKrXcTitc6L/nBUWfWPcTarG3t9PsquqUMuVeXZeca+mglY4Vo5GZjCi0A3Or2lnxA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -1874,6 +1881,7 @@ "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/react": "*" } @@ -2203,6 +2211,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3105,6 +3114,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -3556,6 +3566,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3765,6 +3776,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -3777,6 +3789,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -3806,6 +3819,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -3936,7 +3950,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -4433,6 +4448,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4636,6 +4652,7 @@ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -4744,6 +4761,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/frontend/package.json b/frontend/package.json index b5544f29..fef46c43 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,9 +16,9 @@ "e2e:ui": "playwright test --ui" }, "dependencies": { - "@tanstack/react-virtual": "^3.14.2", "@hugeicons/core-free-icons": "^4.1.4", "@hugeicons/react": "^1.1.6", + "@tanstack/react-virtual": "^3.14.2", "framer-motion": "^12.38.0", "html2canvas": "^1.4.1", "jspdf": "^4.2.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d20131e3..f9844635 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,7 @@ import ApiKeySetupScreen from './components/ApiKeySetupScreen' import { ThemeProvider } from './components/ThemeContext' import { ToastProvider } from './components/ToastContext' import { I18nProvider } from './components/I18nContext' +import { OfflineQueueProvider } from './components/OfflineQueueContext' import { routes } from './routes' import { AUTH_REQUIRED_EVENT, getStoredApiKey } from './api' @@ -55,15 +56,15 @@ export default function App() { {needsKey ? ( - // Render ONLY the setup screen — no page routes are mounted, so no - // API calls can fire and spam 401 failures before the key is saved. setNeedsKey(false)} /> ) : ( + + )} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index c260f056..e1444364 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,3 +1,5 @@ +import * as offlineQueue from './services/offlineQueue' + function resolveApiBase(): string { const configured = (import.meta as any).env.VITE_API_BASE if (configured) return configured @@ -6,11 +8,9 @@ function resolveApiBase(): string { const isLocalHost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1' const isViteDevServer = window.location.port === '5173' - // For localhost preview/static modes (e.g. :8080), call backend directly. if (isLocalHost && !isViteDevServer) return 'http://127.0.0.1:8000/api/v1' } - // Default for Vite dev server where /api is proxied to backend. return '/api/v1' } @@ -42,6 +42,7 @@ export interface PluginFieldSchema { options?: PluginFieldOption[] validation?: Record } + export interface PluginAvailability { runnable: boolean missing_binaries: string[] @@ -310,13 +311,43 @@ function getApiKey(): string | null { /** Fired on the window when any API request receives HTTP 401. */ export const AUTH_REQUIRED_EVENT = 'secuscan:auth-required' -async function request(path: string, init?: RequestInit): Promise { - const controller = new AbortController() - const timeoutId = window.setTimeout(() => controller.abort(), 10000) +interface RequestOptions extends RequestInit { + retryable?: boolean + label?: string + actionType?: offlineQueue.ActionType +} +export class OfflineQueueError extends Error { + constructor(message: string) { + super(message) + this.name = 'OfflineQueueError' + } +} + +async function request(path: string, init?: RequestOptions): Promise { + const method = (init?.method || 'GET').toUpperCase() + const isSafe = method === 'GET' || method === 'HEAD' const apiKey = getApiKey() const authHeaders: Record = apiKey ? { 'X-Api-Key': apiKey } : {} + if (!offlineQueue.isOnline() && !isSafe && init?.retryable) { + offlineQueue.enqueue({ + url: `${API_BASE}${path}`, + method, + headers: { + ...authHeaders, + ...(init?.headers as Record | undefined), + }, + body: init?.body as string | undefined, + maxRetries: 3, + label: init?.label || `${method} ${path}`, + actionType: init?.actionType, + }) + throw new OfflineQueueError(`Queued for replay when online: ${method} ${path}`) + } + const controller = new AbortController() + const timeoutId = window.setTimeout(() => controller.abort(), 10000) + try { const response = await fetch(`${API_BASE}${path}`, { ...init, @@ -328,8 +359,6 @@ async function request(path: string, init?: RequestInit): Promise { }) if (response.status === 401) { - // Notify the app so it can show the API-key setup UI without every - // caller needing to handle auth independently. window.dispatchEvent(new CustomEvent(AUTH_REQUIRED_EVENT)) throw new Error('AUTH_REQUIRED') } @@ -363,7 +392,6 @@ export function getDashboardSummary() { return request('/dashboard/summary') } - export function getFindings() { return request('/findings') } @@ -372,7 +400,6 @@ export function getFindingGroups() { return request<{ groups: FindingGroup[]; total: number }>('/finding-groups') } - export function getReports() { return request('/reports') } @@ -541,6 +568,7 @@ export function streamTask(taskId: string, onEvent: (ev: MessageEvent) => void) es.onerror = () => {} return es } + export interface WorkflowStep { plugin_id: string inputs: Record @@ -624,6 +652,9 @@ export async function createWorkflow(data: WorkflowCreatePayload): Promise void } -/** - * First-run / 401 modal. - * - * Shown when the app receives HTTP 401 or detects no stored API key. - * The operator reads the key from `backend/data/.api_key`, pastes it here, - * and clicks Save. The key is written only to localStorage (secuscan_api_key); - * it is never sent to any server other than as the X-Api-Key request header. - */ export default function ApiKeySetupModal({ onSaved }: Props) { const [key, setKey] = useState('') const [visible, setVisible] = useState(false) diff --git a/frontend/src/components/ApiKeySetupScreen.tsx b/frontend/src/components/ApiKeySetupScreen.tsx index 955ead32..0b16597d 100644 --- a/frontend/src/components/ApiKeySetupScreen.tsx +++ b/frontend/src/components/ApiKeySetupScreen.tsx @@ -5,18 +5,6 @@ interface Props { onSaved: () => void } -/** - * Full-page first-run / 401 gate. - * - * Replaces the entire app until the operator provides the API key. - * Because this component renders instead of the normal route tree, no page - * component mounts and no protected API call fires before the key is saved. - * - * The operator reads the key from the server key file and pastes it here. - * The key is stored only in localStorage under `secuscan_api_key` and sent - * exclusively as the `X-Api-Key` request header — never logged or stored - * server-side. - */ export default function ApiKeySetupScreen({ onSaved }: Props) { const [key, setKey] = useState('') const [error, setError] = useState('') diff --git a/frontend/src/components/OfflineQueueContext.tsx b/frontend/src/components/OfflineQueueContext.tsx new file mode 100644 index 00000000..fe9b6234 --- /dev/null +++ b/frontend/src/components/OfflineQueueContext.tsx @@ -0,0 +1,74 @@ +import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react' +import * as offlineQueue from '../services/offlineQueue' + +interface OfflineQueueContextType { + isOnline: boolean + pendingCount: number + queue: offlineQueue.QueuedAction[] + enqueue: (action: Omit) => offlineQueue.QueuedAction + retryAll: () => Promise + retry: (id: string) => Promise + remove: (id: string) => void + clear: () => void +} + +const OfflineQueueContext = createContext(undefined) + +export function useOfflineQueue() { + const context = useContext(OfflineQueueContext) + if (!context) throw new Error('useOfflineQueue must be used within OfflineQueueProvider') + return context +} + +export function OfflineQueueProvider({ children }: { children: ReactNode }) { + const [isOnline, setIsOnline] = useState(offlineQueue.isOnline()) + const [, setTick] = useState(0) + + useEffect(() => { + const onOnline = () => { + setIsOnline(true) + offlineQueue.onReconnect() + } + const onOffline = () => setIsOnline(false) + window.addEventListener('online', onOnline) + window.addEventListener('offline', onOffline) + return () => { + window.removeEventListener('online', onOnline) + window.removeEventListener('offline', onOffline) + } + }, []) + + useEffect(() => { + const unsub = offlineQueue.subscribe(() => setTick((t) => t + 1)) + return unsub + }, []) + + const enqueue = useCallback( + (action: Omit) => { + return offlineQueue.enqueue(action) + }, + [], + ) + + const retryAll = useCallback(() => offlineQueue.retryAll(), []) + const retry = useCallback((id: string) => offlineQueue.retry(id), []) + const remove = useCallback((id: string) => offlineQueue.remove(id), []) + const clear = useCallback(() => offlineQueue.clear(), []) + + return ( + + {children} + + ) +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 99bf5e2b..b7160eaa 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -3,6 +3,7 @@ import { NavLink } from 'react-router-dom' import { motion, AnimatePresence } from 'framer-motion' import { routes } from '../routes' import ThemeToggle from './ThemeToggle' +import { useOfflineQueue } from './OfflineQueueContext' interface NavItemProps { to: string; @@ -173,6 +174,7 @@ export default function Sidebar() { {/* Bottom Actions */}
+
@@ -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') + }) + }) +})