diff --git a/backend/src/__tests__/containers-route-authz.test.ts b/backend/src/__tests__/containers-route-authz.test.ts new file mode 100644 index 00000000..1dd76e46 --- /dev/null +++ b/backend/src/__tests__/containers-route-authz.test.ts @@ -0,0 +1,115 @@ +/** + * Authorization tests for the container/ports read routes. These reads + * (`GET /api/containers`, `GET /api/containers/:id/logs`, `GET /api/ports/in-use`) + * are gated by `requirePermission('stack:read')`, the same read model used across + * the stacks router. Every shipped role carries `stack:read`, so the denial path + * is exercised by temporarily removing it from a role at runtime. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import bcrypt from 'bcrypt'; +import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET } from './helpers/setupTestDb'; + +let tmpDir: string; +let app: import('express').Express; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let DockerController: typeof import('../services/DockerController').default; +let FileSystemService: typeof import('../services/FileSystemService').FileSystemService; +let ROLE_PERMISSIONS: typeof import('../middleware/permissions').ROLE_PERMISSIONS; + +const VIEWER = 'container-read-viewer'; +const READ_PATHS = ['/api/containers', '/api/containers/abc123/logs', '/api/ports/in-use']; + +/** Sign a viewer JWT using the live token_version so authMiddleware accepts it. */ +function viewerToken(): string { + const user = DatabaseService.getInstance().getUserByUsername(VIEWER)!; + return jwt.sign({ username: VIEWER, role: 'viewer', tv: user.token_version }, TEST_JWT_SECRET, { expiresIn: '1m' }); +} + +/** + * Replace the DockerController / FileSystemService singletons with stubs so the + * handlers run without a Docker daemon. The logs stub ends the response itself + * (the real streamContainerLogs flushes SSE headers and streams), so a request + * that clears the guard resolves instead of hanging. Returns the spies so a test + * can assert whether a handler ever reached the Docker/FS layer. + */ +function stubDockerAndFs(): { docker: ReturnType; fs: ReturnType } { + const docker = vi.spyOn(DockerController, 'getInstance').mockReturnValue({ + getRunningContainers: vi.fn().mockResolvedValue([]), + streamContainerLogs: vi.fn().mockImplementation((_id: string, _req: unknown, res: import('express').Response) => { + res.status(200).end(); + return Promise.resolve(); + }), + getPortsInUse: vi.fn().mockResolvedValue([]), + } as unknown as ReturnType); + const fs = vi.spyOn(FileSystemService, 'getInstance').mockReturnValue({ + getStacks: vi.fn().mockResolvedValue([]), + } as unknown as ReturnType); + return { docker, fs }; +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); + ({ default: DockerController } = await import('../services/DockerController')); + ({ FileSystemService } = await import('../services/FileSystemService')); + ({ ROLE_PERMISSIONS } = await import('../middleware/permissions')); + ({ app } = await import('../index')); + + const hash = await bcrypt.hash('password123', 1); + DatabaseService.getInstance().addUser({ username: VIEWER, password_hash: hash, role: 'viewer' }); +}); + +afterAll(() => { + cleanupTestDb(tmpDir); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('container/ports reads deny a role without stack:read', () => { + let originalViewerPerms: typeof ROLE_PERMISSIONS.viewer; + let docker: ReturnType; + let fs: ReturnType; + + beforeEach(() => { + originalViewerPerms = ROLE_PERMISSIONS.viewer; + ROLE_PERMISSIONS.viewer = originalViewerPerms.filter((p) => p !== 'stack:read'); + ({ docker, fs } = stubDockerAndFs()); + }); + + afterEach(() => { + ROLE_PERMISSIONS.viewer = originalViewerPerms; + }); + + it.each(READ_PATHS)('GET %s is rejected before any Docker work', async (path) => { + const res = await request(app).get(path).set('Authorization', `Bearer ${viewerToken()}`); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + // The guard short-circuits before the handler instantiates the controller. For the + // logs route this proves the 403 is sent before streamContainerLogs flushes SSE + // headers (DockerController.ts), since getInstance precedes that flush. + expect(docker).not.toHaveBeenCalled(); + expect(fs).not.toHaveBeenCalled(); + }); +}); + +describe('container/ports reads admit a role with stack:read', () => { + beforeEach(() => { + stubDockerAndFs(); + }); + + it.each(READ_PATHS)('GET %s is admitted', async (path) => { + const res = await request(app).get(path).set('Authorization', `Bearer ${viewerToken()}`); + expect(res.status).toBe(200); + }); +}); + +describe('container/ports reads reject unauthenticated requests', () => { + it.each(READ_PATHS)('GET %s without a token is 401', async (path) => { + const res = await request(app).get(path); + expect(res.status).toBe(401); + }); +}); diff --git a/backend/src/routes/containers.ts b/backend/src/routes/containers.ts index 887eb1aa..dea81cb9 100644 --- a/backend/src/routes/containers.ts +++ b/backend/src/routes/containers.ts @@ -2,11 +2,13 @@ import { Router, type Request, type Response } from 'express'; import DockerController from '../services/DockerController'; import { FileSystemService } from '../services/FileSystemService'; import { requireAdmin } from '../middleware/tierGates'; +import { requirePermission } from '../middleware/permissions'; import { invalidateNodeCaches } from '../helpers/cacheInvalidation'; export const containersRouter = Router(); containersRouter.get('/', async (req: Request, res: Response) => { + if (!requirePermission(req, res, 'stack:read')) return; try { const dockerController = DockerController.getInstance(req.nodeId); const containers = await dockerController.getRunningContainers(); @@ -17,6 +19,7 @@ containersRouter.get('/', async (req: Request, res: Response) => { }); containersRouter.get('/:id/logs', async (req: Request, res: Response) => { + if (!requirePermission(req, res, 'stack:read')) return; try { const id = req.params.id as string; const dockerController = DockerController.getInstance(req.nodeId); @@ -68,6 +71,7 @@ containersRouter.post('/:id/restart', async (req: Request, res: Response) => { export const portsRouter = Router(); portsRouter.get('/in-use', async (req: Request, res: Response) => { + if (!requirePermission(req, res, 'stack:read')) return; try { const fsService = FileSystemService.getInstance(req.nodeId); const stacks = await fsService.getStacks();