mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 08:57:25 +00:00
feat(labels): harden Stack Labels (gate parity, abort, dry-run, cap) (#1232)
* feat(labels): harden stack-labels surface (gate parity, abort, dry-run policy, cap) - Cap labelIds array at MAX_LABELS_PER_NODE on PUT /api/stacks/:name/labels. - Hide sidebar Labels submenu and Settings mutation buttons for roles without stack:edit, matching the backend requirePermission gate. - Break the per-label bulk-action loop on req.aborted so cancelled requests release the per-node lock once the in-flight op completes. - Reset saving state on success in LabelInlineCreateForm so the form stays interactive when reused outside the kebab/context menus. - Invoke enforcePolicyPreDeploy inside the dry-run deploy branch and report blocked stacks honestly; previously dry-run skipped the gate and would predict success for stacks the real deploy would block. * fix(labels): split sidebar gate so inline create matches unscoped backend guard Codex review of #1232 surfaced a parity miss: POST /api/labels is guarded by unscoped requirePermission('stack:edit'), but the sidebar inline "New label" entry was gated by canEditLabels (per-stack scoped). An Admiral user with only scoped grants on a stack could toggle existing labels but the inline create request would 403. Splits the sidebar gate: - canEditLabels (scoped) keeps gating the Labels submenu trigger and toggle items, matching PUT /api/stacks/:name/labels. - canCreateLabels (unscoped) now gates the inline "New label" entry, matching POST /api/labels. Also restores the swallow-catch in LabelInlineCreateForm. The earlier catch-to-finally change in #1232 let the rethrow from createAndAssignLabel surface as an unhandled event-handler rejection in the browser console. Parents already toast on failure; swallowing in the form is the intended behavior with the finally reset still in place.
This commit is contained in:
@@ -65,6 +65,10 @@ afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
// restoreAllMocks only resets spies; bare vi.fn() mocks keep their call
|
||||
// history across tests. Clear them all so each test sees a fresh slate
|
||||
// before its `.not.toHaveBeenCalled()` assertions run.
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
mockFsStacks = ['alpha', 'beta'];
|
||||
deployStack.mockResolvedValue(undefined);
|
||||
@@ -72,7 +76,6 @@ beforeEach(() => {
|
||||
stopContainer.mockResolvedValue(undefined);
|
||||
restartContainer.mockResolvedValue(undefined);
|
||||
enforcePolicyPreDeploy.mockResolvedValue({ ok: true });
|
||||
invalidateNodeCaches.mockClear();
|
||||
activeBulkActions.clear();
|
||||
db.getDb().prepare('DELETE FROM stack_label_assignments').run();
|
||||
db.getDb().prepare('DELETE FROM stack_labels').run();
|
||||
@@ -146,4 +149,62 @@ describe('Stack Labels bulk actions', () => {
|
||||
expect(res.body.error).toContain('already running');
|
||||
expect(restartContainer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dry-run deploy runs the policy gate and reports blocked stacks honestly', async () => {
|
||||
const label = await createAssignedLabel(['alpha']);
|
||||
enforcePolicyPreDeploy.mockResolvedValue({
|
||||
ok: false,
|
||||
policy: { name: 'block-criticals', max_severity: 'high' },
|
||||
violations: [{ image: 'nginx:latest', severity: 'critical' }],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/labels/${label.id}/action`)
|
||||
.set('Authorization', authHeader)
|
||||
.send({ action: 'deploy', dryRun: true });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.results).toEqual([
|
||||
expect.objectContaining({ stackName: 'alpha', success: false, dryRun: true }),
|
||||
]);
|
||||
expect(res.body.results[0].error).toContain('Policy "block-criticals" blocked deploy');
|
||||
expect(deployStack).not.toHaveBeenCalled();
|
||||
expect(invalidateNodeCaches).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dry-run deploy reports success when the policy gate passes, without touching Docker', async () => {
|
||||
const label = await createAssignedLabel(['alpha']);
|
||||
enforcePolicyPreDeploy.mockResolvedValue({ ok: true });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/labels/${label.id}/action`)
|
||||
.set('Authorization', authHeader)
|
||||
.send({ action: 'deploy', dryRun: true });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.results).toEqual([
|
||||
{ stackName: 'alpha', success: true, dryRun: true },
|
||||
]);
|
||||
expect(enforcePolicyPreDeploy).toHaveBeenCalledWith('alpha', label.node_id, expect.any(Object));
|
||||
expect(deployStack).not.toHaveBeenCalled();
|
||||
expect(invalidateNodeCaches).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dry-run stop reports per-stack success without dispatching real stops', async () => {
|
||||
const label = await createAssignedLabel(['alpha', 'beta']);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/labels/${label.id}/action`)
|
||||
.set('Authorization', authHeader)
|
||||
.send({ action: 'stop', dryRun: true });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.results).toEqual([
|
||||
{ stackName: 'alpha', success: true, dryRun: true },
|
||||
{ stackName: 'beta', success: true, dryRun: true },
|
||||
]);
|
||||
expect(getContainersByStack).not.toHaveBeenCalled();
|
||||
expect(stopContainer).not.toHaveBeenCalled();
|
||||
expect(invalidateNodeCaches).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,14 @@ beforeAll(async () => {
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
// Tests in this file accumulate labels in the shared DB; clear them between
|
||||
// runs so later tests do not bump into MAX_LABELS_PER_NODE.
|
||||
afterEach(() => {
|
||||
const db = DatabaseService.getInstance().getDb();
|
||||
db.prepare('DELETE FROM stack_label_assignments').run();
|
||||
db.prepare('DELETE FROM stack_labels').run();
|
||||
});
|
||||
|
||||
function mockTier(tier: 'paid' | 'community') {
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue(tier);
|
||||
}
|
||||
@@ -126,6 +134,40 @@ describe('Stack Labels on Community tier', () => {
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe('Invalid stack name');
|
||||
});
|
||||
|
||||
it('rejects labelIds arrays over the per-node cap', async () => {
|
||||
mockTier('community');
|
||||
const oversized = Array.from({ length: 51 }, (_, i) => i + 1);
|
||||
const res = await request(app)
|
||||
.put('/api/stacks/cap-stack/labels')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ labelIds: oversized });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain('may not exceed');
|
||||
});
|
||||
|
||||
it('accepts labelIds at exactly the per-node cap', async () => {
|
||||
mockTier('community');
|
||||
// Seed 50 labels directly so the FK check on assignment passes without
|
||||
// running 50 HTTP round trips per test. The route resolves nodeId via
|
||||
// nodeContextMiddleware (default node when no x-node-id header), so the
|
||||
// seeded rows must use the same nodeId the request will look up.
|
||||
const { NodeRegistry } = await import('../services/NodeRegistry');
|
||||
const nodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
||||
const db = DatabaseService.getInstance().getDb();
|
||||
const insert = db.prepare('INSERT INTO stack_labels (node_id, name, color) VALUES (?, ?, ?)');
|
||||
const ids: number[] = [];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const r = insert.run(nodeId, `cap-edge-${i}`, 'teal');
|
||||
ids.push(r.lastInsertRowid as number);
|
||||
}
|
||||
const res = await request(app)
|
||||
.put('/api/stacks/cap-edge-stack/labels')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ labelIds: ids });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stack Labels RBAC', () => {
|
||||
|
||||
Reference in New Issue
Block a user