197 lines
7.1 KiB
TypeScript
197 lines
7.1 KiB
TypeScript
/**
|
|
* Unit tests for the SnapshotLayout contract — the typed metadata.json that capture
|
|
* PRODUCES and restore CONSUMES. The whole point is that a build->serialize->parse
|
|
* round-trip is lossless, and a malformed/mislocated/wrong-version payload parses to
|
|
* null (an explicit "unreadable" branch) rather than a cascade of undefineds.
|
|
*/
|
|
import { describe, test, expect } from 'bun:test';
|
|
import {
|
|
buildSnapshotLayout,
|
|
serializeLayout,
|
|
parseSnapshotLayout,
|
|
redactSnapshotLayout,
|
|
SNAPSHOT_LAYOUT_VERSION,
|
|
type SnapshotLayout,
|
|
} from '../../src/lib/server/backups/snapshot-layout';
|
|
|
|
const stackLayout = (): SnapshotLayout =>
|
|
buildSnapshotLayout({
|
|
type: 'stack',
|
|
targetName: 'immich',
|
|
environmentId: 3,
|
|
backupTime: '2026-08-02T10:00:00.000Z',
|
|
volumes: [
|
|
{ key: 'data', name: 'data', source: 'data', type: 'volume' },
|
|
{ key: 'cfg', name: '/config', source: '/host/appdata/config', type: 'bind' },
|
|
],
|
|
stack: {
|
|
composeFileName: 'immich.yaml',
|
|
fileList: [{ path: 'immich.yaml', bytes: 120 }, { path: 'hwaccel.yml', bytes: 40 }],
|
|
excludedBindDirs: ['data'],
|
|
secrets: [{ key: 'DB_PASSWORD', value: 'enc:v1:abc' }],
|
|
},
|
|
});
|
|
|
|
describe('buildSnapshotLayout', () => {
|
|
test('stamps the current version', () => {
|
|
expect(stackLayout().version).toBe(SNAPSHOT_LAYOUT_VERSION);
|
|
});
|
|
test('no hasStackFiles field — presence is derived from stack', () => {
|
|
const l = stackLayout();
|
|
expect('hasStackFiles' in l).toBe(false);
|
|
expect(l.stack !== undefined).toBe(true); // this IS hasStackFiles
|
|
});
|
|
});
|
|
|
|
describe('round-trip build -> serialize -> parse', () => {
|
|
test('stack layout survives losslessly', () => {
|
|
const parsed = parseSnapshotLayout(serializeLayout(stackLayout()));
|
|
expect(parsed).toEqual(stackLayout());
|
|
});
|
|
|
|
test('bind volume source (host path) is preserved', () => {
|
|
const parsed = parseSnapshotLayout(serializeLayout(stackLayout()));
|
|
const bind = parsed?.volumes.find((v) => v.type === 'bind');
|
|
expect(bind?.source).toBe('/host/appdata/config');
|
|
});
|
|
|
|
test('container layout (no stack) round-trips with stack undefined', () => {
|
|
const c = buildSnapshotLayout({
|
|
type: 'container', targetName: 'nginx', environmentId: null,
|
|
backupTime: '2026-08-02T10:00:00.000Z', volumes: [], container: { Id: 'abc' },
|
|
});
|
|
const parsed = parseSnapshotLayout(serializeLayout(c));
|
|
expect(parsed?.type).toBe('container');
|
|
expect(parsed?.stack).toBeUndefined();
|
|
expect(parsed?.container).toEqual({ Id: 'abc' });
|
|
});
|
|
});
|
|
|
|
describe('parseSnapshotLayout rejects bad payloads (-> null, never throws)', () => {
|
|
test('invalid JSON -> null', () => {
|
|
expect(parseSnapshotLayout('{not json')).toBeNull();
|
|
});
|
|
test('empty string -> null', () => {
|
|
expect(parseSnapshotLayout('')).toBeNull();
|
|
});
|
|
test('wrong type value -> null', () => {
|
|
expect(parseSnapshotLayout(JSON.stringify({ version: 1, type: 'bogus', targetName: 'x' }))).toBeNull();
|
|
});
|
|
test('missing targetName -> null', () => {
|
|
expect(parseSnapshotLayout(JSON.stringify({ version: 1, type: 'stack' }))).toBeNull();
|
|
});
|
|
test('a FUTURE version we cannot read -> null', () => {
|
|
expect(parseSnapshotLayout(JSON.stringify({ version: 999, type: 'stack', targetName: 'x' }))).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('pre-contract beta snapshots (missing version) are accepted as v1', () => {
|
|
test('a payload with no version but valid shape parses', () => {
|
|
const legacy = JSON.stringify({
|
|
type: 'stack', targetName: 'old', environmentId: 1,
|
|
backupTime: '2026-07-01T00:00:00.000Z',
|
|
volumes: [{ key: 'data', name: 'data', source: 'data', type: 'volume' }],
|
|
hasStackFiles: true, composeFileName: 'docker-compose.yml', // old flat fields ignored
|
|
});
|
|
const parsed = parseSnapshotLayout(legacy);
|
|
expect(parsed).not.toBeNull();
|
|
expect(parsed?.version).toBe(SNAPSHOT_LAYOUT_VERSION);
|
|
expect(parsed?.type).toBe('stack');
|
|
expect(parsed?.volumes.length).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('defensive volume coercion', () => {
|
|
test('a malformed volume entry is dropped, the rest survive', () => {
|
|
const raw = JSON.stringify({
|
|
version: 1, type: 'stack', targetName: 'x', environmentId: null, backupTime: '',
|
|
volumes: [
|
|
{ key: 'good', name: 'good', source: 'good', type: 'volume' },
|
|
{ name: 'no-key', type: 'volume' }, // missing key -> dropped
|
|
{ key: 'bad-type', type: 'nonsense' }, // bad type -> dropped
|
|
],
|
|
});
|
|
const parsed = parseSnapshotLayout(raw);
|
|
expect(parsed?.volumes.map((v) => v.key)).toEqual(['good']);
|
|
});
|
|
});
|
|
|
|
describe('redactSnapshotLayout - never leak secrets to the client', () => {
|
|
const containerLayout = (): SnapshotLayout =>
|
|
buildSnapshotLayout({
|
|
type: 'container',
|
|
targetName: 'db',
|
|
environmentId: 2,
|
|
backupTime: '2026-01-01T00:00:00Z',
|
|
volumes: [{ key: 'data', name: 'db_data', source: 'db_data', type: 'volume' }],
|
|
container: {
|
|
Id: 'abc123',
|
|
Name: '/db',
|
|
Config: {
|
|
Image: 'postgres:16',
|
|
Env: ['POSTGRES_PASSWORD=hunter2', 'API_KEY=sk-secret', 'PATH=/usr/bin'],
|
|
Labels: { 'com.example.token': 'shh', 'com.docker.compose.project': 'app' },
|
|
ExposedPorts: { '5432/tcp': {} },
|
|
},
|
|
},
|
|
});
|
|
|
|
test('strips container Config.Env (plaintext secrets) but keeps the rest of the inspect', () => {
|
|
const red = redactSnapshotLayout(containerLayout());
|
|
const c = red.container as any;
|
|
expect(c.Config.Env).toBeUndefined(); // secrets gone
|
|
expect(c.Config.Labels).toBeUndefined(); // labels can hide secrets too
|
|
expect(c.Config.Image).toBe('postgres:16'); // non-secret inspect preserved
|
|
expect(c.Config.ExposedPorts).toEqual({ '5432/tcp': {} });
|
|
expect(c.Id).toBe('abc123');
|
|
});
|
|
|
|
test('no secret value appears anywhere in the serialized redacted output', () => {
|
|
const red = redactSnapshotLayout(containerLayout());
|
|
const blob = JSON.stringify(red);
|
|
expect(blob).not.toContain('hunter2');
|
|
expect(blob).not.toContain('sk-secret');
|
|
expect(blob).not.toContain('shh');
|
|
});
|
|
|
|
test('stack secrets collapse to secretKeys names only', () => {
|
|
const layout = buildSnapshotLayout({
|
|
type: 'stack',
|
|
targetName: 'immich',
|
|
environmentId: 3,
|
|
backupTime: '2026-01-01T00:00:00Z',
|
|
volumes: [],
|
|
stack: {
|
|
composeFileName: 'compose.yaml',
|
|
fileList: [],
|
|
excludedBindDirs: [],
|
|
secrets: [
|
|
{ key: 'DB_PASSWORD', value: 'enc:v1:ciphertextAAA' },
|
|
{ key: 'JWT_SECRET', value: 'enc:v1:ciphertextBBB' },
|
|
],
|
|
},
|
|
});
|
|
const red = redactSnapshotLayout(layout);
|
|
expect(red.stack?.secretKeys).toEqual(['DB_PASSWORD', 'JWT_SECRET']);
|
|
expect((red.stack as any).secrets).toBeUndefined();
|
|
expect(red.hasStackFiles).toBe(true);
|
|
expect(JSON.stringify(red)).not.toContain('ciphertext');
|
|
});
|
|
|
|
test('hasStackFiles is false and stack undefined for a container backup', () => {
|
|
const red = redactSnapshotLayout(containerLayout());
|
|
expect(red.hasStackFiles).toBe(false);
|
|
expect(red.stack).toBeUndefined();
|
|
});
|
|
|
|
test('defensive: odd container shapes pass through without throwing', () => {
|
|
for (const c of [undefined, null, 'a-string', 42, {}, { Config: 'not-an-object' }]) {
|
|
const layout = buildSnapshotLayout({
|
|
type: 'container', targetName: 't', environmentId: null,
|
|
backupTime: '', volumes: [], container: c,
|
|
});
|
|
expect(() => redactSnapshotLayout(layout)).not.toThrow();
|
|
}
|
|
});
|
|
});
|