mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 15:46:43 +00:00
134 lines
5.9 KiB
TypeScript
134 lines
5.9 KiB
TypeScript
/**
|
|
* 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 SelfIdentityService: typeof import('../services/SelfIdentityService').default;
|
|
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'];
|
|
const MUTATION_PATHS = ['/api/containers/abc123/start', '/api/containers/abc123/stop', '/api/containers/abc123/restart'];
|
|
|
|
/** 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 / SelfIdentityService 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<typeof vi.spyOn>; fs: ReturnType<typeof vi.spyOn> } {
|
|
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<typeof DockerController.getInstance>);
|
|
const fs = vi.spyOn(FileSystemService, 'getInstance').mockReturnValue({
|
|
getStacks: vi.fn().mockResolvedValue([]),
|
|
} as unknown as ReturnType<typeof FileSystemService.getInstance>);
|
|
vi.spyOn(SelfIdentityService, 'getInstance').mockReturnValue({
|
|
initialize: vi.fn().mockResolvedValue(undefined),
|
|
isOwnContainer: vi.fn().mockReturnValue(false),
|
|
isOwnImage: vi.fn().mockReturnValue(false),
|
|
} as unknown as ReturnType<typeof SelfIdentityService.getInstance>);
|
|
return { docker, fs };
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await setupTestDb();
|
|
({ DatabaseService } = await import('../services/DatabaseService'));
|
|
({ default: DockerController } = await import('../services/DockerController'));
|
|
({ FileSystemService } = await import('../services/FileSystemService'));
|
|
({ default: SelfIdentityService } = await import('../services/SelfIdentityService'));
|
|
({ 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<typeof vi.spyOn>;
|
|
let fs: ReturnType<typeof vi.spyOn>;
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe('generic container-id mutations remain Admin-only', () => {
|
|
it.each(MUTATION_PATHS)('POST %s rejects a non-admin before Docker work', async (path) => {
|
|
const { docker } = stubDockerAndFs();
|
|
const res = await request(app).post(path).set('Authorization', `Bearer ${viewerToken()}`);
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.code).toBe('ADMIN_REQUIRED');
|
|
expect(docker).not.toHaveBeenCalled();
|
|
});
|
|
});
|