mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 04:38:11 +00:00
fix(blueprints): gate confirmed apply on live intent fingerprint (#1663)
* 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.
This commit is contained in:
@@ -144,6 +144,128 @@ describe('POST /api/blueprints/:id/apply confirm binding', () => {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user