feat(snapshots): preserve stack dossiers with fleet snapshots (#1339)

* feat(snapshots): preserve stack dossiers with fleet snapshots

Fleet snapshots can now optionally capture each stack's Dossier notes
alongside its compose and .env files, so a recovery restores the
operational knowledge around a stack, not just its configuration.

- Opt-in global setting "snapshot_documentation" (default off), toggled
  from the renamed Fleet settings section.
- Capture reads local dossiers from the database and remote dossiers over
  the Distributed API proxy; only stacks with notes are recorded, and
  secret values are never included.
- Captured notes are stored encrypted at rest in a new fleet_snapshots
  column and surfaced in the snapshot detail view behind a badge.
- Cloud and downloaded archives gain a documentation.json (archive_version 2).
- Restore stays conservative: dossier notes are written back only when the
  operator explicitly opts in, on both single-stack and restore-all paths.
- Existing snapshots and archives remain valid; behavior is unchanged when
  the setting is off.

* fix(snapshots): harden dossier-notes restore against bad input and partial failures

Address review findings on the documentation-snapshots restore path:

- Parse `restoreNotes` strictly (=== true) on single-stack restore, matching
  restore-all, so a stray non-boolean can never opt in to overwriting notes.
- Guard findSnapshotDossier: require an array of stacks and real dossier
  content, so a malformed or all-blank entry can't clobber current notes.
- Make the dossier-notes write non-fatal relative to the file restore: a notes
  failure (e.g. a remote dossier PUT) is caught, reported via `notesError`, and
  no longer 500s the single restore or fails the stack in restore-all once the
  files are already written.
- Surface the partial outcome in the UI: a warning toast on single restore, a
  summary note on restore-all, and gate the "Documentation captured" badge and
  restore-all notes control on captured stacks while rendering capture warnings.

Adds tests for strict parsing, malformed/blank blobs, remote notes restore
(success + non-fatal failure, single and bulk), and scheduled capture-on.

* fix(snapshots): drop unused binding in restore-all remote notes test

The restore-all remote notes test destructured a node id it never uses
(restore-all is driven by snapshot id alone), tripping no-unused-vars and
failing the lint step. Bind only the snapshot id.
This commit is contained in:
Anso
2026-06-08 08:44:59 -04:00
committed by GitHub
parent 842ee7dd0c
commit 710647a44f
16 changed files with 1020 additions and 55 deletions
@@ -190,7 +190,10 @@ describe('CloudBackupService — uploadSnapshot', () => {
const parsed = JSON.parse(meta!.content);
expect(parsed.id).toBe(snapshotId);
expect(parsed.instance_id).toBe('test-instance-id');
expect(parsed.archive_version).toBe(1);
expect(parsed.archive_version).toBe(2);
expect(parsed.has_documentation).toBe(false);
// No dossier metadata was captured, so the archive omits documentation.json.
expect(entries.find(e => e.name === 'documentation.json')).toBeUndefined();
// Content is encrypted at rest but the archive must carry plaintext so a
// downloaded snapshot restores on any instance (portability contract).
@@ -202,6 +205,48 @@ describe('CloudBackupService — uploadSnapshot', () => {
expect(CloudBackupService.getInstance().getUploadStatus(snapshotId).status).toBe('success');
});
it('includes documentation.json when the snapshot captured dossier metadata', async () => {
seedCustomProvider();
const db = DatabaseService.getInstance();
const docJson = JSON.stringify({
generated_at: '2026-01-01T00:00:00Z',
stacks: [{ nodeId: 1, nodeName: 'gateway', stackName: 'web', dossier: { purpose: 'edge proxy' } }],
warnings: [],
});
const snapshotId = db.createSnapshot('Documented backup', 'admin', 1, 1, '[]', '[]', docJson);
db.insertSnapshotFiles(snapshotId, [
{ nodeId: 1, nodeName: 'gateway', stackName: 'web', filename: 'compose.yaml', content: 'services: {}\n' },
]);
sentSpy.mockResolvedValue({});
await CloudBackupService.getInstance().uploadSnapshot(snapshotId);
const putCall = sentSpy.mock.calls.find(c => c[0].name === 'PutObjectCommand');
const input = putCall![0].input as { Body: Buffer };
const decompressed = zlib.gunzipSync(input.Body);
const entries: Array<{ name: string; content: string }> = await new Promise((resolve, reject) => {
const extract = tar.extract();
const list: Array<{ name: string; content: string }> = [];
extract.on('entry', (header, stream, next) => {
const chunks: Buffer[] = [];
stream.on('data', (c: Buffer) => chunks.push(c));
stream.on('end', () => { list.push({ name: header.name, content: Buffer.concat(chunks).toString('utf-8') }); next(); });
stream.resume();
});
extract.on('finish', () => resolve(list));
extract.on('error', reject);
Readable.from(decompressed).pipe(extract);
});
const meta = JSON.parse(entries.find(e => e.name === 'metadata.json')!.content);
expect(meta.has_documentation).toBe(true);
const docEntry = entries.find(e => e.name === 'documentation.json');
expect(docEntry).toBeDefined();
// The archive carries plaintext dossier metadata for portability.
expect(JSON.parse(docEntry!.content).stacks[0].dossier.purpose).toBe('edge proxy');
});
it('records failure status when upload throws', async () => {
seedCustomProvider();
const db = DatabaseService.getInstance();
@@ -17,6 +17,7 @@ let DatabaseService: typeof import('../services/DatabaseService').DatabaseServic
let CryptoService: typeof import('../services/CryptoService').CryptoService;
let ComposeService: typeof import('../services/ComposeService').ComposeService;
let LicenseService: typeof import('../services/LicenseService').LicenseService;
let NodeRegistry: typeof import('../services/NodeRegistry').NodeRegistry;
let adminCookie: string;
let viewerCookie: string;
let snapshotId: number;
@@ -25,12 +26,19 @@ const VIEWER_USER = 'viewer-snap';
const VIEWER_PASS = 'viewer-pass-123';
const ENV_SECRET = 'DB_PASSWORD=s3cr3t-value\n';
// Every operator-authored dossier field, blank, for building test dossiers.
const BLANK_FIELDS = {
purpose: '', owner: '', access_urls: '', static_ip: '', vlan: '', firewall_notes: '',
reverse_proxy_notes: '', backup_notes: '', upgrade_notes: '', recovery_notes: '', custom_notes: '',
};
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
({ CryptoService } = await import('../services/CryptoService'));
({ ComposeService } = await import('../services/ComposeService'));
({ LicenseService } = await import('../services/LicenseService'));
({ NodeRegistry } = await import('../services/NodeRegistry'));
({ app } = await import('../index'));
adminCookie = await loginAsTestAdmin(app);
@@ -232,6 +240,280 @@ describe('Single-stack snapshot restore (behavior lock)', () => {
});
});
describe('Snapshot documentation capture (persistence)', () => {
const docJson = JSON.stringify({
generated_at: '2026-01-01T00:00:00Z',
stacks: [{ nodeId: LOCAL_NODE_ID, nodeName: 'local', stackName: 'doc-web', dossier: { ...BLANK_FIELDS, purpose: 'edge', owner: 'ops' } }],
warnings: [],
});
it('stores documentation encrypted at rest and flags has_documentation', () => {
const db = DatabaseService.getInstance();
const id = db.createSnapshot('doc-snap', 'admin', 1, 1, '[]', '[]', docJson);
expect(db.getSnapshot(id)!.has_documentation).toBe(1);
const raw = db.getDb().prepare('SELECT documentation FROM fleet_snapshots WHERE id = ?').get(id) as { documentation: string };
expect(CryptoService.getInstance().isEncrypted(raw.documentation)).toBe(true);
expect(raw.documentation).not.toContain('edge');
expect(db.getSnapshotDocumentation(id)).toBe(docJson);
});
it('leaves documentation empty and has_documentation 0 when none captured', () => {
const db = DatabaseService.getInstance();
const id = db.createSnapshot('no-doc', 'admin', 1, 1, '[]', '[]');
expect(db.getSnapshot(id)!.has_documentation).toBe(0);
expect(db.getSnapshotDocumentation(id)).toBe('');
});
it('GET detail surfaces the documentation object for an admin', async () => {
const db = DatabaseService.getInstance();
const id = db.createSnapshot('doc-detail', 'admin', 1, 1, '[]', '[]', docJson);
db.insertSnapshotFiles(id, [
{ nodeId: LOCAL_NODE_ID, nodeName: 'local', stackName: 'doc-web', filename: 'compose.yaml', content: 'services: {}\n' },
]);
const res = await request(app).get(`/api/fleet/snapshots/${id}`).set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.has_documentation).toBe(1);
expect(res.body.documentation.stacks[0]).toMatchObject({ nodeId: LOCAL_NODE_ID, stackName: 'doc-web', dossier: { purpose: 'edge' } });
});
it('detail degrades to no documentation (still 200) when the blob is unparseable', async () => {
const db = DatabaseService.getInstance();
const id = db.createSnapshot('doc-corrupt', 'admin', 1, 1, '[]', '[]', JSON.stringify({ generated_at: 'x', stacks: [], warnings: [] }));
// Overwrite the encrypted column with non-JSON plaintext to simulate a
// corrupt or tampered blob; getSnapshotDocumentation returns it verbatim
// (decrypt passes non-ciphertext through) and the route's JSON.parse fails.
db.getDb().prepare('UPDATE fleet_snapshots SET documentation = ? WHERE id = ?').run('not-json', id);
const res = await request(app).get(`/api/fleet/snapshots/${id}`).set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.documentation).toBeUndefined();
});
});
describe('Snapshot restore: dossier notes opt-in', () => {
afterEach(() => vi.restoreAllMocks());
function snapWithNotes(stackName: string, purpose: string): number {
const db = DatabaseService.getInstance();
const docJson = JSON.stringify({
generated_at: 'x',
stacks: [{ nodeId: LOCAL_NODE_ID, nodeName: 'local', stackName, dossier: { ...BLANK_FIELDS, purpose } }],
warnings: [],
});
const id = db.createSnapshot(`notes-${stackName}`, 'admin', 1, 1, '[]', '[]', docJson);
db.insertSnapshotFiles(id, [
{ nodeId: LOCAL_NODE_ID, nodeName: 'local', stackName, filename: 'compose.yaml', content: 'services: {}\n' },
]);
seedStackDir(stackName);
return id;
}
it('does NOT overwrite current notes when restoreNotes is omitted', async () => {
const db = DatabaseService.getInstance();
db.upsertStackDossier(LOCAL_NODE_ID, 'notes-keep', { ...BLANK_FIELDS, purpose: 'current' });
const id = snapWithNotes('notes-keep', 'snapshot-version');
const res = await request(app)
.post(`/api/fleet/snapshots/${id}/restore`)
.set('Cookie', adminCookie)
.send({ nodeId: LOCAL_NODE_ID, stackName: 'notes-keep' });
expect(res.status).toBe(200);
expect(res.body.notesRestored).toBe(false);
expect(db.getStackDossier(LOCAL_NODE_ID, 'notes-keep')?.purpose).toBe('current');
});
it('restores notes when restoreNotes is true', async () => {
const db = DatabaseService.getInstance();
db.upsertStackDossier(LOCAL_NODE_ID, 'notes-overwrite', { ...BLANK_FIELDS, purpose: 'current' });
const id = snapWithNotes('notes-overwrite', 'snapshot-version');
const res = await request(app)
.post(`/api/fleet/snapshots/${id}/restore`)
.set('Cookie', adminCookie)
.send({ nodeId: LOCAL_NODE_ID, stackName: 'notes-overwrite', restoreNotes: true });
expect(res.status).toBe(200);
expect(res.body.notesRestored).toBe(true);
expect(db.getStackDossier(LOCAL_NODE_ID, 'notes-overwrite')?.purpose).toBe('snapshot-version');
});
it('reports notesRestored false when the snapshot has no documentation', async () => {
const db = DatabaseService.getInstance();
const id = db.createSnapshot('notes-none', 'admin', 1, 1, '[]', '[]');
db.insertSnapshotFiles(id, [
{ nodeId: LOCAL_NODE_ID, nodeName: 'local', stackName: 'notes-none-web', filename: 'compose.yaml', content: 'services: {}\n' },
]);
seedStackDir('notes-none-web');
const res = await request(app)
.post(`/api/fleet/snapshots/${id}/restore`)
.set('Cookie', adminCookie)
.send({ nodeId: LOCAL_NODE_ID, stackName: 'notes-none-web', restoreNotes: true });
expect(res.status).toBe(200);
expect(res.body.notesRestored).toBe(false);
});
it('ignores a non-boolean restoreNotes (string "false") and leaves notes untouched', async () => {
const db = DatabaseService.getInstance();
db.upsertStackDossier(LOCAL_NODE_ID, 'notes-strict', { ...BLANK_FIELDS, purpose: 'current' });
const id = snapWithNotes('notes-strict', 'snapshot-version');
const res = await request(app)
.post(`/api/fleet/snapshots/${id}/restore`)
.set('Cookie', adminCookie)
.send({ nodeId: LOCAL_NODE_ID, stackName: 'notes-strict', restoreNotes: 'false' });
expect(res.status).toBe(200);
expect(res.body.notesRestored).toBe(false);
expect(db.getStackDossier(LOCAL_NODE_ID, 'notes-strict')?.purpose).toBe('current');
});
it('does not restore notes from a malformed documentation blob', async () => {
const db = DatabaseService.getInstance();
db.upsertStackDossier(LOCAL_NODE_ID, 'notes-malformed', { ...BLANK_FIELDS, purpose: 'current' });
const id = db.createSnapshot('notes-bad-doc', 'admin', 1, 1, '[]', '[]', JSON.stringify({ generated_at: 'x', stacks: null, warnings: [] }));
db.insertSnapshotFiles(id, [
{ nodeId: LOCAL_NODE_ID, nodeName: 'local', stackName: 'notes-malformed', filename: 'compose.yaml', content: 'services: {}\n' },
]);
seedStackDir('notes-malformed');
const res = await request(app)
.post(`/api/fleet/snapshots/${id}/restore`)
.set('Cookie', adminCookie)
.send({ nodeId: LOCAL_NODE_ID, stackName: 'notes-malformed', restoreNotes: true });
expect(res.status).toBe(200);
expect(res.body.notesRestored).toBe(false);
expect(db.getStackDossier(LOCAL_NODE_ID, 'notes-malformed')?.purpose).toBe('current');
});
it('does not overwrite current notes with an all-blank captured dossier', async () => {
const db = DatabaseService.getInstance();
db.upsertStackDossier(LOCAL_NODE_ID, 'notes-blank', { ...BLANK_FIELDS, purpose: 'current' });
const docJson = JSON.stringify({
generated_at: 'x',
stacks: [{ nodeId: LOCAL_NODE_ID, nodeName: 'local', stackName: 'notes-blank', dossier: { ...BLANK_FIELDS } }],
warnings: [],
});
const id = db.createSnapshot('notes-blank-doc', 'admin', 1, 1, '[]', '[]', docJson);
db.insertSnapshotFiles(id, [
{ nodeId: LOCAL_NODE_ID, nodeName: 'local', stackName: 'notes-blank', filename: 'compose.yaml', content: 'services: {}\n' },
]);
seedStackDir('notes-blank');
const res = await request(app)
.post(`/api/fleet/snapshots/${id}/restore`)
.set('Cookie', adminCookie)
.send({ nodeId: LOCAL_NODE_ID, stackName: 'notes-blank', restoreNotes: true });
expect(res.status).toBe(200);
expect(res.body.notesRestored).toBe(false);
expect(db.getStackDossier(LOCAL_NODE_ID, 'notes-blank')?.purpose).toBe('current');
});
it('restore-all restores notes only for stacks the snapshot documented', async () => {
const db = DatabaseService.getInstance();
// Only 'all-a' carries dossier notes; 'all-b' has files but no notes.
const docJson = JSON.stringify({
generated_at: 'x',
stacks: [{ nodeId: LOCAL_NODE_ID, nodeName: 'local', stackName: 'allnotes-a', dossier: { ...BLANK_FIELDS, purpose: 'documented' } }],
warnings: [],
});
const id = db.createSnapshot('restore-all-notes', 'admin', 1, 2, '[]', '[]', docJson);
db.insertSnapshotFiles(id, [
{ nodeId: LOCAL_NODE_ID, nodeName: 'local', stackName: 'allnotes-a', filename: 'compose.yaml', content: 'services: {}\n' },
{ nodeId: LOCAL_NODE_ID, nodeName: 'local', stackName: 'allnotes-b', filename: 'compose.yaml', content: 'services: {}\n' },
]);
seedStackDir('allnotes-a');
seedStackDir('allnotes-b');
const res = await request(app)
.post(`/api/fleet/snapshots/${id}/restore-all`)
.set('Cookie', adminCookie)
.send({ restoreNotes: true });
expect(res.status).toBe(200);
expect(res.body.restored).toBe(2);
const results = res.body.results as Array<{ stackName: string; notesRestored: boolean }>;
expect(results.find(r => r.stackName === 'allnotes-a')?.notesRestored).toBe(true);
expect(results.find(r => r.stackName === 'allnotes-b')?.notesRestored).toBe(false);
expect(db.getStackDossier(LOCAL_NODE_ID, 'allnotes-a')?.purpose).toBe('documented');
expect(db.getStackDossier(LOCAL_NODE_ID, 'allnotes-b')).toBeUndefined();
});
});
describe('Snapshot restore: remote dossier notes (proxy PUT)', () => {
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
function remoteDocSnapshot(stackName: string, purpose: string): { id: number; remoteId: number } {
const db = DatabaseService.getInstance();
const remoteId = db.addNode({ name: `remote-${stackName}`, type: 'remote', api_url: 'http://remote:1852', api_token: 'tok', compose_dir: '/app/compose', is_default: false });
const docJson = JSON.stringify({
generated_at: 'x',
stacks: [{ nodeId: remoteId, nodeName: `remote-${stackName}`, stackName, dossier: { ...BLANK_FIELDS, purpose } }],
warnings: [],
});
const id = db.createSnapshot(`remote-notes-${stackName}`, 'admin', 1, 1, '[]', '[]', docJson);
db.insertSnapshotFiles(id, [
{ nodeId: remoteId, nodeName: `remote-${stackName}`, stackName, filename: 'compose.yaml', content: 'services: {}\n' },
]);
return { id, remoteId };
}
it('writes notes to a remote node via the proxy dossier PUT when opted in', async () => {
const { id, remoteId } = remoteDocSnapshot('rweb', 'documented');
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://remote:1852', apiToken: 'tok' });
const calls: Array<{ url: string; method?: string }> = [];
vi.stubGlobal('fetch', vi.fn(async (url: string, opts?: { method?: string }) => {
calls.push({ url, method: opts?.method });
return { ok: true, status: 200, text: async () => '' } as unknown as Response;
}));
const res = await request(app)
.post(`/api/fleet/snapshots/${id}/restore`)
.set('Cookie', adminCookie)
.send({ nodeId: remoteId, stackName: 'rweb', restoreNotes: true });
expect(res.status).toBe(200);
expect(res.body.notesRestored).toBe(true);
expect(res.body.notesError).toBeUndefined();
expect(calls.some(c => /\/dossier$/.test(c.url) && c.method === 'PUT')).toBe(true);
});
it('reports a non-fatal notesError when the remote dossier PUT fails but files restored', async () => {
const { id, remoteId } = remoteDocSnapshot('rweb2', 'documented');
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://remote:1852', apiToken: 'tok' });
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
if (/\/dossier$/.test(url)) return { ok: false, status: 500, text: async () => 'boom' } as unknown as Response;
return { ok: true, status: 200, text: async () => '' } as unknown as Response;
}));
const res = await request(app)
.post(`/api/fleet/snapshots/${id}/restore`)
.set('Cookie', adminCookie)
.send({ nodeId: remoteId, stackName: 'rweb2', restoreNotes: true });
// File restore succeeded; only the optional notes write failed.
expect(res.status).toBe(200);
expect(res.body.notesRestored).toBe(false);
expect(res.body.notesError).toBeTruthy();
});
it('restore-all records a per-row notesError but keeps the stack success when the remote notes PUT fails', async () => {
// restore-all is driven by snapshot id; the target node is resolved from
// the snapshot's stored files, so the returned remoteId is not needed here.
const { id } = remoteDocSnapshot('rweb3', 'documented');
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://remote:1852', apiToken: 'tok' });
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
if (/\/dossier$/.test(url)) return { ok: false, status: 500, text: async () => 'boom' } as unknown as Response;
return { ok: true, status: 200, text: async () => '' } as unknown as Response;
}));
const res = await request(app)
.post(`/api/fleet/snapshots/${id}/restore-all`)
.set('Cookie', adminCookie)
.send({ restoreNotes: true });
expect(res.status).toBe(200);
expect(res.body.restored).toBe(1);
expect(res.body.failed).toBe(0);
const row = (res.body.results as Array<{ stackName: string; success: boolean; notesRestored: boolean; notesError?: string }>)
.find(r => r.stackName === 'rweb3');
expect(row?.success).toBe(true);
expect(row?.notesRestored).toBe(false);
expect(row?.notesError).toBeTruthy();
});
});
describe('Restore-all', () => {
afterEach(() => vi.restoreAllMocks());
@@ -10,6 +10,7 @@ import type { ScheduledTask } from '../services/DatabaseService';
const {
mockGetDueScheduledTasks, mockCreateScheduledTaskRun, mockUpdateScheduledTaskRun,
mockUpdateScheduledTask, mockCleanupOldTaskRuns, mockGetScheduledTask, mockGetNodes, mockGetNode,
mockGetGlobalSettings, mockGetStackDossier,
mockCreateSnapshot, mockInsertSnapshotFiles, mockClearStackUpdateStatus,
mockMarkStaleRunsAsFailed, mockDeleteOldScans,
mockGetTier, mockGetProxyHeaders,
@@ -36,6 +37,8 @@ const {
mockGetScheduledTask: vi.fn(),
mockGetNodes: vi.fn().mockReturnValue([]),
mockGetNode: vi.fn().mockReturnValue({ id: 1, name: 'local', type: 'local', status: 'online' }),
mockGetGlobalSettings: vi.fn().mockReturnValue({}),
mockGetStackDossier: vi.fn().mockReturnValue(undefined),
mockCreateSnapshot: vi.fn().mockReturnValue(1),
mockInsertSnapshotFiles: vi.fn(),
mockClearStackUpdateStatus: vi.fn(),
@@ -80,6 +83,8 @@ vi.mock('../services/DatabaseService', () => ({
getScheduledTask: mockGetScheduledTask,
getNodes: mockGetNodes,
getNode: mockGetNode,
getGlobalSettings: mockGetGlobalSettings,
getStackDossier: mockGetStackDossier,
createSnapshot: mockCreateSnapshot,
insertSnapshotFiles: mockInsertSnapshotFiles,
clearStackUpdateStatus: mockClearStackUpdateStatus,
@@ -201,6 +206,10 @@ beforeEach(() => {
mockGetTier.mockReturnValue('paid');
mockGetNode.mockReturnValue({ id: 1, name: 'local', type: 'local', status: 'online' });
mockGetProxyTarget.mockReturnValue(null);
// Documentation capture is opt-in and off by default; reset so a test that
// enables it does not leak into later snapshot tests.
mockGetGlobalSettings.mockReturnValue({});
mockGetStackDossier.mockReturnValue(undefined);
// Default: the scan-policy gate allows. Individual tests override to a block.
mockEnforcePolicyPreDeploy.mockResolvedValue({ ok: true, bypassed: false, violations: [] });
(SchedulerService as any).instance = undefined;
@@ -1391,6 +1400,7 @@ describe('SchedulerService - executeSnapshot', () => {
1,
expect.any(String),
expect.any(String),
'',
);
expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(
1,
@@ -1423,6 +1433,30 @@ describe('SchedulerService - executeSnapshot', () => {
expect.objectContaining({ status: 'success' })
);
});
it('captures dossier documentation when the snapshot_documentation setting is on', async () => {
mockGetScheduledTask.mockReturnValue({
id: 77,
name: 'documented-snapshot',
action: 'snapshot',
target_type: 'fleet',
cron_expression: '0 3 * * *',
enabled: true,
created_by: 'admin',
last_status: null,
});
mockGetNodes.mockReturnValue([{ id: 1, name: 'local', type: 'local' }]);
mockGetStacks.mockResolvedValue(['app1']);
mockGetStackContent.mockResolvedValue('services: {}\n');
mockGetGlobalSettings.mockReturnValue({ snapshot_documentation: '1' });
mockGetStackDossier.mockReturnValue({ purpose: 'documented', owner: '', access_urls: '', static_ip: '', vlan: '', firewall_notes: '', reverse_proxy_notes: '', backup_notes: '', upgrade_notes: '', recovery_notes: '', custom_notes: '' });
await SchedulerService.getInstance().triggerTask(77);
const docArg = mockCreateSnapshot.mock.calls.at(-1)?.[6] as string;
expect(docArg).toBeTruthy();
expect(JSON.parse(docArg).stacks[0]).toMatchObject({ stackName: 'app1', dossier: { purpose: 'documented' } });
});
});
// ── executeUpdateRemote (T4) ─────────────────────────────────────────
@@ -10,6 +10,7 @@ const mockGetStacks = vi.fn();
const mockGetStackContent = vi.fn();
const mockGetEnvContent = vi.fn();
const mockGetProxyTarget = vi.fn();
const mockGetStackDossier = vi.fn();
vi.mock('../services/FileSystemService', () => ({
FileSystemService: { getInstance: () => ({
@@ -23,11 +24,29 @@ vi.mock('../services/NodeRegistry', () => ({
NodeRegistry: { getInstance: () => ({ getProxyTarget: mockGetProxyTarget }) },
}));
vi.mock('../services/DatabaseService', () => ({
DatabaseService: { getInstance: () => ({ getStackDossier: mockGetStackDossier }) },
}));
import {
captureLocalNodeFiles,
captureRemoteNodeFiles,
buildSnapshotDocumentation,
pickDossierFields,
dossierHasContent,
MAX_SNAPSHOT_FILE_BYTES,
type SnapshotNodeData,
} from '../utils/snapshot-capture';
import type { StackDossierFields } from '../services/DatabaseService';
const BLANK_DOSSIER: StackDossierFields = {
purpose: '', owner: '', access_urls: '', static_ip: '', vlan: '', firewall_notes: '',
reverse_proxy_notes: '', backup_notes: '', upgrade_notes: '', recovery_notes: '', custom_notes: '',
};
function dossier(partial: Partial<StackDossierFields>): StackDossierFields {
return { ...BLANK_DOSSIER, ...partial };
}
const localNode = { id: 1, name: 'local', mode: 'proxy' as const };
const remoteNode = { id: 2, name: 'remote', mode: 'proxy' as const };
@@ -221,3 +240,137 @@ describe('captureRemoteNodeFiles', () => {
expect(result.warnings[0].reason).toContain('fetch error');
});
});
describe('documentation capture', () => {
function mockFetchRoutes(routes: Record<string, Partial<Response> & { jsonValue?: unknown; textValue?: string; xEnvExists?: string }>) {
const fetchMock = vi.fn(async (url: string) => {
const match = Object.keys(routes).find(k => url.endsWith(k));
const r = match ? routes[match] : { ok: false, status: 404 };
return {
ok: r.ok ?? true,
status: r.status ?? 200,
headers: { get: (name: string) => name.toLowerCase() === 'x-env-exists' ? ((r as { xEnvExists?: string }).xEnvExists ?? null) : null },
json: async () => (r as { jsonValue?: unknown }).jsonValue,
text: async () => (r as { textValue?: string }).textValue ?? '',
} as unknown as Response;
});
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
it('captures local dossier notes when captureDocs is on and the stack has content', async () => {
mockGetStacks.mockResolvedValue(['web']);
mockGetStackContent.mockResolvedValue('services: {}\n');
mockGetEnvContent.mockRejectedValue(Object.assign(new Error('no file'), { code: 'ENOENT' }));
mockGetStackDossier.mockReturnValue(dossier({ purpose: 'edge proxy', owner: 'ops' }));
const result = await captureLocalNodeFiles(localNode, true);
expect(result.stacks[0].dossier).toMatchObject({ purpose: 'edge proxy', owner: 'ops' });
expect(result.docWarnings).toHaveLength(0);
});
it('omits the local dossier when every field is blank', async () => {
mockGetStacks.mockResolvedValue(['web']);
mockGetStackContent.mockResolvedValue('services: {}\n');
mockGetEnvContent.mockRejectedValue(Object.assign(new Error('no file'), { code: 'ENOENT' }));
mockGetStackDossier.mockReturnValue(undefined);
const result = await captureLocalNodeFiles(localNode, true);
expect(result.stacks[0].dossier).toBeUndefined();
});
it('does not read dossiers when captureDocs is off (default)', async () => {
mockGetStacks.mockResolvedValue(['web']);
mockGetStackContent.mockResolvedValue('services: {}\n');
mockGetEnvContent.mockRejectedValue(Object.assign(new Error('no file'), { code: 'ENOENT' }));
const result = await captureLocalNodeFiles(localNode);
expect(result.stacks[0].dossier).toBeUndefined();
expect(mockGetStackDossier).not.toHaveBeenCalled();
});
it('captures dossier notes from the remote dossier endpoint', async () => {
mockFetchRoutes({
'/api/stacks': { ok: true, jsonValue: ['web'] },
'/api/stacks/web': { ok: true, textValue: 'services: {}\n' },
'/api/stacks/web/env': { ok: false, status: 404 },
'/api/stacks/web/dossier': { ok: true, jsonValue: dossier({ purpose: 'edge' }) },
});
const result = await captureRemoteNodeFiles(remoteNode, true);
expect(result.stacks[0].dossier).toMatchObject({ purpose: 'edge' });
expect(result.docWarnings).toHaveLength(0);
});
it('records a doc warning when the remote dossier fetch fails (non-404)', async () => {
mockFetchRoutes({
'/api/stacks': { ok: true, jsonValue: ['web'] },
'/api/stacks/web': { ok: true, textValue: 'services: {}\n' },
'/api/stacks/web/env': { ok: false, status: 404 },
'/api/stacks/web/dossier': { ok: false, status: 500 },
});
const result = await captureRemoteNodeFiles(remoteNode, true);
expect(result.stacks).toHaveLength(1);
expect(result.stacks[0].dossier).toBeUndefined();
expect(result.docWarnings[0].reason).toContain('HTTP 500');
});
it('treats a 404 dossier as absent without warning', async () => {
mockFetchRoutes({
'/api/stacks': { ok: true, jsonValue: ['web'] },
'/api/stacks/web': { ok: true, textValue: 'services: {}\n' },
'/api/stacks/web/env': { ok: false, status: 404 },
'/api/stacks/web/dossier': { ok: false, status: 404 },
});
const result = await captureRemoteNodeFiles(remoteNode, true);
expect(result.stacks[0].dossier).toBeUndefined();
expect(result.docWarnings).toHaveLength(0);
});
it('pickDossierFields keeps only the eleven string fields and drops extras', () => {
const f = pickDossierFields({ purpose: 'p', owner: 'o', node_id: 5, source_hash: 'x' } as Record<string, unknown>);
expect(f.purpose).toBe('p');
expect(f.owner).toBe('o');
expect(Object.keys(f)).toHaveLength(11);
expect((f as unknown as Record<string, unknown>).node_id).toBeUndefined();
});
it('dossierHasContent is false for all-blank or whitespace, true for any real value', () => {
expect(dossierHasContent(BLANK_DOSSIER)).toBe(false);
expect(dossierHasContent(dossier({ vlan: ' ' }))).toBe(false);
expect(dossierHasContent(dossier({ purpose: 'x' }))).toBe(true);
});
it('buildSnapshotDocumentation returns null when nothing was captured', () => {
const nodes: SnapshotNodeData[] = [
{ nodeId: 1, nodeName: 'a', stacks: [{ stackName: 'web', files: [] }], warnings: [], docWarnings: [] },
];
expect(buildSnapshotDocumentation(nodes, 'now')).toBeNull();
});
it('buildSnapshotDocumentation aggregates dossiers and dossier warnings', () => {
const nodes: SnapshotNodeData[] = [{
nodeId: 1,
nodeName: 'a',
stacks: [{ stackName: 'web', files: [], dossier: dossier({ purpose: 'p' }) }],
warnings: [],
docWarnings: [{ stackName: 'db', reason: 'boom' }],
}];
const doc = buildSnapshotDocumentation(nodes, '2026-01-01T00:00:00Z');
expect(doc).not.toBeNull();
expect(doc!.stacks).toHaveLength(1);
expect(doc!.stacks[0]).toMatchObject({ nodeId: 1, nodeName: 'a', stackName: 'web' });
expect(doc!.warnings[0]).toMatchObject({ nodeId: 1, stackName: 'db', reason: 'boom' });
expect(doc!.generated_at).toBe('2026-01-01T00:00:00Z');
});
});