mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 02:12:59 +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.
182 lines
5.9 KiB
TypeScript
182 lines
5.9 KiB
TypeScript
/**
|
|
* Route tests for POST /api/stacks/:name/down and optional ?removeVolumes=true.
|
|
*/
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import crypto from 'crypto';
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
|
import request from 'supertest';
|
|
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
|
import { generateApiToken } from '../utils/apiTokenFormat';
|
|
import {
|
|
disableCapability,
|
|
enableCapability,
|
|
STACK_DOWN_REMOVE_VOLUMES_CAPABILITY,
|
|
} from '../services/CapabilityRegistry';
|
|
|
|
const { mockRunDown } = vi.hoisted(() => ({
|
|
mockRunDown: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../services/ComposeService', async () => {
|
|
const actual = await vi.importActual<typeof import('../services/ComposeService')>(
|
|
'../services/ComposeService',
|
|
);
|
|
return {
|
|
...actual,
|
|
ComposeService: {
|
|
...actual.ComposeService,
|
|
getInstance: () => ({
|
|
runDown: mockRunDown,
|
|
}),
|
|
},
|
|
};
|
|
});
|
|
|
|
let tmpDir: string;
|
|
let app: import('express').Express;
|
|
let authCookie: string;
|
|
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
|
let dispatchAlertSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
function writeStack(name: string) {
|
|
const dir = path.join(process.env.COMPOSE_DIR!, name);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(path.join(dir, 'compose.yaml'), 'services:\n web:\n image: nginx\n');
|
|
}
|
|
|
|
function createDeployOnlyToken(): string {
|
|
const rawToken = generateApiToken();
|
|
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
|
|
const db = DatabaseService.getInstance();
|
|
db.addApiToken({
|
|
token_hash: tokenHash,
|
|
name: `deploy-only-down-${Date.now()}`,
|
|
scope: 'deploy-only',
|
|
user_id: db.getUserByUsername('testadmin')!.id,
|
|
created_at: Date.now(),
|
|
expires_at: null,
|
|
});
|
|
return rawToken;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await setupTestDb();
|
|
({ DatabaseService } = await import('../services/DatabaseService'));
|
|
({ app } = await import('../index'));
|
|
authCookie = await loginAsTestAdmin(app);
|
|
writeStack('web');
|
|
|
|
const { NotificationService } = await import('../services/NotificationService');
|
|
dispatchAlertSpy = vi.spyOn(NotificationService.getInstance(), 'dispatchAlert').mockResolvedValue({ persisted: true });
|
|
});
|
|
|
|
afterAll(() => {
|
|
vi.restoreAllMocks();
|
|
enableCapability(STACK_DOWN_REMOVE_VOLUMES_CAPABILITY);
|
|
cleanupTestDb(tmpDir);
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
mockRunDown.mockReset();
|
|
mockRunDown.mockResolvedValue(undefined);
|
|
enableCapability(STACK_DOWN_REMOVE_VOLUMES_CAPABILITY);
|
|
const { StackOpLockService } = await import('../services/StackOpLockService');
|
|
StackOpLockService.resetForTests();
|
|
});
|
|
|
|
describe('POST /api/stacks/:name/down removeVolumes', () => {
|
|
it('runs plain down when removeVolumes is omitted', async () => {
|
|
const res = await request(app)
|
|
.post('/api/stacks/web/down')
|
|
.set('Cookie', authCookie);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockRunDown).toHaveBeenCalledWith('web', { removeVolumes: false }, undefined);
|
|
});
|
|
|
|
it('dispatches stack_taken_down activity on plain down success', async () => {
|
|
dispatchAlertSpy.mockClear();
|
|
mockRunDown.mockResolvedValue(undefined);
|
|
|
|
const res = await request(app)
|
|
.post('/api/stacks/web/down')
|
|
.set('Cookie', authCookie);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(dispatchAlertSpy).toHaveBeenCalledWith(
|
|
'info',
|
|
'stack_taken_down',
|
|
'web taken down',
|
|
expect.objectContaining({ stackName: 'web', actor: 'testadmin' }),
|
|
);
|
|
});
|
|
|
|
it('notes volume removal in the activity message when removeVolumes=true', async () => {
|
|
dispatchAlertSpy.mockClear();
|
|
mockRunDown.mockResolvedValue(undefined);
|
|
|
|
const res = await request(app)
|
|
.post('/api/stacks/web/down?removeVolumes=true')
|
|
.set('Cookie', authCookie);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(dispatchAlertSpy).toHaveBeenCalledWith(
|
|
'info',
|
|
'stack_taken_down',
|
|
'web taken down (volumes removed)',
|
|
expect.objectContaining({ stackName: 'web' }),
|
|
);
|
|
});
|
|
|
|
it('runs plain down when removeVolumes=false', async () => {
|
|
const res = await request(app)
|
|
.post('/api/stacks/web/down?removeVolumes=false')
|
|
.set('Cookie', authCookie);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockRunDown).toHaveBeenCalledWith('web', { removeVolumes: false }, undefined);
|
|
});
|
|
|
|
it('does not enable volumes when removeVolumes=1 (only exact true)', async () => {
|
|
const res = await request(app)
|
|
.post('/api/stacks/web/down?removeVolumes=1')
|
|
.set('Cookie', authCookie);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockRunDown).toHaveBeenCalledWith('web', { removeVolumes: false }, undefined);
|
|
});
|
|
|
|
it('returns 400 when removeVolumes=true but capability is absent locally', async () => {
|
|
disableCapability(STACK_DOWN_REMOVE_VOLUMES_CAPABILITY);
|
|
|
|
const res = await request(app)
|
|
.post('/api/stacks/web/down?removeVolumes=true')
|
|
.set('Cookie', authCookie);
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/not supported/i);
|
|
expect(mockRunDown).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('passes removeVolumes=true to runDown when capability is present', async () => {
|
|
const res = await request(app)
|
|
.post('/api/stacks/web/down?removeVolumes=true')
|
|
.set('Cookie', authCookie);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockRunDown).toHaveBeenCalledWith('web', { removeVolumes: true }, undefined);
|
|
});
|
|
|
|
it('allows deploy-only API tokens to POST /down?removeVolumes=true', async () => {
|
|
const token = createDeployOnlyToken();
|
|
const res = await request(app)
|
|
.post('/api/stacks/web/down?removeVolumes=true')
|
|
.set('Authorization', `Bearer ${token}`);
|
|
|
|
expect(res.body.code).not.toBe('SCOPE_DENIED');
|
|
expect(res.status).toBe(200);
|
|
expect(mockRunDown).toHaveBeenCalledWith('web', { removeVolumes: true }, undefined);
|
|
});
|
|
});
|