Files
sencho/backend/src/__tests__/fleet-stack-container-authz.test.ts
T
Anso 05c483f213 fix: harden cross-node fleet label actions and guard container reads (#1503)
* fix: harden cross-node fleet label actions and guard container reads

Release-stabilization fixes for the Fleet Actions surface:

- Stop-by-label binds execution to the nodes shown in the confirmed
  preview. The real stop sends the confirmed node ids and the backend
  restricts the fan-out to them, so a node that was unreachable during
  preview and reconnects before the stop can no longer enter execution
  and have unlisted stacks stopped.
- Bulk label assign validates each remote node's result against the
  stacks it was asked to label: a body whose results are empty, partial,
  duplicated, or shaped wrong is a per-node failure instead of reading as
  a successful zero-stack assign. The card mirrors this, rejecting a
  missing or non-array results body and only reporting success when at
  least one stack was assigned.
- Bulk label assign re-reads authoritative per-node stacks and labels on
  demand via a Refresh control, and the confirmation lists the affected
  node and stack names rather than bare counts.
- The stack-specific and fleet container/stack read routes require the
  stack:read permission, matching the generic container and stack routes.
  Every shipped role already carries stack:read, so reachability is
  unchanged; the guard closes the routes that were auth-only.

Adds unit coverage for the assign-result validator, route coverage for
the stop allowlist and assign membership checks, and authorization
coverage for the newly guarded reads.

* test: assert the confirmed node allowlist in the fleet stop-card test

The stop-card component test pinned the real-stop request body to
{ labelName, dryRun } and broke once the stop began carrying the
confirmed-preview node ids. Update it to expect the nodeIds allowlist
derived from the resolved preview, so the test asserts the binding
rather than the pre-fix shape.
2026-06-28 08:13:43 -04:00

119 lines
4.6 KiB
TypeScript

/**
* Authorization tests for the stack-specific and fleet container/stack read
* routes that were previously auth-only (no permission check):
* - GET /api/stacks/:stackName/containers
* - GET /api/fleet/node/:nodeId/stacks
* - GET /api/fleet/node/:nodeId/stacks/:stackName/containers
*
* They are now gated by `requirePermission('stack:read')`, the same read model
* the generic container/port routes and the rest of the stacks router use.
* 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 = 'fleet-read-viewer';
let READ_PATHS: string[];
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' });
}
/** Stub the Docker/FS singletons so the admitted path resolves without a daemon. */
function stubDockerAndFs(): { docker: ReturnType<typeof vi.spyOn>; fs: ReturnType<typeof vi.spyOn> } {
const docker = vi.spyOn(DockerController, 'getInstance').mockReturnValue({
getContainersByStack: 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>);
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' });
const localId = DatabaseService.getInstance().getNodes().find(n => n.is_default)!.id;
READ_PATHS = [
'/api/stacks/alpha/containers',
`/api/fleet/node/${localId}/stacks`,
`/api/fleet/node/${localId}/stacks/alpha/containers`,
];
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('stack/fleet container 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('rejects each read before any Docker/FS work', async () => {
for (const path of READ_PATHS) {
const res = await request(app).get(path).set('Authorization', `Bearer ${viewerToken()}`);
expect(res.status, `denied ${path}`).toBe(403);
expect(res.body.code).toBe('PERMISSION_DENIED');
}
// The guard short-circuits before the handler instantiates the controller.
expect(docker).not.toHaveBeenCalled();
expect(fs).not.toHaveBeenCalled();
});
});
describe('stack/fleet container reads admit a role with stack:read', () => {
beforeEach(() => {
stubDockerAndFs();
});
it('admits each read', async () => {
for (const path of READ_PATHS) {
const res = await request(app).get(path).set('Authorization', `Bearer ${viewerToken()}`);
expect(res.status, `admitted ${path}`).toBe(200);
}
});
});
describe('stack/fleet container reads reject unauthenticated requests', () => {
it('returns 401 without a token', async () => {
for (const path of READ_PATHS) {
const res = await request(app).get(path);
expect(res.status, `unauth ${path}`).toBe(401);
}
});
});