mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 11:47:11 +00:00
85842cc547
* feat: add service-scoped stack alert rules
Stack alerts can target one Compose service or all services. Breach timers
are per container and cooldowns are per service so a healthy sibling no
longer clears another container's timer or silences a different service.
* fix: gate remote scoped alert creates without losing the body
Remote hops skip JSON parsing so the proxy stream stays pipeable, which
left service_name invisible to the capability gate. Buffer POST /alerts
bodies for inspection, fail closed when the remote lacks the capability,
and rewrite the buffered bytes on forward. Restore alert-panel alt text
to match the unchanged screenshot.
* fix: bound remote alert body buffer and reject encoded JSON
Cap proxied POST /alerts buffering at the local 100KB JSON limit with
structured 413 cleanup, reject non-identity Content-Encoding with 415 so
compressed scoped bodies cannot bypass the mixed-version gate, and cover
oversized, chunked, and gzip regressions.
* fix: harden service-scoped alert delete, cooldown, and proxy gates
Reject non-digit alert ids, dual-write last_fired_at for rollback safety,
gate cooldown on persisted notification history, fail-fast oversized proxy
bodies with 413, and clarify Not in compose UI semantics.
* test: expect dispatchAlert persisted result in crash-safety cases
Update notification-routing assertions for the new { persisted } return
shape so CI matches the cooldown-gating contract.
349 lines
13 KiB
TypeScript
349 lines
13 KiB
TypeScript
/**
|
|
* Integration tests for POST /api/stacks/bulk.
|
|
*
|
|
* The bulk endpoint replaces the frontend's per-stack fan-out for
|
|
* start/stop/restart/update with a single server-side request that runs the
|
|
* lifecycle ops under bounded parallelism and returns a per-stack outcome
|
|
* map. The endpoint reuses the per-(nodeId, stackName) lock from H-1 so a
|
|
* stack that is already busy reports `code: stack_op_in_progress` for that
|
|
* row instead of doubling the op.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
|
import request from 'supertest';
|
|
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
|
|
|
const {
|
|
mockUpdateStack,
|
|
mockGetContainersByStack,
|
|
mockRestartContainer,
|
|
mockStopContainer,
|
|
mockStartContainer,
|
|
} = vi.hoisted(() => ({
|
|
mockUpdateStack: vi.fn(),
|
|
mockGetContainersByStack: vi.fn(),
|
|
mockRestartContainer: vi.fn(),
|
|
mockStopContainer: vi.fn(),
|
|
mockStartContainer: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../services/ComposeService', async () => {
|
|
const actual = await vi.importActual<typeof import('../services/ComposeService')>(
|
|
'../services/ComposeService',
|
|
);
|
|
return {
|
|
...actual,
|
|
ComposeService: {
|
|
...actual.ComposeService,
|
|
getInstance: () => ({
|
|
updateStack: mockUpdateStack,
|
|
}),
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock('../services/DockerController', async () => {
|
|
const actual = await vi.importActual<typeof import('../services/DockerController')>(
|
|
'../services/DockerController',
|
|
);
|
|
return {
|
|
...actual,
|
|
default: {
|
|
...actual.default,
|
|
getInstance: () => ({
|
|
getContainersByStack: mockGetContainersByStack,
|
|
restartContainer: mockRestartContainer,
|
|
stopContainer: mockStopContainer,
|
|
startContainer: mockStartContainer,
|
|
}),
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock('../services/FileSystemService', () => ({
|
|
FileSystemService: {
|
|
getInstance: () => ({
|
|
getBaseDir: () => '/tmp/compose',
|
|
hasComposeFile: vi.fn().mockResolvedValue(true),
|
|
}),
|
|
},
|
|
}));
|
|
|
|
let tmpDir: string;
|
|
let app: import('express').Express;
|
|
let authCookie: string;
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await setupTestDb();
|
|
({ app } = await import('../index'));
|
|
authCookie = await loginAsTestAdmin(app);
|
|
|
|
const { NotificationService } = await import('../services/NotificationService');
|
|
vi.spyOn(NotificationService.getInstance(), 'dispatchAlert').mockResolvedValue({ persisted: true });
|
|
});
|
|
|
|
afterAll(() => {
|
|
vi.restoreAllMocks();
|
|
cleanupTestDb(tmpDir);
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
mockUpdateStack.mockReset();
|
|
mockGetContainersByStack.mockReset();
|
|
mockRestartContainer.mockReset();
|
|
mockStopContainer.mockReset();
|
|
mockStartContainer.mockReset();
|
|
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1' }]);
|
|
const { StackOpLockService } = await import('../services/StackOpLockService');
|
|
StackOpLockService.resetForTests();
|
|
});
|
|
|
|
describe('POST /api/stacks/bulk request validation', () => {
|
|
it('rejects an unknown action with 400', async () => {
|
|
const res = await request(app)
|
|
.post('/api/stacks/bulk')
|
|
.set('Cookie', authCookie)
|
|
.send({ action: 'reboot', stackNames: ['web'] });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/Invalid action/);
|
|
});
|
|
|
|
it('rejects an empty stackNames array', async () => {
|
|
const res = await request(app)
|
|
.post('/api/stacks/bulk')
|
|
.set('Cookie', authCookie)
|
|
.send({ action: 'restart', stackNames: [] });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/non-empty array/);
|
|
});
|
|
|
|
it('rejects more than 100 stacks per request', async () => {
|
|
const stackNames = Array.from({ length: 101 }, (_, i) => `stack-${i}`);
|
|
const res = await request(app)
|
|
.post('/api/stacks/bulk')
|
|
.set('Cookie', authCookie)
|
|
.send({ action: 'restart', stackNames });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/limited to 100/);
|
|
});
|
|
|
|
it('rejects non-string stackNames entries', async () => {
|
|
const res = await request(app)
|
|
.post('/api/stacks/bulk')
|
|
.set('Cookie', authCookie)
|
|
.send({ action: 'restart', stackNames: ['web', 42, 'api'] });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/array of strings/);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/stacks/bulk execution', () => {
|
|
it('restarts three stacks and returns ok results for each', async () => {
|
|
const res = await request(app)
|
|
.post('/api/stacks/bulk')
|
|
.set('Cookie', authCookie)
|
|
.send({ action: 'restart', stackNames: ['web', 'api', 'db'] });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.action).toBe('restart');
|
|
expect(res.body.results).toHaveLength(3);
|
|
expect(res.body.results.every((r: { ok: boolean }) => r.ok)).toBe(true);
|
|
expect(mockRestartContainer).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('reports per-stack failures without short-circuiting other stacks', async () => {
|
|
let callCount = 0;
|
|
mockRestartContainer.mockImplementation(() => {
|
|
callCount += 1;
|
|
if (callCount === 2) return Promise.reject(new Error('container crashed'));
|
|
return Promise.resolve();
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post('/api/stacks/bulk')
|
|
.set('Cookie', authCookie)
|
|
.send({ action: 'restart', stackNames: ['web', 'api', 'db'] });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.results).toHaveLength(3);
|
|
const okResults = res.body.results.filter((r: { ok: boolean }) => r.ok);
|
|
const failedResults = res.body.results.filter((r: { ok: boolean }) => !r.ok);
|
|
expect(okResults).toHaveLength(2);
|
|
expect(failedResults).toHaveLength(1);
|
|
expect(failedResults[0].code).toBe('op_failed');
|
|
expect(failedResults[0].error).toMatch(/container crashed/);
|
|
});
|
|
|
|
it('reports stack_op_in_progress when a per-stack lock is already held', async () => {
|
|
const { StackOpLockService } = await import('../services/StackOpLockService');
|
|
StackOpLockService.getInstance().tryAcquire(1, 'busy-stack', 'deploy', 'someone-else');
|
|
|
|
const res = await request(app)
|
|
.post('/api/stacks/bulk')
|
|
.set('Cookie', authCookie)
|
|
.send({ action: 'restart', stackNames: ['busy-stack', 'free-stack'] });
|
|
|
|
expect(res.status).toBe(200);
|
|
const busy = res.body.results.find((r: { stackName: string }) => r.stackName === 'busy-stack');
|
|
const free = res.body.results.find((r: { stackName: string }) => r.stackName === 'free-stack');
|
|
expect(busy.ok).toBe(false);
|
|
expect(busy.code).toBe('stack_op_in_progress');
|
|
expect(busy.error).toMatch(/already deploying/i);
|
|
expect(free.ok).toBe(true);
|
|
});
|
|
|
|
it('rejects invalid stack names with code=invalid_name per-row', async () => {
|
|
const res = await request(app)
|
|
.post('/api/stacks/bulk')
|
|
.set('Cookie', authCookie)
|
|
.send({ action: 'restart', stackNames: ['..bad..', 'web'] });
|
|
|
|
expect(res.status).toBe(200);
|
|
const bad = res.body.results.find((r: { stackName: string }) => r.stackName === '..bad..');
|
|
expect(bad.ok).toBe(false);
|
|
expect(bad.code).toBe('invalid_name');
|
|
});
|
|
|
|
it('returns no_containers when a stack has nothing to restart', async () => {
|
|
mockGetContainersByStack.mockResolvedValueOnce([]);
|
|
const res = await request(app)
|
|
.post('/api/stacks/bulk')
|
|
.set('Cookie', authCookie)
|
|
.send({ action: 'restart', stackNames: ['empty-stack'] });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.results[0].ok).toBe(false);
|
|
expect(res.body.results[0].code).toBe('no_containers');
|
|
});
|
|
|
|
it('releases each stack lock after the op finishes', async () => {
|
|
await request(app)
|
|
.post('/api/stacks/bulk')
|
|
.set('Cookie', authCookie)
|
|
.send({ action: 'restart', stackNames: ['web', 'api'] });
|
|
|
|
const { StackOpLockService } = await import('../services/StackOpLockService');
|
|
expect(StackOpLockService.getInstance().size()).toBe(0);
|
|
});
|
|
|
|
it('runs at most BULK_PARALLELISM ops concurrently', async () => {
|
|
let inFlight = 0;
|
|
let maxInFlight = 0;
|
|
mockRestartContainer.mockImplementation(async () => {
|
|
inFlight += 1;
|
|
maxInFlight = Math.max(maxInFlight, inFlight);
|
|
await new Promise(r => setTimeout(r, 20));
|
|
inFlight -= 1;
|
|
});
|
|
|
|
const stackNames = Array.from({ length: 10 }, (_, i) => `stack-${i}`);
|
|
const res = await request(app)
|
|
.post('/api/stacks/bulk')
|
|
.set('Cookie', authCookie)
|
|
.send({ action: 'restart', stackNames });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.results).toHaveLength(10);
|
|
expect(maxInFlight).toBeLessThanOrEqual(4);
|
|
expect(maxInFlight).toBeGreaterThan(1);
|
|
});
|
|
|
|
it('handles stop action by dispatching stopContainer per stack', async () => {
|
|
const res = await request(app)
|
|
.post('/api/stacks/bulk')
|
|
.set('Cookie', authCookie)
|
|
.send({ action: 'stop', stackNames: ['web'] });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.results[0].ok).toBe(true);
|
|
expect(mockStopContainer).toHaveBeenCalledTimes(1);
|
|
expect(mockRestartContainer).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('handles update action (paid tier) by calling ComposeService.updateStack', async () => {
|
|
mockUpdateStack.mockResolvedValue({ recoveryId: null });
|
|
const { LicenseService } = await import('../services/LicenseService');
|
|
const tierSpy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
|
try {
|
|
const res = await request(app)
|
|
.post('/api/stacks/bulk')
|
|
.set('Cookie', authCookie)
|
|
.send({ action: 'update', stackNames: ['web'] });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.results[0].ok).toBe(true);
|
|
expect(mockUpdateStack).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
tierSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('returns policy_blocked per-row when the policy gate rejects an update', async () => {
|
|
const { LicenseService } = await import('../services/LicenseService');
|
|
const policyMod = await import('../services/PolicyEnforcement');
|
|
const tierSpy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
|
const policySpy = vi.spyOn(policyMod, 'enforcePolicyPreDeploy').mockResolvedValue({
|
|
ok: false,
|
|
bypassed: false,
|
|
policy: { id: 1, name: 'block-criticals', node_id: null, node_identity: '', stack_pattern: null, max_severity: 'HIGH', block_on_deploy: 1, block_on_severity: 1, block_on_kev: 0, block_on_fixable: 0, enabled: 1, replicated_from_control: 0, created_at: Date.now(), updated_at: Date.now() },
|
|
violations: [{ imageRef: 'nginx:latest', severity: 'CRITICAL', criticalCount: 3, highCount: 0, kevCount: 0, fixableCount: 0, reasons: ['severity'], scanId: 1 }],
|
|
});
|
|
mockUpdateStack.mockResolvedValue({ recoveryId: null });
|
|
try {
|
|
const res = await request(app)
|
|
.post('/api/stacks/bulk')
|
|
.set('Cookie', authCookie)
|
|
.send({ action: 'update', stackNames: ['web', 'api'] });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.results).toHaveLength(2);
|
|
expect(res.body.results.every((r: { ok: boolean }) => !r.ok)).toBe(true);
|
|
expect(res.body.results.every((r: { code: string }) => r.code === 'policy_blocked')).toBe(true);
|
|
expect(mockUpdateStack).not.toHaveBeenCalled();
|
|
} finally {
|
|
tierSpy.mockRestore();
|
|
policySpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('dedupes repeated stackNames before scheduling work', async () => {
|
|
const res = await request(app)
|
|
.post('/api/stacks/bulk')
|
|
.set('Cookie', authCookie)
|
|
.send({ action: 'restart', stackNames: ['web', 'web', 'web'] });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.results).toHaveLength(1);
|
|
expect(res.body.results[0].ok).toBe(true);
|
|
expect(mockRestartContainer).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not shadow POST /:stackName/restart for a stack literally named bulk', async () => {
|
|
// The bulk endpoint is mounted at /api/stacks/bulk (no trailing path).
|
|
// A stack named "bulk" must still be reachable at /api/stacks/bulk/restart
|
|
// because Express matches /:stackName/restart there, not the bulk handler.
|
|
const res = await request(app)
|
|
.post('/api/stacks/bulk/restart')
|
|
.set('Cookie', authCookie);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockRestartContainer).toHaveBeenCalledTimes(1);
|
|
expect(res.body.success).toBe(true);
|
|
});
|
|
|
|
it('allows the update action on the community tier (bulk ops are free)', async () => {
|
|
const { LicenseService } = await import('../services/LicenseService');
|
|
const tierSpy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
|
try {
|
|
const res = await request(app)
|
|
.post('/api/stacks/bulk')
|
|
.set('Cookie', authCookie)
|
|
.send({ action: 'update', stackNames: ['web'] });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockUpdateStack).toHaveBeenCalled();
|
|
} finally {
|
|
tierSpy.mockRestore();
|
|
}
|
|
});
|
|
});
|