mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-22 16:16:41 +00:00
fix: harden blueprint deployment guardrails (#1027)
* fix: harden blueprint deployment guardrails * fix: update Docker toolchain to Go 1.26.3 * fix: repair Dockerfile tr argument split across lines * fix: bump protobufjs to clear npm audit high-severity advisories
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
||||
import { MAX_BLUEPRINT_COMPOSE_BYTES } from '../routes/blueprints';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
let LicenseService: typeof import('../services/LicenseService').LicenseService;
|
||||
let adminCookie: string;
|
||||
let counter = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
({ LicenseService } = await import('../services/LicenseService'));
|
||||
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
|
||||
({ app } = await import('../index'));
|
||||
adminCookie = await loginAsTestAdmin(app);
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
const db = DatabaseService.getInstance().getDb();
|
||||
db.prepare('DELETE FROM blueprint_deployments').run();
|
||||
db.prepare('DELETE FROM blueprints').run();
|
||||
});
|
||||
|
||||
function validCreateBody(composeContent: string) {
|
||||
counter += 1;
|
||||
return {
|
||||
name: `route-validate-${counter}`,
|
||||
description: null,
|
||||
compose_content: composeContent,
|
||||
selector: { type: 'nodes', ids: [1] },
|
||||
drift_mode: 'suggest',
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Blueprint route compose validation', () => {
|
||||
it('rejects invalid compose YAML on create', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/blueprints')
|
||||
.set('Cookie', adminCookie)
|
||||
.send(validCreateBody('services:\n bad: : nope:'));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain('compose_content must be valid YAML');
|
||||
expect(DatabaseService.getInstance().listBlueprints()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects oversized compose content on create', async () => {
|
||||
const oversized = `services:\n app:\n image: nginx\n labels:\n filler: "${'x'.repeat(MAX_BLUEPRINT_COMPOSE_BYTES)}"\n`;
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/blueprints')
|
||||
.set('Cookie', adminCookie)
|
||||
.send(validCreateBody(oversized));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain(`${MAX_BLUEPRINT_COMPOSE_BYTES} bytes or fewer`);
|
||||
expect(DatabaseService.getInstance().listBlueprints()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects oversized compose content on analyze', async () => {
|
||||
const oversized = `services:\n app:\n image: nginx\n labels:\n filler: "${'x'.repeat(MAX_BLUEPRINT_COMPOSE_BYTES)}"\n`;
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/blueprints/analyze')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ compose_content: oversized });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain(`${MAX_BLUEPRINT_COMPOSE_BYTES} bytes or fewer`);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* BlueprintReconciler decision-logic tests.
|
||||
*
|
||||
* The reconciler's `computeDecision` is the load-bearing pure logic — given
|
||||
* The reconciler's `computeDecision` is the load-bearing pure logic. Given
|
||||
* a blueprint, an actual deployment table, and a desired node set, it must
|
||||
* decide for each node whether to deploy, withdraw, drift-check, state-review,
|
||||
* or evict-block. We test that decision in isolation by accessing the
|
||||
@@ -9,10 +9,10 @@
|
||||
* pattern.
|
||||
*
|
||||
* Local deploy / remote HTTP / actual `docker compose` invocation are not
|
||||
* exercised here — they're integration concerns covered by the manual
|
||||
* exercised here; they're integration concerns covered by the manual
|
||||
* lifecycle in the plan.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
import type { Blueprint, Node } from '../services/DatabaseService';
|
||||
import type { ReconcileDecision } from '../services/BlueprintReconciler';
|
||||
@@ -42,6 +42,8 @@ beforeEach(() => {
|
||||
db.prepare('DELETE FROM blueprints').run();
|
||||
db.prepare('DELETE FROM node_labels').run();
|
||||
db.prepare("DELETE FROM nodes WHERE is_default = 0").run();
|
||||
db.prepare("UPDATE global_settings SET value = '0' WHERE key = 'developer_mode'").run();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function seedNode(): number {
|
||||
@@ -128,6 +130,22 @@ describe('BlueprintReconciler.computeDecision', () => {
|
||||
expect(decision.deploy.map((n: { id: number }) => n.id)).toContain(nodeId);
|
||||
});
|
||||
|
||||
it('queues state-review when a stateful deployment revision moves', () => {
|
||||
const nodeId = seedNode();
|
||||
const bp = seedBlueprint({ classification: 'stateful', nodeIds: [nodeId] });
|
||||
DatabaseService.getInstance().upsertDeployment({
|
||||
blueprint_id: bp.id,
|
||||
node_id: nodeId,
|
||||
status: 'active',
|
||||
applied_revision: bp.revision - 1,
|
||||
});
|
||||
const reconciler = BlueprintReconciler.getInstance() as unknown as ReconcilerWithCompute;
|
||||
const allNodes = DatabaseService.getInstance().getNodes();
|
||||
const decision = reconciler.computeDecision(bp, allNodes);
|
||||
expect(decision.stateReview.map((n: { id: number }) => n.id)).toContain(nodeId);
|
||||
expect(decision.deploy).toEqual([]);
|
||||
});
|
||||
|
||||
it('queues stateless eviction when a node leaves the selector', () => {
|
||||
const nodeId = seedNode();
|
||||
const bp = seedBlueprint({ classification: 'stateless', nodeIds: [] });
|
||||
@@ -352,6 +370,29 @@ describe('BlueprintReconciler.computeDecision', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('BlueprintReconciler developer-mode diagnostics', () => {
|
||||
it('does not emit diagnostic logs when developer mode is off', async () => {
|
||||
const nodeId = seedNode();
|
||||
const bp = seedBlueprint({ classification: 'stateful', nodeIds: [nodeId] });
|
||||
const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => undefined);
|
||||
|
||||
await BlueprintReconciler.getInstance().reconcileOne(bp.id);
|
||||
|
||||
expect(infoSpy.mock.calls.some(([message]) => String(message).includes('[BlueprintReconciler:diag]'))).toBe(false);
|
||||
});
|
||||
|
||||
it('emits diagnostic decision logs when developer mode is on', async () => {
|
||||
DatabaseService.getInstance().updateGlobalSetting('developer_mode', '1');
|
||||
const nodeId = seedNode();
|
||||
const bp = seedBlueprint({ classification: 'stateful', nodeIds: [nodeId] });
|
||||
const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => undefined);
|
||||
|
||||
await BlueprintReconciler.getInstance().reconcileOne(bp.id);
|
||||
|
||||
expect(infoSpy.mock.calls.some(([message]) => String(message).includes('[BlueprintReconciler:diag]'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BlueprintService marker parsing + name-conflict guard', () => {
|
||||
it('parseMarker accepts a well-formed marker', () => {
|
||||
const marker = BlueprintService.parseMarker(JSON.stringify({ blueprintId: 7, revision: 3, lastApplied: 12345 }));
|
||||
|
||||
@@ -57,7 +57,7 @@ vi.mock('../services/FleetSyncService', () => ({
|
||||
FleetSyncService: { getSelfIdentity: () => 'self-node' },
|
||||
}));
|
||||
|
||||
import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
|
||||
import { enforcePolicyForImageRefs, enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
|
||||
|
||||
function mkPolicy(overrides: Partial<ScanPolicy> = {}): ScanPolicy {
|
||||
return {
|
||||
@@ -275,4 +275,43 @@ describe('enforcePolicyPreDeploy', () => {
|
||||
expect(result.violations).toEqual([]);
|
||||
expect(trivyStub.scanImagePreflight).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('enforces a supplied image list without reading compose from disk', async () => {
|
||||
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy());
|
||||
trivyStub.isTrivyAvailable.mockReturnValue(true);
|
||||
trivyStub.scanImagePreflight.mockResolvedValue(mkScan({
|
||||
id: 42,
|
||||
highest_severity: 'HIGH',
|
||||
high_count: 1,
|
||||
}));
|
||||
|
||||
const result = await enforcePolicyForImageRefs('blueprint-web', 1, ['nginx:1.27-alpine'], { bypass: false, actor: 'u' });
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.violations).toHaveLength(1);
|
||||
expect(result.violations[0]).toMatchObject({
|
||||
imageRef: 'nginx:1.27-alpine',
|
||||
severity: 'HIGH',
|
||||
highCount: 1,
|
||||
scanId: 42,
|
||||
});
|
||||
expect(composeStub.listStackImages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails closed on invalid supplied image refs when requested', async () => {
|
||||
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy());
|
||||
trivyStub.isTrivyAvailable.mockReturnValue(true);
|
||||
|
||||
const result = await enforcePolicyForImageRefs('blueprint-web', 1, ['${IMAGE}'], { bypass: false, actor: 'u' }, undefined, true);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.violations).toHaveLength(1);
|
||||
expect(result.violations[0]).toMatchObject({
|
||||
imageRef: '${IMAGE}',
|
||||
severity: 'UNKNOWN',
|
||||
scanId: 0,
|
||||
});
|
||||
expect(trivyStub.scanImagePreflight).not.toHaveBeenCalled();
|
||||
expect(composeStub.listStackImages).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user