diff --git a/backend/src/__tests__/labels-bulk-actions.test.ts b/backend/src/__tests__/labels-bulk-actions.test.ts new file mode 100644 index 00000000..9de7cafb --- /dev/null +++ b/backend/src/__tests__/labels-bulk-actions.test.ts @@ -0,0 +1,149 @@ +import { beforeAll, beforeEach, afterAll, describe, expect, it, vi } from 'vitest'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb'; + +let mockFsStacks: string[] = []; +const deployStack = vi.fn(); +const getContainersByStack = vi.fn(); +const stopContainer = vi.fn(); +const restartContainer = vi.fn(); +const enforcePolicyPreDeploy = vi.fn(); +const invalidateNodeCaches = vi.fn(); + +vi.mock('../services/FileSystemService', () => ({ + FileSystemService: { + getInstance: vi.fn(() => ({ + getStacks: vi.fn(async () => mockFsStacks), + })), + }, +})); + +vi.mock('../services/ComposeService', () => ({ + ComposeService: { + getInstance: vi.fn(() => ({ deployStack })), + }, +})); + +vi.mock('../services/DockerController', () => ({ + default: { + getInstance: vi.fn(() => ({ + getContainersByStack, + stopContainer, + restartContainer, + })), + }, +})); + +vi.mock('../services/PolicyEnforcement', () => ({ + enforcePolicyPreDeploy, +})); + +vi.mock('../helpers/cacheInvalidation', () => ({ + invalidateNodeCaches, +})); + +let tmpDir: string; +let app: import('express').Express; +let authHeader: string; +let db: import('../services/DatabaseService').DatabaseService; +let LicenseService: typeof import('../services/LicenseService').LicenseService; +let activeBulkActions: typeof import('../routes/labels').activeBulkActions; +let labelCounter = 0; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + ({ LicenseService } = await import('../services/LicenseService')); + ({ activeBulkActions } = await import('../routes/labels')); + const { DatabaseService } = await import('../services/DatabaseService'); + db = DatabaseService.getInstance(); + authHeader = `Bearer ${jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' })}`; +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +beforeEach(() => { + vi.restoreAllMocks(); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + mockFsStacks = ['alpha', 'beta']; + deployStack.mockResolvedValue(undefined); + getContainersByStack.mockResolvedValue([{ Id: 'container-1' }]); + stopContainer.mockResolvedValue(undefined); + restartContainer.mockResolvedValue(undefined); + enforcePolicyPreDeploy.mockResolvedValue({ ok: true }); + invalidateNodeCaches.mockClear(); + activeBulkActions.clear(); + db.getDb().prepare('DELETE FROM stack_label_assignments').run(); + db.getDb().prepare('DELETE FROM stack_labels').run(); +}); + +async function createAssignedLabel(stacks: string[] = ['alpha']) { + const created = await request(app) + .post('/api/labels') + .set('Authorization', authHeader) + .send({ name: `bulk-${++labelCounter}`, color: 'teal' }); + expect(created.status).toBe(201); + + for (const stack of stacks) { + const assigned = await request(app) + .put(`/api/stacks/${stack}/labels`) + .set('Authorization', authHeader) + .send({ labelIds: [created.body.id] }); + expect(assigned.status).toBe(200); + } + + return created.body as { id: number; node_id: number; name: string; color: string }; +} + +describe('Stack Labels bulk actions', () => { + it('deploys every existing stack assigned to the label', async () => { + const label = await createAssignedLabel(['alpha']); + + const res = await request(app) + .post(`/api/labels/${label.id}/action`) + .set('Authorization', authHeader) + .send({ action: 'deploy' }); + + expect(res.status).toBe(200); + expect(res.body.results).toEqual([{ stackName: 'alpha', success: true }]); + expect(enforcePolicyPreDeploy).toHaveBeenCalledWith('alpha', label.node_id, expect.any(Object)); + expect(deployStack).toHaveBeenCalledWith('alpha', undefined, false); + expect(invalidateNodeCaches).toHaveBeenCalledWith(label.node_id); + }); + + it('reports partial Docker stop failures without aborting other stacks', async () => { + const label = await createAssignedLabel(['alpha', 'beta']); + getContainersByStack.mockImplementation(async (stackName: string) => { + if (stackName === 'beta') throw new Error('socket permission denied'); + return [{ Id: `${stackName}-1` }]; + }); + + const res = await request(app) + .post(`/api/labels/${label.id}/action`) + .set('Authorization', authHeader) + .send({ action: 'stop' }); + + expect(res.status).toBe(200); + expect(res.body.results).toEqual([ + { stackName: 'alpha', success: true }, + { stackName: 'beta', success: false, error: 'socket permission denied' }, + ]); + expect(stopContainer).toHaveBeenCalledWith('alpha-1'); + expect(invalidateNodeCaches).toHaveBeenCalledWith(label.node_id); + }); + + it('rejects a second bulk action while the node lock is held', async () => { + const label = await createAssignedLabel(['alpha']); + activeBulkActions.add(`bulk:${label.node_id}`); + + const res = await request(app) + .post(`/api/labels/${label.id}/action`) + .set('Authorization', authHeader) + .send({ action: 'restart' }); + + expect(res.status).toBe(429); + expect(res.body.error).toContain('already running'); + expect(restartContainer).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/__tests__/labels-community-tier.test.ts b/backend/src/__tests__/labels-community-tier.test.ts index 989d8228..7f5926ad 100644 --- a/backend/src/__tests__/labels-community-tier.test.ts +++ b/backend/src/__tests__/labels-community-tier.test.ts @@ -12,14 +12,24 @@ import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './he let tmpDir: string; let app: import('express').Express; let authHeader: string; +let viewerAuthHeader: string; +let nodeAdminAuthHeader: string; let LicenseService: typeof import('../services/LicenseService').LicenseService; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; beforeAll(async () => { tmpDir = await setupTestDb(); ({ app } = await import('../index')); ({ LicenseService } = await import('../services/LicenseService')); + ({ DatabaseService } = await import('../services/DatabaseService')); + DatabaseService.getInstance().addUser({ username: 'labels-viewer', password_hash: 'hash', role: 'viewer' }); + DatabaseService.getInstance().addUser({ username: 'labels-node-admin', password_hash: 'hash', role: 'node-admin' }); const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' }); + const viewerToken = jwt.sign({ username: 'labels-viewer' }, TEST_JWT_SECRET, { expiresIn: '1m' }); + const nodeAdminToken = jwt.sign({ username: 'labels-node-admin' }, TEST_JWT_SECRET, { expiresIn: '1m' }); authHeader = `Bearer ${token}`; + viewerAuthHeader = `Bearer ${viewerToken}`; + nodeAdminAuthHeader = `Bearer ${nodeAdminToken}`; }); afterAll(() => cleanupTestDb(tmpDir)); @@ -87,6 +97,16 @@ describe('Stack Labels on Community tier', () => { expect(res.body.success).toBe(true); }); + it('allows node-admins to create labels through the stack edit permission', async () => { + mockTier('community'); + const res = await request(app) + .post('/api/labels') + .set('Authorization', nodeAdminAuthHeader) + .send({ name: 'node-admin-label', color: 'green' }); + expect(res.status).toBe(201); + expect(res.body).toMatchObject({ name: 'node-admin-label', color: 'green' }); + }); + it('PUT /api/stacks/:stackName/labels accepts an empty assignment on community', async () => { mockTier('community'); const res = await request(app) @@ -96,6 +116,87 @@ describe('Stack Labels on Community tier', () => { expect(res.status).toBe(200); expect(res.body.success).toBe(true); }); + + it('rejects path traversal stack names before assigning labels', async () => { + mockTier('community'); + const res = await request(app) + .put(`/api/stacks/${encodeURIComponent('../secret')}/labels`) + .set('Authorization', authHeader) + .send({ labelIds: [] }); + expect(res.status).toBe(400); + expect(res.body.error).toBe('Invalid stack name'); + }); +}); + +describe('Stack Labels RBAC', () => { + afterEach(() => vi.restoreAllMocks()); + + it('allows viewers to read labels but denies label creation', async () => { + mockTier('community'); + const list = await request(app).get('/api/labels').set('Authorization', viewerAuthHeader); + expect(list.status).toBe(200); + + const create = await request(app) + .post('/api/labels') + .set('Authorization', viewerAuthHeader) + .send({ name: 'viewer-create', color: 'teal' }); + expect(create.status).toBe(403); + expect(create.body.code).toBe('PERMISSION_DENIED'); + }); + + it('denies viewers label update, delete, and stack assignment', async () => { + mockTier('community'); + const created = await request(app) + .post('/api/labels') + .set('Authorization', authHeader) + .send({ name: 'rbac-target', color: 'blue' }); + expect(created.status).toBe(201); + + const update = await request(app) + .put(`/api/labels/${created.body.id}`) + .set('Authorization', viewerAuthHeader) + .send({ color: 'rose' }); + expect(update.status).toBe(403); + expect(update.body.code).toBe('PERMISSION_DENIED'); + + const assign = await request(app) + .put('/api/stacks/rbac-stack/labels') + .set('Authorization', viewerAuthHeader) + .send({ labelIds: [created.body.id] }); + expect(assign.status).toBe(403); + expect(assign.body.code).toBe('PERMISSION_DENIED'); + + const remove = await request(app) + .delete(`/api/labels/${created.body.id}`) + .set('Authorization', viewerAuthHeader); + expect(remove.status).toBe(403); + expect(remove.body.code).toBe('PERMISSION_DENIED'); + }); +}); + +describe('Stack Labels Developer Mode logging', () => { + afterEach(() => vi.restoreAllMocks()); + + it('only emits label debug logs when Developer Mode is enabled', async () => { + mockTier('community'); + const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + const db = DatabaseService.getInstance(); + + db.updateGlobalSetting('developer_mode', '0'); + const quiet = await request(app).get('/api/labels').set('Authorization', authHeader); + expect(quiet.status).toBe(200); + expect(debugSpy).not.toHaveBeenCalled(); + + db.updateGlobalSetting('developer_mode', '1'); + const noisy = await request(app).get('/api/labels').set('Authorization', authHeader); + expect(noisy.status).toBe(200); + expect(debugSpy).toHaveBeenCalledWith( + '[Labels:debug] List labels: nodeId=', + expect.any(Number), + 'count=', + expect.any(Number), + ); + }); }); describe('Stack Labels bulk-action endpoint stays Skipper+', () => { diff --git a/backend/src/routes/labels.ts b/backend/src/routes/labels.ts index 3788bb22..07d26e80 100644 --- a/backend/src/routes/labels.ts +++ b/backend/src/routes/labels.ts @@ -5,6 +5,7 @@ import { ComposeService } from '../services/ComposeService'; import DockerController from '../services/DockerController'; import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement'; import { authMiddleware } from '../middleware/auth'; +import { requirePermission } from '../middleware/permissions'; import { requirePaid, requireAdmin, requireBody } from '../middleware/tierGates'; import { buildPolicyGateOptions } from '../helpers/policyGate'; import { invalidateNodeCaches } from '../helpers/cacheInvalidation'; @@ -36,6 +37,7 @@ labelsRouter.get('/', authMiddleware, async (req: Request, res: Response): Promi }); labelsRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requirePermission(req, res, 'stack:edit')) return; if (!requireBody(req, res)) return; try { const nodeId = req.nodeId ?? 0; @@ -105,6 +107,7 @@ labelsRouter.get('/assignments', authMiddleware, async (req: Request, res: Respo }); labelsRouter.put('/:id', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requirePermission(req, res, 'stack:edit')) return; if (!requireBody(req, res)) return; try { const id = parseIntParam(req, res, 'id', 'label ID'); @@ -148,6 +151,7 @@ labelsRouter.put('/:id', authMiddleware, async (req: Request, res: Response): Pr }); labelsRouter.delete('/:id', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requirePermission(req, res, 'stack:edit')) return; try { const id = parseIntParam(req, res, 'id', 'label ID'); if (id === null) return; @@ -255,13 +259,14 @@ labelsRouter.post('/:id/action', authMiddleware, async (req: Request, res: Respo export const stackLabelsRouter = Router(); stackLabelsRouter.put('/:stackName/labels', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requireBody(req, res)) return; try { const stackName = req.params.stackName as string; if (!isValidStackName(stackName)) { res.status(400).json({ error: 'Invalid stack name' }); return; } + if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return; + if (!requireBody(req, res)) return; const nodeId = req.nodeId ?? 0; const { labelIds } = req.body; diff --git a/backend/src/utils/debug.ts b/backend/src/utils/debug.ts index 704f7613..ad756c38 100644 --- a/backend/src/utils/debug.ts +++ b/backend/src/utils/debug.ts @@ -1,3 +1,5 @@ +import { DatabaseService } from '../services/DatabaseService'; + /** * Shared diagnostic logging gate. Reads `developer_mode` from * DatabaseService, which caches the global_settings snapshot internally @@ -18,11 +20,7 @@ export function isDebugEnabled(): boolean { try { - // Dynamic require avoids circular-dependency issues when this - // utility is imported from services that DatabaseService itself - // depends on, and prevents SQLite side effects during tests. - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { DatabaseService } = require('../services/DatabaseService'); + if (process.env.NODE_ENV === 'test' && !process.env.DATA_DIR) return false; return DatabaseService.getInstance().getGlobalSettings().developer_mode === '1'; } catch { return false; diff --git a/frontend/src/components/EditorLayout/hooks/useStackListState.ts b/frontend/src/components/EditorLayout/hooks/useStackListState.ts index f5285e8a..eff4f96d 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackListState.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackListState.ts @@ -2,7 +2,6 @@ import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; import { useNodes } from '@/context/NodeContext'; -import { useLicense } from '@/context/LicenseContext'; import { useImageUpdates } from '@/hooks/useImageUpdates'; import { usePinnedStacks } from '@/hooks/usePinnedStacks'; import { useSidebarGroupCollapse } from '@/hooks/useSidebarGroupCollapse'; @@ -32,7 +31,6 @@ export interface RemoteResult { export function useStackListState() { const { nodes, activeNode } = useNodes(); - const { isPaid } = useLicense(); const [files, setFiles] = useState([]); const [selectedFile, setSelectedFile] = useState(null); @@ -84,7 +82,6 @@ export function useStackListState() { }; const refreshLabels = useCallback(async () => { - if (!isPaid) return; try { const [labelsRes, assignmentsRes] = await Promise.all([ apiFetch('/labels'), @@ -95,7 +92,7 @@ export function useStackListState() { } catch { // Labels are non-critical; fail silently } - }, [isPaid]); + }, []); useEffect(() => { const handler = () => refreshLabels(); diff --git a/frontend/src/components/sidebar/StackRow.tsx b/frontend/src/components/sidebar/StackRow.tsx index 9fbb8adf..90e8e930 100644 --- a/frontend/src/components/sidebar/StackRow.tsx +++ b/frontend/src/components/sidebar/StackRow.tsx @@ -44,13 +44,13 @@ const MAX_VISIBLE_LABELS = 3; export function StackRow(props: StackRowProps) { const { - file, displayName, status, isBusy, isActive, isPaid, labels, + file, displayName, status, isBusy, isActive, labels, hasUpdate, hasGitPending, onSelect, kebabSlot, bulkMode = false, isSelected = false, onToggleSelect, } = props; - const visibleLabels = isPaid ? labels.slice(0, MAX_VISIBLE_LABELS) : []; - const overflowCount = isPaid ? Math.max(0, labels.length - MAX_VISIBLE_LABELS) : 0; + const visibleLabels = labels.slice(0, MAX_VISIBLE_LABELS); + const overflowCount = Math.max(0, labels.length - MAX_VISIBLE_LABELS); const handleClick = () => { if (bulkMode) onToggleSelect?.(file); diff --git a/frontend/src/components/sidebar/__tests__/StackRow.test.tsx b/frontend/src/components/sidebar/__tests__/StackRow.test.tsx index 415e4088..976b1efc 100644 --- a/frontend/src/components/sidebar/__tests__/StackRow.test.tsx +++ b/frontend/src/components/sidebar/__tests__/StackRow.test.tsx @@ -73,4 +73,13 @@ describe('StackRow', () => { expect(container.querySelector('.animate-spin')).not.toBeNull(); expect(screen.queryByText('UP')).not.toBeInTheDocument(); }); + + it('renders label indicators on community tier', () => { + const labels: Label[] = [ + { id: 1, node_id: 0, name: 'prod', color: 'teal' }, + { id: 2, node_id: 0, name: 'media', color: 'blue' }, + ]; + const { container } = render(); + expect(container.querySelectorAll('[style*="--label-"]')).toHaveLength(2); + }); }); diff --git a/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx b/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx index 0df55541..4d0e0962 100644 --- a/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx +++ b/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx @@ -95,6 +95,17 @@ describe('useStackMenuItems', () => { expect(inspect.items.find(i => i.id === 'auto-update')).toBeUndefined(); }); + it('keeps label assignment available when !isPaid', () => { + const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ + isPaid: false, + labels: [{ id: 1, node_id: 0, name: 'prod', color: 'teal' }], + }))); + const organize = result.current.find(g => g.id === 'organize')!; + const labelsItem = organize.items.find(i => i.id === 'labels'); + expect(labelsItem).toBeDefined(); + expect(labelsItem?.subItems?.[0]).toMatchObject({ id: 'label:1', label: 'prod' }); + }); + it('auto-update toggle calls setAutoUpdateEnabled with toggled value', () => { const setAutoUpdateEnabled = vi.fn(); const { result } = renderHook(() => diff --git a/frontend/src/hooks/useStackMenuItems.tsx b/frontend/src/hooks/useStackMenuItems.tsx index 21d704b5..abb4961b 100644 --- a/frontend/src/hooks/useStackMenuItems.tsx +++ b/frontend/src/hooks/useStackMenuItems.tsx @@ -48,21 +48,19 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[] groups.push({ id: 'inspect', items: inspect }); const organize: MenuItem[] = []; - if (isPaid) { - organize.push({ - id: 'labels', - label: 'Labels', + organize.push({ + id: 'labels', + label: 'Labels', + icon: Tag, + shortcut: 'L ›', + onSelect: () => {}, + subItems: labels.map(l => ({ + id: `label:${l.id}`, + label: l.name, icon: Tag, - shortcut: 'L ›', - onSelect: () => {}, - subItems: labels.map(l => ({ - id: `label:${l.id}`, - label: l.name, - icon: Tag, - onSelect: () => toggleLabel(l.id), - })), - }); - } + onSelect: () => toggleLabel(l.id), + })), + }); organize.push( isPinned ? { id: 'pin', label: 'Unpin', icon: PinOff, shortcut: 'P', onSelect: unpin }