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:
Anso
2026-07-24 15:57:18 -04:00
committed by GitHub
parent e33eda3c38
commit 17a8dc8a94
19 changed files with 1092 additions and 286 deletions
@@ -129,3 +129,71 @@ describe('POST /api/blueprints/apply-local (node-to-node atomic apply)', () => {
expect(StackOpLockService.getInstance().get(1, 'apply-local-busy')?.action).toBe('update');
});
});
describe('POST /api/blueprints/withdraw-local', () => {
let viewerCookie: string;
beforeAll(async () => {
const bcrypt = (await import('bcrypt')).default;
const passwordHash = await bcrypt.hash('bp-wd-viewer-pass', 1);
DatabaseService.getInstance().addUser({
username: 'bp-wd-viewer',
password_hash: passwordHash,
role: 'viewer',
});
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'bp-wd-viewer', password: 'bp-wd-viewer-pass' });
const cookies = res.headers['set-cookie'] as string | string[];
viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies;
});
it('rejects an invalid stack name', async () => {
const res = await request(app)
.post('/api/blueprints/withdraw-local')
.set('Cookie', adminCookie)
.send({ stackName: '../escape', blueprintId: 1 });
expect(res.status).toBe(400);
expect(res.body.error).toContain('Invalid stack name');
});
it('rejects a non-positive blueprintId', async () => {
const res = await request(app)
.post('/api/blueprints/withdraw-local')
.set('Cookie', adminCookie)
.send({ stackName: 'wd-local-stack', blueprintId: 0 });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/blueprintId/i);
});
it('returns 403 for a viewer without stack:delete', async () => {
const res = await request(app)
.post('/api/blueprints/withdraw-local')
.set('Cookie', viewerCookie)
.send({ stackName: 'wd-local-stack', blueprintId: 1 });
expect(res.status).toBe(403);
});
it('returns 409 self_stack_protected for Sencho own stack', async () => {
const selfStackGuard = await import('../helpers/selfStackGuard');
vi.spyOn(selfStackGuard, 'refuseIfSelfStack').mockImplementation(async (_req, res) => {
res.status(409).json({ error: 'self', code: 'self_stack_protected' });
return true;
});
const res = await request(app)
.post('/api/blueprints/withdraw-local')
.set('Cookie', adminCookie)
.send({ stackName: 'sencho-self', blueprintId: 1 });
expect(res.status).toBe(409);
expect(res.body.code).toBe('self_stack_protected');
});
it('returns already_absent when the stack directory is missing', async () => {
const res = await request(app)
.post('/api/blueprints/withdraw-local')
.set('Cookie', adminCookie)
.send({ stackName: `wd-absent-${Date.now()}`, blueprintId: 42 });
expect(res.status).toBe(200);
expect(res.body.status).toBe('already_absent');
});
});