mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-23 16:39:18 +00:00
92d974b13e
* fix: condition --volumes in downStack() on the removeVolumes option
ComposeService.downStack() hardcoded --volumes on every stack delete,
ignoring the "Also remove associated volumes" checkbox and destroying
volumes the operator asked to keep. The sibling Take-down path (runDown)
already conditions --volumes correctly.
- Add options?: { removeVolumes?: boolean } to downStack()
- Default to data-preserving (no --volumes when option absent)
- DeletedStackDeletionService reads the persisted intent flag
- Templates rollback passes removeVolumes: true (clean up failed deploy)
- Blueprint withdraw passes removeVolumes: false (volumes preserved)
* docs: update Delete row to reflect conditional volume removal
The Delete row now describes that volumes are removed only when the
operator opts in, matching the behavior introduced by the downStack fix.
* fix: add capability gate for delete pruneVolumes and fix QA findings
Four P0 issues found in live QA:
P0-1/P0-4 - No capability gate on delete's pruneVolumes:
Add stack-delete-prune-volumes capability so the frontend hides the
"Also remove associated volumes" checkbox on nodes that don't support
conditional volume removal on delete. Without this, an operator on an
old node sees a VOLUMES KEPT promise the old node silently breaks.
Frontend-only gate: no API or proxy gate because the old node's
fallback (always destroy) is correct for the checked case.
P0-2 - Checkbox state leaked across dialogs:
Reset pruneVolumes in onConfirm before calling the parent, so a
previously checked box doesn't appear pre-checked when the dialog
opens for a different stack.
P0-3 - Delete not bound to the active node:
Capture activeNode.id at delete time and pass it as an explicit
nodeId to apiFetch, matching the Take Down pattern. Without this,
switching the active node while the dialog is open silently deletes
the wrong stack on the wrong node.
* fix: update test assertions for nodeId binding and showVolumeOption gate
P0-3 added nodeId to apiFetch DELETE calls — two useStackActions tests
now expect the parameter. P0-1 gated the volume checkbox behind
showVolumeOption — the confirming test now passes the prop.
* fix: gate volume hint on showVolumeOption to prevent false promise
On nodes without stack-delete-prune-volumes, volumes are always
destroyed. Showing VOLUMES KEPT was a lie. Now the hint is hidden
entirely when the capability is absent.
* fix: gate delete against nodes that cannot guarantee volume preservation
Hiding the checkbox and the misleading hint stopped the false promise but
not the data loss: an unchecked delete against a node lacking
stack-delete-prune-volumes still reached that node and its downStack()
still destroyed volumes unconditionally, now with no warning at all.
- remoteNodeProxy.ts: block an unacknowledged DELETE /stacks/:name
(no pruneVolumes=true) to a remote lacking the capability, mirroring
the existing removeVolumes gate on the down route. An explicit
pruneVolumes=true always proxies through since that matches what an
unsupported remote does anyway.
- DeleteStackDialog: rework around a three-state model (supported /
unsupported / unknown) instead of a boolean. A node whose capabilities
have not been confirmed (meta not yet fetched, or a failed probe) is
now treated like a supported node, not forced onto the destructive
path just because its state is unresolved.
- Fix deleteStack's error toast, which surfaced the raw JSON response
body instead of the parsed error message.
- Fix CreateStackDialog's orphan-stack rollback (docker-run import),
which silently no-op'd against a node requiring acknowledgement.
- Update node-compatibility.mdx and stack-management.mdx to describe
the new gate.
* test: advertise stack-delete-prune-volumes on the scoped-evidence fixtures
These mock remotes simulate nodes capable enough to run scoped-stack-auth-evidence
RBAC and were pinned before stack-delete-prune-volumes existed, so the new delete
gate now blocked their unacknowledged DELETE calls before reaching the mock server,
failing the grant-tuple-cleanup assertions the tests actually check.
328 lines
11 KiB
TypeScript
328 lines
11 KiB
TypeScript
/**
|
|
* Orchestrated hub → remote proxy coverage for scoped stack elevation and
|
|
* DELETE tuple cleanup. Exercises createRemoteProxyMiddleware through
|
|
* live loopback remotes (not helper-only unit tests).
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
|
import http from 'http';
|
|
import bcrypt from 'bcrypt';
|
|
import request from 'supertest';
|
|
import jwt from 'jsonwebtoken';
|
|
import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
|
import {
|
|
PROXY_SCOPED_STACK_NAME_HEADER,
|
|
PROXY_SCOPED_STACK_ACTIONS_HEADER,
|
|
} from '../services/license-headers';
|
|
|
|
let tmpDir: string;
|
|
let app: import('express').Express;
|
|
let viewerBearer: string;
|
|
let viewerId: number;
|
|
let evidenceServer: http.Server;
|
|
let noEvidenceServer: http.Server;
|
|
let failDeleteServer: http.Server;
|
|
let wrongNodeServer: http.Server;
|
|
let evidenceNodeId: number;
|
|
let noEvidenceNodeId: number;
|
|
let failDeleteNodeId: number;
|
|
let wrongNodeId: number;
|
|
|
|
interface CapturedHop {
|
|
method: string;
|
|
url: string;
|
|
stackNameHeader: string | undefined;
|
|
stackActionsHeader: string | undefined;
|
|
}
|
|
|
|
const evidenceHops: CapturedHop[] = [];
|
|
const failDeleteHops: CapturedHop[] = [];
|
|
|
|
function captureHop(req: http.IncomingMessage, into: CapturedHop[]): void {
|
|
into.push({
|
|
method: req.method ?? '',
|
|
url: req.url ?? '',
|
|
stackNameHeader: req.headers[PROXY_SCOPED_STACK_NAME_HEADER] as string | undefined,
|
|
stackActionsHeader: req.headers[PROXY_SCOPED_STACK_ACTIONS_HEADER] as string | undefined,
|
|
});
|
|
}
|
|
|
|
function evidenceRemote(): http.Server {
|
|
return http.createServer((req, res) => {
|
|
if (req.url?.startsWith('/api/meta')) {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({
|
|
version: '0.93.0',
|
|
capabilities: ['cross-node-rbac', 'scoped-stack-auth-evidence', 'stack-delete-prune-volumes'],
|
|
}));
|
|
return;
|
|
}
|
|
captureHop(req, evidenceHops);
|
|
if (req.method === 'DELETE') {
|
|
res.writeHead(204);
|
|
res.end();
|
|
return;
|
|
}
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ ok: true }));
|
|
});
|
|
}
|
|
|
|
function noEvidenceRemote(): http.Server {
|
|
return http.createServer((req, res) => {
|
|
if (req.url?.startsWith('/api/meta')) {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({
|
|
version: '0.93.0',
|
|
capabilities: ['cross-node-rbac'],
|
|
}));
|
|
return;
|
|
}
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ ok: true }));
|
|
});
|
|
}
|
|
|
|
function failDeleteRemote(): http.Server {
|
|
return http.createServer((req, res) => {
|
|
if (req.url?.startsWith('/api/meta')) {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({
|
|
version: '0.93.0',
|
|
capabilities: ['cross-node-rbac', 'scoped-stack-auth-evidence', 'stack-delete-prune-volumes'],
|
|
}));
|
|
return;
|
|
}
|
|
captureHop(req, failDeleteHops);
|
|
if (req.method === 'DELETE') {
|
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'upstream delete failed' }));
|
|
return;
|
|
}
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ ok: true }));
|
|
});
|
|
}
|
|
|
|
async function listen(server: http.Server): Promise<number> {
|
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
return (server.address() as import('net').AddressInfo).port;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await setupTestDb();
|
|
({ app } = await import('../index'));
|
|
const { DatabaseService } = await import('../services/DatabaseService');
|
|
const { LicenseService } = await import('../services/LicenseService');
|
|
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
|
|
|
const db = DatabaseService.getInstance();
|
|
const hash = await bcrypt.hash('password123', 1);
|
|
viewerId = db.addUser({ username: 'scoped-proxy-viewer', password_hash: hash, role: 'viewer' });
|
|
const viewer = db.getUserByUsername('scoped-proxy-viewer')!;
|
|
viewerBearer = jwt.sign(
|
|
{ username: 'scoped-proxy-viewer', role: 'viewer', tv: viewer.token_version },
|
|
TEST_JWT_SECRET,
|
|
{ expiresIn: '5m' },
|
|
);
|
|
|
|
evidenceServer = evidenceRemote();
|
|
noEvidenceServer = noEvidenceRemote();
|
|
failDeleteServer = failDeleteRemote();
|
|
wrongNodeServer = evidenceRemote();
|
|
const evidencePort = await listen(evidenceServer);
|
|
const noEvidencePort = await listen(noEvidenceServer);
|
|
const failDeletePort = await listen(failDeleteServer);
|
|
const wrongNodePort = await listen(wrongNodeServer);
|
|
|
|
evidenceNodeId = db.addNode({
|
|
name: 'evidence-remote',
|
|
type: 'remote',
|
|
mode: 'proxy',
|
|
compose_dir: '/tmp',
|
|
is_default: false,
|
|
api_url: `http://127.0.0.1:${evidencePort}`,
|
|
api_token: 'evidence-token',
|
|
});
|
|
noEvidenceNodeId = db.addNode({
|
|
name: 'no-evidence-remote',
|
|
type: 'remote',
|
|
mode: 'proxy',
|
|
compose_dir: '/tmp',
|
|
is_default: false,
|
|
api_url: `http://127.0.0.1:${noEvidencePort}`,
|
|
api_token: 'no-evidence-token',
|
|
});
|
|
failDeleteNodeId = db.addNode({
|
|
name: 'fail-delete-remote',
|
|
type: 'remote',
|
|
mode: 'proxy',
|
|
compose_dir: '/tmp',
|
|
is_default: false,
|
|
api_url: `http://127.0.0.1:${failDeletePort}`,
|
|
api_token: 'fail-delete-token',
|
|
});
|
|
wrongNodeId = db.addNode({
|
|
name: 'wrong-node-remote',
|
|
type: 'remote',
|
|
mode: 'proxy',
|
|
compose_dir: '/tmp',
|
|
is_default: false,
|
|
api_url: `http://127.0.0.1:${wrongNodePort}`,
|
|
api_token: 'wrong-node-token',
|
|
});
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await new Promise<void>((resolve) => evidenceServer.close(() => resolve()));
|
|
await new Promise<void>((resolve) => noEvidenceServer.close(() => resolve()));
|
|
await new Promise<void>((resolve) => failDeleteServer.close(() => resolve()));
|
|
await new Promise<void>((resolve) => wrongNodeServer.close(() => resolve()));
|
|
vi.restoreAllMocks();
|
|
cleanupTestDb(tmpDir);
|
|
});
|
|
|
|
describe('remote proxy scoped stack evidence and DELETE cleanup', () => {
|
|
it('elevates a matching stack grant and forwards bound evidence headers', async () => {
|
|
const db = (await import('../services/DatabaseService')).DatabaseService.getInstance();
|
|
db.addRoleAssignment({
|
|
user_id: viewerId,
|
|
role: 'deployer',
|
|
resource_type: 'stack',
|
|
resource_id: 'shared-name',
|
|
node_id: evidenceNodeId,
|
|
});
|
|
evidenceHops.length = 0;
|
|
|
|
const res = await request(app)
|
|
.post('/api/stacks/shared-name/deploy')
|
|
.set('Authorization', `Bearer ${viewerBearer}`)
|
|
.set('x-node-id', String(evidenceNodeId));
|
|
|
|
expect(res.status).toBe(200);
|
|
const hop = evidenceHops.find((h) => h.url?.includes('/stacks/shared-name/deploy'));
|
|
expect(hop).toBeDefined();
|
|
expect(hop!.stackNameHeader).toBe('shared-name');
|
|
expect(hop!.stackActionsHeader).toContain('stack:deploy');
|
|
expect(hop!.stackActionsHeader).not.toContain('node:manage');
|
|
|
|
db.deleteRoleAssignmentsByStack(evidenceNodeId, 'shared-name');
|
|
});
|
|
|
|
it('denies the same stack name on a node without a grant', async () => {
|
|
const db = (await import('../services/DatabaseService')).DatabaseService.getInstance();
|
|
db.addRoleAssignment({
|
|
user_id: viewerId,
|
|
role: 'deployer',
|
|
resource_type: 'stack',
|
|
resource_id: 'shared-name',
|
|
node_id: evidenceNodeId,
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post('/api/stacks/shared-name/deploy')
|
|
.set('Authorization', `Bearer ${viewerBearer}`)
|
|
.set('x-node-id', String(wrongNodeId));
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.code).toBe('PERMISSION_DENIED');
|
|
|
|
db.deleteRoleAssignmentsByStack(evidenceNodeId, 'shared-name');
|
|
});
|
|
|
|
it('denies scoped elevation when the remote lacks scoped-stack-auth-evidence', async () => {
|
|
const db = (await import('../services/DatabaseService')).DatabaseService.getInstance();
|
|
db.addRoleAssignment({
|
|
user_id: viewerId,
|
|
role: 'deployer',
|
|
resource_type: 'stack',
|
|
resource_id: 'needs-evidence',
|
|
node_id: noEvidenceNodeId,
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post('/api/stacks/needs-evidence/deploy')
|
|
.set('Authorization', `Bearer ${viewerBearer}`)
|
|
.set('x-node-id', String(noEvidenceNodeId));
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toMatch(/scoped stack authorization/i);
|
|
|
|
db.deleteRoleAssignmentsByStack(noEvidenceNodeId, 'needs-evidence');
|
|
});
|
|
|
|
it('clears the hub grant tuple after a successful remote DELETE', async () => {
|
|
const db = (await import('../services/DatabaseService')).DatabaseService.getInstance();
|
|
db.addRoleAssignment({
|
|
user_id: viewerId,
|
|
role: 'node-admin',
|
|
resource_type: 'stack',
|
|
resource_id: 'doomed',
|
|
node_id: evidenceNodeId,
|
|
});
|
|
expect(
|
|
db.getRoleAssignments(viewerId, 'stack', 'doomed', evidenceNodeId),
|
|
).toHaveLength(1);
|
|
|
|
const res = await request(app)
|
|
.delete('/api/stacks/doomed')
|
|
.set('Authorization', `Bearer ${viewerBearer}`)
|
|
.set('x-node-id', String(evidenceNodeId));
|
|
|
|
expect(res.status).toBe(204);
|
|
expect(
|
|
db.getRoleAssignments(viewerId, 'stack', 'doomed', evidenceNodeId),
|
|
).toHaveLength(0);
|
|
});
|
|
|
|
it('preserves the hub grant tuple when remote DELETE is non-2xx', async () => {
|
|
const db = (await import('../services/DatabaseService')).DatabaseService.getInstance();
|
|
db.addRoleAssignment({
|
|
user_id: viewerId,
|
|
role: 'node-admin',
|
|
resource_type: 'stack',
|
|
resource_id: 'keep-me',
|
|
node_id: failDeleteNodeId,
|
|
});
|
|
|
|
const res = await request(app)
|
|
.delete('/api/stacks/keep-me')
|
|
.set('Authorization', `Bearer ${viewerBearer}`)
|
|
.set('x-node-id', String(failDeleteNodeId));
|
|
|
|
expect(res.status).toBe(500);
|
|
expect(
|
|
db.getRoleAssignments(viewerId, 'stack', 'keep-me', failDeleteNodeId),
|
|
).toHaveLength(1);
|
|
|
|
db.deleteRoleAssignmentsByStack(failDeleteNodeId, 'keep-me');
|
|
});
|
|
|
|
it('builds evidence from a node-scoped grant on the target node', async () => {
|
|
const db = (await import('../services/DatabaseService')).DatabaseService.getInstance();
|
|
db.addRoleAssignment({
|
|
user_id: viewerId,
|
|
role: 'node-admin',
|
|
resource_type: 'node',
|
|
resource_id: String(evidenceNodeId),
|
|
});
|
|
evidenceHops.length = 0;
|
|
|
|
const res = await request(app)
|
|
.post('/api/stacks/node-wide-stack/deploy')
|
|
.set('Authorization', `Bearer ${viewerBearer}`)
|
|
.set('x-node-id', String(evidenceNodeId));
|
|
|
|
expect(res.status).toBe(200);
|
|
const hop = evidenceHops.find((h) => h.url?.includes('/stacks/node-wide-stack/deploy'));
|
|
expect(hop).toBeDefined();
|
|
expect(hop!.stackNameHeader).toBe('node-wide-stack');
|
|
expect(hop!.stackActionsHeader).toContain('stack:deploy');
|
|
expect(hop!.stackActionsHeader).toContain('stack:edit');
|
|
|
|
const assignments = db.getAllRoleAssignments(viewerId).filter(
|
|
(a) => a.resource_type === 'node' && a.resource_id === String(evidenceNodeId),
|
|
);
|
|
for (const a of assignments) db.deleteRoleAssignment(a.id!);
|
|
});
|
|
});
|