mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 09:54:26 +00:00
57fe430db8
* feat(stacks): add Stack Dossier tab with operator notes and Markdown export Add a Dossier tab beside Anatomy and Activity on the stack detail panel. It shows a read-only summary auto-derived from the stack's Compose anatomy (services, ports, volumes, network, restart policy, env file, source) plus an editable form for the context Sencho cannot infer: purpose, owner, access URLs, static IP, VLAN, and firewall, reverse-proxy, backup, upgrade, recovery, and custom notes. Notes persist per stack and per node in a new stack_dossiers table, reached transparently through the remote-node proxy so a remote stack's dossier round-trips to the node that owns it. Reading a dossier needs stack read permission; saving needs stack edit. The tab exports a single Markdown document combining the generated facts and the operator notes, with copy-to-clipboard and download actions; env values are never exported, only variable names and counts. Available on all tiers. The standalone anatomy copy-as-Markdown shortcut is removed since the Dossier export supersedes it. * fix(stacks): gate dossier reads/writes on stack existence; clear dossier on node delete A dossier endpoint validated the stack name but not that the stack exists, so an editor could PUT a dossier for a name with no stack, leaving an orphan row that a later stack of the same name would inherit. Require the stack to exist (existing requireStackExists guard) on the dossier GET and PUT, returning 404 otherwise. Also clear a node's stack_dossiers rows when the node is deleted, alongside the other node-scoped cleanup, so removing a node leaves no orphan dossiers. Docs: scope the "no secret exported" statement to the generated facts (which only ever carry variable names and counts) and clarify that operator notes are exported exactly as written.
152 lines
6.6 KiB
TypeScript
152 lines
6.6 KiB
TypeScript
/**
|
|
* Integration tests for the per-stack Dossier endpoints: blank-on-first-read,
|
|
* upsert persistence, full-document save semantics, field validation, RBAC, and
|
|
* per (node, stack) scoping at the DAO layer.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import request from 'supertest';
|
|
import bcrypt from 'bcrypt';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
|
|
|
let tmpDir: string;
|
|
let app: import('express').Express;
|
|
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
|
let adminCookie: string;
|
|
let viewerCookie: string;
|
|
|
|
const FIELD_KEYS = [
|
|
'purpose', 'owner', 'access_urls', 'static_ip', 'vlan',
|
|
'firewall_notes', 'reverse_proxy_notes', 'backup_notes',
|
|
'upgrade_notes', 'recovery_notes', 'custom_notes',
|
|
] as const;
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await setupTestDb();
|
|
// The dossier routes require the stack to exist on disk, so seed a compose
|
|
// file for every stack name these tests drive through the HTTP API.
|
|
const composeDir = process.env.COMPOSE_DIR as string;
|
|
for (const stack of ['web', 'upd', 'clr']) {
|
|
fs.mkdirSync(path.join(composeDir, stack), { recursive: true });
|
|
fs.writeFileSync(path.join(composeDir, stack, 'compose.yaml'), 'services:\n app:\n image: nginx\n');
|
|
}
|
|
({ DatabaseService } = await import('../services/DatabaseService'));
|
|
({ app } = await import('../index'));
|
|
adminCookie = await loginAsTestAdmin(app);
|
|
|
|
const viewerHash = await bcrypt.hash('viewerpass', 1);
|
|
DatabaseService.getInstance().addUser({ username: 'viewer', password_hash: viewerHash, role: 'viewer' });
|
|
const viewerRes = await request(app).post('/api/auth/login').send({ username: 'viewer', password: 'viewerpass' });
|
|
const cookies = viewerRes.headers['set-cookie'] as string | string[];
|
|
viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies;
|
|
});
|
|
|
|
afterAll(() => cleanupTestDb(tmpDir));
|
|
|
|
describe('GET /api/stacks/:stackName/dossier', () => {
|
|
it('rejects unauthenticated requests with 401', async () => {
|
|
const res = await request(app).get('/api/stacks/web/dossier');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('returns a blank dossier (200, all fields empty) when none has been saved', async () => {
|
|
const res = await request(app).get('/api/stacks/web/dossier').set('Cookie', adminCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.stack_name).toBe('web');
|
|
for (const k of FIELD_KEYS) expect(res.body[k]).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('PUT /api/stacks/:stackName/dossier', () => {
|
|
it('persists operator fields and returns the saved row', async () => {
|
|
const res = await request(app)
|
|
.put('/api/stacks/web/dossier')
|
|
.set('Cookie', adminCookie)
|
|
.send({ purpose: 'Reverse proxy', owner: 'ops', static_ip: '10.0.20.5', vlan: '20', firewall_notes: '443 open' });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.purpose).toBe('Reverse proxy');
|
|
expect(res.body.static_ip).toBe('10.0.20.5');
|
|
|
|
const get = await request(app).get('/api/stacks/web/dossier').set('Cookie', adminCookie);
|
|
expect(get.body.owner).toBe('ops');
|
|
expect(get.body.firewall_notes).toBe('443 open');
|
|
});
|
|
|
|
it('updates in place: a second PUT keeps a single row and preserves created_at', async () => {
|
|
const first = await request(app).put('/api/stacks/upd/dossier').set('Cookie', adminCookie).send({ purpose: 'one' });
|
|
const createdAt = first.body.created_at as number;
|
|
await new Promise(r => setTimeout(r, 20));
|
|
const second = await request(app).put('/api/stacks/upd/dossier').set('Cookie', adminCookie).send({ purpose: 'two' });
|
|
expect(second.body.purpose).toBe('two');
|
|
expect(second.body.created_at).toBe(createdAt);
|
|
expect(second.body.updated_at).toBeGreaterThanOrEqual(createdAt);
|
|
|
|
const row = DatabaseService.getInstance().getDb()
|
|
.prepare("SELECT COUNT(*) AS n FROM stack_dossiers WHERE stack_name = 'upd'").get() as { n: number };
|
|
expect(row.n).toBe(1);
|
|
});
|
|
|
|
it('treats a save as a full document: omitted short and block fields are cleared', async () => {
|
|
await request(app).put('/api/stacks/clr/dossier').set('Cookie', adminCookie)
|
|
.send({ purpose: 'p', owner: 'o', firewall_notes: '443 open' });
|
|
const res = await request(app).put('/api/stacks/clr/dossier').set('Cookie', adminCookie)
|
|
.send({ purpose: 'only purpose' });
|
|
expect(res.body.purpose).toBe('only purpose');
|
|
expect(res.body.owner).toBe('');
|
|
expect(res.body.firewall_notes).toBe('');
|
|
});
|
|
|
|
it('rejects an over-long field with 400', async () => {
|
|
const res = await request(app)
|
|
.put('/api/stacks/web/dossier')
|
|
.set('Cookie', adminCookie)
|
|
.send({ static_ip: 'x'.repeat(300) });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
});
|
|
|
|
describe('Dossier requires the stack to exist', () => {
|
|
it('returns 404 for a GET on a stack that does not exist', async () => {
|
|
const res = await request(app).get('/api/stacks/ghost/dossier').set('Cookie', adminCookie);
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it('returns 404 for a PUT on a missing stack and creates no orphan row', async () => {
|
|
const res = await request(app).put('/api/stacks/ghost/dossier').set('Cookie', adminCookie).send({ purpose: 'orphan' });
|
|
expect(res.status).toBe(404);
|
|
expect(DatabaseService.getInstance().getStackDossier(1, 'ghost')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('Dossier RBAC', () => {
|
|
it('lets a viewer read the dossier', async () => {
|
|
const res = await request(app).get('/api/stacks/web/dossier').set('Cookie', viewerCookie);
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it('forbids a viewer from saving the dossier (403)', async () => {
|
|
const res = await request(app).put('/api/stacks/web/dossier').set('Cookie', viewerCookie).send({ purpose: 'nope' });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
describe('Dossier scoping (DAO)', () => {
|
|
it('keeps dossiers isolated per (node, stack)', () => {
|
|
const db = DatabaseService.getInstance();
|
|
const fields = (purpose: string) => ({
|
|
purpose, owner: '', access_urls: '', static_ip: '', vlan: '',
|
|
firewall_notes: '', reverse_proxy_notes: '', backup_notes: '',
|
|
upgrade_notes: '', recovery_notes: '', custom_notes: '',
|
|
});
|
|
db.upsertStackDossier(1, 'shared', fields('node-1'));
|
|
db.upsertStackDossier(2, 'shared', fields('node-2'));
|
|
expect(db.getStackDossier(1, 'shared')?.purpose).toBe('node-1');
|
|
expect(db.getStackDossier(2, 'shared')?.purpose).toBe('node-2');
|
|
|
|
db.deleteStackDossier(1, 'shared');
|
|
expect(db.getStackDossier(1, 'shared')).toBeUndefined();
|
|
expect(db.getStackDossier(2, 'shared')?.purpose).toBe('node-2');
|
|
});
|
|
});
|