mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
155db30554
* fix(blueprints): gate confirmed apply on live intent fingerprint reconcileConfirmedPlan only checked approval_status, so a concurrent compose edit during Apply could deploy unapproved content under a stale fingerprint. Match the tick-path fingerprint gate, refuse Apply when live intent drifts, and surface reconciler refusal as PREVIEW_STALE instead of a false success. * test(blueprints): cover matching-fingerprint reconcileConfirmedPlan path Prove the production allow branch still deploys authorized actions when the approval fingerprint matches, and does not execute unauthorized blast nodes. * fix(blueprints): report live approval after confirmed snapshot apply A concurrent edit can clear approval while multi-node snapshot deploy is still running. Keep the in-flight snapshot contract, but re-read live effectiveApproval before responding and warn in the rollout dialog when approval is no longer current.
430 lines
17 KiB
TypeScript
430 lines
17 KiB
TypeScript
/**
|
|
* Preview-confirm apply binding and stale-guard behaviour.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
|
import request from 'supertest';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
|
|
|
let tmpDir: string;
|
|
let app: import('express').Express;
|
|
let adminCookie: string;
|
|
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
|
let BlueprintReconciler: typeof import('../services/BlueprintReconciler').BlueprintReconciler;
|
|
let counter = 0;
|
|
|
|
function seedNode(): { id: number; name: string } {
|
|
counter += 1;
|
|
const name = `bp-preview-node-${counter}`;
|
|
const db = DatabaseService.getInstance().getDb();
|
|
const result = db.prepare(
|
|
`INSERT INTO nodes (name, type, mode, compose_dir, is_default, status, created_at)
|
|
VALUES (?, 'local', 'proxy', '/tmp/compose', 0, 'online', ?)`,
|
|
).run(name, Date.now());
|
|
return { id: result.lastInsertRowid as number, name };
|
|
}
|
|
|
|
function validBlueprintBody(nodeId: number) {
|
|
return {
|
|
name: `bp-preview-${counter + 1}`,
|
|
compose_content: 'services:\n app:\n image: nginx\n',
|
|
selector: { type: 'nodes', ids: [nodeId] },
|
|
drift_mode: 'observe',
|
|
};
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await setupTestDb();
|
|
({ app } = await import('../index'));
|
|
({ DatabaseService } = await import('../services/DatabaseService'));
|
|
({ BlueprintReconciler } = await import('../services/BlueprintReconciler'));
|
|
adminCookie = await loginAsTestAdmin(app);
|
|
});
|
|
|
|
afterAll(() => cleanupTestDb(tmpDir));
|
|
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
vi.spyOn(BlueprintReconciler.getInstance(), 'reconcileConfirmedPlan').mockResolvedValue({ outcomes: [] });
|
|
const db = DatabaseService.getInstance().getDb();
|
|
db.prepare('DELETE FROM blueprint_deployments').run();
|
|
db.prepare('DELETE FROM blueprints').run();
|
|
db.prepare('DELETE FROM nodes WHERE is_default = 0').run();
|
|
});
|
|
|
|
describe('POST /api/blueprints/:id/apply confirm binding', () => {
|
|
it('rejects apply without planFingerprint', async () => {
|
|
const node = seedNode();
|
|
counter += 1;
|
|
const created = await request(app)
|
|
.post('/api/blueprints')
|
|
.set('Cookie', adminCookie)
|
|
.send(validBlueprintBody(node.id));
|
|
expect(created.status).toBe(201);
|
|
|
|
const res = await request(app)
|
|
.post(`/api/blueprints/${created.body.id}/apply`)
|
|
.set('Cookie', adminCookie)
|
|
.send({});
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.code).toBe('CONFIRM_REQUIRED');
|
|
});
|
|
|
|
it('rejects stale fingerprint with PREVIEW_STALE', async () => {
|
|
const node = seedNode();
|
|
counter += 1;
|
|
const created = await request(app)
|
|
.post('/api/blueprints')
|
|
.set('Cookie', adminCookie)
|
|
.send(validBlueprintBody(node.id));
|
|
expect(created.status).toBe(201);
|
|
|
|
const preview = await request(app)
|
|
.get(`/api/blueprints/${created.body.id}/preview`)
|
|
.set('Cookie', adminCookie);
|
|
expect(preview.status).toBe(200);
|
|
|
|
const res = await request(app)
|
|
.post(`/api/blueprints/${created.body.id}/apply`)
|
|
.set('Cookie', adminCookie)
|
|
.send({
|
|
planFingerprint: 'deadbeef',
|
|
actions: preview.body.confirmableActions,
|
|
});
|
|
expect(res.status).toBe(409);
|
|
expect(res.body.code).toBe('PREVIEW_STALE');
|
|
});
|
|
|
|
it('persists approval and calls reconcileConfirmedPlan with executor actions', async () => {
|
|
const node = seedNode();
|
|
counter += 1;
|
|
const created = await request(app)
|
|
.post('/api/blueprints')
|
|
.set('Cookie', adminCookie)
|
|
.send(validBlueprintBody(node.id));
|
|
expect(created.status).toBe(201);
|
|
|
|
const preview = await request(app)
|
|
.get(`/api/blueprints/${created.body.id}/preview`)
|
|
.set('Cookie', adminCookie);
|
|
expect(preview.status).toBe(200);
|
|
expect(preview.body.effectiveApproval).toBe('pending');
|
|
expect(preview.body.planFingerprint).toBeTruthy();
|
|
|
|
const reconcileSpy = vi.mocked(BlueprintReconciler.getInstance().reconcileConfirmedPlan);
|
|
const res = await request(app)
|
|
.post(`/api/blueprints/${created.body.id}/apply`)
|
|
.set('Cookie', adminCookie)
|
|
.send({
|
|
planFingerprint: preview.body.planFingerprint,
|
|
actions: preview.body.confirmableActions,
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.effectiveApproval).toBe('approved');
|
|
expect(res.body.outcomes).toEqual([]);
|
|
expect(res.body.outcomeSummary).toEqual({
|
|
total: 0,
|
|
ok: 0,
|
|
failed: 0,
|
|
pending: 0,
|
|
skipped: 0,
|
|
});
|
|
expect(reconcileSpy).toHaveBeenCalledWith(created.body.id, preview.body.executorActions);
|
|
|
|
const detail = await request(app)
|
|
.get(`/api/blueprints/${created.body.id}`)
|
|
.set('Cookie', adminCookie);
|
|
expect(detail.status).toBe(200);
|
|
expect(detail.body.effectiveApproval).toBe('approved');
|
|
|
|
const stored = DatabaseService.getInstance().getBlueprint(created.body.id)!;
|
|
expect(stored.approval_status).toBe('approved');
|
|
expect(stored.approved_intent_fingerprint).toBe(preview.body.planFingerprint);
|
|
expect(stored.updated_at).toBe(created.body.updated_at);
|
|
});
|
|
|
|
it('returns PREVIEW_STALE when compose drifts between preview and approval persist', async () => {
|
|
const node = seedNode();
|
|
counter += 1;
|
|
const created = await request(app)
|
|
.post('/api/blueprints')
|
|
.set('Cookie', adminCookie)
|
|
.send(validBlueprintBody(node.id));
|
|
expect(created.status).toBe(201);
|
|
|
|
const preview = await request(app)
|
|
.get(`/api/blueprints/${created.body.id}/preview`)
|
|
.set('Cookie', adminCookie);
|
|
expect(preview.status).toBe(200);
|
|
|
|
const db = DatabaseService.getInstance();
|
|
const originalSet = db.setBlueprintApproval.bind(db);
|
|
vi.spyOn(db, 'setBlueprintApproval').mockImplementation((id, input) => {
|
|
// Concurrent edit landed compose v2 before approval was written; Apply
|
|
// still persists the v1 fingerprint onto that row.
|
|
db.getDb().prepare('UPDATE blueprints SET compose_content = ? WHERE id = ?').run(
|
|
'services:\n app:\n image: nginx:evil\n',
|
|
id,
|
|
);
|
|
return originalSet(id, input);
|
|
});
|
|
|
|
const reconcileSpy = vi.mocked(BlueprintReconciler.getInstance().reconcileConfirmedPlan);
|
|
const res = await request(app)
|
|
.post(`/api/blueprints/${created.body.id}/apply`)
|
|
.set('Cookie', adminCookie)
|
|
.send({
|
|
planFingerprint: preview.body.planFingerprint,
|
|
actions: preview.body.confirmableActions,
|
|
});
|
|
expect(res.status).toBe(409);
|
|
expect(res.body.code).toBe('PREVIEW_STALE');
|
|
expect(reconcileSpy).not.toHaveBeenCalled();
|
|
|
|
const stored = DatabaseService.getInstance().getBlueprint(created.body.id)!;
|
|
expect(stored.approval_status).toBe('pending');
|
|
expect(stored.approved_intent_fingerprint).toBeNull();
|
|
});
|
|
|
|
it('returns PREVIEW_STALE when reconcileConfirmedPlan refuses after approval', async () => {
|
|
const node = seedNode();
|
|
counter += 1;
|
|
const created = await request(app)
|
|
.post('/api/blueprints')
|
|
.set('Cookie', adminCookie)
|
|
.send(validBlueprintBody(node.id));
|
|
expect(created.status).toBe(201);
|
|
|
|
const preview = await request(app)
|
|
.get(`/api/blueprints/${created.body.id}/preview`)
|
|
.set('Cookie', adminCookie);
|
|
expect(preview.status).toBe(200);
|
|
|
|
vi.mocked(BlueprintReconciler.getInstance().reconcileConfirmedPlan).mockResolvedValueOnce({
|
|
outcomes: [],
|
|
refused: true,
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post(`/api/blueprints/${created.body.id}/apply`)
|
|
.set('Cookie', adminCookie)
|
|
.send({
|
|
planFingerprint: preview.body.planFingerprint,
|
|
actions: preview.body.confirmableActions,
|
|
});
|
|
expect(res.status).toBe(409);
|
|
expect(res.body.code).toBe('PREVIEW_STALE');
|
|
|
|
const stored = DatabaseService.getInstance().getBlueprint(created.body.id)!;
|
|
expect(stored.approval_status).toBe('pending');
|
|
expect(stored.approved_intent_fingerprint).toBeNull();
|
|
});
|
|
|
|
it('reports live pending approval when approval is cleared during reconcile', async () => {
|
|
const node = seedNode();
|
|
counter += 1;
|
|
const created = await request(app)
|
|
.post('/api/blueprints')
|
|
.set('Cookie', adminCookie)
|
|
.send(validBlueprintBody(node.id));
|
|
expect(created.status).toBe(201);
|
|
|
|
const preview = await request(app)
|
|
.get(`/api/blueprints/${created.body.id}/preview`)
|
|
.set('Cookie', adminCookie);
|
|
expect(preview.status).toBe(200);
|
|
|
|
vi.mocked(BlueprintReconciler.getInstance().reconcileConfirmedPlan).mockImplementationOnce(async (id) => {
|
|
// Concurrent edit invalidated approval while the confirmed snapshot ran.
|
|
DatabaseService.getInstance().clearBlueprintApproval(id);
|
|
return {
|
|
outcomes: [{
|
|
nodeId: node.id,
|
|
nodeName: node.name,
|
|
action: 'create',
|
|
status: 'ok',
|
|
}],
|
|
};
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post(`/api/blueprints/${created.body.id}/apply`)
|
|
.set('Cookie', adminCookie)
|
|
.send({
|
|
planFingerprint: preview.body.planFingerprint,
|
|
actions: preview.body.confirmableActions,
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.effectiveApproval).toBe('pending');
|
|
expect(res.body.message).toMatch(/approval is no longer current/i);
|
|
expect(res.body.outcomes).toHaveLength(1);
|
|
expect(res.body.outcomes[0].status).toBe('ok');
|
|
expect(res.body.outcomeSummary.ok).toBe(1);
|
|
|
|
const stored = DatabaseService.getInstance().getBlueprint(created.body.id)!;
|
|
expect(stored.approval_status).toBe('pending');
|
|
});
|
|
|
|
it('returns failed outcomes without pretending the rollout was clean', async () => {
|
|
const node = seedNode();
|
|
counter += 1;
|
|
const created = await request(app)
|
|
.post('/api/blueprints')
|
|
.set('Cookie', adminCookie)
|
|
.send(validBlueprintBody(node.id));
|
|
expect(created.status).toBe(201);
|
|
|
|
const preview = await request(app)
|
|
.get(`/api/blueprints/${created.body.id}/preview`)
|
|
.set('Cookie', adminCookie);
|
|
expect(preview.status).toBe(200);
|
|
|
|
vi.mocked(BlueprintReconciler.getInstance().reconcileConfirmedPlan).mockResolvedValueOnce({
|
|
outcomes: [{
|
|
nodeId: node.id,
|
|
nodeName: node.name,
|
|
action: 'create',
|
|
status: 'failed',
|
|
error: 'EHOSTUNREACH',
|
|
}],
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post(`/api/blueprints/${created.body.id}/apply`)
|
|
.set('Cookie', adminCookie)
|
|
.send({
|
|
planFingerprint: preview.body.planFingerprint,
|
|
actions: preview.body.confirmableActions,
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.effectiveApproval).toBe('approved');
|
|
expect(res.body.message).toMatch(/node failures/i);
|
|
expect(res.body.outcomeSummary.failed).toBe(1);
|
|
expect(res.body.outcomes[0].error).toBe('EHOSTUNREACH');
|
|
});
|
|
|
|
it('marks create as blocked when an unmanaged same-name stack already exists', async () => {
|
|
const node = seedNode();
|
|
counter += 1;
|
|
const created = await request(app)
|
|
.post('/api/blueprints')
|
|
.set('Cookie', adminCookie)
|
|
.send(validBlueprintBody(node.id));
|
|
expect(created.status).toBe(201);
|
|
|
|
const composeDir = process.env.COMPOSE_DIR!;
|
|
DatabaseService.getInstance().getDb()
|
|
.prepare('UPDATE nodes SET compose_dir = ? WHERE id = ?')
|
|
.run(composeDir, node.id);
|
|
|
|
const stackDir = path.join(composeDir, created.body.name as string);
|
|
fs.mkdirSync(stackDir, { recursive: true });
|
|
fs.writeFileSync(path.join(stackDir, 'docker-compose.yml'), 'services:\n app:\n image: nginx\n');
|
|
|
|
const { BlueprintService } = await import('../services/BlueprintService');
|
|
const conflict = await BlueprintService.getInstance().hasNameConflict(
|
|
created.body.name as string,
|
|
DatabaseService.getInstance().getNode(node.id)!,
|
|
);
|
|
expect(conflict).toBe(true);
|
|
|
|
const preview = await request(app)
|
|
.get(`/api/blueprints/${created.body.id}/preview`)
|
|
.set('Cookie', adminCookie);
|
|
expect(preview.status).toBe(200);
|
|
const change = preview.body.changes.find((c: { nodeId: number }) => c.nodeId === node.id);
|
|
expect(change.action).toBe('blocked_name_conflict');
|
|
expect(change.severity).toBe('blocker');
|
|
expect(preview.body.summary.blocker).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('blocks rename while a non-withdrawn deployment exists', async () => {
|
|
const node = seedNode();
|
|
counter += 1;
|
|
const created = await request(app)
|
|
.post('/api/blueprints')
|
|
.set('Cookie', adminCookie)
|
|
.send(validBlueprintBody(node.id));
|
|
expect(created.status).toBe(201);
|
|
|
|
DatabaseService.getInstance().upsertDeployment({
|
|
blueprint_id: created.body.id,
|
|
node_id: node.id,
|
|
status: 'active',
|
|
applied_revision: 1,
|
|
last_deployed_at: Date.now(),
|
|
});
|
|
|
|
const res = await request(app)
|
|
.put(`/api/blueprints/${created.body.id}`)
|
|
.set('Cookie', adminCookie)
|
|
.send({ name: 'renamed-while-live' });
|
|
expect(res.status).toBe(409);
|
|
expect(res.body.code).toBe('RENAME_BLOCKED');
|
|
});
|
|
});
|
|
|
|
describe('preview projection status precedence', () => {
|
|
it('emits informational in_flight_deploy for a leaving deploying row, not remove', async () => {
|
|
const node = seedNode();
|
|
counter += 1;
|
|
const created = await request(app)
|
|
.post('/api/blueprints')
|
|
.set('Cookie', adminCookie)
|
|
.send({
|
|
...validBlueprintBody(node.id),
|
|
selector: { type: 'nodes', ids: [] },
|
|
});
|
|
expect(created.status).toBe(201);
|
|
|
|
DatabaseService.getInstance().upsertDeployment({
|
|
blueprint_id: created.body.id,
|
|
node_id: node.id,
|
|
status: 'deploying',
|
|
applied_revision: 1,
|
|
});
|
|
|
|
const preview = await request(app)
|
|
.get(`/api/blueprints/${created.body.id}/preview`)
|
|
.set('Cookie', adminCookie);
|
|
expect(preview.status).toBe(200);
|
|
const row = preview.body.changes.find((c: { nodeId: number }) => c.nodeId === node.id);
|
|
expect(row?.action).toBe('in_flight_deploy');
|
|
expect(row?.kind).toBe('informational');
|
|
expect(preview.body.executorActions.some(
|
|
(a: { nodeId: number; action: string }) => a.nodeId === node.id && a.action === 'remove',
|
|
)).toBe(false);
|
|
});
|
|
|
|
it('emits clear_stale_guard for never-deployed pending_state_review leavers', async () => {
|
|
const node = seedNode();
|
|
counter += 1;
|
|
const created = await request(app)
|
|
.post('/api/blueprints')
|
|
.set('Cookie', adminCookie)
|
|
.send({
|
|
name: `bp-stale-${counter}`,
|
|
compose_content: 'services:\n app:\n image: nginx\n volumes:\n - data:/data\nvolumes:\n data:\n',
|
|
selector: { type: 'nodes', ids: [] },
|
|
drift_mode: 'observe',
|
|
});
|
|
expect(created.status).toBe(201);
|
|
|
|
DatabaseService.getInstance().upsertDeployment({
|
|
blueprint_id: created.body.id,
|
|
node_id: node.id,
|
|
status: 'pending_state_review',
|
|
last_deployed_at: null,
|
|
});
|
|
|
|
const preview = await request(app)
|
|
.get(`/api/blueprints/${created.body.id}/preview`)
|
|
.set('Cookie', adminCookie);
|
|
expect(preview.status).toBe(200);
|
|
const row = preview.body.changes.find((c: { nodeId: number }) => c.nodeId === node.id);
|
|
expect(row?.action).toBe('clear_stale_guard');
|
|
expect(preview.body.executorActions).toContainEqual({ nodeId: node.id, action: 'clear_stale_guard' });
|
|
});
|
|
});
|