mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-29 03:36:55 +00:00
fix(blueprints): fail closed on marker ownership for apply and withdraw (#1694)
* fix(blueprints): fail closed on marker ownership for apply and withdraw Require a matching .blueprint.json under the stack lock, persist required_blueprint_id on deletion intents, remove the legacy remote apply fallback, and protect the marker in the file explorer. * fix(blueprints): add CodeQL path barriers on ownership probes Use the canonical resolve-and-startsWith sanitizer inline at the marker and stack-directory fs sinks so js/path-injection clears. * fix(blueprints): block delete on failed withdraw and defer marker write Refuse Blueprint DELETE when pre-delete withdraw does not complete, and write .blueprint.json only after a successful deploy so failed applies cannot orphan stacks or claim an unapplied revision. * test(blueprints): align lock-order assert with deferred marker write Update the per-stack lock ordering expectations to compose, cleanup, deploy, then marker after the partial-apply fix. * fix(deps): bump postcss past GHSA-r28c-9q8g-f849 for npm audit Raise the Vitest/Vite transitive postcss to 8.5.23 so Backend CI audit --audit-level=high passes.
This commit is contained in:
@@ -399,7 +399,7 @@ describe('BlueprintReconciler developer-mode diagnostics', () => {
|
||||
});
|
||||
|
||||
describe('BlueprintService per-stack lock', () => {
|
||||
it('deploy under a free lock writes compose then marker, then deploys', async () => {
|
||||
it('deploy under a free lock writes compose, cleans siblings, deploys, then writes the marker', async () => {
|
||||
const nodeId = seedNode();
|
||||
const bp = seedBlueprint({ classification: 'stateless', nodeIds: [nodeId] });
|
||||
const node = DatabaseService.getInstance().getNode(nodeId)!;
|
||||
@@ -419,7 +419,7 @@ describe('BlueprintService per-stack lock', () => {
|
||||
source: 'blueprint',
|
||||
actor: 'system:blueprint',
|
||||
});
|
||||
// Compose is written first, then the marker, both before sibling cleanup and deploy.
|
||||
// Compose is written first; the marker is deferred until after sibling cleanup and deploy.
|
||||
expect(writeSpy).toHaveBeenCalledTimes(2);
|
||||
expect(writeSpy.mock.calls[0][1]).toBe('compose.yaml');
|
||||
expect(writeSpy.mock.calls[0][2]).toBe(bp.compose_content);
|
||||
@@ -429,9 +429,9 @@ describe('BlueprintService per-stack lock', () => {
|
||||
const [composeOrder, markerOrder] = writeSpy.mock.invocationCallOrder;
|
||||
const [cleanupOrder] = cleanupSpy.mock.invocationCallOrder;
|
||||
const [deployOrder] = deploySpy.mock.invocationCallOrder;
|
||||
expect(composeOrder).toBeLessThan(markerOrder);
|
||||
expect(markerOrder).toBeLessThan(cleanupOrder);
|
||||
expect(composeOrder).toBeLessThan(cleanupOrder);
|
||||
expect(cleanupOrder).toBeLessThan(deployOrder);
|
||||
expect(deployOrder).toBeLessThan(markerOrder);
|
||||
});
|
||||
|
||||
it('deploy skips, writes no stack files, and records failed when the stack lock is held', async () => {
|
||||
@@ -476,7 +476,10 @@ describe('BlueprintService local withdraw clears stack-scoped role assignments',
|
||||
|
||||
async function arrangeLocalWithdraw() {
|
||||
const db = DatabaseService.getInstance();
|
||||
const composeDir = process.env.COMPOSE_DIR!;
|
||||
const nodeId = seedNode();
|
||||
db.getDb().prepare('UPDATE nodes SET compose_dir = ? WHERE id = ?').run(composeDir, nodeId);
|
||||
|
||||
const bp = seedBlueprint({ name: `rbac-wd-${counter}`, classification: 'stateless', nodeIds: [nodeId] });
|
||||
const node = db.getNode(nodeId)!;
|
||||
db.upsertDeployment({
|
||||
@@ -499,7 +502,15 @@ describe('BlueprintService local withdraw clears stack-scoped role assignments',
|
||||
user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(otherNodeId),
|
||||
});
|
||||
|
||||
vi.spyOn(BlueprintService.getInstance(), 'readMarker').mockResolvedValue(null);
|
||||
// Matching on-disk marker so ownership passes; FS/down are stubbed.
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
const stackDir = path.join(composeDir, bp.name);
|
||||
fs.mkdirSync(stackDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(stackDir, '.blueprint.json'),
|
||||
JSON.stringify({ blueprintId: bp.id, revision: bp.revision, lastApplied: Date.now() }),
|
||||
);
|
||||
const { ComposeService } = await import('../services/ComposeService');
|
||||
const { FileSystemService } = await import('../services/FileSystemService');
|
||||
vi.spyOn(ComposeService.prototype, 'downStack').mockResolvedValue(undefined);
|
||||
@@ -522,15 +533,16 @@ describe('BlueprintService local withdraw clears stack-scoped role assignments',
|
||||
db.deleteUser(userId);
|
||||
});
|
||||
|
||||
it('clears the target assignment when deleteStack treats the directory as already absent (ENOENT)', async () => {
|
||||
// Shared deletion always calls deleteStack; FileSystemService maps ENOENT to success.
|
||||
it('clears the target assignment when the stack directory is already absent', async () => {
|
||||
const { bp, node, userId, deleteStackSpy, db } = await arrangeLocalWithdraw();
|
||||
deleteStackSpy.mockResolvedValue(undefined);
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
fs.rmSync(path.join(process.env.COMPOSE_DIR!, bp.name), { recursive: true, force: true });
|
||||
|
||||
const outcome = await BlueprintService.getInstance().withdrawFromNode(bp, node);
|
||||
|
||||
expect(outcome.status).toBe('withdrawn');
|
||||
expect(deleteStackSpy).toHaveBeenCalledWith(bp.name);
|
||||
expect(deleteStackSpy).not.toHaveBeenCalled();
|
||||
expect(hasAssignment(userId, 'stack', bp.name)).toBe(false);
|
||||
expect(db.getAllRoleAssignments(userId)).toHaveLength(2);
|
||||
db.deleteUser(userId);
|
||||
|
||||
Reference in New Issue
Block a user