diff --git a/backend/src/__tests__/scheduled-tasks-routes.test.ts b/backend/src/__tests__/scheduled-tasks-routes.test.ts index ce1cb58c..5623e7d5 100644 --- a/backend/src/__tests__/scheduled-tasks-routes.test.ts +++ b/backend/src/__tests__/scheduled-tasks-routes.test.ts @@ -409,6 +409,50 @@ describe('POST /api/scheduled-tasks - new lifecycle actions', () => { }); }); +describe('POST /api/scheduled-tasks - Skipper tier gating', () => { + beforeEach(() => { + variantSpy.mockReturnValue('skipper'); + }); + + it('allows Skipper admins to create update tasks', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({ + name: 'skipper-update', target_type: 'stack', target_id: 'my-stack', node_id: 1, + action: 'update', cron_expression: '0 3 * * *', enabled: true, + }); + expect(res.status).toBe(201); + expect(res.body.action).toBe('update'); + }); + + it('allows Skipper admins to create scan tasks', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({ + name: 'skipper-scan', target_type: 'system', node_id: 1, + action: 'scan', cron_expression: '0 0 * * *', enabled: true, + }); + expect(res.status).toBe(201); + expect(res.body.action).toBe('scan'); + }); + + it('allows Skipper admins to create snapshot tasks', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({ + name: 'skipper-snapshot', target_type: 'fleet', node_id: 1, + action: 'snapshot', cron_expression: '0 1 * * *', enabled: true, + }); + expect(res.status).toBe(201); + expect(res.body.action).toBe('snapshot'); + }); + + for (const action of ['restart', 'prune', 'auto_backup', 'auto_stop', 'auto_down', 'auto_start']) { + it(`rejects Skipper admins from creating ${action} tasks with 403`, async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({ + name: `skipper-${action}`, target_type: 'stack', target_id: 'my-stack', node_id: 1, + action, cron_expression: '0 3 * * *', enabled: true, + }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('ADMIRAL_REQUIRED'); + }); + } +}); + describe('PUT /api/scheduled-tasks/:id - delete_after_run', () => { it('can toggle delete_after_run via update', async () => { const now = Date.now(); diff --git a/backend/src/middleware/tierGates.ts b/backend/src/middleware/tierGates.ts index e6bf992c..478a86a5 100644 --- a/backend/src/middleware/tierGates.ts +++ b/backend/src/middleware/tierGates.ts @@ -59,9 +59,12 @@ export const requireNodeProxy = (req: Request, res: Response): boolean => { return true; }; -/** Tier gate for scheduled tasks: `update`, `scan`, and `snapshot` require Skipper+, everything else requires Admiral. */ +/** Scheduled task actions a Skipper-tier license may create and view. All other actions are Admiral-only. */ +export const SKIPPER_SCHEDULED_ACTIONS: ReadonlySet = new Set(['update', 'scan', 'snapshot']); + +/** Tier gate for scheduled tasks: SKIPPER_SCHEDULED_ACTIONS require Skipper+, everything else requires Admiral. */ export const requireScheduledTaskTier = (action: string, req: Request, res: Response): boolean => { - if (action === 'update' || action === 'scan' || action === 'snapshot') return requirePaid(req, res); + if (SKIPPER_SCHEDULED_ACTIONS.has(action)) return requirePaid(req, res); return requireAdmiral(req, res); }; diff --git a/backend/src/routes/scheduledTasks.ts b/backend/src/routes/scheduledTasks.ts index dc4fcf5e..c602ece5 100644 --- a/backend/src/routes/scheduledTasks.ts +++ b/backend/src/routes/scheduledTasks.ts @@ -3,7 +3,7 @@ import { CronExpressionParser } from 'cron-parser'; import { DatabaseService, type ScheduledTask } from '../services/DatabaseService'; import { LicenseService } from '../services/LicenseService'; import { SchedulerService } from '../services/SchedulerService'; -import { requirePaid, requireAdmin, requireScheduledTaskTier } from '../middleware/tierGates'; +import { requirePaid, requireAdmin, requireScheduledTaskTier, SKIPPER_SCHEDULED_ACTIONS } from '../middleware/tierGates'; import { escapeCsvField } from '../utils/csv'; import { getErrorMessage } from '../utils/errors'; import { parseIntParam } from '../utils/parseIntParam'; @@ -19,7 +19,6 @@ type TargetType = typeof VALID_TARGET_TYPES[number]; type ScheduledAction = typeof VALID_ACTIONS[number]; const STACK_ONLY_ACTIONS = new Set(['auto_backup', 'auto_stop', 'auto_down', 'auto_start']); -const SKIPPER_VISIBLE_ACTIONS = new Set(['update', 'scan', 'snapshot']); /** * Validate that the target_type is compatible with the action. Each action @@ -113,7 +112,7 @@ scheduledTasksRouter.get('/', (req: Request, res: Response): void => { // Skipper users see v1 fleet-maintenance tasks; Admiral sees all. const ls = LicenseService.getInstance(); if (ls.getVariant() !== 'admiral') { - tasks = tasks.filter(t => SKIPPER_VISIBLE_ACTIONS.has(t.action as ScheduledAction)); + tasks = tasks.filter(t => SKIPPER_SCHEDULED_ACTIONS.has(t.action)); } // Split Auto-Update and Scheduled Operations into distinct views. const actionFilter = typeof req.query.action === 'string' ? req.query.action : undefined; diff --git a/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx b/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx index 27d5dd15..513b25a2 100644 --- a/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx +++ b/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx @@ -227,14 +227,14 @@ describe('useViewNavigationState', () => { // ── navItems: skipper admin ──────────────────────────────────────────────── - it('navItems for skipper paid admin contains auto-updates but not admiral items', () => { + it('navItems for skipper paid admin contains schedules and auto-updates but not admiral items', () => { mockSkipperAdmin(); const { result } = renderHook(() => useViewNavigationState()); const values = result.current.navItems.map(i => i.value); expect(values).toContain('auto-updates'); + expect(values).toContain('scheduled-ops'); expect(values).not.toContain('host-console'); expect(values).not.toContain('audit-log'); - expect(values).not.toContain('scheduled-ops'); }); // ── navItems: hub-only gating on remote node ─────────────────────────────── diff --git a/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts b/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts index 90c0dd48..90a0af7c 100644 --- a/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts +++ b/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts @@ -108,11 +108,11 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions) ]; if (isPaid && isAdmin) { items.push({ value: 'auto-updates', label: 'Auto-Update', icon: RefreshCw }); + items.push({ value: 'scheduled-ops', label: 'Schedules', icon: Clock }); } if (isPaid && license?.variant === 'admiral') { if (isAdmin) items.push({ value: 'host-console', label: 'Console', icon: Terminal }); if (can('system:audit')) items.push({ value: 'audit-log', label: 'Audit', icon: ScrollText }); - if (isAdmin) items.push({ value: 'scheduled-ops', label: 'Schedules', icon: Clock }); } return isRemote ? items.filter(i => !HUB_ONLY_VIEWS.has(i.value)) diff --git a/frontend/src/components/ScheduledOperationsView.tsx b/frontend/src/components/ScheduledOperationsView.tsx index a4ba7671..3f1b7b26 100644 --- a/frontend/src/components/ScheduledOperationsView.tsx +++ b/frontend/src/components/ScheduledOperationsView.tsx @@ -13,11 +13,22 @@ import { Clock, Plus, Pencil, Trash2, History, RefreshCw, Play, ChevronLeft, Che import { toast } from '@/components/ui/toast-store'; import { apiFetch, fetchForNode } from '@/lib/api'; import { Combobox } from '@/components/ui/combobox'; +import { useLicense } from '@/context/LicenseContext'; import type { ScheduledTask, TaskRun, NodeOption } from '@/types/scheduling'; import { getCronDescription, formatTimestamp } from '@/lib/scheduling'; const UPDATE_FLEET_ACTION = 'update-fleet' as const; +// Mirrors backend `SKIPPER_SCHEDULED_ACTIONS` in tierGates.ts. Picker options +// whose backend action falls outside this set are Admiral-only and hidden from +// Skipper users so the Combobox never offers a choice the API will reject. +const SKIPPER_BACKEND_ACTIONS: ReadonlySet = new Set(['update', 'scan', 'snapshot']); + +function isActionAllowedForVariant(option: { value: string; backendAction?: string }, variant: string | null | undefined): boolean { + if (variant === 'admiral') return true; + return SKIPPER_BACKEND_ACTIONS.has(option.backendAction ?? option.value); +} + const ACTION_OPTIONS: Array<{ value: string; label: string; @@ -75,6 +86,11 @@ interface ScheduledOperationsViewProps { } export default function ScheduledOperationsView({ filterNodeId, onClearFilter, prefill, onPrefillConsumed }: ScheduledOperationsViewProps) { + const { license } = useLicense(); + const visibleActionOptions = useMemo( + () => ACTION_OPTIONS.filter(o => isActionAllowedForVariant(o, license?.variant)), + [license?.variant] + ); const [tasks, setTasks] = useState([]); const [loading, setLoading] = useState(true); const [view, setView] = useState<'timeline' | 'table'>('timeline'); @@ -209,7 +225,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p const nodeId = prefillData?.nodeId ?? (filterNodeId != null ? String(filterNodeId) : ''); setEditingTask(null); setFormName(''); - setFormAction('restart'); + setFormAction(visibleActionOptions[0]?.value ?? 'restart'); setFormTargetId(prefillData?.stackName ?? ''); setFormNodeId(nodeId); setFormCron('0 3 * * *'); @@ -676,7 +692,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
({ value: o.value, label: o.label }))} + options={visibleActionOptions.map(o => ({ value: o.value, label: o.label }))} value={formAction} onValueChange={(val) => { setFormAction(val); setFormTargetId(''); setFormNodeId(''); setFormTargetServices([]); setFormPruneLabelFilter(''); }} placeholder="Select action..."