add missing unit test suite

This commit is contained in:
Jarek Krochmalski committed 2026-08-22 09:09:50 +02:00
1 parent 7227b334f9
commit 5f9e6bde3c
160 files changed
+19260 -1164

No files matched your search

+28 -1
View File
@@ -17,10 +17,31 @@ build/
# CI/CD
.drone.yml
.gitea/
deployment/
buildkitd.toml
.build-trigger
.gitleaks.toml
.gitleaksignore
.snyk
.mcp.json
.gitmodules
# Tests
# Sync-to-GitHub + test/dev tooling (not needed to build the image)
sync-gh.sh
sync-plan.py
sync-whitelist.sh
test-env-vars.sh
playwright.config.ts
# Ops/infra + vendored demo submodule (not app runtime)
infra/
support/
# Tests (unit) + integration tests + one-off debug scripts - none belong in the image
tests/
integration/
debug/
# Hawser agent (separate project)
hawser/
@@ -34,6 +55,12 @@ test-image/
# Landing page
webpage/
# Source design assets (README/marketing) - runtime favicons live in static/
screenshots/
logo/
favicon_io/
*.pxd
# Misc
.DS_Store
*.log
+28
View File
@@ -0,0 +1,28 @@
name: CI
# Runs the unit test suite on pull requests and pushes. Only the unit tests in
# tests/ live in this public mirror; the integration suite needs private CI
# infrastructure and is not published here.
on:
pull_request:
push:
branches:
- main
- master
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Bun
uses: oven-sh/setup-bun@v2
# No lockfile is published to the mirror, so resolve from package.json.
- name: Install dependencies
run: bun install
- name: Run unit tests
run: bun test tests/
+5 -3
View File
@@ -8,6 +8,8 @@ exact = true
# Enable source maps for better error messages
sourcemap = "external"
[test]
# Disable auth before any integration test runs
preload = ["./tests/helpers/preload.ts"]
# NOTE: no global [test] preload. The auth-disable preload is integration-only
# (it hits the test server + SSH; it is a no-op for pure unit tests). The shard
# runner injects it explicitly via `--preload integration-tests/helpers/preload.ts`
# (see scripts/run-tests-shard.sh). Unit runs (`bun test tests/unit/`) get no
# preload, which is correct - they have no server to talk to.
+104
View File
@@ -0,0 +1,104 @@
import { describe, test, expect } from 'bun:test';
import { computeAuditDiff, formatFieldName } from '../src/lib/utils/diff';
describe('computeAuditDiff', () => {
test('returns null when no changes', () => {
expect(computeAuditDiff({ name: 'foo' }, { name: 'foo' })).toBeNull();
});
test('returns null for null inputs', () => {
expect(computeAuditDiff(null, { name: 'foo' })).toBeNull();
expect(computeAuditDiff({ name: 'foo' }, null)).toBeNull();
});
test('detects simple field change', () => {
const diff = computeAuditDiff({ name: 'old' }, { name: 'new' });
expect(diff).not.toBeNull();
expect(diff!.changes).toHaveLength(1);
expect(diff!.changes[0]).toEqual({ field: 'name', oldValue: 'old', newValue: 'new' });
});
test('detects added field', () => {
const diff = computeAuditDiff({ name: 'foo' }, { name: 'foo', host: 'localhost' });
expect(diff).not.toBeNull();
expect(diff!.changes[0].field).toBe('host');
});
test('skips internal fields (id, createdAt, updatedAt)', () => {
const diff = computeAuditDiff(
{ id: 1, name: 'foo', createdAt: 'old', updatedAt: 'old' },
{ id: 2, name: 'foo', createdAt: 'new', updatedAt: 'new' }
);
expect(diff).toBeNull();
});
test('masks sensitive fields', () => {
const diff = computeAuditDiff({ password: 'old-pass' }, { password: 'new-pass' });
expect(diff).not.toBeNull();
expect(diff!.changes[0].oldValue).toBe('••••••••');
expect(diff!.changes[0].newValue).toBe('••••••••');
});
test('shows masked field set to null', () => {
const diff = computeAuditDiff({ password: 'secret' }, { password: null });
expect(diff).not.toBeNull();
expect(diff!.changes[0].oldValue).toBe('••••••••');
expect(diff!.changes[0].newValue).toBeNull();
});
test('respects includeFields option', () => {
const diff = computeAuditDiff(
{ name: 'old', host: 'old', port: 1 },
{ name: 'new', host: 'new', port: 2 },
{ includeFields: ['name'] }
);
expect(diff!.changes).toHaveLength(1);
expect(diff!.changes[0].field).toBe('name');
});
test('respects excludeFields option', () => {
const diff = computeAuditDiff(
{ name: 'old', host: 'old' },
{ name: 'new', host: 'new' },
{ excludeFields: ['host'] }
);
expect(diff!.changes).toHaveLength(1);
expect(diff!.changes[0].field).toBe('name');
});
test('truncates long strings', () => {
const longStr = 'a'.repeat(300);
const diff = computeAuditDiff({ desc: 'short' }, { desc: longStr });
expect(diff!.changes[0].newValue.length).toBeLessThan(300);
expect(diff!.changes[0].newValue).toContain('...');
});
test('handles array changes', () => {
const diff = computeAuditDiff({ tags: ['a', 'b'] }, { tags: ['a', 'c'] });
expect(diff).not.toBeNull();
expect(diff!.changes[0].field).toBe('tags');
});
});
describe('formatFieldName', () => {
test('converts camelCase to title case', () => {
expect(formatFieldName('hostName')).toBe('Host Name');
});
test('handles special cases', () => {
expect(formatFieldName('tlsCa')).toBe('TLS CA');
expect(formatFieldName('tlsCert')).toBe('TLS certificate');
expect(formatFieldName('sshPrivateKey')).toBe('SSH private key');
expect(formatFieldName('ipAddress')).toBe('IP address');
expect(formatFieldName('connectionType')).toBe('Connection type');
});
test('capitalizes first letter', () => {
expect(formatFieldName('name')).toBe('Name');
});
test('handles multi-word camelCase', () => {
expect(formatFieldName('socketPath')).toBe('Socket path');
expect(formatFieldName('collectActivity')).toBe('Collect activity');
});
});
+784
View File
@@ -0,0 +1,784 @@
/**
* Unit tests for pure helper functions in src/lib/server/backups/helpers.ts and
* src/lib/server/host-path.ts.
*
* Pure means: no DB, no network, no spawn. Just (input) → (output) functions.
* Goal here is to make every helper that touches user-controllable data have a
* fast, deterministic, runs-everywhere safety net so we can refactor without fear.
*/
import { describe, expect, test } from 'bun:test';
import {
parseRetentionJson,
parseOptionsJson,
parsePoliciesJson,
isValidSnapshotId,
isRepoNotInitializedError,
wouldDeleteAllSnapshots,
retentionToStore,
validateFlags,
validatePolicySchedules,
validateRetention,
isAllowedRepository,
sanitizeWebhookUrlForLog,
classifyBackupError,
validateRepositoryForSave,
parseSelectedVolumes,
parseBackupFlags,
serializeBackupFlags,
validateAndSerializeFlags,
sanitizeResticFlags,
sanitizeRestoreFlags,
buildJobOptions
} from '../src/lib/server/backups/helpers';
import { isLocalRepo } from '../src/lib/server/backups/models';
import { findRelativeBindSources, extractUidFromSocketPath } from '../src/lib/server/host-path';
// ============================================================================
// Split backup/restore flags: JSON in the flags column, legacy string compat, and
// per-scope allowlists (restore permits --exclude-xattr, backup does NOT).
// ============================================================================
describe('parseBackupFlags (string-vs-JSON boundary, backward compat)', () => {
test('new JSON shape parses into {backup, restore}', () => {
expect(parseBackupFlags('{"backup":"--verbose","restore":"--exclude-xattr security.selinux"}'))
.toEqual({ backup: '--verbose', restore: '--exclude-xattr security.selinux' });
});
test('LEGACY bare string = backup flags, empty restore (no migration, no data loss)', () => {
expect(parseBackupFlags('--limit-upload 5120')).toEqual({ backup: '--limit-upload 5120', restore: '' });
});
test('null / empty -> both empty', () => {
expect(parseBackupFlags(null)).toEqual({ backup: '', restore: '' });
expect(parseBackupFlags('')).toEqual({ backup: '', restore: '' });
expect(parseBackupFlags(' ')).toEqual({ backup: '', restore: '' });
});
test('malformed JSON object -> treated as legacy backup string', () => {
expect(parseBackupFlags('{not json')).toEqual({ backup: '{not json', restore: '' });
});
test('JSON with missing fields coerces the missing one to empty', () => {
expect(parseBackupFlags('{"backup":"--verbose"}')).toEqual({ backup: '--verbose', restore: '' });
});
});
describe('serializeBackupFlags (round-trips, null when empty)', () => {
test('both empty -> null (column stays NULL, not {})', () => {
expect(serializeBackupFlags({ backup: '', restore: '' })).toBeNull();
expect(serializeBackupFlags({ backup: ' ', restore: '' })).toBeNull();
});
test('round-trips through parse', () => {
const s = serializeBackupFlags({ backup: '--verbose', restore: '--exclude-xattr security.selinux' });
expect(parseBackupFlags(s)).toEqual({ backup: '--verbose', restore: '--exclude-xattr security.selinux' });
});
});
describe('per-scope allowlists — restore-only flags never reach backup', () => {
test('--exclude-xattr is allowed for RESTORE', () => {
expect(sanitizeRestoreFlags('--exclude-xattr security.selinux')).toEqual(['--exclude-xattr', 'security.selinux']);
});
test('--exclude-xattr is REJECTED for backup (would break restic backup)', () => {
expect(() => sanitizeResticFlags('--exclude-xattr security.selinux')).toThrow(/Disallowed/);
});
test('common flags pass on both', () => {
expect(sanitizeResticFlags('--compression max')).toEqual(['--compression', 'max']);
expect(sanitizeRestoreFlags('--compression max')).toEqual(['--compression', 'max']);
});
test('a dangerous flag is rejected on both', () => {
expect(() => sanitizeResticFlags('--option x')).toThrow(/Disallowed/);
expect(() => sanitizeRestoreFlags('--password-command evil')).toThrow(/Disallowed/);
});
});
describe('validateAndSerializeFlags (API save path)', () => {
test('valid backup + restore -> JSON', () => {
const s = validateAndSerializeFlags('--verbose', '--exclude-xattr security.selinux');
expect(parseBackupFlags(s)).toEqual({ backup: '--verbose', restore: '--exclude-xattr security.selinux' });
});
test('both empty -> null', () => {
expect(validateAndSerializeFlags('', '')).toBeNull();
});
test('THROWS when a restore-only flag is put in the BACKUP field', () => {
expect(() => validateAndSerializeFlags('--exclude-xattr security.selinux', '')).toThrow(/Disallowed/);
});
test('THROWS on a disallowed restore flag', () => {
expect(() => validateAndSerializeFlags('', '--option evil')).toThrow(/Disallowed/);
});
});
// ============================================================================
// parseSelectedVolumes — SHAPE guard, not just JSON (B2)
// ============================================================================
describe('parseSelectedVolumes', () => {
test('a valid string[] round-trips', () => {
expect(parseSelectedVolumes('["data","config"]')).toEqual(['data', 'config']);
});
test('null / empty -> null (all volumes)', () => {
expect(parseSelectedVolumes(null)).toBeNull();
expect(parseSelectedVolumes(undefined)).toBeNull();
expect(parseSelectedVolumes('')).toBeNull();
});
test('a NON-array (corrupt/legacy string) -> null, NOT an iterable-of-chars', () => {
// The bug: a bare string flowed into new Set(...) as chars, matching no volume key
// and silently filtering EVERY volume out (metadata-only snapshot). Must be null.
expect(parseSelectedVolumes('"data"')).toBeNull();
expect(parseSelectedVolumes('42')).toBeNull();
expect(parseSelectedVolumes('{"a":1}')).toBeNull();
});
test('invalid JSON -> null', () => {
expect(parseSelectedVolumes('not json')).toBeNull();
});
test('drops non-string array members', () => {
expect(parseSelectedVolumes('["data",5,null,"config"]')).toEqual(['data', 'config']);
});
});
// ============================================================================
// isLocalRepo — the SINGLE local-repo predicate (B4)
// ============================================================================
describe('isLocalRepo', () => {
test('an absolute path is local', () => {
expect(isLocalRepo('/mnt/nas/backups')).toBe(true);
});
test('a ./ relative path is NOT local (matches the save gate isAllowedRepository)', () => {
expect(isLocalRepo('./data/repo')).toBe(false);
expect(isAllowedRepository('./data/repo')).toBe(false); // the two agree
});
test('scheme URLs are not local', () => {
expect(isLocalRepo('s3:bucket')).toBe(false);
expect(isLocalRepo('rest:http://host:8000/')).toBe(false);
});
});
// ============================================================================
// parseRetentionJson — small but trusted by retention scheduling
// ============================================================================
describe('parseRetentionJson', () => {
test('null → empty object (no retention configured = no policy applied)', () => {
expect(parseRetentionJson(null)).toEqual({});
});
test('undefined → empty object', () => {
expect(parseRetentionJson(undefined)).toEqual({});
});
test('empty string → empty object', () => {
expect(parseRetentionJson('')).toEqual({});
});
test('valid JSON with keepLast roundtrips', () => {
expect(parseRetentionJson('{"keepLast":3}')).toEqual({ keepLast: 3 });
});
test('valid JSON with full policy roundtrips', () => {
const full = { keepLast: 5, keepDaily: 7, keepWeekly: 4, keepMonthly: 12, keepYearly: 3 };
expect(parseRetentionJson(JSON.stringify(full))).toEqual(full);
});
test('malformed JSON → empty object (NOT thrown — caller must not crash on bad DB data)', () => {
// This is the contract: a corrupted retention field in the DB must not
// kill the backup. The whole retention step is skipped instead.
expect(parseRetentionJson('{not json')).toEqual({});
expect(parseRetentionJson('[1,2,3]')).toEqual([1, 2, 3] as any);
});
test('JSON null → null (caller must treat as no retention; parser does not coerce)', () => {
// Documenting current behaviour: the function only catches parse errors,
// it does not normalize falsy parse results. Callers using `retention.keepLast`
// will get `undefined` on `null` and skip retention, which is the intended outcome.
expect(parseRetentionJson('null')).toBe(null as any);
});
test('JSON with extra fields preserved (forward-compat for future keep-* options)', () => {
expect(parseRetentionJson('{"keepLast":3,"unknownField":"x"}')).toEqual({
keepLast: 3,
unknownField: 'x'
});
});
});
// ============================================================================
// parseOptionsJson — semantically identical but separate function
// ============================================================================
describe('parseOptionsJson', () => {
test('null → empty object', () => {
expect(parseOptionsJson(null)).toEqual({});
});
test('undefined → empty object', () => {
expect(parseOptionsJson(undefined)).toEqual({});
});
test('compression option roundtrips', () => {
expect(parseOptionsJson('{"compression":"max"}')).toEqual({ compression: 'max' });
});
test('exclude patterns roundtrip as a single string (not parsed further)', () => {
// The string-splitting on comma happens at backup time, not here.
expect(parseOptionsJson('{"excludePatterns":"*.tmp,*.log"}')).toEqual({
excludePatterns: '*.tmp,*.log'
});
});
test('malformed JSON → empty object', () => {
expect(parseOptionsJson('garbage')).toEqual({});
});
test('options with webhooks survive (these are URLs, must not be mangled)', () => {
const opts = {
webhookSuccess: 'https://hooks.example.com/x?token=abc&y=1',
webhookFailure: 'https://hooks.example.com/x?token=def'
};
expect(parseOptionsJson(JSON.stringify(opts))).toEqual(opts);
});
});
// ============================================================================
// isValidSnapshotId — guards every restore endpoint from arbitrary input
// ============================================================================
describe('isValidSnapshotId', () => {
test('accepts restic short ID (8 hex chars)', () => {
expect(isValidSnapshotId('deadbeef')).toBe(true);
});
test('accepts restic full ID (64 hex chars)', () => {
expect(isValidSnapshotId('a'.repeat(64))).toBe(true);
expect(isValidSnapshotId('0123456789abcdef'.repeat(4))).toBe(true);
});
test('rejects too-short ID (<8 chars)', () => {
expect(isValidSnapshotId('abcdef')).toBe(false);
expect(isValidSnapshotId('')).toBe(false);
});
test('rejects too-long ID (>64 chars)', () => {
expect(isValidSnapshotId('a'.repeat(65))).toBe(false);
});
test('rejects non-hex characters (path traversal / injection bait)', () => {
expect(isValidSnapshotId('deadbeef../etc')).toBe(false);
expect(isValidSnapshotId('../../passwd')).toBe(false);
expect(isValidSnapshotId('deadbeef;rm -rf')).toBe(false);
expect(isValidSnapshotId('DEADBEEF')).toBe(false); // uppercase not accepted
});
test('rejects whitespace, newlines', () => {
expect(isValidSnapshotId(' deadbeef ')).toBe(false);
expect(isValidSnapshotId('deadbeef\n')).toBe(false);
expect(isValidSnapshotId('dead beef')).toBe(false);
});
});
// ============================================================================
// isRepoNotInitializedError — exit-code detection from restic
// ============================================================================
describe('isRepoNotInitializedError', () => {
test('detects restic exit code 10 (repo not initialized)', () => {
expect(isRepoNotInitializedError({ exitCode: 10 })).toBe(true);
});
test('rejects other exit codes', () => {
expect(isRepoNotInitializedError({ exitCode: 0 })).toBe(false);
expect(isRepoNotInitializedError({ exitCode: 1 })).toBe(false);
expect(isRepoNotInitializedError({ exitCode: 11 })).toBe(false);
});
test('safe on undefined / null / non-objects', () => {
expect(isRepoNotInitializedError(undefined)).toBe(false);
expect(isRepoNotInitializedError(null)).toBe(false);
expect(isRepoNotInitializedError('error string')).toBe(false);
expect(isRepoNotInitializedError(42)).toBe(false);
});
test('safe on object without exitCode', () => {
expect(isRepoNotInitializedError({})).toBe(false);
expect(isRepoNotInitializedError({ message: 'something' })).toBe(false);
});
});
// ============================================================================
// findRelativeBindSources — gates cross-env deploys
// ============================================================================
describe('findRelativeBindSources', () => {
test('empty compose → empty array', () => {
expect(findRelativeBindSources('')).toEqual([]);
});
test('compose with no volumes section → empty', () => {
const c = `services:
web:
image: nginx
`;
expect(findRelativeBindSources(c)).toEqual([]);
});
test('compose with only named volumes → empty (named volumes are portable)', () => {
const c = `services:
db:
image: postgres
volumes:
- data:/var/lib/postgresql/data
- logs:/var/log
`;
expect(findRelativeBindSources(c)).toEqual([]);
});
test('compose with absolute bind path → empty (absolute paths are portable, even if wrong)', () => {
const c = `services:
web:
image: nginx
volumes:
- /etc/nginx/conf.d:/etc/nginx/conf.d:ro
`;
expect(findRelativeBindSources(c)).toEqual([]);
});
test('compose with ./ bind path → detected', () => {
const c = `services:
web:
image: nginx
volumes:
- ./html:/usr/share/nginx/html
`;
expect(findRelativeBindSources(c)).toEqual(['./html']);
});
test('compose with ../ bind path → detected (parent dir reference)', () => {
const c = `services:
web:
image: nginx
volumes:
- ../shared:/data
`;
expect(findRelativeBindSources(c)).toEqual(['../shared']);
});
test('multiple relative binds → all detected, in declaration order', () => {
const c = `services:
web:
image: nginx
volumes:
- ./html:/web
- ./conf:/etc/nginx/conf.d
db:
image: postgres
volumes:
- ./pgdata:/var/lib/postgresql/data
`;
expect(findRelativeBindSources(c)).toEqual(['./html', './conf', './pgdata']);
});
test('quoted bind path (single quotes) → detected', () => {
const c = `services:
web:
volumes:
- './html':/web
`;
expect(findRelativeBindSources(c)).toEqual(['./html']);
});
test('quoted bind path (double quotes) → detected', () => {
const c = `services:
web:
volumes:
- "./html":/web
`;
expect(findRelativeBindSources(c)).toEqual(['./html']);
});
test('mixed named + relative bind → only relative reported', () => {
const c = `services:
app:
volumes:
- mydata:/data
- ./config:/etc/app
- shared:/shared
`;
expect(findRelativeBindSources(c)).toEqual(['./config']);
});
test('bind with :ro flag → still detected (flag does not change source-path classification)', () => {
const c = `services:
web:
volumes:
- ./html:/web:ro
`;
expect(findRelativeBindSources(c)).toEqual(['./html']);
});
test('compose with comments containing ./ → not falsely detected', () => {
// The regex anchors at start-of-line, so comments would only false-match
// if they were also list items, which is unusual. Validate the
// current behaviour anyway.
const c = `services:
# don't use ./html here
web:
image: nginx
`;
expect(findRelativeBindSources(c)).toEqual([]);
});
test('long-form bind syntax (type: bind) is NOT currently detected', () => {
// KNOWN LIMITATION: the regex only matches short-form binds.
// Long-form (volumes: - type: bind, source: ./html, target: /web)
// slips through. Document the gap here so we remember; fix later.
const c = `services:
web:
volumes:
- type: bind
source: ./html
target: /web
`;
expect(findRelativeBindSources(c)).toEqual([]);
});
});
// ============================================================================
// extractUidFromSocketPath — pull the UID out of a rootless docker socket path
// so scanner helpers bind the right host socket (host-path.ts, was untested).
// ============================================================================
describe('extractUidFromSocketPath', () => {
test('extracts the UID from a rootless socket path', () => {
expect(extractUidFromSocketPath('/run/user/1000/docker.sock')).toBe('1000');
expect(extractUidFromSocketPath('/var/run/user/1000/docker.sock')).toBe('1000');
expect(extractUidFromSocketPath('/run/user/0/docker.sock')).toBe('0');
});
test('returns null for the standard (root) socket path', () => {
expect(extractUidFromSocketPath('/var/run/docker.sock')).toBeNull();
});
test('returns null when the path is not a docker.sock or the uid is non-numeric', () => {
expect(extractUidFromSocketPath('/run/user/1000/podman.sock')).toBeNull();
expect(extractUidFromSocketPath('/run/user/abc/docker.sock')).toBeNull();
});
test('is anchored on docker.sock — a suffixed path does not match', () => {
expect(extractUidFromSocketPath('/run/user/1000/docker.sock.bak')).toBeNull();
expect(extractUidFromSocketPath('')).toBeNull();
});
});
// ============================================================================
// wouldDeleteAllSnapshots — the prune safety guard's brain
//
// Restic's `forget --dry-run --json` output is what we feed this. Real samples
// captured from restic 0.17 (the format hasn't changed in years but if it does
// these tests will fail loudly). The point of the guard is to refuse pruning
// when the policy would empty the repo for a target — typo'd retention
// (keepLast: 0) should not delete every snapshot the user has.
// ============================================================================
describe('wouldDeleteAllSnapshots', () => {
test('empty stdout (restic not run yet / repo empty) → not a wipe', () => {
const r = wouldDeleteAllSnapshots('');
expect(r.wouldWipe).toBe(false);
expect(r.keep).toBe(0);
expect(r.remove).toBe(0);
expect(r.total).toBe(0);
});
test('whitespace-only stdout → not a wipe (treat as empty)', () => {
expect(wouldDeleteAllSnapshots(' \n\t ').wouldWipe).toBe(false);
});
test('unparseable stdout → not a wipe (refuse to guess — let the run fail naturally)', () => {
expect(wouldDeleteAllSnapshots('not json at all').wouldWipe).toBe(false);
expect(wouldDeleteAllSnapshots('{partial').wouldWipe).toBe(false);
});
test('JSON that is not an array → not a wipe (defensive)', () => {
expect(wouldDeleteAllSnapshots('{"keep":[],"remove":[]}').wouldWipe).toBe(false);
expect(wouldDeleteAllSnapshots('null').wouldWipe).toBe(false);
expect(wouldDeleteAllSnapshots('42').wouldWipe).toBe(false);
});
test('empty array (repo has no matching snapshots) → not a wipe (nothing to lose)', () => {
const r = wouldDeleteAllSnapshots('[]');
expect(r.wouldWipe).toBe(false);
expect(r.total).toBe(0);
});
test('keep N, remove 0 → not a wipe (the happy path)', () => {
const sample = JSON.stringify([{
keep: [{ id: 'a1' }, { id: 'a2' }, { id: 'a3' }],
remove: [],
reasons: []
}]);
const r = wouldDeleteAllSnapshots(sample);
expect(r.wouldWipe).toBe(false);
expect(r.keep).toBe(3);
expect(r.remove).toBe(0);
expect(r.total).toBe(3);
});
test('keep some, remove some → not a wipe (normal retention pass)', () => {
const sample = JSON.stringify([{
keep: [{ id: 'a1' }, { id: 'a2' }],
remove: [{ id: 'b1' }, { id: 'b2' }, { id: 'b3' }],
reasons: []
}]);
const r = wouldDeleteAllSnapshots(sample);
expect(r.wouldWipe).toBe(false);
expect(r.keep).toBe(2);
expect(r.remove).toBe(3);
expect(r.total).toBe(5);
});
test('keep 0, remove N → WIPE (the case we exist to catch)', () => {
const sample = JSON.stringify([{
keep: [],
remove: [{ id: 'a1' }, { id: 'a2' }, { id: 'a3' }],
reasons: []
}]);
const r = wouldDeleteAllSnapshots(sample);
expect(r.wouldWipe).toBe(true);
expect(r.keep).toBe(0);
expect(r.remove).toBe(3);
expect(r.total).toBe(3);
});
test('multiple groups — aggregate across all groups', () => {
// Although Dockhand forces --group-by '' so there's normally one group,
// the parser must still tolerate multi-group output for robustness in
// case a shared repo has snapshots from another tool.
const sample = JSON.stringify([
{ keep: [{ id: 'a1' }], remove: [] },
{ keep: [], remove: [{ id: 'b1' }] }
]);
const r = wouldDeleteAllSnapshots(sample);
// Not a wipe — at least one snapshot is kept across all groups
expect(r.wouldWipe).toBe(false);
expect(r.keep).toBe(1);
expect(r.remove).toBe(1);
});
test('multiple groups all wiping → wipe', () => {
const sample = JSON.stringify([
{ keep: [], remove: [{ id: 'a1' }] },
{ keep: [], remove: [{ id: 'b1' }] }
]);
const r = wouldDeleteAllSnapshots(sample);
expect(r.wouldWipe).toBe(true);
expect(r.keep).toBe(0);
expect(r.remove).toBe(2);
});
test('group with no keep/remove arrays (malformed restic output) → no crash, count 0', () => {
const sample = JSON.stringify([{ reasons: [], somethingElse: true }]);
expect(() => wouldDeleteAllSnapshots(sample)).not.toThrow();
const r = wouldDeleteAllSnapshots(sample);
expect(r.total).toBe(0);
});
});
// ============================================================================
// validateRetention: bounds-check each retention count (integer 0..10000).
// ============================================================================
describe('validateRetention', () => {
test('a valid retention object passes', () => {
expect(validateRetention({ keepLast: 7, keepDaily: 30 })).toEqual({ ok: true });
});
test('a negative count is rejected with a field-named reason', () => {
const r = validateRetention({ keepLast: -1 });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toMatch(/keepLast/);
});
test('a non-integer / NaN count is rejected', () => {
expect(validateRetention({ keepLast: 'x' }).ok).toBe(false);
});
});
// ============================================================================
// isAllowedRepository / validateRepositoryForSave: repo-scheme allowlist + SSRF
// guard on URL-form backends. Security-relevant (a destination is user input).
// ============================================================================
describe('isAllowedRepository', () => {
test('accepts an absolute local path and supported schemes', () => {
expect(isAllowedRepository('/data/repo')).toBe(true);
expect(isAllowedRepository('s3:http://minio/bucket')).toBe(true);
});
test('rejects an unknown scheme and empty/nullish input', () => {
expect(isAllowedRepository('ftp://x')).toBe(false);
expect(isAllowedRepository('')).toBe(false);
expect(isAllowedRepository(null)).toBe(false);
expect(isAllowedRepository(undefined)).toBe(false);
});
});
describe('validateRepositoryForSave', () => {
test('a local path and a LAN rest repo are accepted (null = no error)', () => {
expect(validateRepositoryForSave('/data/repo')).toBeNull();
});
test('an unsupported scheme returns an error string', () => {
expect(validateRepositoryForSave('ftp://x')).toMatch(/Invalid repository/);
});
test('a URL-form repo pointing at the cloud-metadata IP is rejected (SSRF guard)', () => {
expect(validateRepositoryForSave('rest:http://169.254.169.254/x')).toMatch(/not allowed/);
});
});
// ============================================================================
// sanitizeWebhookUrlForLog: redact a webhook URL for logs without leaking the
// secret path. Fail-closed to a placeholder on an unparseable URL (#6).
// ============================================================================
describe('sanitizeWebhookUrlForLog', () => {
test('keeps origin + first path segment, redacts the rest', () => {
expect(sanitizeWebhookUrlForLog('https://hooks.slack.com/services/T/B/XXX'))
.toBe('https://hooks.slack.com/services/***');
});
test('a URL with no path is returned as its origin', () => {
expect(sanitizeWebhookUrlForLog('https://example.com')).toBe('https://example.com');
});
test('an unparseable URL fails closed to a placeholder (never leaks the raw value)', () => {
expect(sanitizeWebhookUrlForLog('not a url')).toBe('<webhook>');
});
});
// ============================================================================
// classifyBackupError: map a restic failure to a stable code vocabulary; must
// never throw and defaults to UNKNOWN.
// ============================================================================
describe('classifyBackupError', () => {
test('maps a known signature (already locked → REPO_LOCKED)', () => {
expect(classifyBackupError(new Error('repository is already locked'))).toBe('REPO_LOCKED');
});
test('defaults to UNKNOWN for an unrecognised message', () => {
expect(classifyBackupError(new Error('something weird'))).toBe('UNKNOWN');
});
test('is safe on null / non-Error input', () => {
expect(classifyBackupError(null)).toBe('UNKNOWN');
expect(classifyBackupError(undefined)).toBe('UNKNOWN');
expect(classifyBackupError('a string')).toBe('UNKNOWN');
});
});
// ============================================================================
// parsePoliciesJson: tolerant parse of a destination's policies JSON — returns
// {} on malformed input (logged) rather than throwing.
// ============================================================================
describe('parsePoliciesJson', () => {
test('parses valid JSON', () => {
expect(parsePoliciesJson('{"pruneSchedule":"0 3 * * *"}')).toEqual({ pruneSchedule: '0 3 * * *' });
});
test('returns {} on malformed JSON (never throws)', () => {
expect(parsePoliciesJson('{bad')).toEqual({});
});
test('returns {} on null/undefined', () => {
expect(parsePoliciesJson(null)).toEqual({});
expect(parsePoliciesJson(undefined)).toEqual({});
});
});
// ============================================================================
// retentionToStore: turn a retention object + schedule into the stored JSON,
// or null. Feeds what actually gets persisted for a config's retention policy.
// ============================================================================
describe('retentionToStore', () => {
test('a retention object with any truthy value is stored verbatim', () => {
expect(retentionToStore({ keepLast: 7 }, null)).toBe(JSON.stringify({ keepLast: 7 }));
});
test('a retention object with only zero/empty values is treated as no policy', () => {
expect(retentionToStore({ keepLast: 0, keepDaily: 0 }, null)).toBeNull();
});
test('no retention but a non-empty schedule falls back to the default scheduled retention', () => {
expect(retentionToStore(null, '0 3 * * *')).toBe(JSON.stringify({ keepDaily: 7, keepWeekly: 4, keepMonthly: 6 }));
});
test('no retention and no (or whitespace-only) schedule stores null', () => {
expect(retentionToStore(null, null)).toBeNull();
expect(retentionToStore(null, ' ')).toBeNull();
});
});
// ============================================================================
// validateFlags: save-time wrapper over sanitizeResticFlags — surfaces the
// throw as an error string (or null when the flags are acceptable / absent).
// ============================================================================
describe('validateFlags', () => {
test('null / undefined flags are acceptable (no error)', () => {
expect(validateFlags(null)).toBeNull();
expect(validateFlags(undefined)).toBeNull();
});
test('an allowlisted flag is acceptable', () => {
expect(validateFlags('--limit-upload=100')).toBeNull();
});
test('a disallowed flag surfaces the sanitizer error as a string', () => {
expect(validateFlags('--password-command=x')).toMatch(/Disallowed/);
});
});
// ============================================================================
// validatePolicySchedules: validate prune/check/verify cron schedules in a
// destination's policies payload (string or already-parsed object).
// ============================================================================
describe('validatePolicySchedules', () => {
test('null policies are acceptable', () => {
expect(validatePolicySchedules(null)).toBeNull();
});
test('unparseable JSON is rejected (fail-closed)', () => {
expect(validatePolicySchedules('{bad')).toBe('Invalid policies JSON');
});
test('a valid cron in any schedule field passes', () => {
expect(validatePolicySchedules(JSON.stringify({ pruneSchedule: '0 3 * * *' }))).toBeNull();
});
test('an invalid cron is reported with its field name', () => {
expect(validatePolicySchedules(JSON.stringify({ checkSchedule: 'nonsense' }))).toMatch(/checkSchedule/);
});
test('an empty / whitespace-only schedule is skipped (not validated)', () => {
expect(validatePolicySchedules(JSON.stringify({ pruneSchedule: ' ' }))).toBeNull();
});
test('accepts an already-parsed object, not just a JSON string', () => {
expect(validatePolicySchedules({ verifySchedule: '0 0 1 * *' })).toBeNull();
});
});
// ============================================================================
// assertStackBackupable: external-stack guard (not in this module — but the
// test belongs next to the helpers so it's easy to find. The guard itself
// is in backup.ts because it touches stacks.ts.)
// ============================================================================
// ============================================================================
// buildJobOptions: maps a config's stored options -> the job.options a run uses.
// Regression guard: excludedStackFiles MUST survive this mapping, or a SAVED backup
// (scheduled or "run now") silently ignores the user's stack-file deselections while
// an in-request run honors them. This is exactly the field that was being dropped.
// ============================================================================
describe('buildJobOptions', () => {
test('forwards excludedStackFiles (the field that was being dropped)', () => {
const out = buildJobOptions({ excludedStackFiles: ['secrets.txt', 'notes.md'] });
expect(out.excludedStackFiles).toEqual(['secrets.txt', 'notes.md']);
});
test('excludedStackFiles is undefined when absent or not an array', () => {
expect(buildJobOptions({}).excludedStackFiles).toBeUndefined();
expect(buildJobOptions({ excludedStackFiles: 'nope' }).excludedStackFiles).toBeUndefined();
expect(buildJobOptions({ excludedStackFiles: null }).excludedStackFiles).toBeUndefined();
});
test('splits excludePatterns CSV, trims, drops empties', () => {
expect(buildJobOptions({ excludePatterns: 'a, b ,,c' }).excludePatterns).toEqual(['a', 'b', 'c']);
expect(buildJobOptions({}).excludePatterns).toBeUndefined();
});
test('carries the plain scalar options through', () => {
const out = buildJobOptions({
excludeCaches: true, compression: 'max', limitUpload: 100, limitDownload: 200,
webhookSuccess: 'https://ok', webhookFailure: 'https://fail'
});
expect(out.excludeCaches).toBe(true);
expect(out.compression).toBe('max');
expect(out.limitUpload).toBe(100);
expect(out.limitDownload).toBe(200);
expect(out.webhookSuccess).toBe('https://ok');
expect(out.webhookFailure).toBe('https://fail');
});
test('non-string webhook values are dropped (not forwarded as junk)', () => {
const out = buildJobOptions({ webhookSuccess: 123, webhookFailure: {} });
expect(out.webhookSuccess).toBeUndefined();
expect(out.webhookFailure).toBeUndefined();
});
});
+45
View File
@@ -0,0 +1,45 @@
/**
* shouldSaveBackupImage decides whether the "Backup helper image" setting is
* persisted on Save. It must persist ONLY a real change — saving the pre-filled
* versioned default would pin the DB to that version and defeat the engine's
* auto-version-tracking across upgrades (see src/lib/utils/backup-image.ts).
*/
import { describe, it, expect } from 'bun:test';
import { shouldSaveBackupImage } from '../src/lib/utils/backup-image';
describe('shouldSaveBackupImage', () => {
it('does NOT save when the value is unchanged (pre-filled default left as-is)', () => {
expect(shouldSaveBackupImage('fnsys/dockhand-backup:1.0.38', 'fnsys/dockhand-backup:1.0.38')).toBe(false);
});
it('saves when the user typed a different image', () => {
expect(shouldSaveBackupImage('myregistry/helper:v2', 'fnsys/dockhand-backup:1.0.38')).toBe(true);
});
it('saves when the user keeps an existing override untouched? — no, unchanged is unchanged', () => {
// Field loaded with a saved override; Save without editing → nothing to persist.
expect(shouldSaveBackupImage('myregistry/helper:v2', 'myregistry/helper:v2')).toBe(false);
});
it('treats surrounding whitespace as no change (trim on both sides)', () => {
expect(shouldSaveBackupImage(' fnsys/dockhand-backup:1.0.38 ', 'fnsys/dockhand-backup:1.0.38')).toBe(false);
});
it('detects a real change even when whitespace differs', () => {
expect(shouldSaveBackupImage(' myregistry/helper:v2 ', 'fnsys/dockhand-backup:1.0.38')).toBe(true);
});
it('saves when the user clears the field (empty != non-empty default)', () => {
// Clearing is a deliberate change — it lets the server fall back to its default.
expect(shouldSaveBackupImage('', 'fnsys/dockhand-backup:1.0.38')).toBe(true);
});
it('does not save empty→empty', () => {
expect(shouldSaveBackupImage('', '')).toBe(false);
});
it('tolerates null/undefined inputs without throwing', () => {
expect(shouldSaveBackupImage(undefined as unknown as string, '')).toBe(false);
expect(shouldSaveBackupImage('x', null as unknown as string)).toBe(true);
});
});
+142
View File
@@ -0,0 +1,142 @@
/**
* Regression tests for the 2026-07-02 medium/low backup-audit fixes — the pure,
* extractable pieces. Behavioral/route/DB-bound findings are exercised via the
* shared helpers they were built on.
*
* #37 parsePoliciesJson — safe parse, logs once, degrades to {}
* #13 validateRetention — reject negative/fractional/string/out-of-range
* #7/#53 isAllowedRepository / — scheme allowlist + SSRF host reject
* privateIpReason
* #6 sanitizeWebhookUrlForLog — strip query + redact path, fail closed
*/
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
parsePoliciesJson,
validateRetention,
isAllowedRepository,
privateIpReason,
sanitizeWebhookUrlForLog,
classifyBackupError
} from '../src/lib/server/backups/helpers';
describe('parsePoliciesJson — safe policy parse (audit #37)', () => {
it('parses valid JSON', () => {
assert.deepEqual(parsePoliciesJson('{"autoUnlock":true}'), { autoUnlock: true });
});
it('null/empty → {}', () => {
assert.deepEqual(parsePoliciesJson(null), {});
assert.deepEqual(parsePoliciesJson(undefined), {});
assert.deepEqual(parsePoliciesJson(''), {});
});
it('malformed JSON → {} (does not throw)', () => {
assert.deepEqual(parsePoliciesJson('{not json'), {});
});
});
describe('validateRetention — keep-* bounds (audit #13)', () => {
it('null/undefined retention is allowed (optional)', () => {
assert.equal(validateRetention(null).ok, true);
assert.equal(validateRetention(undefined).ok, true);
});
it('accepts valid non-negative integers', () => {
assert.equal(validateRetention({ keepLast: 3, keepDaily: 7, keepMonthly: 0 }).ok, true);
});
it('rejects negative, fractional, string, NaN, Infinity', () => {
assert.equal(validateRetention({ keepLast: -1 }).ok, false);
assert.equal(validateRetention({ keepDaily: 0.5 }).ok, false);
assert.equal(validateRetention({ keepWeekly: '5' }).ok, false);
assert.equal(validateRetention({ keepMonthly: NaN }).ok, false);
assert.equal(validateRetention({ keepYearly: Infinity }).ok, false);
});
it('rejects out-of-range (> 10000)', () => {
assert.equal(validateRetention({ keepLast: 10001 }).ok, false);
assert.equal(validateRetention({ keepLast: 10000 }).ok, true);
});
it('rejects a non-object', () => {
assert.equal(validateRetention('nope').ok, false);
assert.equal(validateRetention([1, 2]).ok, false);
});
it('names the offending field in the reason', () => {
const r = validateRetention({ keepDaily: -3 });
assert.equal(r.ok, false);
assert.match((r as { reason: string }).reason, /keepDaily/);
});
});
describe('isAllowedRepository — scheme allowlist (audit #7/#53)', () => {
it('accepts local absolute paths and known schemes', () => {
for (const r of ['/srv/backups', 's3:s3.amazonaws.com/bucket', 'rest:https://r.example/repo', 'b2:bucket', 'azure:container', 'gs:bucket']) {
assert.equal(isAllowedRepository(r), true, `should allow ${r}`);
}
});
it('rejects unknown schemes, relative paths, and empty', () => {
// rclone:/sftp:/swift: were removed from the allowlist (unfinished/untested backends)
for (const r of ['s3//typo', 'ftp://host/x', './relative', 'file:///etc/passwd', 'rclone:remote:path', 'sftp:user@host:/path', 'swift:container', '', null, undefined]) {
assert.equal(isAllowedRepository(r as any), false, `should reject ${r}`);
}
});
});
describe('privateIpReason — SSRF range check (audit #46/#53/#54)', () => {
it('flags loopback / private / link-local / metadata IPv4', () => {
for (const h of ['127.0.0.1', '10.0.0.5', '192.168.1.1', '172.16.0.9', '169.254.169.254', '0.0.0.0']) {
assert.ok(privateIpReason(h), `should flag ${h}`);
}
});
it('flags IPv6 loopback / ULA / link-local and v4-mapped metadata', () => {
assert.ok(privateIpReason('::1'));
assert.ok(privateIpReason('fd00::1'));
assert.ok(privateIpReason('fe80::1'));
assert.ok(privateIpReason('::ffff:169.254.169.254'));
});
it('flags v4-mapped IPv6 in HEX / expanded form (new URL normalizes to these)', () => {
// The real bypass: new URL('http://[::ffff:169.254.169.254]') -> ::ffff:a9fe:a9fe
assert.ok(privateIpReason('::ffff:a9fe:a9fe'), 'metadata (hex)');
assert.ok(privateIpReason('::ffff:7f00:1'), 'loopback (hex)');
assert.ok(privateIpReason('0:0:0:0:0:ffff:127.0.0.1'), 'loopback (expanded)');
assert.ok(privateIpReason('::'), 'unspecified');
});
it('returns null for public IPs and hostnames', () => {
assert.equal(privateIpReason('93.184.216.34'), null);
assert.equal(privateIpReason('hooks.example.com'), null);
});
});
describe('sanitizeWebhookUrlForLog — no secret leak (audit #6)', () => {
it('strips the query string entirely', () => {
const out = sanitizeWebhookUrlForLog('https://hook.example/notify?token=secret');
assert.ok(!out.includes('secret'), 'must not contain the query secret');
assert.ok(!out.includes('token'), 'must not contain the query key');
});
it('redacts trailing path segments (healthchecks-style uuid)', () => {
const out = sanitizeWebhookUrlForLog('https://hc.example/ping/2f3d-uuid-secret');
assert.ok(!out.includes('2f3d-uuid-secret'), 'must not contain the path secret');
assert.match(out, /\/\*\*\*$/);
});
it('keeps just the origin when there is no path', () => {
assert.equal(sanitizeWebhookUrlForLog('https://hook.example/'), 'https://hook.example');
});
it('fails closed on an unparseable URL', () => {
assert.equal(sanitizeWebhookUrlForLog('not a url'), '<webhook>');
});
});
describe('classifyBackupError — failure category vocabulary (audit #20)', () => {
it('maps known restic failure signatures to codes', () => {
assert.equal(classifyBackupError(new Error('restic timed out after 300s (killed by SIGTERM)')), 'RESTIC_TIMEOUT');
assert.equal(classifyBackupError(new Error('wrong password or no key found')), 'WRONG_PASSWORD');
assert.equal(classifyBackupError(new Error('repository is already locked exclusively')), 'REPO_LOCKED');
assert.equal(classifyBackupError(new Error('dial tcp: lookup s3.example: no such host')), 'NETWORK');
});
it('maps a repo-not-initialized error via exitCode', () => {
const e: any = new Error('repo'); e.exitCode = 10;
assert.equal(classifyBackupError(e), 'REPO_NOT_INIT');
});
it('defaults to UNKNOWN and never throws', () => {
assert.equal(classifyBackupError(new Error('something weird')), 'UNKNOWN');
assert.equal(classifyBackupError(null), 'UNKNOWN');
assert.equal(classifyBackupError(undefined), 'UNKNOWN');
});
});
+335
View File
@@ -0,0 +1,335 @@
/**
* Unit tests for backup volume/bind normalization + discovery (src/lib/utils/mounts.ts).
*
* Regression guard: backup UI surfaces used to open-code "Docker mounts ->
* pickable volume list" and drifted — one dropped bind mounts entirely, one
* never set a mount type so every row rendered as a named volume. These tests
* lock the single shared normalizer's behavior.
*
* Discovery guard: EnvironmentBackupsTab used to build its whole list (stacks +
* containers) from container labels via groupContainersForBackup — which can't
* see a stack's sourceType and misses stopped stacks. It now takes stacks from
* /api/stacks and only STANDALONE containers from standaloneContainers(); these
* tests pin that standalone slice (never a stack member).
*/
import { describe, test, expect } from 'bun:test';
import {
normalizeMounts,
normalizeStackMounts,
mountTypeFromHostPath,
volumeInfoFromBind,
groupContainersForBackup,
volumesForStack,
standaloneContainers
} from '../src/lib/utils/mounts';
/** The shape /api/containers actually returns (docker.ts list mapper):
* lowercase `labels`, `mounts: [{ type, source, destination }]`. Tests below
* use this shape so they can't pass on a shape the API never sends. */
const PROJECT = 'com.docker.compose.project';
describe('mountTypeFromHostPath', () => {
test('absolute host path is a bind mount', () => {
expect(mountTypeFromHostPath('/docker/data/backup-test/container-bind')).toBe('bind');
});
test('bare name (no leading slash) is a named volume', () => {
expect(mountTypeFromHostPath('bt-container-vol')).toBe('volume');
});
});
describe('volumeInfoFromBind — subset-backup identifier (regression)', () => {
// The bug: EditContainerModal built the picker list with name = containerPath
// for EVERY mount. For a named volume that's the destination (/data/a), not the
// volume name (dhvt-vol-a) the server filters on — so a subset selection saved
// the wrong key and the backup captured nothing. name MUST equal what
// normalizeMounts()/discovery produce for the same volume.
test('named volume: name is the volume NAME (hostPath), not the destination', () => {
expect(volumeInfoFromBind({ hostPath: 'dhvt-vol-a', containerPath: '/data/a' })).toEqual({
key: 'dhvt-vol-a', name: 'dhvt-vol-a', mountPoint: '/data/a', mountType: 'volume'
});
});
test('bind: name is the container destination, source is the host path', () => {
expect(volumeInfoFromBind({ hostPath: '/tmp/dhvt-bind', containerPath: '/data/bind' })).toEqual({
key: 'data_bind__dhvt-bind', name: '/data/bind', mountPoint: '/data/bind', mountType: 'bind', source: '/tmp/dhvt-bind'
});
});
test('bind: docker.sock is flagged unbackupable (EditContainerModal picker path)', () => {
const out = volumeInfoFromBind({ hostPath: '/var/run/docker.sock', containerPath: '/var/run/docker.sock' });
expect(out.unbackupable).toBe(true);
// a real data bind is not flagged
expect(volumeInfoFromBind({ hostPath: '/srv/app-data', containerPath: '/app/data' }).unbackupable).toBeUndefined();
});
test('its name matches normalizeMounts() for the SAME named volume (both sides agree)', () => {
const fromBind = volumeInfoFromBind({ hostPath: 'shared-vol', containerPath: '/data' });
const fromMounts = normalizeMounts([{ type: 'volume', name: 'shared-vol', destination: '/data' }])[0];
expect(fromBind.name).toBe(fromMounts.name); // 'shared-vol' on both — the filter can match
});
// The fnstudio-factory bug: normalizeMounts() (the picker's source) keyed a bind
// on its host SOURCE, but the engine's selectedVolumes filter matches on the
// container DESTINATION — so every bind picked as a subset was silently dropped
// ("Volumes to backup: none"). Both picker normalizers must agree with the engine.
test('bind name matches normalizeMounts() AND volumeInfoFromBind() (all three agree)', () => {
const fromMounts = normalizeMounts([{ type: 'bind', source: '/docker/data/x/keys', destination: '/app/keys' }])[0];
const fromBind = volumeInfoFromBind({ hostPath: '/docker/data/x/keys', containerPath: '/app/keys' });
expect(fromMounts.name).toBe('/app/keys'); // container destination — what the engine filters on
expect(fromMounts.name).toBe(fromBind.name); // both picker paths key identically
expect(fromMounts.source).toBe('/docker/data/x/keys'); // host path retained for display
});
});
describe('normalizeMounts', () => {
test('keeps both volume and bind, with correct mountType', () => {
const mounts = [
{ Type: 'volume', Name: 'bt-container-vol', Destination: '/data/vol' },
{ Type: 'bind', Source: '/docker/data/backup-test/container-bind', Destination: '/data/bind' }
];
const out = normalizeMounts(mounts);
expect(out).toHaveLength(2);
expect(out[0]).toEqual({ key: 'bt-container-vol', name: 'bt-container-vol', mountPoint: '/data/vol', mountType: 'volume' });
// bind name is the container DESTINATION (display); key encodes destination + source tail.
expect(out[1]).toEqual({ key: 'data_bind__container-bind', name: '/data/bind', mountPoint: '/data/bind', mountType: 'bind', source: '/docker/data/backup-test/container-bind' });
});
test('does NOT drop bind mounts (the EnvironmentBackupsTab bug)', () => {
const out = normalizeMounts([
{ Type: 'bind', Source: '/host/path', Destination: '/data/bind' }
]);
expect(out).toHaveLength(1);
expect(out[0].mountType).toBe('bind');
});
test('tolerates lowercase (our normalized) keys', () => {
const out = normalizeMounts([
{ type: 'volume', name: 'v1', destination: '/data' }
]);
expect(out[0]).toEqual({ key: 'v1', name: 'v1', mountPoint: '/data', mountType: 'volume' });
});
test('skips non-volume/bind mounts (tmpfs, npipe, etc.)', () => {
const out = normalizeMounts([
{ Type: 'tmpfs', Destination: '/tmp' },
{ Type: 'volume', Name: 'keep', Destination: '/data' }
]);
expect(out).toHaveLength(1);
expect(out[0].name).toBe('keep');
});
test('bind name is the destination; a bind with no destination falls back to source', () => {
expect(normalizeMounts([{ Type: 'bind', Source: '/only/source', Destination: '/d' }])[0].name).toBe('/d');
expect(normalizeMounts([{ Type: 'bind', Source: '/only/source' }])[0].name).toBe('/only/source');
expect(normalizeMounts([{ Type: 'volume', Destination: '/only/dest' }])[0].name).toBe('/only/dest');
});
test('handles null / undefined / non-array input', () => {
expect(normalizeMounts(null)).toEqual([]);
expect(normalizeMounts(undefined)).toEqual([]);
// @ts-expect-error deliberately wrong type
expect(normalizeMounts('nope')).toEqual([]);
});
});
describe('normalizeStackMounts', () => {
test('merges multiple containers and dedupes by volume identity', () => {
const containers = [
{ mounts: [
{ type: 'volume', name: 'shared-vol', destination: '/data' },
{ type: 'bind', source: '/host/a', destination: '/a' }
] },
{ mounts: [
{ type: 'volume', name: 'shared-vol', destination: '/data' }, // dup: same name -> one
{ type: 'bind', source: '/host/b', destination: '/b' }
] }
];
const out = normalizeStackMounts(containers);
expect(out.map(v => v.name).sort()).toEqual(['/a', '/b', 'shared-vol']);
expect(out.filter(v => v.name === 'shared-vol')).toHaveLength(1);
});
// #1373: two containers bind DIFFERENT host paths to the SAME container path. They are
// different volumes - both must appear, each with a distinct, independently-selectable key.
test('keeps two binds that share a destination but have different sources (#1373)', () => {
const out = normalizeStackMounts([
{ mounts: [{ type: 'bind', source: '/mnt/cache/appdata/gitea/data', destination: '/data' }] },
{ mounts: [{ type: 'bind', source: '/mnt/cache/appdata/gitea-runner/data', destination: '/data' }] }
]);
expect(out).toHaveLength(2); // neither dropped
expect(out.map(v => v.name)).toEqual(['/data', '/data']); // same display destination
expect(new Set(out.map(v => v.key)).size).toBe(2); // distinct, selectable keys
expect(out.map(v => v.source).sort()).toEqual([
'/mnt/cache/appdata/gitea-runner/data', '/mnt/cache/appdata/gitea/data'
]);
});
// An identical (source,destination) bind across containers is the SAME volume -> deduped once.
test('dedupes an identical bind (same source AND destination) across containers', () => {
const out = normalizeStackMounts([
{ mounts: [{ type: 'bind', source: '/host/shared', destination: '/data' }] },
{ mounts: [{ type: 'bind', source: '/host/shared', destination: '/data' }] }
]);
expect(out).toHaveLength(1);
});
test('accepts PascalCase Mounts key', () => {
const out = normalizeStackMounts([{ Mounts: [{ Type: 'bind', Source: '/host/x', Destination: '/x' }] }]);
expect(out).toHaveLength(1);
expect(out[0].mountType).toBe('bind');
});
});
// --------------------------------------------------------------------------
// Discovery layer — the recurring "no volumes detected" bug lived HERE, in the
// group/filter step that every live backup surface used to open-code. These
// lock the single shared primitive so occurrence #3 can't slip in.
// --------------------------------------------------------------------------
describe('groupContainersForBackup', () => {
test('groups stack containers by project label, keeps standalone separate', () => {
const containers = [
{ name: 'web-1', labels: { [PROJECT]: 'shop' }, mounts: [{ type: 'volume', source: 'shop_db', destination: '/data' }] },
{ name: 'worker-1', labels: { [PROJECT]: 'shop' }, mounts: [{ type: 'bind', source: '/host/logs', destination: '/logs' }] },
{ name: 'lonely', labels: {}, mounts: [{ type: 'volume', source: 'lonely_vol', destination: '/v' }] }
];
const items = groupContainersForBackup(containers);
// stacks first (alphabetical), then containers
expect(items.map(i => `${i.type}:${i.name}`)).toEqual(['stack:shop', 'container:lonely']);
const shop = items.find(i => i.name === 'shop')!;
// both containers' mounts merged into the one stack item (bind keys on destination)
expect(shop.volumes.map(v => v.name).sort()).toEqual(['/logs', 'shop_db']);
});
test('a stack container with volume AND bind surfaces both with correct mountType (regression shape)', () => {
const items = groupContainersForBackup([
{ name: 'app-1', labels: { [PROJECT]: 'mystack' }, mounts: [
{ type: 'volume', source: 'mystack_data', destination: '/data' },
{ type: 'bind', source: '/docker/mystack/conf', destination: '/conf' }
] }
]);
expect(items).toHaveLength(1);
const byName = Object.fromEntries(items[0].volumes.map(v => [v.name, v.mountType]));
expect(byName['mystack_data']).toBe('volume');
expect(byName['/conf']).toBe('bind'); // bind keys on destination
});
test('dedupes a shared volume across a stack\'s containers', () => {
const items = groupContainersForBackup([
{ name: 'a', labels: { [PROJECT]: 's' }, mounts: [{ type: 'volume', source: 'shared', destination: '/d' }] },
{ name: 'b', labels: { [PROJECT]: 's' }, mounts: [{ type: 'volume', source: 'shared', destination: '/d' }] }
]);
expect(items[0].volumes.filter(v => v.name === 'shared')).toHaveLength(1);
});
test('sorts stacks before containers, each alphabetical', () => {
const items = groupContainersForBackup([
{ name: 'zeta', labels: {}, mounts: [] },
{ name: 'alpha', labels: {}, mounts: [] },
{ name: 'b-1', labels: { [PROJECT]: 'bravo' }, mounts: [] },
{ name: 'a-1', labels: { [PROJECT]: 'apex' }, mounts: [] }
]);
expect(items.map(i => i.name)).toEqual(['apex', 'bravo', 'alpha', 'zeta']);
});
test('tolerates Docker-raw shape (Names / Labels / Mounts)', () => {
const items = groupContainersForBackup([
{ Names: ['/raw-1'], Labels: { [PROJECT]: 'rawstack' }, Mounts: [{ Type: 'volume', Name: 'rawvol', Destination: '/d' }] }
]);
expect(items).toEqual([{ name: 'rawstack', type: 'stack', volumes: [{ key: 'rawvol', name: 'rawvol', mountPoint: '/d', mountType: 'volume' }] }]);
});
test('empty / null input yields []', () => {
expect(groupContainersForBackup([])).toEqual([]);
expect(groupContainersForBackup(null)).toEqual([]);
expect(groupContainersForBackup(undefined)).toEqual([]);
});
});
describe('volumesForStack', () => {
const containers = [
{ name: 'web-1', labels: { [PROJECT]: 'shop' }, mounts: [{ type: 'volume', source: 'shop_db', destination: '/data' }] },
{ name: 'worker-1', labels: { [PROJECT]: 'shop' }, mounts: [{ type: 'bind', source: '/host/logs', destination: '/logs' }] },
{ name: 'other-1', labels: { [PROJECT]: 'other' }, mounts: [{ type: 'volume', source: 'other_vol', destination: '/o' }] }
];
test('matching project label returns the stack\'s merged volumes (THE regression: was empty)', () => {
const out = volumesForStack(containers, 'shop');
expect(out.length).toBeGreaterThan(0);
expect(out.map(v => v.name).sort()).toEqual(['/logs', 'shop_db']); // bind keys on destination
});
test('does not leak another stack\'s volumes', () => {
expect(volumesForStack(containers, 'shop').some(v => v.name === 'other_vol')).toBe(false);
});
test('unknown / not-yet-deployed stack returns [] (the only legitimate empty)', () => {
expect(volumesForStack(containers, 'nope')).toEqual([]);
expect(volumesForStack([], 'shop')).toEqual([]);
expect(volumesForStack(null, 'shop')).toEqual([]);
});
});
describe('standaloneContainers', () => {
// EnvironmentBackupsTab takes STACKS from /api/stacks (sourceType-aware, sees
// stopped stacks) and only STANDALONE containers from here — so this must NOT
// return anything carrying a compose-project label (that's a stack member).
const containers = [
{ name: 'web-1', labels: { [PROJECT]: 'shop' }, mounts: [{ type: 'volume', source: 'shop_db', destination: '/data' }] },
{ name: 'redis', labels: {}, mounts: [{ type: 'volume', source: 'redis_data', destination: '/data' }] },
{ name: 'pihole', labels: {}, mounts: [{ type: 'bind', source: '/host/pihole', destination: '/etc/pihole' }] }
];
test('returns ONLY containers without a compose-project label', () => {
const out = standaloneContainers(containers);
expect(out.map(i => i.name)).toEqual(['pihole', 'redis']); // web-1 (stack member) excluded, sorted
expect(out.every(i => i.type === 'container')).toBe(true);
});
test('normalizes each container\'s mounts (volume + bind both kept)', () => {
const out = standaloneContainers(containers);
const redis = out.find(i => i.name === 'redis')!;
const pihole = out.find(i => i.name === 'pihole')!;
expect(redis.volumes.map(v => v.name)).toEqual(['redis_data']);
expect(pihole.volumes.map(v => v.name)).toEqual(['/etc/pihole']); // bind NOT dropped (keys on destination)
});
test('excludes ALL stack members even if that leaves nothing', () => {
expect(standaloneContainers([
{ name: 'a-1', labels: { [PROJECT]: 's' }, mounts: [] },
{ name: 'b-1', labels: { [PROJECT]: 's' }, mounts: [] }
])).toEqual([]);
});
test('tolerates Docker-raw shape (Names / Labels / Mounts)', () => {
const out = standaloneContainers([
{ Names: ['/raw'], Labels: {}, Mounts: [{ Type: 'volume', Name: 'rawvol', Destination: '/d' }] }
]);
expect(out).toEqual([{ name: 'raw', type: 'container', volumes: [{ key: 'rawvol', name: 'rawvol', mountPoint: '/d', mountType: 'volume' }] }]);
});
test('empty / null input yields []', () => {
expect(standaloneContainers([])).toEqual([]);
expect(standaloneContainers(null)).toEqual([]);
expect(standaloneContainers(undefined)).toEqual([]);
});
});
describe('normalizeMounts — unbackupable flag (socket / host system binds)', () => {
// The picker lists these but disables them and excludes them from "backup all".
// Flag must mirror the engine's isUnbackupableBindSource so UI and engine agree.
test('flags docker.sock bind, leaves data binds and named volumes unflagged', () => {
const out = normalizeMounts([
{ type: 'bind', source: '/var/run/docker.sock', destination: '/var/run/docker.sock' },
{ type: 'bind', source: '/srv/app-data', destination: '/app/data' },
{ type: 'volume', name: 'appvol', destination: '/vol' }
]);
const sock = out.find((v) => v.source === '/var/run/docker.sock');
const data = out.find((v) => v.mountPoint === '/app/data');
const named = out.find((v) => v.name === 'appvol');
expect(sock?.unbackupable).toBe(true);
// unflagged rows leave the field undefined (not false) so it stays absent in JSON
expect(data?.unbackupable).toBeUndefined();
expect(named?.unbackupable).toBeUndefined();
});
});
+235
View File
@@ -0,0 +1,235 @@
/**
* Regression tests for the restic-subprocess hardening from the 2026-06-26
* HIGH audit: env allowlist (#1), envVars injection block (#32), and the
* restic flag allowlist (#34). Pure helpers → exercised directly here.
*/
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { buildResticEnv, sanitizeResticFlags, filterCloudEnvVars, isSafeWebhookUrl, fireWebhook } from '../src/lib/server/backups/helpers';
describe('isSafeWebhookUrl — SSRF guard (audit #46)', () => {
it('allows public http/https URLs', () => {
assert.equal(isSafeWebhookUrl('https://hooks.example.com/x').ok, true);
assert.equal(isSafeWebhookUrl('http://93.184.216.34/notify').ok, true);
});
it('blocks the cloud metadata IP and private/loopback/link-local', () => {
for (const u of [
'http://169.254.169.254/latest/meta-data/',
'http://127.0.0.1:6379/',
'http://localhost/x',
'http://10.0.0.5/x',
'http://192.168.1.1/x',
'http://172.16.0.9/x',
'http://[::1]/x'
]) {
assert.equal(isSafeWebhookUrl(u).ok, false, `should block ${u}`);
}
});
it('blocks non-http schemes and garbage', () => {
assert.equal(isSafeWebhookUrl('file:///etc/passwd').ok, false);
assert.equal(isSafeWebhookUrl('gopher://x').ok, false);
assert.equal(isSafeWebhookUrl('not a url').ok, false);
});
});
describe('filterCloudEnvVars — container envVars allowlist (audit #30)', () => {
it('keeps cloud credential vars, drops PATH/LD_PRELOAD injection', () => {
const out = filterCloudEnvVars({
AWS_ACCESS_KEY_ID: 'AKIA', B2_ACCOUNT_KEY: 'k', AZURE_ACCOUNT_KEY: 'z',
PATH: '/evil', LD_PRELOAD: '/tmp/x.so', HOME: '/root',
RCLONE_CONFIG_PASS: 'p' // rclone backend removed → its prefix is no longer allowlisted
});
assert.equal(out.AWS_ACCESS_KEY_ID, 'AKIA');
assert.equal(out.B2_ACCOUNT_KEY, 'k');
assert.equal(out.AZURE_ACCOUNT_KEY, 'z');
assert.equal(out.PATH, undefined);
assert.equal(out.LD_PRELOAD, undefined);
assert.equal(out.HOME, undefined);
assert.equal(out.RCLONE_CONFIG_PASS, undefined);
});
it('handles empty/undefined input', () => {
assert.deepEqual(filterCloudEnvVars({}), {});
assert.deepEqual(filterCloudEnvVars(undefined as any), {});
});
});
describe('buildResticEnv — env allowlist (audit #1, #32)', () => {
const procEnv = {
PATH: '/usr/bin',
HOME: '/root',
HTTPS_PROXY: 'http://proxy:3128',
ENCRYPTION_KEY: 'super-secret-master-key',
DATABASE_URL: 'postgres://user:pw@db/dockhand',
SOME_OTHER_SECRET: 'nope'
};
it('passes through the allowlisted base vars', () => {
const env = buildResticEnv(procEnv, { repository: 'rest:http://x', password: 'pw' });
assert.equal(env.PATH, '/usr/bin');
assert.equal(env.HOME, '/root');
assert.equal(env.HTTPS_PROXY, 'http://proxy:3128');
});
it('NEVER leaks ENCRYPTION_KEY / DATABASE_URL / other process secrets', () => {
const env = buildResticEnv(procEnv, { repository: 'rest:http://x', password: 'pw' });
assert.equal(env.ENCRYPTION_KEY, undefined);
assert.equal(env.DATABASE_URL, undefined);
assert.equal(env.SOME_OTHER_SECRET, undefined);
});
it('sets the restic repo + password', () => {
const env = buildResticEnv(procEnv, { repository: 'rest:http://repo', password: 's3cret' });
assert.equal(env.RESTIC_REPOSITORY, 'rest:http://repo');
assert.equal(env.RESTIC_PASSWORD, 's3cret');
});
it('allows cloud credential envVars but blocks PATH/LD_PRELOAD injection', () => {
const env = buildResticEnv(procEnv, {
repository: 'r', password: 'p',
envVars: {
AWS_ACCESS_KEY_ID: 'AKIA', AWS_SECRET_ACCESS_KEY: 'shh', B2_ACCOUNT_ID: 'b2',
RCLONE_CONFIG_PASS: 'x', // rclone backend removed → prefix no longer allowlisted
PATH: '/evil/bin', LD_PRELOAD: '/tmp/evil.so', LD_LIBRARY_PATH: '/tmp'
}
});
assert.equal(env.AWS_ACCESS_KEY_ID, 'AKIA');
assert.equal(env.AWS_SECRET_ACCESS_KEY, 'shh');
assert.equal(env.B2_ACCOUNT_ID, 'b2');
assert.equal(env.RCLONE_CONFIG_PASS, undefined);
// injection attempts via envVars must NOT override the process PATH etc.
assert.equal(env.PATH, '/usr/bin');
assert.equal(env.LD_PRELOAD, undefined);
assert.equal(env.LD_LIBRARY_PATH, undefined);
});
it('points RESTIC_CACHE_DIR at DATA_DIR/.restic-cache (durable, survives redeploy)', () => {
const env = buildResticEnv({ ...procEnv, DATA_DIR: '/app/data' }, { repository: 'r', password: 'p' });
assert.equal(env.RESTIC_CACHE_DIR, '/app/data/.restic-cache');
});
it('defaults the cache dir to /app/data when DATA_DIR is unset', () => {
const env = buildResticEnv(procEnv, { repository: 'r', password: 'p' });
assert.equal(env.RESTIC_CACHE_DIR, '/app/data/.restic-cache');
});
it('an operator-set RESTIC_CACHE_DIR wins', () => {
const env = buildResticEnv({ ...procEnv, RESTIC_CACHE_DIR: '/custom/cache' }, { repository: 'r', password: 'p' });
assert.equal(env.RESTIC_CACHE_DIR, '/custom/cache');
});
});
describe('sanitizeResticFlags — flag allowlist (audit #34)', () => {
it('accepts allowlisted flags with and without values', () => {
assert.deepEqual(sanitizeResticFlags('--no-cache --limit-upload=1024'), ['--no-cache', '--limit-upload=1024']);
});
it('accepts a SPACE-separated value-taking flag (the value is not a flag)', () => {
// `--retry-lock 10m` — the `10m` is the flag's value, not a positional. This
// used to be wrongly rejected as "Disallowed restic flag: 10m".
assert.deepEqual(sanitizeResticFlags('--verbose --retry-lock 10m'), ['--verbose', '--retry-lock', '10m']);
assert.deepEqual(sanitizeResticFlags('--compression max'), ['--compression', 'max']);
});
it('a value-taking flag missing its value is rejected', () => {
assert.throws(() => sanitizeResticFlags('--retry-lock'), /missing its value/);
});
it('returns [] for empty/undefined/whitespace', () => {
assert.deepEqual(sanitizeResticFlags(''), []);
assert.deepEqual(sanitizeResticFlags(undefined), []);
assert.deepEqual(sanitizeResticFlags(' '), []);
});
it('rejects dangerous flags like --option and --password-command', () => {
assert.throws(() => sanitizeResticFlags('--option=s3.storage-class=X'), /Disallowed restic flag/);
assert.throws(() => sanitizeResticFlags('--password-command=cat /etc/shadow'), /Disallowed restic flag/);
});
it('rejects a bare non-flag argument (no leading --)', () => {
assert.throws(() => sanitizeResticFlags('snapshots'), /Disallowed restic flag/);
assert.throws(() => sanitizeResticFlags('--no-cache evilpositional'), /Disallowed restic flag/);
});
});
describe('fireWebhook — per-config backup webhook delivery (audit #6/#46/#54)', () => {
// Injectable deps so we exercise the guard/branch logic with no real network/DNS.
function harness(over: Partial<Parameters<typeof fireWebhook>[2]> = {}) {
const calls: Array<{ url: string; init?: any }> = [];
const logs: string[] = [];
const deps = {
fetch: (async (url: any, init?: any) => { calls.push({ url: String(url), init }); return new Response('ok'); }) as any,
resolveHost: async () => ['93.184.216.34'], // a public IP by default
log: (m: string) => logs.push(m),
now: () => '2026-01-01T00:00:00.000Z',
...over,
};
return { calls, logs, deps };
}
it('POSTs a JSON body (with a stamped timestamp) to a safe public URL', async () => {
const { calls, deps } = harness();
await fireWebhook('https://hooks.example.com/backup', { event: 'backup_success', target: 'web' }, deps);
assert.equal(calls.length, 1);
assert.equal(calls[0].init.method, 'POST');
const body = JSON.parse(calls[0].init.body);
assert.equal(body.event, 'backup_success');
assert.equal(body.target, 'web');
assert.equal(body.timestamp, '2026-01-01T00:00:00.000Z');
});
it('blocks a loopback / cloud-metadata literal-IP URL BEFORE any fetch (SSRF)', async () => {
for (const bad of ['http://127.0.0.1/x', 'http://169.254.169.254/latest/meta-data/']) {
const { calls, logs, deps } = harness();
await fireWebhook(bad, { event: 'backup_success' }, deps);
assert.equal(calls.length, 0, `must not fetch ${bad}`);
assert.ok(logs.some(l => /blocked/i.test(l)));
}
});
it('ALLOWS a self-hosted LAN receiver (192.168.x / 10.x) — webhook trust model', async () => {
// Webhooks are notification-grade: a LAN catcher (ntfy/healthchecks on the
// user network, e.g. webhook.lan.example -> 10.0.0.5) must be reachable.
const { calls, deps } = harness({ resolveHost: async () => ['10.0.0.5'] });
await fireWebhook('https://webhook.lan.example/backup', { event: 'backup_success' }, deps);
assert.equal(calls.length, 1, 'a LAN webhook receiver must be allowed');
assert.equal(calls[0].init.method, 'POST');
});
it('blocks a public hostname that RESOLVES to loopback/metadata (DNS rebinding #54)', async () => {
const { calls, logs, deps } = harness({ resolveHost: async () => ['169.254.169.254'] });
await fireWebhook('https://sneaky.example.com/x', { event: 'backup_success' }, deps);
assert.equal(calls.length, 0, 'must not connect when the host resolves to a dangerous IP');
assert.ok(logs.some(l => /disallowed address|blocked/i.test(l)));
});
it('falls back to a bare GET when the POST throws', async () => {
let n = 0;
const seen: string[] = [];
const deps = harness().deps;
deps.fetch = (async (_url: any, init?: any) => {
seen.push(init?.method ?? 'GET');
if (n++ === 0) throw new Error('POST refused');
return new Response('ok');
}) as any;
await fireWebhook('https://hooks.example.com/ping', { event: 'backup_success' }, deps);
assert.deepEqual(seen, ['POST', 'GET']);
});
it('never throws even when both POST and GET fail (fire-and-forget)', async () => {
const deps = harness().deps;
deps.fetch = (async () => { throw new Error('network down'); }) as any;
// Must resolve, not reject — a webhook failure can never break a backup.
await fireWebhook('https://hooks.example.com/x', { event: 'backup_success' }, deps);
});
it('blocks (fail-closed) and never fetches when DNS resolution itself throws', async () => {
// If we can't resolve the host, we can't prove it's safe → block, don't fetch.
const { calls, logs, deps } = harness({
resolveHost: async () => { throw new Error('NXDOMAIN'); },
});
await fireWebhook('https://unresolvable.example.com/x', { event: 'backup_success' }, deps);
assert.equal(calls.length, 0, 'must not fetch when the host can not be resolved');
assert.ok(logs.some(l => /could not resolve|blocked/i.test(l)));
});
});
+49
View File
@@ -0,0 +1,49 @@
/**
* Unit tests for the "Test all destinations" tally. Regression guard: the toast used
* to report needs-init repos as "failed" (a reachable-but-uninitialized repo counted
* as a failure), producing "6 failed, 4 passed" when 5 of the 6 only needed init.
*/
import { describe, test, expect } from 'bun:test';
import { summarizeTestResults, formatTestSummary } from '../src/lib/utils/backup-test-summary';
describe('summarizeTestResults', () => {
test('counts the three outcomes separately', () => {
expect(summarizeTestResults(['success', 'success', 'needs_init', 'failed', 'needs_init']))
.toEqual({ passed: 2, failed: 1, needsInit: 2 });
});
test('needs_init is never counted as failed (the bug)', () => {
const c = summarizeTestResults(['needs_init', 'needs_init', 'needs_init', 'needs_init', 'needs_init', 'failed', 'success', 'success', 'success', 'success']);
expect(c).toEqual({ passed: 4, failed: 1, needsInit: 5 });
});
test('empty input yields all zeroes', () => {
expect(summarizeTestResults([])).toEqual({ passed: 0, failed: 0, needsInit: 0 });
});
});
describe('formatTestSummary', () => {
test('all passed -> success', () => {
const r = formatTestSummary({ passed: 3, failed: 0, needsInit: 0 });
expect(r.severity).toBe('success');
expect(r.text).toContain('3');
});
test('only needs-init (no real failures) -> warning', () => {
const r = formatTestSummary({ passed: 4, failed: 0, needsInit: 5 });
expect(r.severity).toBe('warning');
expect(r.text).toBe('4 passed, 5 need init');
});
test('any real failure -> error, includes all buckets present', () => {
const r = formatTestSummary({ passed: 4, failed: 1, needsInit: 5 });
expect(r.severity).toBe('error');
expect(r.text).toBe('4 passed, 1 failed, 5 need init');
});
test('failures without needs-init -> error, no "need init" clause', () => {
const r = formatTestSummary({ passed: 2, failed: 3, needsInit: 0 });
expect(r.severity).toBe('error');
expect(r.text).toBe('2 passed, 3 failed');
});
});
+268
View File
@@ -0,0 +1,268 @@
/**
* Unit tests for backups/backup-script.ts — the pure backup session-script and
* argv builders. Includes real-shell round-trips proving the metadata is written
* and the restic exit code is surfaced via the marker.
*/
import { describe, it, expect } from 'bun:test';
import { buildBackupScript, buildBackupArgs } from '../../src/lib/server/backups/backup-script';
import { readExitMarker } from '../../src/lib/server/backups/restic-script';
describe('buildBackupArgs', () => {
const base = { host: 'dockhand.local', tags: ['dockhand:instance=i'], hasVolumes: true, swapArtifacts: ['.dockhand-restore-old'] };
it('builds the core backup argv over /volumes and /metadata', () => {
const a = buildBackupArgs(base);
expect(a.slice(0, 6)).toEqual(['backup', '--json', '--retry-lock', '5m', '--host', 'dockhand.local']);
expect(a).toContain('/volumes/');
expect(a).toContain('/metadata/');
});
it('backs up ONLY /metadata when the target has no volumes (config-only snapshot)', () => {
const a = buildBackupArgs({ ...base, hasVolumes: false });
expect(a).toContain('/metadata/');
expect(a).not.toContain('/volumes/'); // omitted so restic doesn't exit 3 on a missing path
});
it('adds every tag and always excludes the swap artifacts', () => {
const a = buildBackupArgs({ ...base, tags: ['t1', 't2'] });
expect(a.filter((x) => x === '--tag')).toHaveLength(2);
const exIdx = a.indexOf('--exclude');
expect(a[exIdx + 1]).toBe('.dockhand-restore-old');
});
it('defaults to --exclude-caches, honours opt-out', () => {
expect(buildBackupArgs(base)).toContain('--exclude-caches');
expect(buildBackupArgs({ ...base, excludeCaches: false })).not.toContain('--exclude-caches');
});
it('adds user excludes, compression, and limits when set', () => {
const a = buildBackupArgs({ ...base, excludePatterns: ['*.tmp', ' '], compression: 'max', limitUpload: 1000, limitDownload: 2000 });
expect(a).toContain('*.tmp');
expect(a.filter((x) => x === '*.tmp')).toHaveLength(1); // blank pattern dropped
expect(a).toContain('--compression'); expect(a).toContain('max');
expect(a).toContain('--limit-upload'); expect(a).toContain('1000');
expect(a).toContain('--limit-download'); expect(a).toContain('2000');
});
});
describe('buildBackupScript', () => {
// Metadata/stack files are streamed into /metadata via put-archive (docker cp),
// NOT written by the script — so the script must NOT embed any file contents
// (that inline base64 blew ARG_MAX for large stack files). It only ensures
// /metadata exists and runs restic, surfacing the real exit via the marker.
it('only ensures /metadata and runs restic — never embeds file contents', () => {
const script = buildBackupScript(['backup', '/volumes/', '/metadata/']);
expect(script).toContain('mkdir -p /metadata');
expect(script).not.toContain('base64'); // no inline file bytes → no ARG_MAX
expect(script).not.toContain('printf');
expect(script).toContain('set +e'); // restic step runs without errexit
expect(script).toMatch(/restic 'backup'/);
});
it('runs restic in the background with a SIGINT-forwarding trap (graceful cancel)', () => {
// Cancel sends SIGINT so restic releases its repo lock before exiting; a plain
// SIGKILL would orphan the lock and hang the next backup on --retry-lock.
const script = buildBackupScript(['backup', '/volumes/']);
expect(script).toMatch(/restic 'backup' '\/volumes\/' &/); // backgrounded
expect(script).toContain('trap'); // signal forwarder installed
expect(script).toContain('kill -INT'); // forwards SIGINT to restic
expect(script).toContain('kill -0'); // re-wait loop guarded on liveness
});
it('is small regardless of how many/large the stack files are (no ARG_MAX)', () => {
// The script no longer scales with file payload — it is a fixed few lines.
const script = buildBackupScript(['backup', '/volumes/', '/metadata/', '--tag', 'x']);
expect(script.length).toBeLessThan(500);
});
it('round-trip: the script runs restic and reports exit 0 via the marker', () => {
const { execFileSync } = require('child_process');
const { mkdtempSync, writeFileSync, rmSync } = require('fs');
const { tmpdir } = require('os');
const { join } = require('path');
const dir = mkdtempSync(join(tmpdir(), 'bkscript-'));
try {
// Fake restic = `true`; the script's mkdir + set +e + marker must run and
// print the real exit code (0). Swap the `restic ...` head for `true`.
const script = buildBackupScript(['backup']).replace(/restic '[^']*'/, 'true');
const file = join(dir, 'run.sh');
writeFileSync(file, script);
const out = execFileSync('sh', [file], { encoding: 'utf8' });
expect(readExitMarker(out)).toBe(0);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
// The stack-dir volume PROBE is the 100% runtime guard: in 'volume' capture mode the
// helper must assert the compose file is visible under the mount before backing up, so
// a phantom mount (a remote daemon's empty auto-created dir) HARD-FAILS instead of
// writing a silent-empty snapshot.
describe('stack-dir volume probe', () => {
it('emits a compose-visibility guard when a probe is given', () => {
const script = buildBackupScript(['backup', '/volumes/', '/metadata/'], { volumeKey: '__dockhand_stackdir__', composeFileName: 'docker-compose.yml' });
expect(script).toContain("[ ! -f '/volumes/__dockhand_stackdir__/docker-compose.yml' ]");
expect(script).toContain('exit 1');
expect(script).toContain('STACKDIR PROBE FAILED');
});
it('emits NO probe when none is given (tar / container path unchanged)', () => {
const script = buildBackupScript(['backup', '/volumes/', '/metadata/']);
expect(script).not.toContain('STACKDIR PROBE');
});
it('stamps the env/target label + wall-clock into the probe failure line for CI correlation', () => {
const script = buildBackupScript(['backup', '/volumes/', '/metadata/'], {
volumeKey: '__dockhand_stackdir__', composeFileName: 'docker-compose.yml', label: 'env=166 target=anton'
});
// The failure echo carries the key so a parallel CI log identifies WHICH backup failed,
// and a shell $(date ...) so it can be lined up with the shard test log by time.
expect(script).toContain('env=166 target=anton STACKDIR PROBE FAILED');
expect(script).toContain('$(date -u +%Y-%m-%dT%H:%M:%SZ)');
});
it('names the host path + a redeploy hint in the probe failure when hostPath is given (no hint = local)', () => {
const script = buildBackupScript(['backup', '/volumes/', '/metadata/'], {
volumeKey: '__dockhand_stackdir__', composeFileName: 'compose.yaml',
hostPath: '/opt/dockhand/stacks/myapp'
});
// The operator must see WHERE it looked and WHAT to do about it.
expect(script).toContain('not found in /opt/dockhand/stacks/myapp on the host');
expect(script).toContain('Redeploy the stack');
});
it('hawser-defaulted hint: names the empty host path and tells the user to set the host path, NOT redeploy', () => {
const script = buildBackupScript(['backup', '/volumes/', '/metadata/'], {
volumeKey: '__dockhand_stackdir__', composeFileName: 'compose.yaml',
hostPath: '/data/stacks/gitlab',
hint: { kind: 'hawser-defaulted', hostPath: '/data/stacks/gitlab', envName: 'prod-hawser' }
});
// The default /data/stacks came up empty: name WHERE (path on the env), the fix, and the
// exact settings location so the operator can go straight there.
expect(script).toContain('compose compose.yaml not found in /data/stacks/gitlab on prod-hawser');
expect(script).toContain('Set "Remote stack path (for backup)" in Settings > Environments > prod-hawser');
expect(script).not.toContain('Redeploy the stack');
});
it('uses the stack\'s REAL compose filename in the probe (adopted stacks can be anything)', () => {
const script = buildBackupScript(['backup', '/volumes/', '/metadata/'], {
volumeKey: '__dockhand_stackdir__', composeFileName: 'dupa.yaml',
hostPath: '/data/stacks/adopted', hint: { kind: 'hawser-defaulted', hostPath: '/data/stacks/adopted', envName: 'prod' }
});
// The name is taken from the stack's own composePath, never hardcoded to compose.yaml.
expect(script).toContain("[ ! -f '/volumes/__dockhand_stackdir__/dupa.yaml' ]");
expect(script).toContain('compose dupa.yaml not found');
});
it('user-set hint: same clear "set the real host path" instruction', () => {
const script = buildBackupScript(['backup', '/volumes/', '/metadata/'], {
volumeKey: '__dockhand_stackdir__', composeFileName: 'compose.yaml',
hostPath: '/opt/agent/stacks/gitlab',
hint: { kind: 'user-set', hostPath: '/opt/agent/stacks/gitlab', envName: 'remote-1' }
});
expect(script).toContain('not found in /opt/agent/stacks/gitlab on remote-1');
expect(script).toContain('Set "Remote stack path (for backup)" in Settings > Environments > remote-1');
expect(script).not.toContain('Redeploy the stack');
});
it('falls back to "on the host" when the env name is unknown (never prints null)', () => {
const script = buildBackupScript(['backup', '/volumes/', '/metadata/'], {
volumeKey: '__dockhand_stackdir__', composeFileName: 'compose.yaml',
hostPath: '/data/stacks/x', hint: { kind: 'hawser-defaulted', hostPath: '/data/stacks/x', envName: null }
});
expect(script).toContain('not found in /data/stacks/x on the host');
expect(script).toContain('Set "Remote stack path (for backup)" in Settings > Environments to');
expect(script).not.toContain('on null');
expect(script).not.toContain('Environments > null');
});
it('local hint: keeps the redeploy hint (files are staged by a local redeploy)', () => {
const script = buildBackupScript(['backup', '/volumes/', '/metadata/'], {
volumeKey: '__dockhand_stackdir__', composeFileName: 'compose.yaml',
hostPath: '/srv/stacks/myapp', hint: { kind: 'local' }
});
expect(script).toContain('not found in /srv/stacks/myapp on the host');
expect(script).toContain('Redeploy the stack');
});
it('round-trip: probe FAILS (exit 1) when the compose is not under the mount', () => {
const { execFileSync } = require('child_process');
const { mkdtempSync, mkdirSync, writeFileSync, rmSync } = require('fs');
const { tmpdir } = require('os');
const { join } = require('path');
const dir = mkdtempSync(join(tmpdir(), 'bkprobe-'));
try {
// Empty /volumes/__dockhand_stackdir__ (phantom mount) — no compose file.
mkdirSync(join(dir, 'volumes', '__dockhand_stackdir__'), { recursive: true });
// Point the probe at our temp tree and stub restic to `true`.
let script = buildBackupScript(['backup'], { volumeKey: '__dockhand_stackdir__', composeFileName: 'compose.yaml' })
.replace(/restic '[^']*'/, 'true')
.replaceAll('/volumes/', `${dir}/volumes/`);
const file = join(dir, 'run.sh');
writeFileSync(file, script);
let failed = false;
try { execFileSync('sh', [file], { encoding: 'utf8' }); }
catch (e: any) { failed = true; expect(String(e.stderr || e.stdout)).toContain('STACKDIR PROBE FAILED'); }
expect(failed).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
// The hint messages contain quotes, parens and `>` ("Remote stack path (for backup)",
// "Settings > Environments > <env>"). If they aren't shell-quoted they break `sh` with
// "syntax error near (" and EVERY hawser/user-set backup dies at the probe. Run the REAL
// generated script through sh for each hint kind to guarantee it stays valid.
for (const hint of [
{ kind: 'hawser-defaulted', hostPath: '/data/stacks/x', envName: "prod's-env (eu)" } as const,
{ kind: 'user-set', hostPath: '/opt/agent/stacks/x', envName: 'remote-1' } as const,
{ kind: 'local' } as const,
]) {
it(`round-trip: generated probe script is valid sh for hint kind=${hint.kind}`, () => {
const { execFileSync } = require('child_process');
const { mkdtempSync, mkdirSync, writeFileSync, rmSync } = require('fs');
const { tmpdir } = require('os');
const { join } = require('path');
const dir = mkdtempSync(join(tmpdir(), 'bkprobe-'));
try {
mkdirSync(join(dir, 'volumes', '__dockhand_stackdir__'), { recursive: true });
const script = buildBackupScript(['backup'], {
volumeKey: '__dockhand_stackdir__', composeFileName: 'compose.yaml',
label: 'env=26 target=t', hostPath: '/data/stacks/x', hint,
})
.replace(/restic '[^']*'/, 'true')
.replaceAll('/volumes/', `${dir}/volumes/`);
const file = join(dir, 'run.sh');
writeFileSync(file, script);
// `sh -n` = syntax-only check: no quoting bug can slip through.
execFileSync('sh', ['-n', file], { encoding: 'utf8' });
// And it must still FAIL loud (empty mount) with the message, not silently.
let failed = false;
try { execFileSync('sh', [file], { encoding: 'utf8' }); }
catch (e: any) { failed = true; expect(String(e.stderr || e.stdout)).toContain('STACKDIR PROBE FAILED'); }
expect(failed).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
}
it('round-trip: probe PASSES when the compose IS under the mount', () => {
const { execFileSync } = require('child_process');
const { mkdtempSync, mkdirSync, writeFileSync, rmSync } = require('fs');
const { tmpdir } = require('os');
const { join } = require('path');
const dir = mkdtempSync(join(tmpdir(), 'bkprobe-'));
try {
const mount = join(dir, 'volumes', '__dockhand_stackdir__');
mkdirSync(mount, { recursive: true });
writeFileSync(join(mount, 'compose.yaml'), 'services: {}\n'); // compose present
const script = buildBackupScript(['backup'], { volumeKey: '__dockhand_stackdir__', composeFileName: 'compose.yaml' })
.replace(/restic '[^']*'/, 'true')
.replaceAll('/volumes/', `${dir}/volumes/`);
const file = join(dir, 'run.sh');
writeFileSync(file, script);
const out = execFileSync('sh', [file], { encoding: 'utf8' });
expect(readExitMarker(out)).toBe(0); // probe passed, restic (stubbed) ran
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
});
+662
View File
@@ -0,0 +1,662 @@
/**
* Behavioural tests for BackupService — the fail-fast backup pipeline asserted
* against a fake BackupPorts. Each test pins one safety property:
* - success only if the helper exited ok AND a snapshot summary was parsed
* - a verify step must pass before success is declared / retention runs
* - a missing target errors; zero volumes still yields a config-only snapshot
* - retention refuses a delete-everything policy and runs only after success
* - a restart failure is surfaced (warning + notification), never swallowed
* - concurrent op on the same live target is rejected
*/
import { describe, it, expect } from 'bun:test';
import { BackupService, type BackupPorts, type BackupJob, type OperationHandle } from '../../src/lib/server/backups/backup-service';
import type { ResticRun } from '../../src/lib/server/backups/models';
import { LiveTargetLocks } from '../../src/lib/server/backups/locks';
const SNAP = 'abcdef1234567890';
/** A restic backup summary line for a given snapshot id. */
function summaryStdout(snapshotId = SNAP, dataAdded = 100): string {
return JSON.stringify({ message_type: 'summary', snapshot_id: snapshotId, data_added: dataAdded, files_new: 1 });
}
/** Records everything the service does, for assertions. */
interface Recorder {
localCalls: string[][];
helperRuns: number;
notifications: Array<{ event: string; payload: any }>;
restarts: number;
configStatus: string[];
opClosed?: { kind: string; details?: any };
opSkipped?: string;
progress: string[];
liveTargetAcquired: number;
liveTargetReleased: number;
webhooks: Array<{ url: string; payload: any }>;
helperSpec?: any; // the last spec passed to runInHelper (to assert binds/args forwarding)
}
function newRecorder(): Recorder {
return {
localCalls: [], helperRuns: 0, notifications: [], restarts: 0, configStatus: [], progress: [],
liveTargetAcquired: 0, liveTargetReleased: 0, webhooks: [],
};
}
function makePorts(overrides: Partial<BackupPorts> = {}, rec?: Recorder): BackupPorts {
const r = rec ?? newRecorder();
const op: OperationHandle = {
id: 1,
progress: (_s, m) => { r.progress.push(m); },
log: () => {},
close: async (outcome, details) => { r.opClosed = { kind: outcome.kind, details }; },
skip: async (reason) => { r.opSkipped = reason; },
};
const base: BackupPorts = {
resolveTargets: async () => ({ containers: [{ id: 'c1', name: 'web', state: 'running' }] }),
discoverVolumes: async () => ({ volumes: [{ key: 'data', bind: 'data:/volumes/data:ro', name: 'data', type: 'volume', source: 'data' }], skipped: [] }),
planStackDirVolume: async () => ({
kind: 'candidate' as const,
syntheticVolume: { key: '__dockhand_stackdir__', bind: '/srv/stacks/web:/volumes/__dockhand_stackdir__:ro', name: '__dockhand_stackdir__', type: 'bind' as const, source: '/srv/stacks/web' },
volumeKey: '__dockhand_stackdir__',
composeFileName: 'docker-compose.yml',
excludePaths: [],
bindSources: [],
}),
stopForBackup: async () => ({ restart: async () => ({ failed: [] }) }),
runInHelper: async (spec) => { r.helperRuns++; r.helperSpec = spec; return { exitCode: 0, stdout: summaryStdout(), stderr: '' }; },
runLocal: async (args) => {
r.localCalls.push(args);
// verify: `snapshots ... <snap>` → return a doc containing the short id
if (args[0] === 'snapshots') return { exitCode: 0, stdout: JSON.stringify([{ id: SNAP, short_id: SNAP.slice(0, 8), tags: [] }]), stderr: '' };
// forget dry-run / real → keep some (not a wipe)
if (args[0] === 'forget') return { exitCode: 0, stdout: JSON.stringify([{ keep: [{ id: 'x' }], remove: [{ id: 'y' }] }]), stderr: '' };
return { exitCode: 0, stdout: '', stderr: '' };
},
collectMetadata: async () => ({ files: [{ path: 'metadata/metadata.json', contentBase64: 'e30=' }] }),
host: () => 'dockhand.local',
instanceId: async () => 'inst',
acquireLiveTarget: () => { r.liveTargetAcquired++; return () => { r.liveTargetReleased++; }; },
serializeDestination: async (_id, fn) => fn(),
openOperation: async () => op,
notify: async (event, payload) => { r.notifications.push({ event, payload }); },
fireWebhook: (url, payload) => { r.webhooks.push({ url, payload }); },
setConfigStatus: async (_id, status) => { r.configStatus.push(status); },
isCancelling: () => false,
};
return { ...base, ...overrides };
}
const job: BackupJob = {
configId: 7, type: 'container', targetName: 'web', environmentId: null, destinationId: 3,
allVolumes: true, selectedVolumes: null, stopBeforeBackup: false, retention: null,
options: {}, helperName: 'dockhand-backup-7',
};
describe('BackupService — happy path', () => {
it('records success with the parsed snapshot id and summary', async () => {
const rec = newRecorder();
const svc = new BackupService(makePorts({}, rec));
const res = await svc.run(job, 'manual');
expect(res.status).toBe('success');
if (res.status === 'success') expect(res.snapshotId).toBe(SNAP);
expect(rec.configStatus).toContain('success');
expect(rec.notifications.some((n) => n.event === 'backup_success')).toBe(true);
});
it('a stack backup mounts the __dockhand_stackdir__ HOST bind into the helper', async () => {
// ONE FLOW: the stack dir is captured by bind-mounting its host folder into the helper
// at /volumes/__dockhand_stackdir__:ro. planStackDirVolume returns that synthetic
// volume; BackupService must merge it into the helper's binds so restic reads it.
const rec = newRecorder();
const stackJob: BackupJob = { ...job, type: 'stack', targetName: 'web' };
const svc = new BackupService(makePorts({}, rec));
const res = await svc.run(stackJob, 'manual');
expect(res.status).toBe('success');
expect(rec.helperSpec?.binds).toContain('/srv/stacks/web:/volumes/__dockhand_stackdir__:ro');
});
it('a stack whose host folder cannot be located (unknown) HARD-FAILS, never a silent partial', async () => {
// Option A: if planStackDirVolume can't locate the stack folder on the host (no compose
// working_dir label - not compose-managed), we refuse the backup rather than silently
// dropping the stack's compose/config. The user must never think they have a backup
// that is missing files.
const rec = newRecorder();
const stackJob: BackupJob = { ...job, type: 'stack', targetName: 'web' };
const svc = new BackupService(makePorts({
planStackDirVolume: async () => ({ kind: 'unknown' as const, reason: 'no compose working_dir label' }),
}, rec));
const res = await svc.run(stackJob, 'manual');
expect(res.status).not.toBe('success');
expect(rec.helperRuns).toBe(0); // never even ran the helper
});
});
describe('BackupService — notifications carry a title/message (#1414)', () => {
// The Apprise/Slack formatter reads payload.title and payload.message. Backup
// events must supply both (like every other event type) or Slack renders the
// literal "undefined <environment> undefined".
it('backup_success supplies title, message and type', async () => {
const rec = newRecorder();
const svc = new BackupService(makePorts({}, rec));
await svc.run(job, 'manual');
const n = rec.notifications.find((n) => n.event === 'backup_success');
expect(n).toBeDefined();
expect(typeof n!.payload.title).toBe('string');
expect((n!.payload.title as string).length).toBeGreaterThan(0);
expect(typeof n!.payload.message).toBe('string');
expect(n!.payload.message as string).toContain(job.targetName);
expect(n!.payload.type).toBe('success');
});
it('backup_failed supplies title, message and type', async () => {
const rec = newRecorder();
const svc = new BackupService(makePorts({ runInHelper: async () => ({ exitCode: 1, stdout: summaryStdout(), stderr: 'restic failed' }) }, rec));
await svc.run(job, 'manual');
const n = rec.notifications.find((n) => n.event === 'backup_failed');
expect(n).toBeDefined();
expect(typeof n!.payload.title).toBe('string');
expect((n!.payload.title as string).length).toBeGreaterThan(0);
expect(typeof n!.payload.message).toBe('string');
expect((n!.payload.message as string).length).toBeGreaterThan(0);
expect(n!.payload.type).toBe('error');
});
});
describe('BackupService — stale repo-lock recovery', () => {
// A prior backup/restore killed or crashed mid-op (helper OOM, host restart,
// cancelBackup) leaves restic's repo lock orphaned. The NEXT op to that repo
// would otherwise wait out `--retry-lock` (~10 min) on a dead owner — this was
// the real cause of the 300s CI backup timeouts (all envs share one repo; a
// killed-helper crash-safety test orphaned the lock). We run `restic unlock`
// BEFORE the backup (and on the failure path) to clear it. Serialized per
// destination, so no live op holds the repo → any lock present is stale.
it('runs `restic unlock` BEFORE the backup helper on the happy path', async () => {
const rec = newRecorder();
const order: string[] = [];
const svc = new BackupService(makePorts({
runLocal: async (args: string[]) => {
if (args[0] === 'unlock') order.push('unlock');
if (args[0] === 'snapshots') return { exitCode: 0, stdout: JSON.stringify([{ id: SNAP, short_id: SNAP.slice(0, 8), tags: [] }]), stderr: '' };
return { exitCode: 0, stdout: '', stderr: '' };
},
runInHelper: async () => { order.push('helper'); rec.helperRuns++; return { exitCode: 0, stdout: summaryStdout(), stderr: '' }; },
}, rec));
const res = await svc.run(job, 'manual');
expect(res.status).toBe('success');
// unlock must precede the backup helper — it clears a stale lock so the
// backup never waits on restic's retry-lock.
expect(order[0]).toBe('unlock');
expect(order).toContain('helper');
expect(order.indexOf('unlock')).toBeLessThan(order.indexOf('helper'));
});
it('runs `restic unlock` on the failure path (killed helper leaves a lock)', async () => {
const unlocks: string[] = [];
const svc = new BackupService(makePorts({
// helper "killed" → undefined exit → backup fails
runInHelper: async () => ({ exitCode: undefined, stdout: '', stderr: 'killed' }),
runLocal: async (args: string[]) => {
if (args[0] === 'unlock') unlocks.push('unlock');
return { exitCode: 0, stdout: '', stderr: '' };
},
}));
const res = await svc.run(job, 'manual');
expect(res.status).toBe('error');
// unlock fired at least once (pre-backup) and the failed op cleaned up its
// own orphaned lock so the NEXT backup to this repo isn't blocked.
expect(unlocks.length).toBeGreaterThanOrEqual(1);
});
it('a metadata upload failure fails the backup CLOSED (never a snapshot missing metadata.json)', async () => {
// The helper put-archives metadata.json into /metadata BEFORE restic starts (restic.ts
// beforeStart runs before the container start). If that upload throws, the helper never
// starts restic, so runInHelper surfaces an undefined exit — which MUST be treated as a
// failure, never a success. This guards against a snapshot that captured /volumes but not
// /metadata (an incomplete-but-"successful" backup). We simulate the upload failure as the
// undefined-exit it produces at the port boundary.
const rec = newRecorder();
const svc = new BackupService(makePorts({
runInHelper: async () => ({ exitCode: undefined, stdout: '', stderr: 'Failed to upload archive: metadata put-archive boom' }),
}, rec));
const res = await svc.run(job, 'manual');
expect(res.status).toBe('error');
expect(rec.notifications.some((n) => n.event === 'backup_success')).toBe(false);
});
it('a failing `unlock` never blocks the backup (best-effort)', async () => {
const rec = newRecorder();
const svc = new BackupService(makePorts({
runLocal: async (args: string[]) => {
if (args[0] === 'unlock') throw new Error('unlock boom');
if (args[0] === 'snapshots') return { exitCode: 0, stdout: JSON.stringify([{ id: SNAP, short_id: SNAP.slice(0, 8), tags: [] }]), stderr: '' };
return { exitCode: 0, stdout: '', stderr: '' };
},
}, rec));
const res = await svc.run(job, 'manual');
// unlock threw, but the backup still succeeded — restic's own --retry-lock
// remains the backstop, so a flaky unlock can't fail an otherwise-good backup.
expect(res.status).toBe('success');
});
});
describe('BackupService — live-target lock is always released', () => {
// The finally { release() } in run() must free the live-target key on BOTH the
// success and error paths. A leak leaves the key held forever, silently
// rejecting every future backup+restore of that data — and a status-only test
// would never notice. So assert the release spy fires exactly once each time.
it('acquires once and releases once on the happy path', async () => {
const rec = newRecorder();
const svc = new BackupService(makePorts({}, rec));
const res = await svc.run(job, 'manual');
expect(res.status).toBe('success');
expect(rec.liveTargetAcquired).toBe(1);
expect(rec.liveTargetReleased).toBe(1);
});
it('still releases the lock when the backup fails (helper error)', async () => {
const rec = newRecorder();
const svc = new BackupService(makePorts({
runInHelper: async () => { rec.helperRuns++; return { exitCode: undefined, stdout: '', stderr: 'killed' }; },
}, rec));
const res = await svc.run(job, 'manual');
expect(res.status).toBe('error');
// The lock was taken and MUST be freed even though the run failed.
expect(rec.liveTargetAcquired).toBe(1);
expect(rec.liveTargetReleased).toBe(1);
});
it('releases the live-target lock BEFORE retention (repo-only) runs', async () => {
// Retention is a repo forget/prune that never touches the live volume, so
// the live-target lock must already be freed by the time it runs — otherwise
// a concurrent restore sees 'skipped' during the (potentially slow) prune.
// Record the interleaving of the release spy and the `forget` restic call.
const timeline: string[] = [];
const rec = newRecorder();
const svc = new BackupService(makePorts({
acquireLiveTarget: () => { rec.liveTargetAcquired++; return () => { rec.liveTargetReleased++; timeline.push('release'); }; },
runLocal: async (args: string[]) => {
if (args[0] === 'snapshots') return { exitCode: 0, stdout: JSON.stringify([{ id: SNAP, short_id: SNAP.slice(0, 8), tags: [] }]), stderr: '' };
if (args[0] === 'forget') { timeline.push('forget'); return { exitCode: 0, stdout: JSON.stringify([{ keep: [{ id: 'x' }], remove: [{ id: 'y' }] }]), stderr: '' }; }
return { exitCode: 0, stdout: '', stderr: '' };
},
}, rec));
// A retention policy so forget actually runs (dry-run + real = two forgets).
const res = await svc.run({ ...job, retention: { keepLast: 1 } as any }, 'manual');
expect(res.status).toBe('success');
expect(rec.liveTargetReleased).toBe(1);
// The lock release must precede EVERY forget — retention runs unlocked.
expect(timeline).toContain('release');
expect(timeline).toContain('forget');
expect(timeline.indexOf('release')).toBeLessThan(timeline.indexOf('forget'));
});
it('lets a concurrent op acquire the same target while the first is in retention', async () => {
// The direct property behind the ordering test: a second op arriving DURING
// the first backup's (slow) retention prune must ACQUIRE the live-target
// slot, not get 'skipped'. Model a real single-slot lock — free ⇒ release
// closure, held ⇒ null (exactly how the engine skips). Hold retention open
// until the second op has tried to acquire. Under the fix the slot is freed
// before retention, so the second op wins; revert the reorder and this fails.
let held = false;
const acquire = () => {
if (held) return null;
held = true;
return () => { held = false; };
};
let releaseRetention!: () => void;
const retentionGate = new Promise<void>((r) => { releaseRetention = r; });
let secondAcquiredDuringRetention: boolean | null = null;
const rec = newRecorder();
const svc = new BackupService(makePorts({
acquireLiveTarget: acquire,
runLocal: async (args: string[]) => {
if (args[0] === 'snapshots') return { exitCode: 0, stdout: JSON.stringify([{ id: SNAP, short_id: SNAP.slice(0, 8), tags: [] }]), stderr: '' };
if (args[0] === 'forget') {
// First forget: pause here so a second op can race the retention tail.
if (secondAcquiredDuringRetention === null) {
const second = new BackupService(makePorts({ acquireLiveTarget: acquire }));
const r2 = await second.run(job, 'manual');
secondAcquiredDuringRetention = r2.status !== 'skipped';
releaseRetention();
}
return { exitCode: 0, stdout: JSON.stringify([{ keep: [{ id: 'x' }], remove: [{ id: 'y' }] }]), stderr: '' };
}
return { exitCode: 0, stdout: '', stderr: '' };
},
}, rec));
const res = await svc.run({ ...job, retention: { keepLast: 1 } as any }, 'manual');
await retentionGate;
expect(res.status).toBe('success');
// The second op ran the whole backup while the first was pruning — it never
// saw the lock, because retention runs unlocked.
expect(secondAcquiredDuringRetention).toBe(true);
});
});
describe('BackupService — success requires exit ok AND a parsed snapshot', () => {
it('fails when the helper exit is undefined (unknown outcome)', async () => {
const svc = new BackupService(makePorts({ runInHelper: async () => ({ exitCode: undefined, stdout: '', stderr: 'killed' }) }));
const res = await svc.run(job, 'manual');
expect(res.status).toBe('error');
});
it('fails when restic exits 0 but produced NO summary (phantom snapshot guard)', async () => {
const svc = new BackupService(makePorts({ runInHelper: async () => ({ exitCode: 0, stdout: 'no summary here', stderr: '' }) }));
const res = await svc.run(job, 'manual');
expect(res.status).toBe('error');
if (res.status === 'error') expect(res.code).toBe('INTEGRITY');
});
it('exit 3 (partial read) commits as a WARNING, not a failure', async () => {
const svc = new BackupService(makePorts({ runInHelper: async () => ({ exitCode: 3, stdout: summaryStdout(), stderr: '' }) }));
const res = await svc.run(job, 'manual');
expect(res.status).toBe('warning');
});
it('a plain restic error exit (1 or 2) is a FAILURE, never a partial-warning', async () => {
// Only exit 3 is the "partial read" warning; every other non-zero code is a
// real error and must NOT be smuggled through as success/warning even when
// restic printed a summary line before failing.
for (const code of [1, 2]) {
const svc = new BackupService(makePorts({ runInHelper: async () => ({ exitCode: code, stdout: summaryStdout(), stderr: 'restic failed' }) }));
const res = await svc.run(job, 'manual');
expect(res.status).toBe('error');
}
});
it('a stack dir over the capture cap HARD-FAILS the backup (never a truncated success)', async () => {
// The stack dir exceeding the cap is now a hard error raised INSIDE
// collectMetadata (compose/.env are cap-exempt, so this is the user's
// non-compose sidecars alone being over the limit). A silently-truncated
// snapshot that reports success is a worse failure than a loud one, so the
// run must FAIL, not warn.
const { BackupError } = await import('../../src/lib/server/backups/models');
const svc = new BackupService(makePorts({
collectMetadata: async () => { throw new BackupError('INTEGRITY', 'config files exceed the stackfile capture cap'); },
runInHelper: async () => ({ exitCode: 0, stdout: summaryStdout(), stderr: '' }),
}));
const res = await svc.run(job, 'manual');
expect(res.status).toBe('error');
if (res.status === 'error') expect(res.error).toContain('capture cap');
});
});
describe('BackupService — verify before success and before retention', () => {
it('fails if the new snapshot cannot be verified as readable', async () => {
const rec = newRecorder();
const svc = new BackupService(makePorts({
runLocal: async (args) => {
rec.localCalls.push(args);
if (args[0] === 'snapshots') return { exitCode: 1, stdout: '', stderr: 'not found' }; // verify fails
return { exitCode: 0, stdout: '', stderr: '' };
},
}, rec));
const res = await svc.run(job, 'manual');
expect(res.status).toBe('error');
if (res.status === 'error') expect(res.code).toBe('INTEGRITY');
// retention (forget) must NOT have run after a failed verify
expect(rec.localCalls.some((c) => c[0] === 'forget')).toBe(false);
});
});
describe('BackupService — no empty snapshot reaches retention', () => {
it('errors when the target has no containers (deleted target)', async () => {
const svc = new BackupService(makePorts({ resolveTargets: async () => ({ containers: [] }) }));
const res = await svc.run(job, 'manual');
expect(res.status).toBe('error');
expect(res.status === 'error' && res.code).toBe('VALIDATION');
});
it('records the DELETED-target run as a FAILED error row, not a benign "skipped" (audit-trail must not hide it)', async () => {
// The bug: earlySkipOrError used op.skip() which hardcodes CONCURRENCY + skipped:true,
// so a config pointing at a deleted target logged a benign "skipped" every cron run.
// It must close as a real error (VALIDATION), so an operator filtering failed backups sees it.
const rec = newRecorder();
const svc = new BackupService(makePorts({ resolveTargets: async () => ({ containers: [] }) }, rec));
await svc.run(job, 'manual');
expect(rec.opClosed?.kind).toBe('error'); // durable row is an error...
expect(rec.opClosed?.details?.skipped).not.toBe(true); // ...NOT a skip
expect(rec.opSkipped).toBeUndefined(); // op.skip() was NOT used
});
it('succeeds with a config-only (metadata-only) snapshot when the target has zero volumes', async () => {
const rec = newRecorder();
const svc = new BackupService(makePorts({ discoverVolumes: async () => ({ volumes: [], skipped: [] }) }, rec));
const res = await svc.run(job, 'manual');
expect(res.status).toBe('success'); // metadata.json still captured — restorable via recreate/redeploy
});
it('propagates a fail-closed discovery error (partial inspect)', async () => {
const svc = new BackupService(makePorts({ discoverVolumes: async () => { throw new (await import('../../src/lib/server/backups/models')).BackupError('DOCKER', 'inspect failed'); } }));
const res = await svc.run(job, 'manual');
expect(res.status).toBe('error');
if (res.status === 'error') expect(res.code).toBe('DOCKER');
});
});
describe('BackupService — retention', () => {
it('runs retention only after a confirmed+verified snapshot', async () => {
const rec = newRecorder();
const svc = new BackupService(makePorts({}, rec));
await svc.run({ ...job, retention: { keepLast: 5 } }, 'manual');
const order = rec.localCalls.map((c) => c[0]);
// snapshots (verify) comes before forget (retention)
expect(order.indexOf('snapshots')).toBeLessThan(order.indexOf('forget'));
});
it('refuses a policy that would delete every snapshot', async () => {
const rec = newRecorder();
const svc = new BackupService(makePorts({
runLocal: async (args) => {
rec.localCalls.push(args);
if (args[0] === 'snapshots') return { exitCode: 0, stdout: JSON.stringify([{ id: SNAP, short_id: SNAP.slice(0, 8), tags: [] }]), stderr: '' };
if (args[0] === 'forget' && args.includes('--dry-run')) return { exitCode: 0, stdout: JSON.stringify([{ keep: [], remove: [{ id: 'a' }, { id: 'b' }] }]), stderr: '' };
return { exitCode: 0, stdout: '', stderr: '' };
},
}, rec));
const res = await svc.run({ ...job, retention: { keepLast: 1 } }, 'manual');
expect(res.status).toBe('success');
// A real (non-dry-run) forget must NOT have executed.
const realForget = rec.localCalls.filter((c) => c[0] === 'forget' && !c.includes('--dry-run'));
expect(realForget).toHaveLength(0);
expect(rec.opClosed?.details?.retention).toBe('skipped-would-wipe');
});
});
describe('BackupService — stop/restart', () => {
it('surfaces a restart failure as a warning + notification (service still stopped)', async () => {
const rec = newRecorder();
const svc = new BackupService(makePorts({
stopForBackup: async () => ({ restart: async () => ({ failed: [{ name: 'web', error: 'port in use' }] }) }),
}, rec));
const res = await svc.run({ ...job, stopBeforeBackup: true }, 'manual');
// The backup itself still succeeded (data is safe)...
expect(res.status).toBe('success');
// ...but the restart failure is surfaced.
expect(rec.progress.some((m) => m.includes('STILL STOPPED'))).toBe(true);
expect(rec.notifications.some((n) => n.payload?.errorCode === 'STOPPED_RESTART_FAILED')).toBe(true);
});
it('restarts exactly once on the success path (no double start)', async () => {
let restartCount = 0;
const svc = new BackupService(makePorts({
stopForBackup: async () => ({ restart: async () => { restartCount++; return { failed: [] }; } }),
}));
await svc.run({ ...job, stopBeforeBackup: true }, 'manual');
expect(restartCount).toBe(1);
});
});
describe('BackupService — concurrency', () => {
it('rejects (skips) when the live-target lock is already held', async () => {
const svc = new BackupService(makePorts({ acquireLiveTarget: () => null }));
const res = await svc.run(job, 'manual');
expect(res.status).toBe('skipped');
});
it('runs the backup inside the destination serializer', async () => {
let serialized = false;
const svc = new BackupService(makePorts({ serializeDestination: async (_id, fn) => { serialized = true; return fn(); } }));
await svc.run(job, 'manual');
expect(serialized).toBe(true);
});
});
describe('BackupService — per-config webhooks', () => {
it('fires the success webhook with the backup-context payload', async () => {
const rec = newRecorder();
const svc = new BackupService(makePorts({}, rec));
const res = await svc.run(
{ ...job, options: { webhookSuccess: 'https://hook.example.com/ok' } },
'manual',
);
expect(res.status).toBe('success');
expect(rec.webhooks.length).toBe(1);
const wh = rec.webhooks[0];
expect(wh.url).toBe('https://hook.example.com/ok');
expect(wh.payload.event).toBe('backup_success');
expect(wh.payload.target).toBe('web');
expect(wh.payload.type).toBe('container');
expect(wh.payload.snapshotId).toBe(SNAP);
expect(typeof wh.payload.duration).toBe('number');
expect(wh.payload.executionId).toBeDefined();
});
it('fires the failure webhook with the error payload when the backup fails', async () => {
const rec = newRecorder();
const svc = new BackupService(makePorts({
runInHelper: async () => { rec.helperRuns++; return { exitCode: undefined, stdout: '', stderr: 'killed' }; },
}, rec));
const res = await svc.run(
{ ...job, options: { webhookFailure: 'https://hook.example.com/fail' } },
'manual',
);
expect(res.status).toBe('error');
expect(rec.webhooks.length).toBe(1);
const wh = rec.webhooks[0];
expect(wh.url).toBe('https://hook.example.com/fail');
expect(wh.payload.event).toBe('backup_failed');
expect(wh.payload.target).toBe('web');
expect(wh.payload.error).toBeDefined();
expect(wh.payload.errorCode).toBeDefined();
});
it('does NOT fire the failure webhook when only a success hook is set (and vice versa)', async () => {
// Failing run with only a SUCCESS hook configured → no webhook fires.
const recFail = newRecorder();
const svcFail = new BackupService(makePorts({
runInHelper: async () => { recFail.helperRuns++; return { exitCode: undefined, stdout: '', stderr: 'killed' }; },
}, recFail));
await svcFail.run({ ...job, options: { webhookSuccess: 'https://hook.example.com/ok' } }, 'manual');
expect(recFail.webhooks.length).toBe(0);
// Succeeding run with only a FAILURE hook configured → no webhook fires.
const recOk = newRecorder();
const svcOk = new BackupService(makePorts({}, recOk));
await svcOk.run({ ...job, options: { webhookFailure: 'https://hook.example.com/fail' } }, 'manual');
expect(recOk.webhooks.length).toBe(0);
});
it('fires no webhook at all when neither hook URL is configured', async () => {
const rec = newRecorder();
const svc = new BackupService(makePorts({}, rec));
await svc.run(job, 'manual'); // job.options = {}
expect(rec.webhooks.length).toBe(0);
});
});
describe('BackupService — user-cancelled backup', () => {
// A cancel SIGINT/SIGKILLs the helper → it exits non-zero (137). Without the
// isCancelling check that reads as a real failure. With it, the run is reported
// as cancelled, not failed: no failure notification, no failure webhook.
function killedPorts(rec: Recorder, cancelling: boolean) {
return makePorts({
runInHelper: async () => { rec.helperRuns++; return { exitCode: undefined, stdout: '', stderr: 'killed' }; },
isCancelling: () => cancelling,
}, rec);
}
it('closes the op as cancelled with a clean message and returns skipped', async () => {
const rec = newRecorder();
const svc = new BackupService(killedPorts(rec, true));
const result = await svc.run(job, 'manual');
expect(result.status).toBe('skipped');
expect(rec.opClosed?.kind).toBe('cancelled');
});
it('does NOT fire the failure webhook or notification when cancelled', async () => {
const rec = newRecorder();
const svc = new BackupService(killedPorts(rec, true));
await svc.run({ ...job, options: { webhookFailure: 'https://hook.example.com/fail' } }, 'manual');
expect(rec.webhooks.length).toBe(0);
expect(rec.notifications.some((n) => n.event === 'backup_failed')).toBe(false);
});
it('the SAME killed run WITHOUT cancel is a real failure (regression guard)', async () => {
const rec = newRecorder();
const svc = new BackupService(killedPorts(rec, false));
const result = await svc.run({ ...job, options: { webhookFailure: 'https://hook.example.com/fail' } }, 'manual');
expect(result.status).toBe('error');
expect(rec.opClosed?.kind).toBe('error');
expect(rec.webhooks.length).toBe(1);
expect(rec.notifications.some((n) => n.event === 'backup_failed')).toBe(true);
});
});
describe('Config-only backups do not spuriously block each other (live-target key)', () => {
// Two DIFFERENT config-only targets (no volumes) on the same env must both back up. Wire a
// REAL LiveTargetLocks so the lock key actually decides collision (not a mock that always
// grants). Config-only = discoverVolumes returns zero volumes.
const configOnlyPorts = (locks: LiveTargetLocks, rec?: any) => makePorts({
discoverVolumes: async () => ({ volumes: [], skipped: [] }),
acquireLiveTarget: (key: string) => locks.tryAcquire(key),
}, rec);
const jobFor = (configId: number, targetName: string): BackupJob =>
({ ...job, configId, targetName });
it('two disjoint config-only targets on the same env BOTH succeed (were both `${env}::`)', async () => {
const locks = new LiveTargetLocks();
const svcWeb = new BackupService(configOnlyPorts(locks));
const svcApi = new BackupService(configOnlyPorts(locks));
// resolveTargets returns a container named 'web' by default; give 'api' its own container.
const apiPorts = configOnlyPorts(locks);
apiPorts.resolveTargets = async () => ({ containers: [{ id: 'c2', name: 'api', state: 'running' }] });
const [web, api] = await Promise.all([
svcWeb.run(jobFor(7, 'web'), 'cron'),
new BackupService(apiPorts).run(jobFor(8, 'api'), 'cron'),
]);
expect(['success', 'warning']).toContain(web.status);
expect(['success', 'warning']).toContain(api.status);
});
it('the SAME config-only target held concurrently still rejects the second (lock works)', async () => {
const locks = new LiveTargetLocks();
// Manually hold the key the config-only 'web' target will compute, then run it -> skip.
const held = locks.tryAcquire('local::config:container:web');
expect(held).not.toBeNull();
const res = await new BackupService(configOnlyPorts(locks)).run(jobFor(7, 'web'), 'cron');
expect(res.status).toBe('skipped');
held!();
});
});
describe('BackupService — volume log distinguishes binds by host source (#1373)', () => {
it('two binds to the same container path get distinct log lines with their host source', async () => {
const rec = newRecorder();
const svc = new BackupService(makePorts({
discoverVolumes: async () => ({
volumes: [
{ key: 'data__alpha', bind: '/host/alpha:/volumes/data__alpha:ro', name: '/data', type: 'bind', source: '/host/alpha' },
{ key: 'data__beta', bind: '/host/beta:/volumes/data__beta:ro', name: '/data', type: 'bind', source: '/host/beta' },
],
skipped: [],
}),
}, rec));
const res = await svc.run(job, 'manual');
expect(res.status).toBe('success');
const bindLines = rec.progress.filter((m) => m.includes('[BIND]'));
expect(bindLines).toHaveLength(2);
// Each line names its host source, so the two /data binds are not identical.
expect(bindLines.some((m) => m.includes('/host/alpha') && m.includes('/data'))).toBe(true);
expect(bindLines.some((m) => m.includes('/host/beta') && m.includes('/data'))).toBe(true);
expect(bindLines[0]).not.toBe(bindLines[1]);
});
});
+103
View File
@@ -0,0 +1,103 @@
/**
* Unit tests for backups/browse-core.ts — parsing `restic ls --json` output into
* a directory's child nodes. No docker, no DB.
*
* Regression: `restic ls --json <dir>` emits a node for the queried dir ITSELF in
* addition to its children. If it isn't dropped, the snapshot browser shows a
* phantom empty child named after the directory (an empty `volumes/` inside
* `/volumes/`, `bt-container-vol/` inside `/volumes/bt-container-vol/`, etc.).
*/
import { describe, it, expect } from 'bun:test';
import { parseSnapshotLsEntries } from '../../src/lib/server/backups/browse-core';
// One JSON object per line, exactly as `restic ls --json` emits: a snapshot
// header line, the self-node for the queried dir, then the children.
function lines(...objs: any[]): string {
return objs.map((o) => JSON.stringify(o)).join('\n');
}
const header = { struct_type: 'snapshot', id: 'abc', paths: ['/volumes'] };
const node = (path: string, name: string, type = 'dir') => ({
struct_type: 'node', path, name, type,
});
describe('parseSnapshotLsEntries', () => {
it('drops the self-node for the queried directory (the ghost)', () => {
// This is the exact shape seen live: `restic ls --json <snap> /volumes`.
const stdout = lines(
header,
node('/volumes', 'volumes'), // <- self, must be dropped
node('/volumes/bt-container-vol', 'bt-container-vol'),
node('/volumes/data_bind', 'data_bind'),
);
const names = parseSnapshotLsEntries(stdout, '/volumes').map((e) => e.name);
expect(names).toEqual(['bt-container-vol', 'data_bind']);
expect(names).not.toContain('volumes');
});
it('drops the self-node one level deep too', () => {
const stdout = lines(
header,
node('/volumes/bt-container-vol', 'bt-container-vol'), // <- self
node('/volumes/bt-container-vol/container-vol.txt', 'container-vol.txt', 'file'),
);
const names = parseSnapshotLsEntries(stdout, '/volumes/bt-container-vol').map((e) => e.name);
expect(names).toEqual(['container-vol.txt']);
});
it('keeps real top-level children when browsing root', () => {
const stdout = lines(
header,
node('/', ''), // <- self root
node('/metadata', 'metadata'),
node('/volumes', 'volumes'),
);
const names = parseSnapshotLsEntries(stdout, '/').map((e) => e.name);
expect(names).toEqual(['metadata', 'volumes']);
});
it('handles a trailing slash on the queried path', () => {
const stdout = lines(
header,
node('/volumes', 'volumes'),
node('/volumes/x', 'x'),
);
const names = parseSnapshotLsEntries(stdout, '/volumes/').map((e) => e.name);
expect(names).toEqual(['x']);
});
it('ignores snapshot header and non-node lines', () => {
const stdout = lines(
header,
{ struct_type: 'other', foo: 1 },
node('/volumes/keep', 'keep'),
);
const names = parseSnapshotLsEntries(stdout, '/volumes').map((e) => e.name);
expect(names).toEqual(['keep']);
});
it('tolerates blank and malformed lines', () => {
const stdout = [
'',
'not json',
JSON.stringify(node('/volumes', 'volumes')),
' ',
JSON.stringify(node('/volumes/ok', 'ok')),
].join('\n');
const names = parseSnapshotLsEntries(stdout, '/volumes').map((e) => e.name);
expect(names).toEqual(['ok']);
});
it('accepts message_type nodes (current restic) as well as struct_type (deprecated)', () => {
// restic deprecated struct_type in favor of message_type. A restic bump that drops
// struct_type must NOT empty the browser - both discriminators must be recognized.
const mtNode = (path: string, name: string) => ({ message_type: 'node', path, name, type: 'dir' });
const stdout = lines(
{ message_type: 'snapshot', id: 'abc' },
mtNode('/volumes', 'volumes'), // self, dropped
mtNode('/volumes/child', 'child'),
);
const names = parseSnapshotLsEntries(stdout, '/volumes').map((e) => e.name);
expect(names).toEqual(['child']);
});
});
+168
View File
@@ -0,0 +1,168 @@
/**
* Unit tests for backups/discovery-core.ts — the pure mount → volume-bind
* mapping. No docker, no DB.
*/
import { describe, it, expect } from 'bun:test';
import {
discoverVolumesFromMounts,
filterSelectedVolumes,
discoverVolumes,
DiscoveryInspectError,
type Mount,
} from '../../src/lib/server/backups/discovery-core';
const vol = (name: string): Mount => ({ Type: 'volume', Name: name, Destination: `/data/${name}` });
const bind = (source: string, dest: string): Mount => ({ Type: 'bind', Source: source, Destination: dest });
describe('discoverVolumesFromMounts — named volumes', () => {
it('maps a named volume to a :ro bind keyed on its name', () => {
const r = discoverVolumesFromMounts([{ name: 'app', mounts: [vol('data')] }]);
expect(r.volumes).toEqual([{ key: 'data', bind: 'data:/volumes/data:ro', name: 'data', type: 'volume', source: 'data' }]);
expect(r.skipped).toEqual([]);
});
it('de-duplicates a volume shared across containers (captured once)', () => {
const r = discoverVolumesFromMounts([
{ name: 'a', mounts: [vol('shared')] },
{ name: 'b', mounts: [vol('shared')] },
]);
expect(r.volumes.map((v) => v.name)).toEqual(['shared']);
});
});
describe('discoverVolumesFromMounts — bind mounts', () => {
it('maps a bind to a sanitized /volumes key', () => {
const r = discoverVolumesFromMounts([{ name: 'a', mounts: [bind('/srv/host', '/etc/app')] }]);
expect(r.volumes).toEqual([{ key: 'etc_app__host', bind: '/srv/host:/volumes/etc_app__host:ro', name: '/etc/app', type: 'bind', source: '/srv/host' }]);
});
it('de-duplicates identical (source,destination) bind pairs', () => {
const r = discoverVolumesFromMounts([
{ name: 'a', mounts: [bind('/srv/x', '/data')] },
{ name: 'b', mounts: [bind('/srv/x', '/data')] },
]);
expect(r.volumes).toHaveLength(1);
});
it('captures distinct binds that map to the same destination under unique keys', () => {
// Two different sources → same container dest must both be captured, and get
// DISTINCT /volumes keys so restic doesn't overwrite one with the other.
const r = discoverVolumesFromMounts([
{ name: 'a', mounts: [bind('/srv/one', '/data')] },
{ name: 'b', mounts: [bind('/srv/two', '/data')] },
]);
expect(r.volumes).toHaveLength(2);
const keys = r.volumes.map((v) => v.key);
expect(new Set(keys).size).toBe(2); // unique keys
});
it('sanitizes shell-unsafe characters in the key', () => {
const r = discoverVolumesFromMounts([{ name: 'a', mounts: [bind('/srv/x', '/data/$(id)')] }]);
expect(r.volumes[0].key).toMatch(/^[A-Za-z0-9._-]+$/);
});
it('a named volume and a bind whose slug equals the volume name get DISTINCT keys (no shadow)', () => {
// A bind to /data slugs to key "data"; a named volume "data" would ALSO claim key
// "data" -> both map to /volumes/data, the second :ro helper bind shadows the first,
// and restic captures only one (silent data loss). safeKey must disambiguate them.
const r = discoverVolumesFromMounts([
{ name: 'a', mounts: [bind('/host/data', '/data'), vol('data')] },
]);
expect(r.volumes).toHaveLength(2);
const keys = r.volumes.map((v) => v.key);
expect(new Set(keys).size).toBe(2); // no collision
expect(new Set(r.volumes.map((v) => `/volumes/${v.key}`)).size).toBe(2); // distinct mount paths
// the named volume keeps its name/source (restore rebinds by source, not key)
const namedVol = r.volumes.find((v) => v.type === 'volume');
expect(namedVol?.name).toBe('data');
expect(namedVol?.source).toBe('data');
});
});
describe('discoverVolumesFromMounts — unsupported mounts are SKIPPED, not dropped silently', () => {
it('reports tmpfs / cluster / unknown as skipped with a reason', () => {
const r = discoverVolumesFromMounts([{
name: 'a',
mounts: [
{ Type: 'tmpfs', Destination: '/tmp' },
{ Type: 'cluster', Destination: '/csi' },
{ Type: 'weird', Destination: '/x' },
vol('keep'),
],
}]);
expect(r.volumes.map((v) => v.name)).toEqual(['keep']);
expect(r.skipped.map((s) => s.type).sort()).toEqual(['cluster', 'tmpfs', 'weird']);
for (const s of r.skipped) expect(s.reason).toContain('not backed up');
});
it('skips malformed mounts (volume w/o name, bind w/o source) without capturing', () => {
const r = discoverVolumesFromMounts([{
name: 'a',
mounts: [
{ Type: 'volume' }, // no Name
{ Type: 'bind', Destination: '/x' }, // no Source
],
}]);
expect(r.volumes).toEqual([]);
});
});
describe('filterSelectedVolumes', () => {
const discovered = [
{ key: 'data', bind: 'data:/volumes/data:ro', name: 'data', type: 'volume' as const, source: 'data' },
{ key: 'etc_app', bind: '/srv:/volumes/etc_app:ro', name: '/etc/app', type: 'bind' as const, source: '/srv' },
];
it('null selection = all volumes', () => {
expect(filterSelectedVolumes(discovered, null)).toEqual(discovered);
});
it('matches by name (volume name or bind destination)', () => {
expect(filterSelectedVolumes(discovered, ['data']).map((v) => v.key)).toEqual(['data']);
expect(filterSelectedVolumes(discovered, ['/etc/app']).map((v) => v.key)).toEqual(['etc_app']);
});
it('matches by key too', () => {
expect(filterSelectedVolumes(discovered, ['etc_app']).map((v) => v.key)).toEqual(['etc_app']);
});
it('empty selection = back up nothing', () => {
expect(filterSelectedVolumes(discovered, [])).toEqual([]);
});
});
describe('discoverVolumesFromMounts — sockets and host system paths are skipped', () => {
it('skips /var/run/docker.sock (not a backup target, even under "backup all")', () => {
const r = discoverVolumesFromMounts([
{ name: 'dockhand', mounts: [bind('/var/run/docker.sock', '/var/run/docker.sock'), bind('/srv/app-data', '/app/data')] },
]);
expect(r.volumes.map((v) => v.name)).toEqual(['/app/data']);
expect(r.skipped.some((s) => s.destination === '/var/run/docker.sock' && /socket or host system path/.test(s.reason))).toBe(true);
});
it('skips host system paths (/proc, /sys) but keeps real data binds', () => {
const r = discoverVolumesFromMounts([
{ name: 'monitor', mounts: [bind('/proc', '/host/proc'), bind('/sys', '/host/sys'), bind('/srv/metrics', '/data')] },
]);
expect(r.volumes.map((v) => v.name)).toEqual(['/data']);
expect(r.skipped.filter((s) => /socket or host system path/.test(s.reason)).length).toBe(2);
});
});
describe('discoverVolumes — FAIL CLOSED on any inspect failure', () => {
const containers = [{ id: 'c1', name: 'web' }, { id: 'c2', name: 'db' }];
it('aggregates mounts across all containers when every inspect succeeds', async () => {
const inspect = async (id: string): Promise<Mount[]> =>
id === 'c1' ? [vol('web-data')] : [vol('db-data')];
const r = await discoverVolumes(containers, null, null, inspect);
expect(r.volumes.map((v) => v.name).sort()).toEqual(['db-data', 'web-data']);
});
it('throws DiscoveryInspectError if ONE container fails to inspect (never a partial backup)', async () => {
const inspect = async (id: string): Promise<Mount[]> => {
if (id === 'c2') throw new Error('daemon hiccup');
return [vol('web-data')];
};
await expect(discoverVolumes(containers, null, null, inspect)).rejects.toThrow(DiscoveryInspectError);
// and the error names the offending container
await expect(discoverVolumes(containers, null, null, inspect)).rejects.toThrow(/db/);
});
it('applies the selection filter after a fully-successful discovery', async () => {
const inspect = async (id: string): Promise<Mount[]> =>
id === 'c1' ? [vol('keep')] : [vol('drop')];
const r = await discoverVolumes(containers, null, ['keep'], inspect);
expect(r.volumes.map((v) => v.name)).toEqual(['keep']);
});
});
+51
View File
@@ -0,0 +1,51 @@
/**
* Unit tests for formatResticLines — turning raw restic (--json or plain) output
* into human log lines for the backup/restore UI. No docker, no DB.
*/
import { describe, it, expect } from 'bun:test';
import { formatResticLines } from '../../src/lib/server/backups/backup-service';
describe('formatResticLines', () => {
it('formats a --json status line to a percent (+ current file)', () => {
const line = JSON.stringify({ message_type: 'status', percent_done: 0.42, current_files: ['/data/big.bin'] });
expect(formatResticLines(line)).toEqual(['42% done — /data/big.bin']);
});
it('formats a status line with no current file', () => {
const line = JSON.stringify({ message_type: 'status', percent_done: 1 });
expect(formatResticLines(line)).toEqual(['100% done']);
});
it('formats a summary line', () => {
const line = JSON.stringify({ message_type: 'summary', files_new: 3, files_changed: 1, files_unmodified: 10, data_added: 5_000_000 });
expect(formatResticLines(line)).toEqual(['Done: 3 new, 1 changed, 10 unchanged · 5.0 MB added']);
});
it('formats an error line', () => {
const line = JSON.stringify({ message_type: 'error', error: { message: 'permission denied' } });
expect(formatResticLines(line)).toEqual(['error: permission denied']);
});
it('passes plain (non-JSON) restic/shell text through verbatim', () => {
expect(formatResticLines('Fatal: unable to open config file')).toEqual(['Fatal: unable to open config file']);
});
it('passes JSON without message_type through verbatim', () => {
expect(formatResticLines('{"foo":1}')).toEqual(['{"foo":1}']);
});
it('splits a multi-line chunk and drops blank lines', () => {
const chunk = [
JSON.stringify({ message_type: 'status', percent_done: 0.5 }),
'',
'plain line',
' ',
].join('\n');
expect(formatResticLines(chunk)).toEqual(['50% done', 'plain line']);
});
it('returns [] for empty/whitespace input', () => {
expect(formatResticLines('')).toEqual([]);
expect(formatResticLines('\n \n')).toEqual([]);
});
});
+118
View File
@@ -0,0 +1,118 @@
/**
* Unit tests for backups/helpers.ts — currently the fail-fast withTimeout used to
* bound helper-image resolution so a stalled pull can never hang a backup.
*/
import { describe, it, expect } from 'bun:test';
import { withTimeout, resolveEnabledOnScheduleChange } from '../../src/lib/server/backups/helpers';
import { BackupError } from '../../src/lib/server/backups/models';
describe('withTimeout', () => {
it('resolves with the value when the promise settles before the deadline', async () => {
const v = await withTimeout(Promise.resolve(42), 1000, 'nope');
expect(v).toBe(42);
});
it('rejects with a BackupError(DOCKER) when the promise outlives the deadline', async () => {
const slow = new Promise((r) => setTimeout(r, 200));
let err: unknown;
try {
await withTimeout(slow, 20, 'timed out pulling helper image "x"');
} catch (e) {
err = e;
}
expect(err).toBeInstanceOf(BackupError);
expect((err as BackupError).code).toBe('DOCKER');
expect((err as Error).message).toContain('timed out pulling helper image');
});
it('propagates the underlying rejection (not a timeout) when it loses the race', async () => {
const boom = Promise.reject(new Error('registry auth failed'));
let err: unknown;
try {
await withTimeout(boom, 1000, 'timeout message');
} catch (e) {
err = e;
}
expect((err as Error).message).toBe('registry auth failed');
expect(err).not.toBeInstanceOf(BackupError);
});
it('clears its timer on success (no dangling timeout keeps the loop alive)', async () => {
// If the timer weren't cleared, a rejection would fire later and surface as an
// unhandled rejection. Resolve fast, then wait past the deadline to prove quiet.
await withTimeout(Promise.resolve('ok'), 30, 'should never fire');
await new Promise((r) => setTimeout(r, 60));
expect(true).toBe(true); // reaching here without an unhandled rejection is the assertion
});
});
describe('resolveEnabledOnScheduleChange', () => {
// THE BUG: run-once persists a manual, paused config (schedule=null, enabled=false).
// Editing it to add a cron used to keep it paused because the UI sent the stale
// enabled=false. Adding a schedule must auto-enable.
it('auto-enables when a manual config (no schedule) gains a cron, even if the request says enabled=false', () => {
expect(resolveEnabledOnScheduleChange({
requestedEnabled: false,
existingSchedule: null,
newSchedule: '0 2 * * *'
})).toBe(true);
});
it('auto-enables manual -> scheduled when existing schedule is an empty string', () => {
expect(resolveEnabledOnScheduleChange({
requestedEnabled: false,
existingSchedule: '',
newSchedule: '*/5 * * * *'
})).toBe(true);
});
it('does NOT force-enable a config that was ALREADY scheduled (respects a deliberate pause)', () => {
expect(resolveEnabledOnScheduleChange({
requestedEnabled: false,
existingSchedule: '0 2 * * *',
newSchedule: '0 3 * * *'
})).toBe(false);
});
it('honours an explicit enabled=true request unchanged', () => {
expect(resolveEnabledOnScheduleChange({
requestedEnabled: true,
existingSchedule: '0 2 * * *',
newSchedule: '0 2 * * *'
})).toBe(true);
});
it('leaves enabled UNCHANGED (undefined) when the request omits the flag and there is no manual->scheduled transition', () => {
// undefined must pass through so the DB layer keeps the existing value — a PUT
// that omits `enabled` must never silently pause the config.
expect(resolveEnabledOnScheduleChange({
requestedEnabled: undefined,
existingSchedule: '0 2 * * *',
newSchedule: '0 2 * * *'
})).toBeUndefined();
});
it('still auto-enables on manual->scheduled even when the request omits the flag', () => {
expect(resolveEnabledOnScheduleChange({
requestedEnabled: undefined,
existingSchedule: null,
newSchedule: '0 2 * * *'
})).toBe(true);
});
it('does not enable when the new schedule is also empty (manual stays manual)', () => {
expect(resolveEnabledOnScheduleChange({
requestedEnabled: false,
existingSchedule: null,
newSchedule: null
})).toBe(false);
});
it('treats a whitespace-only cron as no schedule (no false auto-enable)', () => {
expect(resolveEnabledOnScheduleChange({
requestedEnabled: false,
existingSchedule: null,
newSchedule: ' '
})).toBe(false);
});
});
+63
View File
@@ -0,0 +1,63 @@
/**
* Unit tests for backups/identity-core.ts — the pure instance-id resolver,
* driven by an injected store + id generator (no DB, no randomUUID).
*/
import { describe, it, expect } from 'bun:test';
import { resolveInstanceId, type InstanceIdStore } from '../../src/lib/server/backups/identity-core';
/** In-memory store that records how many times set() was called. */
function memStore(seed?: string): InstanceIdStore & { raw: Map<string, string>; setCount: number } {
const raw = new Map<string, string>();
if (seed !== undefined) raw.set('backup_instance_id', seed);
const box = {
raw,
setCount: 0,
async get(key: string) { return raw.has(key) ? raw.get(key) : null; },
async set(key: string, value: string) { box.setCount++; raw.set(key, value); },
};
return box;
}
describe('resolveInstanceId', () => {
it('returns the existing id when one is stored, without generating or writing', async () => {
const store = memStore('existing-uuid');
let generated = false;
const id = await resolveInstanceId(store, () => { generated = true; return 'fresh'; });
expect(id).toBe('existing-uuid');
expect(generated).toBe(false);
expect(store.setCount).toBe(0);
});
it('generates, persists, and returns a fresh id when none is stored', async () => {
const store = memStore();
const id = await resolveInstanceId(store, () => 'fresh-uuid');
expect(id).toBe('fresh-uuid');
expect(store.raw.get('backup_instance_id')).toBe('fresh-uuid');
expect(store.setCount).toBe(1);
});
it('treats an empty stored value as absent and generates', async () => {
const store = memStore('');
const id = await resolveInstanceId(store, () => 'fresh-uuid');
expect(id).toBe('fresh-uuid');
});
});
describe('resolveInstanceId — single-flight (shared by the wrapper)', () => {
it('two concurrent first-callers through ONE promise generate + persist once', async () => {
// The wrapper (identity.ts) memoises the in-flight promise, so both callers
// await the SAME resolveInstanceId call. That means one generate + one set,
// and both see the same id — never two conflicting UUIDs.
const store = memStore();
let generateCount = 0;
const gen = () => { generateCount++; return `uuid-${generateCount}`; };
let shared: Promise<string> | null = null;
const run = () => (shared ??= resolveInstanceId(store, gen));
const [a, b] = await Promise.all([run(), run()]);
expect(a).toBe(b);
expect(generateCount).toBe(1);
expect(store.setCount).toBe(1);
});
});
+158
View File
@@ -0,0 +1,158 @@
/**
* Unit tests for backups/locks.ts — the two exclusion mechanisms.
* LiveTargetLocks: REJECT a second op on the same live data.
* DestinationSerializer: SERIALIZE (queue) ops on the same destination.
*/
import { describe, it, expect } from 'bun:test';
import { liveTargetKey, LiveTargetLocks, DestinationSerializer } from '../../src/lib/server/backups/locks';
describe('liveTargetKey — same data ⇒ same key regardless of order/env-shape', () => {
it('sorts and joins data paths so order does not matter', () => {
expect(liveTargetKey(1, ['b', 'a'])).toBe(liveTargetKey(1, ['a', 'b']));
});
it('distinguishes environments', () => {
expect(liveTargetKey(1, ['v'])).not.toBe(liveTargetKey(2, ['v']));
});
it('treats null/undefined env as "local"', () => {
expect(liveTargetKey(null, ['v'])).toBe(liveTargetKey(undefined, ['v']));
expect(liveTargetKey(null, ['v'])).toContain('local');
});
it('a shared volume under different container names collides (same data key)', () => {
// Two backups keyed on the SAME volume path must produce the same key even
// though the "target" differs — that's the whole point.
expect(liveTargetKey(1, ['/volumes/shared'])).toBe(liveTargetKey(1, ['/volumes/shared']));
});
it('ignores blank entries', () => {
expect(liveTargetKey(1, ['a', '', ' '])).toBe(liveTargetKey(1, ['a']));
});
it('a backup (:ro) and a restore (:rw) of the same target collide (mode-independent key)', () => {
// Backup passes the discovery :ro bind; restore passes the :rw bind. Both
// touch the same live volume, so the keys MUST match or they run concurrently.
const backupBind = 'vol:/volumes/vol:ro';
const restoreBind = 'vol:/volumes/vol:rw';
expect(liveTargetKey(1, [backupBind])).toBe(liveTargetKey(1, [restoreBind]));
});
it('keys on the SOURCE, ignoring the container mount-point and mode', () => {
// A bind source is the absolute host path; the /volumes/<key> mount-point and
// mode must not affect the identity.
expect(liveTargetKey(1, ['/data/config:/volumes/data_config:ro']))
.toBe(liveTargetKey(1, ['/data/config:/volumes/data_config:rw']));
});
it('genuinely different targets still do not collide', () => {
expect(liveTargetKey(1, ['vol-a:/volumes/vol-a:ro']))
.not.toBe(liveTargetKey(1, ['vol-b:/volumes/vol-b:rw']));
expect(liveTargetKey(1, ['/data/a:/volumes/data_a:ro']))
.not.toBe(liveTargetKey(1, ['/data/b:/volumes/data_b:rw']));
});
// Config-only targets (no volumes/binds): the fallback identity keeps them apart.
it('two DIFFERENT config-only targets on the same env do NOT collide (the bug)', () => {
// Before the fix both were `${env}::` and the REJECT lock skipped one every cron run.
expect(liveTargetKey(1, [], 'container:web'))
.not.toBe(liveTargetKey(1, [], 'container:api'));
expect(liveTargetKey(null, [], 'stack:blog'))
.not.toBe(liveTargetKey(null, [], 'stack:shop'));
});
it('the SAME config-only target still collides across backup and restore', () => {
// backup passes `${type}:${targetName}`, restore passes the same -> they MUST match so
// a concurrent backup+restore of one config-only target is still serialized.
expect(liveTargetKey(1, [], 'container:web')).toBe(liveTargetKey(1, [], 'container:web'));
});
it('a config-only target and a data-bearing target of the same name do not alias', () => {
expect(liveTargetKey(1, [], 'container:web'))
.not.toBe(liveTargetKey(1, ['vol:/volumes/vol:ro'], 'container:web'));
});
it('the fallback identity is IGNORED when the target has data sources (still collides on shared data)', () => {
// Two different targets that share a volume must still collide (torn-snapshot guard) -
// the fallback must not de-alias them.
expect(liveTargetKey(1, ['shared:/volumes/shared:ro'], 'container:web'))
.toBe(liveTargetKey(1, ['shared:/volumes/shared:ro'], 'container:api'));
});
});
describe('LiveTargetLocks — a backup and restore of the same data mutually exclude', () => {
it('the restore-style bind is rejected while the backup-style bind holds the same source', () => {
const locks = new LiveTargetLocks();
const backupKey = liveTargetKey(1, ['vol:/volumes/vol:ro']);
const restoreKey = liveTargetKey(1, ['vol:/volumes/vol:rw']);
expect(locks.tryAcquire(backupKey)).not.toBeNull();
expect(locks.tryAcquire(restoreKey)).toBeNull(); // same data ⇒ rejected
});
});
describe('LiveTargetLocks — reject a second op on the same key', () => {
it('first acquire succeeds, second on the same key is rejected (null)', () => {
const locks = new LiveTargetLocks();
const release = locks.tryAcquire('k');
expect(release).not.toBeNull();
expect(locks.tryAcquire('k')).toBeNull(); // rejected while held
expect(locks.isHeld('k')).toBe(true);
});
it('different keys do not block each other', () => {
const locks = new LiveTargetLocks();
expect(locks.tryAcquire('a')).not.toBeNull();
expect(locks.tryAcquire('b')).not.toBeNull();
});
it('releasing frees the key for re-acquire', () => {
const locks = new LiveTargetLocks();
const release = locks.tryAcquire('k')!;
release();
expect(locks.isHeld('k')).toBe(false);
expect(locks.tryAcquire('k')).not.toBeNull();
});
it('release is idempotent', () => {
const locks = new LiveTargetLocks();
const release = locks.tryAcquire('k')!;
release();
release(); // no throw, no double-delete side effect
expect(locks.size).toBe(0);
});
});
describe('DestinationSerializer — serialize ops on the same destination', () => {
it('runs ops on the same destination one at a time, in order', async () => {
const ser = new DestinationSerializer();
const events: string[] = [];
const op = (name: string, ms: number) => async () => {
events.push(`start:${name}`);
await new Promise((r) => setTimeout(r, ms));
events.push(`end:${name}`);
return name;
};
// Start two ops on the SAME destination; the second must wait for the first.
const p1 = ser.run(1, op('A', 30));
const p2 = ser.run(1, op('B', 5));
await Promise.all([p1, p2]);
expect(events).toEqual(['start:A', 'end:A', 'start:B', 'end:B']);
});
it('ops on DIFFERENT destinations run concurrently', async () => {
const ser = new DestinationSerializer();
const events: string[] = [];
const op = (name: string, ms: number) => async () => {
events.push(`start:${name}`);
await new Promise((r) => setTimeout(r, ms));
events.push(`end:${name}`);
};
await Promise.all([ser.run(1, op('A', 20)), ser.run(2, op('B', 5))]);
// B (dest 2) starts before A ends because they don't share a destination.
expect(events.indexOf('start:B')).toBeLessThan(events.indexOf('end:A'));
});
it('a failing op does not break the queue — the next op still runs', async () => {
const ser = new DestinationSerializer();
const ran: string[] = [];
const p1 = ser.run(1, async () => { ran.push('A'); throw new Error('boom'); }).catch(() => 'caught');
const p2 = ser.run(1, async () => { ran.push('B'); return 'ok'; });
const [r1, r2] = await Promise.all([p1, p2]);
expect(r1).toBe('caught');
expect(r2).toBe('ok');
expect(ran).toEqual(['A', 'B']);
});
it('propagates the op result and error to the caller', async () => {
const ser = new DestinationSerializer();
await expect(ser.run(1, async () => 42)).resolves.toBe(42);
await expect(ser.run(1, async () => { throw new Error('x'); })).rejects.toThrow('x');
});
});
+184
View File
@@ -0,0 +1,184 @@
/**
* Unit tests for backups/models.ts — pure DTO parsers, tag builders, result
* helpers. No mocks. Covers the coverage-checklist row for models.ts, and the
* behavioral guarantee that a garbage/partial/absent restic summary NEVER throws and
* a missing summary yields null (so the service fails the backup).
*/
import { describe, it, expect } from 'bun:test';
import {
parseResticLine,
parseResticBackupSummary,
parseResticProgress,
parseSnapshots,
resticOk,
resticPartial,
buildSnapshotTags,
retentionTagFilter,
instanceTagFilter,
readEnvIdFromTags,
readConfigIdFromTags,
BackupError,
TIMEOUTS,
type ResticRun,
} from '../../src/lib/server/backups/models';
describe('parseResticLine', () => {
it('parses a JSON object line', () => {
expect(parseResticLine('{"a":1}')).toEqual({ a: 1 });
});
it('returns null for non-JSON / empty / non-object', () => {
expect(parseResticLine('')).toBeNull();
expect(parseResticLine('not json')).toBeNull();
expect(parseResticLine('[1,2]')).toBeNull(); // array, not object
expect(parseResticLine(' ')).toBeNull();
expect(parseResticLine('12')).toBeNull();
});
it('never throws on garbage', () => {
expect(() => parseResticLine('{unterminated')).not.toThrow();
});
});
describe('parseResticBackupSummary (no summary ⇒ null, never throw)', () => {
it('extracts the summary with all fields', () => {
const stdout = [
'{"message_type":"status","percent_done":0.5}',
'{"message_type":"summary","snapshot_id":"abcd1234","files_new":3,"files_changed":1,"files_unmodified":10,"data_added":2048,"total_bytes_processed":4096,"total_files_processed":14}',
].join('\n');
expect(parseResticBackupSummary(stdout)).toEqual({
snapshotId: 'abcd1234', filesNew: 3, filesChanged: 1, filesUnmodified: 10,
dataAdded: 2048, totalBytesProcessed: 4096, totalFilesProcessed: 14,
});
});
it('coerces missing numeric fields to 0 (truncated summary never throws)', () => {
const s = parseResticBackupSummary('{"message_type":"summary","snapshot_id":"x"}');
expect(s).toEqual({ snapshotId: 'x', filesNew: 0, filesChanged: 0, filesUnmodified: 0, dataAdded: 0, totalBytesProcessed: 0, totalFilesProcessed: 0 });
});
it('returns null when there is NO summary line (unknown outcome ⇒ fail)', () => {
expect(parseResticBackupSummary('{"message_type":"status","percent_done":1}')).toBeNull();
expect(parseResticBackupSummary('')).toBeNull();
expect(parseResticBackupSummary('total garbage\nmore garbage')).toBeNull();
});
it('returns null when summary lacks a snapshot_id', () => {
expect(parseResticBackupSummary('{"message_type":"summary","files_new":1}')).toBeNull();
expect(parseResticBackupSummary('{"message_type":"summary","snapshot_id":""}')).toBeNull();
});
});
describe('parseResticProgress', () => {
it('parses a status line', () => {
expect(parseResticProgress('{"message_type":"status","percent_done":0.42,"bytes_done":10,"total_bytes":100}'))
.toEqual({ percentDone: 0.42, bytesDone: 10, totalBytes: 100 });
});
it('returns null for non-status / garbage', () => {
expect(parseResticProgress('{"message_type":"summary"}')).toBeNull();
expect(parseResticProgress('garbage')).toBeNull();
});
it('defaults numerics to 0', () => {
expect(parseResticProgress('{"message_type":"status"}')).toEqual({ percentDone: 0, bytesDone: 0, totalBytes: 0 });
});
});
describe('parseSnapshots', () => {
it('parses and sorts newest-first', () => {
const stdout = JSON.stringify([
{ id: 'old', short_id: 'old', time: '2024-01-01T00:00:00Z', hostname: 'h', tags: ['a'], paths: ['/v'] },
{ id: 'new', short_id: 'new', time: '2024-06-01T00:00:00Z', hostname: 'h', tags: [], paths: [] },
]);
const snaps = parseSnapshots(stdout);
expect(snaps.map((s) => s.id)).toEqual(['new', 'old']);
expect(snaps[1].tags).toEqual(['a']);
});
it('returns [] on garbage or non-array (unparseable ≠ populated repo)', () => {
expect(parseSnapshots('not json')).toEqual([]);
expect(parseSnapshots('{"not":"array"}')).toEqual([]);
expect(parseSnapshots('')).toEqual([]);
});
it('skips malformed entries, keeps valid ones', () => {
const stdout = JSON.stringify([{ notid: 1 }, { id: 'good', time: '2024-01-01' }]);
const snaps = parseSnapshots(stdout);
expect(snaps.map((s) => s.id)).toEqual(['good']);
expect(snaps[0].shortId).toBe('good'.slice(0, 8));
});
it('filters non-string tags/paths defensively', () => {
const stdout = JSON.stringify([{ id: 'x', tags: ['ok', 5, null], paths: ['/p', {}] }]);
expect(parseSnapshots(stdout)[0].tags).toEqual(['ok']);
expect(parseSnapshots(stdout)[0].paths).toEqual(['/p']);
});
});
describe('resticOk / resticPartial (undefined exit is not ok)', () => {
const run = (exitCode: number | undefined): ResticRun => ({ exitCode, stdout: '', stderr: '' });
it('ok only for exit 0', () => {
expect(resticOk(run(0))).toBe(true);
expect(resticOk(run(1))).toBe(false);
expect(resticOk(run(3))).toBe(false);
expect(resticOk(run(undefined))).toBe(false); // the critical case
});
it('partial only for exit 3', () => {
expect(resticPartial(run(3))).toBe(true);
expect(resticPartial(run(0))).toBe(false);
expect(resticPartial(run(undefined))).toBe(false);
});
});
describe('snapshot tags — stable ids, never normalized names', () => {
it('builds instance/configid/envid + decoration tags', () => {
const tags = buildSnapshotTags({ instanceId: 'inst', configId: 7, environmentId: 3, targetName: 'web', type: 'container' });
expect(tags).toContain('dockhand:instance=inst');
expect(tags).toContain('dockhand:configid=7');
expect(tags).toContain('dockhand:envid=3');
expect(tags).toContain('dockhand:type=container');
expect(tags).toContain('dockhand:name=web');
});
it('uses "local" for a null environment', () => {
const tags = buildSnapshotTags({ instanceId: 'i', configId: 1, environmentId: null, targetName: 't', type: 'stack' });
expect(tags).toContain('dockhand:envid=local');
});
it('retention filter ANDs instance + configid (collision-proof scope)', () => {
expect(retentionTagFilter('inst', 7)).toBe('dockhand:instance=inst,dockhand:configid=7');
});
it('instance filter is instance-only', () => {
expect(instanceTagFilter('inst')).toBe('dockhand:instance=inst');
});
});
describe('reading ids back from tags (fail closed on missing/malformed)', () => {
it('reads envid: local, numeric, or null', () => {
expect(readEnvIdFromTags(['dockhand:envid=local'])).toBe('local');
expect(readEnvIdFromTags(['dockhand:envid=5'])).toBe(5);
expect(readEnvIdFromTags(['dockhand:envid=0'])).toBeNull(); // 0 not a valid id
expect(readEnvIdFromTags(['dockhand:envid=abc'])).toBeNull();
expect(readEnvIdFromTags(['other'])).toBeNull(); // absent ⇒ fail closed
expect(readEnvIdFromTags([])).toBeNull();
});
it('reads configid or null', () => {
expect(readConfigIdFromTags(['dockhand:configid=9'])).toBe(9);
expect(readConfigIdFromTags(['dockhand:configid=x'])).toBeNull();
expect(readConfigIdFromTags([])).toBeNull();
});
});
describe('BackupError', () => {
it('carries a code + optional context', () => {
const e = new BackupError('INTEGRITY', 'snapshot unreadable', { snapshotId: 'x' });
expect(e).toBeInstanceOf(Error);
expect(e.code).toBe('INTEGRITY');
expect(e.message).toBe('snapshot unreadable');
expect(e.context).toEqual({ snapshotId: 'x' });
});
});
describe('TIMEOUTS — unbounded by default (#1382)', () => {
it('both tiers default to 0 (no timeout) so a long backup/list is never killed', () => {
// A fixed cap killed healthy long-running ops mid-run; a stuck op is handled by
// manual cancel + the helper reaper instead. Operators can still opt into a cap via
// the single RESTIC_TIMEOUT env (not set in the test env).
expect(TIMEOUTS.interactive).toBe(0);
expect(TIMEOUTS.data).toBe(0);
});
it('both tiers read the SAME env, so there is one RESTIC_TIMEOUT to set', () => {
// Folded from two envs into one: interactive and data must stay in lockstep.
expect(TIMEOUTS.interactive).toBe(TIMEOUTS.data);
});
});
+91
View File
@@ -0,0 +1,91 @@
/**
* Unit tests for backups/operations-core.ts — the pure decision logic of the
* operation record: terminal-status derivation, the startup scrub rule, and the
* progress throttle.
*/
import { describe, it, expect } from 'bun:test';
import {
deriveTerminalState,
toExecutionStatus,
shouldWriteProgress,
isAlwaysPersistedStatus,
ProgressThrottle,
type Outcome,
} from '../../src/lib/server/backups/operations-core';
describe('deriveTerminalState — one place decides the terminal status', () => {
it('ok → success', () => {
expect(deriveTerminalState({ kind: 'ok' })).toEqual({ status: 'success' });
});
it('warning → warning with the message', () => {
expect(deriveTerminalState({ kind: 'warning', message: 'partial read' }))
.toEqual({ status: 'warning', message: 'partial read' });
});
it('cancelled → cancelled (default message)', () => {
expect(deriveTerminalState({ kind: 'cancelled' })).toEqual({ status: 'cancelled', message: 'Cancelled' });
expect(deriveTerminalState({ kind: 'cancelled', message: 'by user' }))
.toEqual({ status: 'cancelled', message: 'by user' });
});
it('error → error with message + code', () => {
const o: Outcome = { kind: 'error', code: 'INTEGRITY', message: 'snapshot unreadable' };
expect(deriveTerminalState(o)).toEqual({ status: 'error', message: 'snapshot unreadable', errorCode: 'INTEGRITY' });
});
});
describe('toExecutionStatus — engine vocab → shared executions-table vocab', () => {
it('error → failed (the schedules UI reads "failed", not the engine-internal "error")', () => {
expect(toExecutionStatus('error')).toBe('failed');
});
it('cancelled → failed (no "cancelled" in the executions vocab)', () => {
expect(toExecutionStatus('cancelled')).toBe('failed');
});
it('success/warning pass through', () => {
expect(toExecutionStatus('success')).toBe('success');
expect(toExecutionStatus('warning')).toBe('warning');
});
it('a skipped-flagged close maps to skipped regardless of the terminal status', () => {
expect(toExecutionStatus('error', { skipped: true })).toBe('skipped');
expect(toExecutionStatus('success', { skipped: true })).toBe('skipped');
});
});
describe('shouldWriteProgress — first write immediate, then throttled', () => {
it('always writes the first update (lastWrittenAt null)', () => {
expect(shouldWriteProgress(null, 1000, 1000)).toBe(true);
});
it('suppresses a write within the interval', () => {
expect(shouldWriteProgress(1000, 1500, 1000)).toBe(false);
});
it('allows a write once the interval has elapsed', () => {
expect(shouldWriteProgress(1000, 2000, 1000)).toBe(true);
expect(shouldWriteProgress(1000, 2001, 1000)).toBe(true);
});
});
describe('ProgressThrottle — stateful, clock injected', () => {
it('writes first, throttles within interval, writes again after', () => {
let t = 0;
const th = new ProgressThrottle(1000, () => t);
expect(th.shouldWrite()).toBe(true); // t=0, first
t = 500;
expect(th.shouldWrite()).toBe(false); // within 1000
t = 1000;
expect(th.shouldWrite()).toBe(true); // interval elapsed
t = 1200;
expect(th.shouldWrite()).toBe(false); // within next interval
});
});
describe('isAlwaysPersistedStatus — only restic spam is throttled', () => {
it("throttles ONLY the high-frequency restic stream (status 'progress')", () => {
expect(isAlwaysPersistedStatus('progress')).toBe(false);
});
it('ALWAYS persists every meaningful low-frequency event', () => {
// Stage markers, the volume list, lifecycle steps, errors/warnings must never
// drop out of the execution log when they collide with restic spam.
for (const s of ['metadata', 'backing-up', 'verifying', 'pruning', 'restoring',
'recreating', 'redeploying', 'starting', 'stopping', 'restarted', 'error', 'warning']) {
expect(isAlwaysPersistedStatus(s)).toBe(true);
}
});
});
+48
View File
@@ -0,0 +1,48 @@
/**
* Unit tests for parseResticDiff — turns `restic diff --json` (and the plain-text
* fallback) into the {added, removed, modified, metadataChanged} shape the diff
* modal renders. A regression here shows up as the compare dialog spinning forever
* (the client reads diff.added.length on an unparsed {raw} blob).
*/
import { describe, it, expect } from 'bun:test';
import { parseResticDiff } from '../../src/lib/server/backups/helpers';
describe('parseResticDiff — restic --json change lines', () => {
it('buckets +/-/M and metadata-only (U/T) modifiers', () => {
const raw = [
JSON.stringify({ message_type: 'change', path: '/volumes/data/new.txt', modifier: '+' }),
JSON.stringify({ message_type: 'change', path: '/volumes/data/gone.txt', modifier: '-' }),
JSON.stringify({ message_type: 'change', path: '/volumes/data/edited.txt', modifier: 'M' }),
JSON.stringify({ message_type: 'change', path: '/volumes/data/perms.txt', modifier: 'U' }),
JSON.stringify({ message_type: 'statistics', changed_files: 3 }), // must be ignored
].join('\n');
const d = parseResticDiff(raw);
expect(d.added).toEqual(['/volumes/data/new.txt']);
expect(d.removed).toEqual(['/volumes/data/gone.txt']);
expect(d.modified).toEqual(['/volumes/data/edited.txt']);
expect(d.metadataChanged).toEqual(['/volumes/data/perms.txt']);
});
it('ignores blank lines and non-change JSON', () => {
const raw = '\n' + JSON.stringify({ message_type: 'statistics' }) + '\n\n';
const d = parseResticDiff(raw);
expect(d.added).toHaveLength(0);
expect(d.removed).toHaveLength(0);
expect(d.modified).toHaveLength(0);
expect(d.metadataChanged).toHaveLength(0);
});
it('falls back to plain-text restic diff ("<modifier> <path>")', () => {
const raw = ['+ /a', '- /b', 'M /c', 'T /d'].join('\n');
const d = parseResticDiff(raw);
expect(d.added).toEqual(['/a']);
expect(d.removed).toEqual(['/b']);
expect(d.modified).toEqual(['/c']);
expect(d.metadataChanged).toEqual(['/d']);
});
it('empty diff → all empty arrays (identical snapshots)', () => {
const d = parseResticDiff('');
expect(d).toEqual({ added: [], removed: [], modified: [], metadataChanged: [] });
});
});
+34
View File
@@ -0,0 +1,34 @@
/**
* Unit tests for classifyProbeListing - the pure part of the host/volume data probe.
*
* The probe container's stdout is classified into has-data / empty / missing. The fourth kind,
* helper-failed (the container could not run at all), is decided by the caller catching the run
* error, not here - so this classifier never returns it. The missing-vs-empty split is the reason
* this exists: both otherwise list nothing, so the list-script prints a sentinel when the path is
* absent, and the classifier keys off it.
*/
import { describe, expect, test } from 'bun:test';
import { classifyProbeListing, PROBE_MISSING_SENTINEL } from '../../src/lib/server/backups/stackdir-plan';
describe('classifyProbeListing', () => {
test('lines present -> has-data', () => {
expect(classifyProbeListing('f\t12\tindex.html\nd\t0\tassets\n')).toBe('has-data');
});
test('empty stdout (helper ran, dir empty) -> empty', () => {
expect(classifyProbeListing('')).toBe('empty');
expect(classifyProbeListing('\n')).toBe('empty');
});
test('missing sentinel -> missing (path does not exist on host)', () => {
expect(classifyProbeListing(PROBE_MISSING_SENTINEL)).toBe('missing');
});
test('sentinel wins even if garbage precedes it', () => {
expect(classifyProbeListing(`noise\n${PROBE_MISSING_SENTINEL}`)).toBe('missing');
});
test('malformed lines (no tabs) are ignored -> empty, not has-data', () => {
expect(classifyProbeListing('not-a-listing-line\nanother')).toBe('empty');
});
});
@@ -0,0 +1,71 @@
/**
* The edge-cap SAFEGUARD: transportCanStream decides stream (uncapped, O(1)) vs buffered
* (capped) put-archive. If it ever reported a hawser-edge env as streamable, the uncapped
* path would run over the WS transport that can't stream it -> OOM. This is the single
* load-bearing predicate the streaming design rests on, so pin it exactly.
*/
import { describe, it, expect } from 'bun:test';
import { Readable } from 'node:stream';
import { transportCanStream, sanitizeArchivePath, withIdleWatchdog } from '../../src/lib/server/backups/put-archive-transport';
describe('transportCanStream - edge-cap safeguard', () => {
it('is FALSE only for hawser-edge (the one transport that cannot stream)', () => {
expect(transportCanStream({ connectionType: 'hawser-edge' })).toBe(false);
});
it('is TRUE for every streamable transport (socket, direct, hawser-standard, local)', () => {
for (const connectionType of ['socket', 'direct', 'hawser-standard', undefined, null]) {
expect(transportCanStream({ connectionType })).toBe(true);
}
});
it('an unknown/new connection type defaults to streamable (fail-open is fine here: worst case a slow buffered fallback, never data loss)', () => {
expect(transportCanStream({ connectionType: 'some-future-type' })).toBe(true);
});
});
describe('sanitizeArchivePath - command/URL injection guard', () => {
it('leaves a normal archive path untouched', () => {
expect(sanitizeArchivePath('/volumes/__dockhand_stackdir__/compose.yaml')).toBe('/volumes/__dockhand_stackdir__/compose.yaml');
});
it('strips shell/URL metacharacters', () => {
expect(sanitizeArchivePath('/x;rm -rf /')).toBe('/xrm -rf /');
expect(sanitizeArchivePath('/a`b`c')).toBe('/abc');
expect(sanitizeArchivePath('/a$(whoami)b')).toBe('/awhoamib');
expect(sanitizeArchivePath('/a|b&c')).toBe('/abc');
expect(sanitizeArchivePath('/a<b>c"d\'e\\f')).toBe('/abcdef');
});
});
describe('withIdleWatchdog - aborts a stalled upload, lets a progressing one through', () => {
it('destroys the stream with an error after idleMs of zero throughput', async () => {
// A tar that produces nothing (or a receiver that never reads) -> no chunks flow -> abort.
const stuck = new Readable({ read() { /* never push: simulates a stalled producer/reader */ } });
const guarded = withIdleWatchdog(stuck, 'test', 40);
guarded.resume(); // put it in flowing mode; still no data will ever arrive
const err = await new Promise<Error | null>((resolve) => {
guarded.on('error', (e) => resolve(e));
guarded.on('end', () => resolve(null));
setTimeout(() => resolve(null), 300);
});
expect(err).toBeInstanceOf(Error);
expect(String(err?.message)).toContain('not reading');
});
it('does NOT abort while chunks keep flowing, and ends cleanly', async () => {
// Emit a chunk every 15ms for ~90ms (idle window 40ms) - each chunk resets the timer.
let n = 0;
const src = new Readable({ read() {} });
const iv = setInterval(() => { if (n++ < 6) src.push(Buffer.from('x')); else { clearInterval(iv); src.push(null); } }, 15);
const guarded = withIdleWatchdog(src, 'test', 40);
const chunks: Buffer[] = [];
const outcome = await new Promise<'end' | Error>((resolve) => {
guarded.on('data', (c) => chunks.push(c));
guarded.on('end', () => resolve('end'));
guarded.on('error', (e) => resolve(e));
});
expect(outcome).toBe('end'); // never tripped the idle timer
expect(chunks.length).toBe(6); // all data delivered
});
});
+34
View File
@@ -0,0 +1,34 @@
/**
* Unit tests for backups/reap-core.ts — the pure ownership predicate that decides
* which orphaned helper containers this installation may reap. Fail-closed on the
* instance label so a co-located Dockhand's helper on a shared daemon is safe.
*/
import { describe, it, expect } from 'bun:test';
import { isOwnedBackupHelper, INSTANCE_LABEL } from '../../src/lib/server/backups/reap-core';
const ME = 'inst-me';
const owned = { [INSTANCE_LABEL]: ME };
describe('isOwnedBackupHelper', () => {
it('matches an owned backup helper', () => {
expect(isOwnedBackupHelper('dockhand-backup-12', owned, ME)).toBe(true);
});
it('matches an owned restore helper', () => {
expect(isOwnedBackupHelper('dockhand-restore-abc', owned, ME)).toBe(true);
});
it('skips a foreign-instance label (never kill another install\'s helper)', () => {
expect(isOwnedBackupHelper('dockhand-backup-12', { [INSTANCE_LABEL]: 'other' }, ME)).toBe(false);
});
it('skips a missing label (fail closed)', () => {
expect(isOwnedBackupHelper('dockhand-backup-12', {}, ME)).toBe(false);
expect(isOwnedBackupHelper('dockhand-backup-12', undefined, ME)).toBe(false);
});
it('skips a non-helper name even with our label', () => {
expect(isOwnedBackupHelper('my-web-app', owned, ME)).toBe(false);
expect(isOwnedBackupHelper('dockhand-volume-helper', owned, ME)).toBe(false);
});
it('skips an empty/undefined name', () => {
expect(isOwnedBackupHelper('', owned, ME)).toBe(false);
expect(isOwnedBackupHelper(undefined, owned, ME)).toBe(false);
});
});
+53
View File
@@ -0,0 +1,53 @@
import { describe, it, expect } from 'bun:test';
import { runRedeployStack, type RedeployStackSteps } from '../../src/lib/server/backups/redeploy-stack-core';
// Records the order steps run in, so we can assert register-before-deploy.
function recorder(overrides: Partial<RedeployStackSteps> = {}) {
const calls: string[] = [];
const steps: RedeployStackSteps = {
restoreSecrets: async () => { calls.push('restoreSecrets'); },
materialise: async () => { calls.push('materialise'); return true; },
register: async () => { calls.push('register'); },
deploy: async () => { calls.push('deploy'); },
...overrides
};
return { calls, steps };
}
describe('runRedeployStack — step order', () => {
it('runs restoreSecrets -> materialise -> register -> deploy in that order', async () => {
const { calls, steps } = recorder();
await runRedeployStack(steps);
expect(calls).toEqual(['restoreSecrets', 'materialise', 'register', 'deploy']);
});
it('registers the stack BEFORE deploying (register precedes deploy)', async () => {
const { calls, steps } = recorder();
await runRedeployStack(steps);
expect(calls.indexOf('register')).toBeLessThan(calls.indexOf('deploy'));
});
it('still registers even when deploy fails - the stack survives as editable/internal', async () => {
const { calls, steps } = recorder({
deploy: async () => { calls.push('deploy'); throw new Error('docker compose up failed'); }
});
await expect(runRedeployStack(steps)).rejects.toThrow('docker compose up failed');
// register ran (before the throw); this is the whole fix - a failed deploy does not
// cost the user their managed stack.
expect(calls).toContain('register');
expect(calls.indexOf('register')).toBeLessThan(calls.indexOf('deploy'));
});
it('hard-errors and does NOT register when materialise reports no files landed', async () => {
const { calls, steps } = recorder({ materialise: async () => { calls.push('materialise'); return false; } });
await expect(runRedeployStack(steps)).rejects.toThrow('could not materialise');
expect(calls).not.toContain('register');
expect(calls).not.toContain('deploy');
});
it('propagates a materialise throw before registering', async () => {
const { calls, steps } = recorder({ materialise: async () => { throw new Error('restic boom'); } });
await expect(runRedeployStack(steps)).rejects.toThrow('restic boom');
expect(calls).not.toContain('register');
});
});
+210
View File
@@ -0,0 +1,210 @@
/**
* Unit tests for backups/repo.ts — repository lifecycle ops + failure
* classification, driven by an injected restic runner.
*/
import { describe, it, expect } from 'bun:test';
import {
classifyRepoFailure,
initRepository,
testRepository,
checkRepository,
pruneRepository,
unlockRepository,
rotateDestinationPassword,
type ResticLocal,
type RotatePorts,
} from '../../src/lib/server/backups/repo';
import type { ResticRun } from '../../src/lib/server/backups/models';
/** A runner that captures its args and returns a fixed run. */
function runner(run: ResticRun): ResticLocal & { lastArgs: string[]; lastTier?: string } {
const box: any = {
lastArgs: [],
async runLocal(_dest: any, args: string[], tier?: string) {
box.lastArgs = args;
box.lastTier = tier;
return run;
},
};
return box;
}
const ok = (stdout = 'ok'): ResticRun => ({ exitCode: 0, stdout, stderr: '' });
const fail = (exitCode: number | undefined, stderr: string): ResticRun => ({ exitCode, stdout: '', stderr });
describe('classifyRepoFailure', () => {
it('exit 10 → REPO_NOT_INITIALIZED', () => {
expect(classifyRepoFailure(fail(10, 'x')).code).toBe('REPO_NOT_INITIALIZED');
});
it('"is not a restic repository" → REPO_NOT_INITIALIZED', () => {
expect(classifyRepoFailure(fail(1, 'Fatal: is not a restic repository')).code).toBe('REPO_NOT_INITIALIZED');
});
it('wrong password → WRONG_PASSWORD', () => {
expect(classifyRepoFailure(fail(1, 'wrong password or no key found')).code).toBe('WRONG_PASSWORD');
});
it('locked → REPO_LOCKED', () => {
expect(classifyRepoFailure(fail(1, 'repository is already locked exclusively')).code).toBe('REPO_LOCKED');
});
it('undefined exit → RESTIC unknown outcome', () => {
expect(classifyRepoFailure(fail(undefined, '')).code).toBe('RESTIC');
expect(classifyRepoFailure(fail(undefined, '')).error).toMatch(/unknown outcome/);
});
it('other failure → RESTIC with the stderr', () => {
const c = classifyRepoFailure(fail(1, 'some other error'));
expect(c.code).toBe('RESTIC');
expect(c.error).toBe('some other error');
});
});
describe('initRepository', () => {
it('ok on a fresh init', async () => {
expect(await initRepository(runner(ok('created restic repository')), {})).toEqual({ ok: true, output: 'repository initialised' });
});
it('treats "already initialized" as success', async () => {
const r = await initRepository(runner(fail(1, 'Fatal: config file already exists / already initialized')), {});
expect(r.ok).toBe(true);
});
it('classifies a real failure', async () => {
const r = await initRepository(runner(fail(1, 'permission denied')), {});
expect(r.ok).toBe(false);
});
});
describe('testRepository', () => {
it('reads the repo config (cat config)', async () => {
const run = runner(ok());
await testRepository(run, {});
expect(run.lastArgs).toEqual(['cat', 'config', '--no-lock']);
});
it('reports not-initialised clearly', async () => {
const r = await testRepository(runner(fail(10, '')), {});
expect(r.ok).toBe(false);
if (!r.ok) expect(r.code).toBe('REPO_NOT_INITIALIZED');
});
});
describe('checkRepository', () => {
it('structure-only check omits --read-data-subset', async () => {
const run = runner(ok());
await checkRepository(run, {});
expect(run.lastArgs).toEqual(['check', '--no-lock']);
});
it('data check adds the subset and uses the long timeout tier', async () => {
const run = runner(ok());
await checkRepository(run, {}, '5%');
expect(run.lastArgs).toContain('--read-data-subset');
expect(run.lastArgs).toContain('5%');
expect(run.lastTier).toBe('data');
});
});
describe('pruneRepository / unlockRepository', () => {
it('prune uses retry-lock and the long tier, optional max-unused', async () => {
const run = runner(ok());
await pruneRepository(run, {}, '10%');
expect(run.lastArgs).toContain('--retry-lock');
expect(run.lastArgs).toContain('--max-unused');
expect(run.lastArgs).toContain('10%');
expect(run.lastTier).toBe('data');
});
it('unlock defaults to --remove-all (the explicit user Unlock action)', async () => {
// --remove-all clears locks left by a crashed/killed helper whose container-
// hostname restic won't age out as stale for 30min. Only the explicit button.
const run = runner(ok());
await unlockRepository(run, {});
expect(run.lastArgs).toEqual(['unlock', '--remove-all']);
});
it('unlock with removeAll=false is a plain unlock (automatic/scheduled path)', async () => {
// The scheduled auto-unlock MUST NOT --remove-all: on a shared repo it would wipe
// a live foreign lock of another instance's in-flight backup. Plain unlock reaps
// only provably-stale locks and leaves a live one alone.
const run = runner(ok());
await unlockRepository(run, {}, false);
expect(run.lastArgs).toEqual(['unlock']);
expect(run.lastArgs).not.toContain('--remove-all');
});
});
describe('rotateDestinationPassword', () => {
const CURRENT = 'current-password';
/** Build injectable ports over a fake restic + destination store. */
function ports(opts: {
restic: ResticLocal;
current?: string;
updateThrows?: boolean;
}): RotatePorts & { updated: string | null; serializedCount: number } {
const box: any = {
updated: null,
serializedCount: 0,
restic: opts.restic,
async getDecryptedDestination(id: number) {
return { id, decryptedPassword: opts.current ?? CURRENT };
},
async updatePassword(_id: number, password: string) {
if (opts.updateThrows) throw new Error('db write failed');
box.updated = password;
},
async serializeDestination(_id: number, fn: () => Promise<any>) {
box.serializedCount++;
return fn();
},
};
return box;
}
it('rejects a new password shorter than 8 characters (no restic call)', async () => {
const run = runner(ok());
const r = await rotateDestinationPassword(ports({ restic: run }), 1, CURRENT, 'short');
expect(r.ok).toBe(false);
expect(run.lastArgs).toEqual([]); // restic never ran
});
it('rejects a new password equal to the current', async () => {
const run = runner(ok());
const r = await rotateDestinationPassword(ports({ restic: run }), 1, CURRENT, CURRENT);
expect(r).toEqual({ ok: false, error: 'New password must differ from current password' });
expect(run.lastArgs).toEqual([]);
});
it('rejects a wrong current password (constant-time compare) without touching restic', async () => {
const run = runner(ok());
const r = await rotateDestinationPassword(ports({ restic: run }), 1, 'wrong-current', 'a-new-password');
expect(r).toEqual({ ok: false, error: 'Current password does not match' });
expect(run.lastArgs).toEqual([]);
});
it('reports destination-not-found', async () => {
const p = ports({ restic: runner(ok()) });
p.getDecryptedDestination = async () => null;
const r = await rotateDestinationPassword(p, 1, CURRENT, 'a-new-password');
expect(r).toEqual({ ok: false, error: 'Destination not found' });
});
it('happy path: runs restic key passwd (serialized) then persists', async () => {
const run = runner(ok());
const p = ports({ restic: run });
const r = await rotateDestinationPassword(p, 1, CURRENT, 'a-new-password');
expect(r).toEqual({ ok: true });
expect(run.lastArgs.slice(0, 2)).toEqual(['key', 'passwd']);
expect(run.lastArgs).toContain('--new-password-file');
expect(p.updated).toBe('a-new-password');
expect(p.serializedCount).toBe(1);
});
it('surfaces a restic failure and does NOT persist', async () => {
const run = runner(fail(1, 'wrong password or no key found'));
const p = ports({ restic: run });
const r = await rotateDestinationPassword(p, 1, CURRENT, 'a-new-password');
expect(r.ok).toBe(false);
expect(p.updated).toBeNull();
});
it('flags dbOutOfSync when restic succeeds but the DB update throws', async () => {
const run = runner(ok());
const p = ports({ restic: run, updateThrows: true });
const r = await rotateDestinationPassword(p, 1, CURRENT, 'a-new-password');
expect(r.ok).toBe(false);
if (!r.ok) expect(r.dbOutOfSync).toBe(true);
});
});
+33
View File
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'bun:test';
import { computeRequestTimeoutMs } from '../../src/lib/server/backups/request-timeout';
const base = { path: '/containers/x/json', streamingBody: false, streamingResponse: false };
describe('computeRequestTimeoutMs - idle-timeout policy', () => {
it('a normal small request gets the 30s idle timeout', () => {
expect(computeRequestTimeoutMs(base)).toBe(30000);
});
it('a STREAMING BODY (large tar PUT) gets NO timeout, even for a small response (the #9 fix)', () => {
expect(computeRequestTimeoutMs({ path: '/containers/x/archive', streamingBody: true, streamingResponse: false })).toBeNull();
});
it('a STREAMING RESPONSE (logs/events) gets NO timeout', () => {
expect(computeRequestTimeoutMs({ ...base, streamingResponse: true })).toBeNull();
});
it('a prune request gets 300s (it can take a while)', () => {
expect(computeRequestTimeoutMs({ ...base, path: '/images/prune' })).toBe(300000);
expect(computeRequestTimeoutMs({ ...base, path: '/containers/prune' })).toBe(300000);
});
it('a compose operation gets the COMPOSE_TIMEOUT (default 900s)', () => {
expect(computeRequestTimeoutMs({ ...base, path: '/_hawser/compose' })).toBe(900000);
expect(computeRequestTimeoutMs({ ...base, path: '/_hawser/compose', composeTimeoutSecs: 1800 })).toBe(1800000);
});
it('streaming beats the path-based bumps (a streamed prune/compose still has no timeout)', () => {
expect(computeRequestTimeoutMs({ path: '/containers/prune', streamingBody: true, streamingResponse: false })).toBeNull();
expect(computeRequestTimeoutMs({ path: '/_hawser/compose', streamingBody: false, streamingResponse: true })).toBeNull();
});
});
+291
View File
@@ -0,0 +1,291 @@
/**
* Unit tests for backups/restic-script.ts — the pure shell-command builders and
* exit-marker reader used by the restic helper container path. No mocks.
*/
import { describe, it, expect } from 'bun:test';
import {
EXIT_MARKER,
shellQuote,
resticCommand,
finishScript,
readExitMarker,
buildHelperEnv,
buildHelperBinds,
localRepoGuard,
localRepoChown,
classifyProcClose,
classifyProcError,
gcsCredentialPreamble,
GCS_CRED_FILE,
} from '../../src/lib/server/backups/restic-script';
describe('shellQuote', () => {
it('wraps a plain string in single quotes', () => {
expect(shellQuote('data')).toBe(`'data'`);
});
it('escapes embedded single quotes (no shell injection)', () => {
expect(shellQuote(`a'b`)).toBe(`'a'\\''b'`);
});
it('neutralises shell metacharacters by quoting', () => {
expect(shellQuote('$(rm -rf /)')).toBe(`'$(rm -rf /)'`);
expect(shellQuote('a; b')).toBe(`'a; b'`);
});
});
describe('resticCommand', () => {
it('builds a restic command with every arg quoted', () => {
expect(resticCommand(['backup', '/volumes/', '--tag', 'x'])).toBe(
`restic 'backup' '/volumes/' '--tag' 'x'`
);
});
it('quotes an arg that contains a single quote', () => {
expect(resticCommand(['--host', `o'brien`])).toBe(`restic '--host' 'o'\\''brien'`);
});
});
describe('finishScript + readExitMarker (surface the REAL restic exit code)', () => {
it('appends the exit marker capturing $?', () => {
const s = finishScript(`restic 'backup'`);
expect(s).toContain('restic \'backup\';');
expect(s).toContain('__rc=$?');
expect(s).toContain(`echo "${EXIT_MARKER}`);
});
it('reads the exit code from the marker line', () => {
expect(readExitMarker(`some output\n${EXIT_MARKER}0`)).toBe(0);
expect(readExitMarker(`${EXIT_MARKER}3`)).toBe(3); // partial-backup code preserved
expect(readExitMarker(`x\n${EXIT_MARKER}1\ny`)).toBe(1);
});
it('reads the LAST marker when output has trailing lines', () => {
expect(readExitMarker(`${EXIT_MARKER}1\ntrailing\n${EXIT_MARKER}0`)).toBe(0);
});
// A caller-supplied script (e.g. the in-place restore swap) uses `set -e`.
// Wrapping it as `finishScript('( <script> )')` must still emit the marker so
// a clean run isn't misread as a failure (no marker → undefined exit → error).
it('captures a set -e script exit via the marker (no false failure on success)', () => {
const swap = 'set -e; echo recover; echo restic; echo swap';
const cmd = finishScript(`( ${swap} )`);
expect(cmd).toContain('__rc=$?');
expect(cmd).toContain(`echo "${EXIT_MARKER}`);
// A successful run prints its output then the marker with 0.
expect(readExitMarker(`recover\nrestic\nswap\n${EXIT_MARKER}0`)).toBe(0);
// A set -e abort mid-script surfaces the real non-zero code.
expect(readExitMarker(`recover\n${EXIT_MARKER}1`)).toBe(1);
});
it('returns undefined when the marker is absent (killed/crashed ⇒ unknown outcome)', () => {
expect(readExitMarker('restic ran but was killed mid-output')).toBeUndefined();
expect(readExitMarker('')).toBeUndefined();
});
it('returns undefined for a malformed marker value', () => {
expect(readExitMarker(`${EXIT_MARKER}notanumber`)).toBeUndefined();
});
// Prove the shell contract in a REAL shell: finishScript preserves a
// command's exit code as the marker value AND the wrapper itself exits 0
// (so the docker layer never sees a "failed" container for a mere restic
// non-zero). We run the generated script by writing it to a temp file to
// avoid any nested-quoting hazard.
function runFinished(command: string): { markerCode: number | undefined; wrapperExit: number } {
const { execFileSync } = require('child_process');
const { mkdtempSync, writeFileSync, rmSync } = require('fs');
const { tmpdir } = require('os');
const { join } = require('path');
const dir = mkdtempSync(join(tmpdir(), 'restic-script-'));
const file = join(dir, 'run.sh');
writeFileSync(file, finishScript(command));
try {
let wrapperExit = 0;
let out = '';
try {
out = execFileSync('sh', [file], { encoding: 'utf8' });
} catch (e: any) {
wrapperExit = e.status ?? 1;
out = e.stdout ?? '';
}
return { markerCode: readExitMarker(out), wrapperExit };
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
// Use a subshell `(exit N)` — like restic, it RETURNS a non-zero status
// without terminating the wrapper shell (a bare `exit N` would end the whole
// script before the marker prints; restic never does that).
it('a command returning 3 is reported as 3, and the wrapper still exits 0', () => {
const { markerCode, wrapperExit } = runFinished('(exit 3)');
expect(markerCode).toBe(3);
expect(wrapperExit).toBe(0); // docker layer must NOT see this as a crash
});
it('a successful command yields marker 0, wrapper 0', () => {
expect(runFinished('true')).toEqual({ markerCode: 0, wrapperExit: 0 });
});
it('a command returning 1 is reported as 1, wrapper still 0', () => {
expect(runFinished('(exit 1)')).toEqual({ markerCode: 1, wrapperExit: 0 });
});
});
// The dump path (dumpSnapshotFileBytes / dumpSnapshotArchive) captures restic's
// stdout with Buffer.concat rather than string concatenation. This asserts the
// underlying contract that makes that fix correct: appending Buffer chunks to a
// JS string mangles non-UTF-8 bytes (each → U+FFFD, and multibyte sequences split
// across chunks), while Buffer.concat is lossless. binary.bin in the integration
// suite uses exactly these bytes.
describe('binary stdout capture — Buffer.concat is lossless where string concat is not', () => {
const SENTINEL = Buffer.from([0xff, 0xfe, 0x00, 0x81, 0x82]);
it('Buffer.concat preserves every byte across chunk boundaries', () => {
// Simulate two data events splitting the sentinel mid-stream.
const chunks = [SENTINEL.subarray(0, 2), SENTINEL.subarray(2)];
const joined = Buffer.concat(chunks);
expect(Array.from(joined)).toEqual([0xff, 0xfe, 0x00, 0x81, 0x82]);
});
it('the old string-concat path corrupts the same bytes (regression rationale)', () => {
// `stdout += d` coerces each Buffer to a UTF-8 string; re-encoding loses the
// original bytes. This is the bug the binary path fixes.
let s = '';
s += SENTINEL.subarray(0, 2);
s += SENTINEL.subarray(2);
const roundTripped = Array.from(Buffer.from(s, 'utf8'));
expect(roundTripped).not.toEqual([0xff, 0xfe, 0x00, 0x81, 0x82]);
});
});
describe('buildHelperEnv', () => {
it('sets repo, password, progress, plus allowlisted cloud vars', () => {
const env = buildHelperEnv('s3:bucket', 'pw', { AWS_ACCESS_KEY_ID: 'k' });
expect(env).toContain('RESTIC_REPOSITORY=s3:bucket');
expect(env).toContain('RESTIC_PASSWORD=pw');
expect(env).toContain('RESTIC_PROGRESS_FPS=2');
expect(env).toContain('AWS_ACCESS_KEY_ID=k');
});
it('is caller-filtered — it does NOT re-filter, so pass the allowlisted map', () => {
// The caller passes filterCloudEnvVars(...); this fn trusts that. Given an
// empty map, no cloud vars are added.
const env = buildHelperEnv('r', 'p', {});
expect(env).toHaveLength(3);
});
});
describe('buildHelperBinds', () => {
const resolve = (p: string) => `/host${p}`;
it('passes volume binds through unchanged for a remote repo', () => {
const binds = buildHelperBinds('s3:bucket', ['vol:/volumes/vol:ro'], resolve);
expect(binds).toEqual(['vol:/volumes/vol:ro']);
});
it('adds the repo path bind for a local (filesystem) repo', () => {
const binds = buildHelperBinds('/srv/restic', ['vol:/volumes/vol:ro'], resolve);
expect(binds).toEqual(['vol:/volumes/vol:ro', '/host/srv/restic:/srv/restic']);
});
it('does not mutate the caller array', () => {
const input = ['a:/volumes/a:ro'];
buildHelperBinds('/srv/restic', input, resolve);
expect(input).toEqual(['a:/volumes/a:ro']);
});
});
describe('localRepoGuard (#1316 fail-loud on wrong-host mount)', () => {
it('emits a config-existence check for a local (filesystem) repo', () => {
const g = localRepoGuard('/mnt/nas/backups');
expect(g).toContain('test -f "$RESTIC_REPOSITORY/config"');
expect(g).toContain('exit 1');
expect(g.endsWith('; ')).toBe(true); // trailing separator so it prefixes the restic command cleanly
});
it('emits nothing for a cloud / rest repo (no path is bound)', () => {
expect(localRepoGuard('s3:s3.amazonaws.com/bucket')).toBe('');
expect(localRepoGuard('rest:http://host:8000/')).toBe('');
expect(localRepoGuard('b2:bucket:path')).toBe('');
});
it('a relative ./ path is NOT local (matches the save gate)', () => {
// A relative ./ path is NOT local: isAllowedRepository (the single save gate) accepts
// only absolute local paths, and all local-repo sites share isLocalRepo now, so guard
// agrees - no path bound/guarded for a ./ repo.
expect(localRepoGuard('./data/repo')).toBe('');
});
});
describe('localRepoChown (helper writes repo as root; re-own for the main uid)', () => {
it('emits a recursive chown to the given owner for a local repo', () => {
const c = localRepoChown('/mnt/nas/backups', '1001:1001');
expect(c).toContain('chown -R 1001:1001 "$RESTIC_REPOSITORY"');
expect(c).toContain('|| true'); // best-effort: never fails the backup
expect(c.startsWith('; ')).toBe(true); // appends AFTER the exit marker
});
it('a relative ./ path is NOT local (matches the save gate)', () => {
// ./ is not local (see localRepoGuard above) - no repo files to chown for it.
expect(localRepoChown('./data/repo', '1000:1000')).toBe('');
});
it('emits nothing for a cloud / rest repo (no local files to own)', () => {
expect(localRepoChown('s3:s3.amazonaws.com/bucket', '1001:1001')).toBe('');
expect(localRepoChown('rest:http://host:8000/', '1001:1001')).toBe('');
expect(localRepoChown('b2:bucket:path', '1001:1001')).toBe('');
});
it('emits nothing when ownerSpec is empty (uid unknown)', () => {
expect(localRepoChown('/mnt/nas/backups', '')).toBe('');
});
it('PUID=0 (root) chowns to 0:0 — a harmless no-op on an already-root repo', () => {
expect(localRepoChown('/mnt/nas/backups', '0:0')).toContain('chown -R 0:0');
});
});
// The fail-closed exit/stderr mapping the runLocal / runLocalBinary spawn callbacks use.
// The critical property: a clean exit is the ONLY thing that yields a defined exitCode;
// a timeout kill (code null + signal) and a spawn error both map to undefined = failure,
// so an unknown outcome is never mistaken for success.
describe('classifyProcClose (spawn close → fail-closed exit/stderr)', () => {
it('a clean exit 0 is exitCode 0, stderr unchanged', () => {
expect(classifyProcClose(0, null, 'warnings')).toEqual({ exitCode: 0, stderr: 'warnings' });
});
it('a non-zero exit is preserved (e.g. restic 3 = partial)', () => {
expect(classifyProcClose(3, null, '')).toEqual({ exitCode: 3, stderr: '' });
expect(classifyProcClose(1, null, 'err')).toEqual({ exitCode: 1, stderr: 'err' });
});
it('code === null (timeout kill) → undefined exit (unknown outcome = failure)', () => {
const r = classifyProcClose(null, 'SIGTERM', 'partial output');
expect(r.exitCode).toBeUndefined();
});
it('a killing signal is appended to stderr so it is visible', () => {
const r = classifyProcClose(null, 'SIGKILL', 'before-kill');
expect(r.stderr).toBe('before-kill\n(process killed by SIGKILL)');
});
it('no signal leaves stderr untouched (no spurious "killed by" line)', () => {
expect(classifyProcClose(0, null, 'x').stderr).toBe('x');
});
});
describe('classifyProcError (spawn error → fail-closed data, never a throw)', () => {
it('a spawn error (restic missing) is undefined exit with the message appended', () => {
const r = classifyProcError(new Error('spawn restic ENOENT'), 'prior stderr');
expect(r.exitCode).toBeUndefined();
expect(r.stderr).toBe('prior stderr\nspawn restic ENOENT');
});
it('appends onto empty stderr cleanly', () => {
expect(classifyProcError(new Error('boom'), '').stderr).toBe('\nboom');
});
});
describe('gcsCredentialPreamble', () => {
const p = gcsCredentialPreamble();
it('is guarded on the JSON var being non-empty (no-op for other backends)', () => {
// The whole thing is inside `if [ -n "${GOOGLE_APPLICATION_CREDENTIALS_JSON:-}" ]`,
// so an S3/B2/Azure/local run (var unset) does nothing.
expect(p).toContain('if [ -n "${GOOGLE_APPLICATION_CREDENTIALS_JSON:-}" ]');
expect(p.trim().endsWith('fi;')).toBe(true);
});
it('writes the JSON to the private cred file and exports the path', () => {
expect(p).toContain(`printf '%s' "$GOOGLE_APPLICATION_CREDENTIALS_JSON" > ${GCS_CRED_FILE}`);
expect(p).toContain(`export GOOGLE_APPLICATION_CREDENTIALS=${GCS_CRED_FILE}`);
});
it('sets a restrictive umask so the cred file is not world-readable', () => {
expect(p).toContain('umask 077');
});
it('ends with a separator so it can be prepended before the next command', () => {
expect(p.endsWith('; ')).toBe(true);
});
});
+109
View File
@@ -0,0 +1,109 @@
/**
* Unit tests for backups/restore-core.ts — resolving a snapshot volume `key`
* back to the live bind an in-place restore writes to. No docker, no DB.
*
* The safety property under test: a BIND key must resolve to its ABSOLUTE host
* path, never a slug. A slug source would make docker auto-create a fresh named
* volume, so the restore would write to an orphan and never touch the real host
* path — silent data loss. When the source can't be recovered we must THROW.
*/
import { describe, it, expect } from 'bun:test';
import { resolveBindFromMetadata, type SnapshotMetadata } from '../../src/lib/server/backups/restore-core';
import { BackupError } from '../../src/lib/server/backups/models';
describe('resolveBindFromMetadata — enriched volumes[]', () => {
it('a bind key resolves to <absoluteHostSource>:/volumes/<key>:rw (never a slug)', () => {
const meta: SnapshotMetadata = {
volumes: [{ key: 'data_config', name: '/data/config', source: '/data/config', type: 'bind' }],
};
const { bind, include } = resolveBindFromMetadata(meta, 'data_config');
expect(bind).toBe('/data/config:/volumes/data_config:rw');
expect(include).toBe('/volumes/data_config');
// the source (part before the first colon) must be an absolute host path
expect(bind.split(':')[0].startsWith('/')).toBe(true);
});
it('a named-volume key resolves to <name>:/volumes/<name>:rw', () => {
const meta: SnapshotMetadata = {
volumes: [{ key: 'pgdata', name: 'pgdata', source: 'pgdata', type: 'volume' }],
};
expect(resolveBindFromMetadata(meta, 'pgdata')).toEqual({
bind: 'pgdata:/volumes/pgdata:rw',
include: '/volumes/pgdata',
source: 'pgdata',
type: 'volume',
});
});
it('THROWS if an enriched bind entry has a non-absolute source (never slug-binds)', () => {
const meta: SnapshotMetadata = {
volumes: [{ key: 'data_config', name: '/data/config', source: 'data_config', type: 'bind' }],
};
expect(() => resolveBindFromMetadata(meta, 'data_config')).toThrow(BackupError);
});
});
describe('resolveBindFromMetadata — recovery from stored docker inspect Mounts', () => {
it('recovers a bind source from container.Mounts by matching the destination slug', () => {
const meta: SnapshotMetadata = {
container: { Mounts: [{ Type: 'bind', Source: '/srv/app', Destination: '/etc/app' }] },
};
// safeKey('/etc/app') === 'etc_app'
expect(resolveBindFromMetadata(meta, 'etc_app')).toEqual({
bind: '/srv/app:/volumes/etc_app:rw',
include: '/volumes/etc_app',
source: '/srv/app',
type: 'bind',
});
});
it('recovers a named volume source from container.Mounts by name', () => {
const meta: SnapshotMetadata = {
container: { Mounts: [{ Type: 'volume', Name: 'pgdata', Destination: '/var/lib/pg' }] },
};
expect(resolveBindFromMetadata(meta, 'pgdata')).toEqual({
bind: 'pgdata:/volumes/pgdata:rw',
include: '/volumes/pgdata',
source: 'pgdata',
type: 'volume',
});
});
});
describe('resolveBindFromMetadata — fail loud when a bind source cannot be recovered', () => {
it('THROWS for a bind key present only as a legacy destination string (no source stored)', () => {
// Pre-enrichment snapshots stored volumes as string[] of names/destinations.
// '/data/config' slugs to 'data_config' but carries no source ⇒ must refuse.
const meta: SnapshotMetadata = { volumes: ['/data/config'] };
expect(() => resolveBindFromMetadata(meta, 'data_config')).toThrow(BackupError);
expect(() => resolveBindFromMetadata(meta, 'data_config')).toThrow(/cannot resolve the original location/);
});
it('THROWS for a bind key from an inspect Mount with no Source', () => {
const meta: SnapshotMetadata = {
container: { Mounts: [{ Type: 'bind', Destination: '/etc/app' }] },
};
expect(() => resolveBindFromMetadata(meta, 'etc_app')).toThrow(BackupError);
});
});
describe('resolveBindFromMetadata — named-volume fallback (key === name, no ambiguity)', () => {
it('a legacy named-volume key (string equals key) binds directly', () => {
const meta: SnapshotMetadata = { volumes: ['pgdata'] };
expect(resolveBindFromMetadata(meta, 'pgdata')).toEqual({
bind: 'pgdata:/volumes/pgdata:rw',
include: '/volumes/pgdata',
source: 'pgdata',
type: 'volume',
});
});
it('with no metadata at all, a bare key binds directly as a named volume', () => {
expect(resolveBindFromMetadata(null, 'pgdata')).toEqual({
bind: 'pgdata:/volumes/pgdata:rw',
include: '/volumes/pgdata',
source: 'pgdata',
type: 'volume',
});
});
});
+301
View File
@@ -0,0 +1,301 @@
/**
* Unit tests for backups/restore-script.ts. The new-location builder is a simple
* argv check. The in-place builder is verified structurally AND with a real-fs
* round-trip that models restic (writing into <staging>/volumes/<name>) and
* proves the flatten + swap lands the restored contents in the live root.
*/
import { describe, it, expect } from 'bun:test';
import { buildInPlaceRestore, buildNewLocationRestore, buildCloneRestore, cloneStagingName } from '../../src/lib/server/backups/restore-script';
import { SWAP_NEW, SWAP_ARTIFACTS } from '../../src/lib/server/backups/swap';
describe('buildNewLocationRestore', () => {
it('restores the requested includes into the fresh target', () => {
const a = buildNewLocationRestore('snap', '/restore/here', ['/volumes/data'], []);
expect(a).toEqual(['restore', '--json', 'snap', '--target', '/restore/here', '--include', '/volumes/data']);
});
it('restores the whole snapshot when no includes given, and appends flags', () => {
const a = buildNewLocationRestore('snap', '/out', [], ['--verbose']);
expect(a).toEqual(['restore', '--json', 'snap', '--target', '/out', '--verbose']);
});
});
describe('buildInPlaceRestore — structure', () => {
it('runs under set -e, restores into the staging dir, and includes the swap', () => {
const script = buildInPlaceRestore('snap', ['/volumes/data'], []);
expect(script).toMatch(/^set -e;/);
expect(script).toContain(SWAP_NEW);
expect(script).toContain("restic 'restore'");
expect(script).toContain("'--target'");
});
it('references every swap artifact (so the swap + backup exclude stay in sync)', () => {
const script = buildInPlaceRestore('snap', ['/volumes/data'], []);
for (const a of SWAP_ARTIFACTS) expect(script).toContain(a);
});
it('DIR branch clears the staging dir BEFORE restic (no stale-merge from a killed prior restore)', () => {
// restic restore has no --delete: without wiping the staging dir first, a marker-less
// leftover from a previously-killed restore would keep files not in this snapshot and the
// swap would commit them as "restored". The rm -rf must precede the restic restore.
const script = buildInPlaceRestore('snap', ['/volumes/data'], []);
const rmIdx = script.indexOf(`rm -rf '${SWAP_NEW}'`) >= 0
? script.indexOf(`rm -rf '${SWAP_NEW}'`)
: script.search(new RegExp(`rm -rf '[^']*${SWAP_NEW}[^']*'`));
const resticIdx = script.indexOf("restic 'restore'");
expect(rmIdx).toBeGreaterThanOrEqual(0);
expect(resticIdx).toBeGreaterThanOrEqual(0);
expect(rmIdx).toBeLessThan(resticIdx); // staging wiped before restore
});
it('single-file branch guards existence BEFORE overwriting the live file (never truncate on empty restore)', () => {
// `cat X > live` truncates `live` before cat runs; if the restored file is missing
// (restic exit 0 but nothing extracted) the live user file would be zeroed then the
// command fails - destroying data. The file branch must check `[ -f <restored> ]`
// first and refuse, leaving the live file untouched.
const script = buildInPlaceRestore('snap', ['/volumes/etc_app_cfg.yaml'], []);
expect(script).toMatch(/if \[ -f [^\]]+ \]; then cat .+ > /);
expect(script).toContain('refusing to truncate live file');
});
});
describe('buildCloneRestore — structure', () => {
it('runs under set -e, extracts into an isolated staging dir (never rm -rf a user dir at the mount)', () => {
const script = buildCloneRestore('snap', ['/volumes/data'], []);
expect(script).toMatch(/^set -e;/);
expect(script).toContain("restic 'restore'");
// restic targets the staging dir INSIDE the mount, not the mount root itself.
expect(script).toContain("'--target' '/volumes/data/.dockhand-clone-snap'");
expect(script).toContain("'--include' '/volumes/data'");
// The ONLY rm is of our own staging dir — NEVER '<mount>/volumes' (which could
// be a user's own directory at a bind destination).
expect(script).toContain("rm -rf '/volumes/data/.dockhand-clone-snap'");
expect(script).not.toContain("rm -rf '/volumes/data/volumes'");
// Not the in-place swap machinery.
expect(script).not.toContain(SWAP_NEW);
for (const a of SWAP_ARTIFACTS) expect(script).not.toContain(a);
});
it('handles multiple mount roots', () => {
const script = buildCloneRestore('snap', ['/volumes/a', '/volumes/b'], ['--verbose']);
expect(script).toContain("'--target' '/volumes/a/.dockhand-clone-snap'");
expect(script).toContain("'--target' '/volumes/b/.dockhand-clone-snap'");
expect(script).toContain("'--verbose'");
});
it('NEVER passes restic --delete — a restore ADDS to the destination, never wipes it', () => {
// Data-safety guard: when a bind destination points at a host path that
// already has the user's files, restic must overlay the snapshot's files on
// top, not delete anything that isn't in the snapshot. `restic restore`
// without --delete does exactly that; assert we never add the flag.
const script = buildCloneRestore('snap', ['/volumes/data'], []);
expect(script).not.toContain('--delete');
});
it('gives DIFFERENT snapshots DIFFERENT staging dirs (concurrent clones to one path never collide)', () => {
// Two clones into the same host path from different snapshots would otherwise
// share one staging dir, and one's cleanup would wipe the other's files.
const a = cloneStagingName('aaaaaaaaaaaa1111');
const b = cloneStagingName('bbbbbbbbbbbb2222');
expect(a).not.toBe(b);
expect(a.startsWith('.dockhand-clone-')).toBe(true);
// It's inert to path traversal / shell metachars in the snapshot id.
expect(cloneStagingName('../../etc/passwd')).toBe('.dockhand-clone-etcpasswd');
});
});
// A single-FILE bind (./cfg.yaml:/x) mounts file-on-file, so the live volume root is a
// FILE and an ACTIVE mount point — it can be written through but NOT renamed over (`mv`
// -> "Device or resource busy"). The old script mkdir'd a staging dir INSIDE it (`mkdir
// <file>: not a directory`) and the restore FAILED (found by the data-in-stack-folder
// matrix). The builder now branches at runtime: a file restores into a sibling temp and
// overwrites the live file's CONTENT via `cat` (writes through the mount).
describe('buildInPlaceRestore — single-file bind', () => {
it('emits a file branch that does not mkdir a staging dir inside the live path', () => {
const script = buildInPlaceRestore('snap', ['/volumes/etc_cfg.yaml'], []);
// Runtime dir/file branch.
expect(script).toContain("if [ -d '/volumes/etc_cfg.yaml' ]; then");
// The file branch restores into a SIBLING temp under /volumes, never inside the file.
expect(script).toContain("'--target' '/volumes/.dockhand-restore-file-etc_cfg.yaml'");
// And commits by writing content THROUGH the mount (cat), NOT renaming (which is busy).
expect(script).toContain("cat '/volumes/.dockhand-restore-file-etc_cfg.yaml/volumes/etc_cfg.yaml' > '/volumes/etc_cfg.yaml'");
expect(script).not.toContain("mv -f '/volumes/.dockhand-restore-file");
});
it('real-fs round-trip: replaces a live FILE with the restored one, no leftovers', () => {
const { execFileSync } = require('child_process');
const { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync } = require('fs');
const { tmpdir } = require('os');
const { join } = require('path');
// Sandbox: a /volumes dir with the live file to be replaced.
const root = mkdtempSync(join(tmpdir(), 'filebind-'));
const volumes = join(root, 'volumes');
mkdirSync(volumes, { recursive: true });
const live = join(volumes, 'cfg');
writeFileSync(live, 'LIVE-OLD\n');
// Build for the sandbox path, then stub restic. Real restic recreates the include's
// FULL path under --target, i.e. writes to <target><live>. Model that faithfully so
// the script's `cat <target><live> > <live>` finds the file where it expects it.
const script = buildInPlaceRestore('snap', [live], []);
const stagingTarget = `${volumes}/.dockhand-restore-file-cfg`;
const mockRestic = `mkdir -p "$(dirname '${stagingTarget}${live}')"; printf 'RESTORED-NEW' > '${stagingTarget}${live}'`;
const runnable = script.replace(/restic 'restore'[^;]*/g, mockRestic);
const file = join(root, 'run.sh');
writeFileSync(file, runnable);
try {
execFileSync('sh', [file], { stdio: 'pipe' });
// The live file now holds the restored content...
expect(readFileSync(live, 'utf8')).toBe('RESTORED-NEW');
// ...and nothing but `cfg` remains in /volumes (temp dir cleaned up).
expect(readdirSync(volumes)).toEqual(['cfg']);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
describe('buildCloneRestore — real-fs round-trip (models restic writing to <root>/volumes/<name>)', () => {
const { execFileSync } = require('child_process');
const { mkdtempSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync } = require('fs');
const { tmpdir } = require('os');
const { join } = require('path');
it('flattens the restic output up to the mount root (no swap, fresh volume)', () => {
// The mount root in production is `/volumes/<name>` (the helper bind dest).
// Model a sandbox whose mount root ends in /volumes/data.
const root = mkdtempSync(join(tmpdir(), 'clone-'));
const mount = join(root, 'volumes', 'data');
mkdirSync(mount, { recursive: true });
const script = buildCloneRestore('snap', [mount], []);
// Model restic `restore --target <mount>/.dockhand-clone-snap --include <mount>`:
// it recreates the include path under the target, i.e. writes to
// <staging><mount>. The script moves <staging><mount>/* up to <mount>/ then
// removes the staging dir.
const staging = `${mount}/.dockhand-clone-snap`;
const mockRestic =
`mkdir -p '${staging}${mount}'; printf DATA > '${staging}${mount}/file.txt'`;
const runnable = script.replace(/restic 'restore'[^;]*/, mockRestic);
const file = join(root, 'run.sh');
writeFileSync(file, runnable);
try {
execFileSync('sh', [file], { stdio: 'pipe' });
const names = readdirSync(mount);
expect(names).toEqual(['file.txt']);
expect(readFileSync(join(mount, 'file.txt'), 'utf8')).toBe('DATA');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('KEEPS the user\'s pre-existing files at the destination — restore only adds', () => {
// The exact data-safety property the user cares about: restoring a bind into a
// host path that already holds the user's files must PRESERVE them and add the
// snapshot's files alongside. Seed the mount root with a user file, run the
// clone restore (modelling restic writing one new file), assert both survive.
const root = mkdtempSync(join(tmpdir(), 'clone-keep-'));
const mount = join(root, 'volumes', 'data');
mkdirSync(mount, { recursive: true });
// The user's pre-existing data on the host path.
writeFileSync(join(mount, 'user-keep.txt'), 'USER_DATA');
mkdirSync(join(mount, 'user-dir'), { recursive: true });
writeFileSync(join(mount, 'user-dir', 'nested.txt'), 'USER_NESTED');
// THE edge case that motivated the staging-dir rewrite: the user has their OWN
// directory literally named `volumes` at the bind destination. The old code did
// `rm -rf <mount>/volumes` and would have wiped it. It must survive.
mkdirSync(join(mount, 'volumes'), { recursive: true });
writeFileSync(join(mount, 'volumes', 'mine.txt'), 'USER_VOLUMES_DIR');
const script = buildCloneRestore('snap', [mount], []);
// restic (no --delete) writes the snapshot's file under <staging><mount>.
const staging = `${mount}/.dockhand-clone-snap`;
const mockRestic =
`mkdir -p '${staging}${mount}'; printf FROM_BACKUP > '${staging}${mount}/restored.txt'`;
const runnable = script.replace(/restic 'restore'[^;]*/, mockRestic);
const file = join(root, 'run.sh');
writeFileSync(file, runnable);
try {
execFileSync('sh', [file], { stdio: 'pipe' });
const names = readdirSync(mount).sort();
// The user's files AND their `volumes/` dir AND the restored file all
// present; the staging dir is gone. Nothing of the user's was wiped.
expect(names).toEqual(['restored.txt', 'user-dir', 'user-keep.txt', 'volumes']);
expect(readFileSync(join(mount, 'user-keep.txt'), 'utf8')).toBe('USER_DATA');
expect(readFileSync(join(mount, 'user-dir', 'nested.txt'), 'utf8')).toBe('USER_NESTED');
expect(readFileSync(join(mount, 'volumes', 'mine.txt'), 'utf8')).toBe('USER_VOLUMES_DIR');
expect(readFileSync(join(mount, 'restored.txt'), 'utf8')).toBe('FROM_BACKUP');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('MERGES the restored tree into a pre-existing NON-EMPTY dir (the mv "Directory not empty" bug)', () => {
// Cross-env restore where the destination already has the same subdirectory
// with contents (e.g. Home Assistant `config/blueprints`). Plain `mv` refuses
// with "can't rename 'blueprints': Directory not empty"; cp -a must merge:
// keep the user's existing file, add the restored one, overwrite collisions.
const root = mkdtempSync(join(tmpdir(), 'clone-merge-'));
const mount = join(root, 'volumes', 'config');
mkdirSync(join(mount, 'blueprints'), { recursive: true });
writeFileSync(join(mount, 'blueprints', 'existing.yaml'), 'OLD');
writeFileSync(join(mount, 'blueprints', 'shared.yaml'), 'OLD_SHARED');
const script = buildCloneRestore('snap', [mount], []);
const staging = `${mount}/.dockhand-clone-snap`;
// restic restores a blueprints/ dir with a NEW file + an OVERWRITE of shared.yaml.
const mockRestic =
`mkdir -p '${staging}${mount}/blueprints'; ` +
`printf NEW > '${staging}${mount}/blueprints/restored.yaml'; ` +
`printf NEW_SHARED > '${staging}${mount}/blueprints/shared.yaml'`;
const runnable = script.replace(/restic 'restore'[^;]*/, mockRestic);
const file = join(root, 'run.sh');
writeFileSync(file, runnable);
try {
execFileSync('sh', [file], { stdio: 'pipe' }); // must NOT throw
const bp = readdirSync(join(mount, 'blueprints')).sort();
expect(bp).toEqual(['existing.yaml', 'restored.yaml', 'shared.yaml']);
expect(readFileSync(join(mount, 'blueprints', 'existing.yaml'), 'utf8')).toBe('OLD'); // kept
expect(readFileSync(join(mount, 'blueprints', 'restored.yaml'), 'utf8')).toBe('NEW'); // added
expect(readFileSync(join(mount, 'blueprints', 'shared.yaml'), 'utf8')).toBe('NEW_SHARED'); // overwritten
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
describe('buildInPlaceRestore — real-fs round-trip (models restic writing to staging/volumes/<name>)', () => {
const { execFileSync } = require('child_process');
const { mkdtempSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync } = require('fs');
const { tmpdir } = require('os');
const { join } = require('path');
it('flattens the restic output and swaps it into the live root', () => {
// The live root is always a `/volumes/<name>` path in production; the
// script's scaffold cleanup (`rm -rf <staging>/volumes`) depends on that.
// Build a sandbox whose live dir ends in /volumes/data and drive the script
// with `live` = that absolute path.
const root = mkdtempSync(join(tmpdir(), 'restore-'));
const live = join(root, 'volumes', 'data');
mkdirSync(live, { recursive: true });
writeFileSync(join(live, 'old.txt'), 'OLD');
const script = buildInPlaceRestore('snap', [live], []);
const staging = `${live}/${SWAP_NEW}`;
// Model restic `restore --target <staging> --include <live>`: it writes to
// <staging><live> (i.e. <staging>/<root>/volumes/data). The script flattens
// <staging><live>/* up to <staging> and removes the <staging>/volumes
// scaffold. Reproduce restic's output layout exactly.
const mockRestic =
`mkdir -p '${staging}${live}'; printf NEW > '${staging}${live}/restored.txt'`;
const runnable = script.replace(/restic 'restore'[^;]*/, mockRestic);
const file = join(root, 'run.sh');
writeFileSync(file, runnable);
try {
execFileSync('sh', [file], { stdio: 'pipe' });
const names = readdirSync(live).filter((n: string) => !(SWAP_ARTIFACTS as readonly string[]).includes(n));
expect(names).toEqual(['restored.txt']);
expect(readFileSync(join(live, 'restored.txt'), 'utf8')).toBe('NEW');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
+708
View File
@@ -0,0 +1,708 @@
/**
* Behavioural tests for RestoreService against a fake RestorePorts. Pins the
* restore-specific safety properties:
* - the caller must own the snapshot before any read
* - requested volumes must exist in the snapshot before anything is touched
* - in-place requires explicit confirmation
* - new-location is non-destructive (no live-target lock, no stop)
* - an undefined restic exit is a failure, never a success
* - a pre-restore stop failure aborts before the swap
* - a concurrent op on the same live target is rejected
*/
import { describe, it, expect } from 'bun:test';
import { RestoreService, type RestorePorts, type RestoreJob } from '../../src/lib/server/backups/restore-service';
import type { ResticRun } from '../../src/lib/server/backups/models';
import type { OperationHandle } from '../../src/lib/server/backups/backup-service';
import { buildSnapshotLayout, type SnapshotLayout } from '../../src/lib/server/backups/snapshot-layout';
const SNAP = 'a'.repeat(64);
// Build a typed SnapshotLayout for readSnapshotMetadata mocks. `hasStack` toggles the
// stack half (redeploy is refused when absent); `container` is the recreate-as-is inspect.
const layout = (over: { type?: 'container' | 'stack'; container?: unknown; hasStack?: boolean; volumes?: SnapshotLayout['volumes']; environmentId?: number | null } = {}): SnapshotLayout =>
buildSnapshotLayout({
type: over.type ?? (over.container ? 'container' : 'stack'),
targetName: 'web', environmentId: over.environmentId ?? null, backupTime: '2026-08-02T00:00:00.000Z',
volumes: over.volumes ?? [],
container: over.container,
stack: over.hasStack === false ? undefined : { composeFileName: 'docker-compose.yml', fileList: [], excludedBindDirs: [], secrets: [] },
});
interface Rec {
guarded: boolean;
helperRuns: number;
stopped: boolean;
restarted: boolean;
stackStopped: string[];
notifications: Array<{ event: string; payload: any }>;
opClosed?: { kind: string; details?: any };
progress: string[];
// post-restore spies
started: string[];
recreated: string[];
redeployed: string[];
localStackFilesWritten: string[];
localStackFilesOverwrite: boolean[];
localStackFilesEnvId: Array<number | null | undefined>;
localStackFilesTargetPath: string[]; // WHERE adopt materialises (must be the canonical stack dir, not job.targetPath)
// clone spies
existingVolumes: string[]; // set up front: volumes that already exist on target
createdVolumes: string[]; // recorded when createTargetVolume is called
}
function makePorts(overrides: Partial<RestorePorts> = {}, rec?: Rec): RestorePorts {
const r = rec ?? base();
const op: OperationHandle = {
id: 1,
progress: (_s, m) => r.progress.push(m),
log: () => {},
close: async (o, d) => { r.opClosed = { kind: o.kind, details: d }; },
skip: async () => {},
};
const base_: RestorePorts = {
guardSnapshotAccess: async () => { r.guarded = true; },
listSnapshotVolumes: async () => ['data', 'config'],
resolveTargets: async () => ({ containers: [{ id: 'c1', name: 'web', state: 'running' }] }),
stopBeforeRestore: async () => { r.stopped = true; return { restart: async () => { r.restarted = true; } }; },
stopStackBeforeRestore: async (name) => { r.stackStopped.push(name); return { restart: async () => { r.restarted = true; } }; },
runInHelper: async () => { r.helperRuns++; return { exitCode: 0, stdout: '', stderr: '' }; },
resolveVolumeBind: async (_meta, _e, v) => ({ bind: `${v}:/volumes/${v}:rw`, include: `/volumes/${v}`, source: v, type: 'volume' as const }),
// Uses the REAL resolver so the test exercises the shared clone path-computation.
resolveCloneTargets: async (job, volumes) => {
const { resolveRestoreTargets } = await import('../../src/lib/server/backups/restore-targets');
return resolveRestoreTargets({ job, volumes, metadata: null, stackDir: null }).volumes
.map((t) => ({ key: t.key, type: t.type, target: t.target, include: t.include, bind: t.bind }));
},
volumeExists: async (name) => r.existingVolumes.includes(name),
createTargetVolume: async (name) => { r.createdVolumes.push(name); },
acquireLiveTarget: () => () => {},
serializeDestination: async (_id, fn) => fn(),
openOperation: async () => op,
notify: async (event, payload) => { r.notifications.push({ event, payload }); },
helperName: (s) => `dockhand-restore-${s.slice(0, 12)}`,
// post-restore: container exists by default (in-place start/recreate rely on
// this); clone tests that need a FRESH target env override to false. stackExists
// defaults to false — no stack on a fresh target unless a test says otherwise.
containerExists: async () => true,
stackExists: async () => false,
startContainer: async (name) => { r.started.push(name); },
recreateContainerFromMetadata: async (name) => { r.recreated.push(name); },
redeployStack: async (name) => { r.redeployed.push(name); },
readSnapshotMetadata: async () => layout({ container: { Config: {}, HostConfig: {} } }),
writeLocalStackFiles: async (_d, _s, name, tp, overwrite, envId) => { r.localStackFilesWritten.push(name); r.localStackFilesTargetPath.push(tp); r.localStackFilesOverwrite.push(overwrite); r.localStackFilesEnvId.push(envId); return true; },
stackDirFor: async (name, _envId) => `/app/data/stacks/env/${name}`,
};
return { ...base_, ...overrides };
}
const newLoc: RestoreJob = { destinationId: 3, snapshotId: SNAP, mode: 'new-location', volumes: ['data'], environmentId: null, targetPath: '/restore/here' };
const inPlace: RestoreJob = { destinationId: 3, snapshotId: SNAP, mode: 'in-place', volumes: ['data'], environmentId: null, confirmOverwrite: true, targetName: 'web' };
const inPlaceStack: RestoreJob = { destinationId: 3, snapshotId: SNAP, mode: 'in-place', targetType: 'stack', volumes: ['data'], environmentId: null, confirmOverwrite: true, targetName: 'mystack' };
const newLocStack: RestoreJob = { destinationId: 3, snapshotId: SNAP, mode: 'new-location', targetType: 'stack', volumes: ['data'], environmentId: null, targetPath: '/restore/here', targetName: 'mystack' };
describe('RestoreService — ownership + existence gates run first', () => {
it('checks snapshot ownership before restoring', async () => {
const rec = base();
const svc = new RestoreService(makePorts({}, rec));
await svc.run(newLoc);
expect(rec.guarded).toBe(true);
});
it('refuses when ownership check throws', async () => {
const svc = new RestoreService(makePorts({ guardSnapshotAccess: async () => { throw new Error('not yours'); } }));
const res = await svc.run(newLoc);
expect(res.status).toBe('error');
});
it('refuses a volume not present in the snapshot (before touching anything)', async () => {
const rec = base();
const svc = new RestoreService(makePorts({ listSnapshotVolumes: async () => ['other'] }, rec));
const res = await svc.run({ ...inPlace, volumes: ['data'] });
expect(res.status).toBe('error');
expect(rec.helperRuns).toBe(0); // never ran the restore
});
});
describe('RestoreService — new-location (non-destructive)', () => {
it('restores to the fresh path and succeeds', async () => {
const rec = base();
const svc = new RestoreService(makePorts({}, rec));
const res = await svc.run(newLoc);
expect(res.status).toBe('success');
if (res.status === 'success') expect(res.targetPath).toBe('/restore/here');
expect(rec.stopped).toBe(false); // no stop for new-location
});
it('fails on an undefined restic exit (unknown outcome)', async () => {
const svc = new RestoreService(makePorts({ runInHelper: async () => ({ exitCode: undefined, stdout: '', stderr: 'killed' }) }));
const res = await svc.run(newLoc);
expect(res.status).toBe('error');
});
it('binds the target path OUT so the extracted data is durable (not lost in the helper)', async () => {
// restic --target writes inside the helper container; without a bind of
// targetPath the extracted data vanishes when the helper exits (a silent
// no-op restore). The helper must mount targetPath through to the host.
let capturedBinds: string[] = [];
const svc = new RestoreService(makePorts({
runInHelper: async (spec) => { capturedBinds = spec.binds; return { exitCode: 0, stdout: '', stderr: '' }; },
}));
const res = await svc.run(newLoc);
expect(res.status).toBe('success');
expect(capturedBinds).toContain('/restore/here:/restore/here');
});
it('REFUSES a new-location restore into a protected system dir (never mounts it)', async () => {
// Defense-in-depth: even if the request bypassed API validation, the service must
// not bind-mount /etc rw as root and let restic write into it.
const rec = base();
const svc = new RestoreService(makePorts({}, rec));
const res = await svc.run({ ...newLoc, targetPath: '/etc' });
expect(res.status).toBe('error');
expect(rec.helperRuns).toBe(0);
});
});
describe('RestoreService — in-place (destructive)', () => {
it('requires explicit confirmation', async () => {
const rec = base();
const svc = new RestoreService(makePorts({}, rec));
const res = await svc.run({ ...inPlace, confirmOverwrite: false });
expect(res.status).toBe('error');
expect(rec.helperRuns).toBe(0);
});
it('runs the swap (restic helper) on an in-place restore', async () => {
const rec = base();
const svc = new RestoreService(makePorts({}, rec));
const res = await svc.run(inPlace);
expect(res.status).toBe('success');
expect(rec.helperRuns).toBeGreaterThan(0);
});
it('restarts the target on the catch path if the swap itself fails', async () => {
const rec = base();
const svc = new RestoreService(makePorts({ runInHelper: async () => ({ exitCode: undefined, stdout: '', stderr: 'killed mid-swap' }) }, rec));
const res = await svc.run(inPlace);
expect(res.status).toBe('error');
// The catch path restarts the target so it is back up after a failed swap.
expect(rec.restarted).toBe(true);
});
it('surfaces a STILL STOPPED warning + notification if the catch-path restart itself fails', async () => {
// No boot-time recovery exists, so a failed restart of a target stopped for
// the restore MUST be surfaced — the user has to start it by hand.
const rec = base();
const svc = new RestoreService(makePorts({
runInHelper: async () => ({ exitCode: undefined, stdout: '', stderr: 'killed mid-swap' }),
stopBeforeRestore: async () => { rec.stopped = true; return { restart: async () => { throw new Error('daemon down'); } }; },
}, rec));
const res = await svc.run(inPlace);
expect(res.status).toBe('error');
expect(rec.progress.some((m) => /STILL STOPPED/.test(m))).toBe(true);
expect(rec.notifications.some((n) => n.event === 'restore_failed' && n.payload?.errorCode === 'STOPPED_RESTART_FAILED')).toBe(true);
});
it('stops the target and starts it back via the default post-restore action', async () => {
const rec = base();
const svc = new RestoreService(makePorts({}, rec));
const res = await svc.run(inPlace);
expect(res.status).toBe('success');
expect(rec.stopped).toBe(true);
// The default 'start' action brings the container back up.
expect(rec.started).toEqual(['web']);
if (res.status === 'success') expect(res.postRestore).toEqual({ action: 'start', ok: true });
});
it('aborts before the swap if the pre-restore stop fails', async () => {
const rec = base();
const svc = new RestoreService(makePorts({ stopBeforeRestore: async () => { throw new Error('cannot stop'); } }, rec));
const res = await svc.run(inPlace);
expect(res.status).toBe('error');
expect(rec.helperRuns).toBe(0); // swap never ran
});
it('rejects when the live-target lock is held', async () => {
const svc = new RestoreService(makePorts({ acquireLiveTarget: () => null }));
const res = await svc.run(inPlace);
expect(res.status).toBe('skipped');
});
// DATA-SAFETY: in-place restore of a BIND volume writes to the snapshot's SOURCE-env
// host path. Doing that on a DIFFERENT env clobbers an unrelated dir on the wrong host.
describe('bind-mount in-place cross-env guard', () => {
// A snapshot taken on env 5 whose only volume is a bind at /srv/appA/data.
const bindMeta = (sourceEnv: number | null) => async () => layout({
type: 'container', container: { Config: {}, HostConfig: {} }, environmentId: sourceEnv,
volumes: [{ key: 'data', name: 'data', source: '/srv/appA/data', type: 'bind' }],
});
const bindResolve = async (_meta: any, _e: any, v: string) => ({ bind: `/srv/appA/data:/volumes/${v}:rw`, include: `/volumes/${v}`, source: '/srv/appA/data', type: 'bind' as const });
it('REFUSES a bind in-place restore onto a different env (fail-closed, no swap)', async () => {
const rec = base();
const svc = new RestoreService(makePorts({ readSnapshotMetadata: bindMeta(5), resolveVolumeBind: bindResolve }, rec));
const res = await svc.run({ ...inPlace, environmentId: 9, confirmOverwrite: true }); // target 9 != source 5
expect(res.status).toBe('error');
expect(res.status === 'error' && res.code).toBe('VALIDATION');
expect(rec.helperRuns).toBe(0); // never touched a volume
expect(rec.stopped).toBe(false); // aborted before stopping the target
});
it('ALLOWS a bind in-place restore onto the SAME env as the snapshot', async () => {
const rec = base();
const svc = new RestoreService(makePorts({ readSnapshotMetadata: bindMeta(5), resolveVolumeBind: bindResolve }, rec));
const res = await svc.run({ ...inPlace, environmentId: 5, confirmOverwrite: true }); // target 5 == source 5
expect(res.status).toBe('success');
expect(rec.helperRuns).toBe(1);
});
it('ALLOWS a NAMED-volume in-place restore cross-env (name resolves on the local daemon)', async () => {
const rec = base();
// default resolveVolumeBind returns type:'volume'; metadata says the volume is named.
const svc = new RestoreService(makePorts({
readSnapshotMetadata: async () => layout({ type: 'container', container: { Config: {}, HostConfig: {} }, environmentId: 5, volumes: [{ key: 'data', name: 'data', source: 'data', type: 'volume' }] }),
}, rec));
const res = await svc.run({ ...inPlace, environmentId: 9, confirmOverwrite: true }); // cross-env, but named vol
expect(res.status).toBe('success');
expect(rec.helperRuns).toBe(1);
});
it('fails CLOSED when metadata is unreadable and volumes were requested', async () => {
const rec = base();
const svc = new RestoreService(makePorts({ readSnapshotMetadata: async () => null }, rec));
const res = await svc.run({ ...inPlace, environmentId: 9, confirmOverwrite: true });
expect(res.status).toBe('error');
expect(rec.helperRuns).toBe(0);
});
it('C2: null metadata never slug-binds a bind - fail-closed BEFORE resolving any bind', async () => {
// The gate reads metadata once and reuses it. If that read is null (transient dump
// failure), metaUnreadable fires and the restore aborts before the resolve loop, so a
// bind can never fall through to bindFor(key,key,'volume') (an orphan-volume data loss).
// A resolveVolumeBind that WOULD slug-bind on null must never be reached.
const rec = base();
let resolveCalls = 0;
const svc = new RestoreService(makePorts({
readSnapshotMetadata: async () => null,
resolveVolumeBind: async (_m, _e, v) => { resolveCalls++; return { bind: `${v}:/volumes/${v}:rw`, include: `/volumes/${v}`, source: v, type: 'volume' as const }; },
}, rec));
const res = await svc.run({ ...inPlace, environmentId: 5, confirmOverwrite: true }); // same-env
expect(res.status).toBe('error'); // metaUnreadable -> fail-closed
expect(resolveCalls).toBe(0); // never reached the bind resolver
expect(rec.helperRuns).toBe(0);
});
});
});
describe('RestoreService — in-place STACK restore', () => {
it('stops the whole stack (compose stop) before the swap, not individual containers', async () => {
const rec = base();
const svc = new RestoreService(makePorts({}, rec));
const res = await svc.run(inPlaceStack);
expect(res.status).toBe('success');
expect(rec.stackStopped).toEqual(['mystack']); // native compose stop
expect(rec.stopped).toBe(false); // NOT the per-container stop path
});
it('redeploys the stack from stored compose files after the swap', async () => {
const rec = base();
const svc = new RestoreService(makePorts({}, rec));
const res = await svc.run(inPlaceStack);
expect(res.status).toBe('success');
expect(rec.redeployed).toEqual(['mystack']); // redeploy is the stack post-action
expect(rec.started).toHaveLength(0); // NOT the container 'start' action
});
it('a failed swap is a hard error (live data left intact)', async () => {
const rec = base();
const svc = new RestoreService(makePorts({ runInHelper: async () => ({ exitCode: undefined, stdout: '', stderr: 'killed' }) }, rec));
const res = await svc.run(inPlaceStack);
expect(res.status).toBe('error');
});
it('a missing stored compose file downgrades to warning (data restored, redeploy failed)', async () => {
const rec = base();
const svc = new RestoreService(makePorts({ redeployStack: async () => { throw new Error('no stack files stored in this snapshot'); } }, rec));
const res = await svc.run(inPlaceStack);
expect(res.status).toBe('warning');
if (res.status === 'warning') expect(res.postRestore.action).toBe('redeploy');
});
});
describe('RestoreService — new-location STACK restore', () => {
it('restores the volume data AND the stored compose files to the target', async () => {
let capturedArgs: string[] = [];
const svc = new RestoreService(makePorts({
runInHelper: async (spec) => { capturedArgs = spec.args ?? []; return { exitCode: 0, stdout: '', stderr: '' }; },
}));
const res = await svc.run(newLocStack);
expect(res.status).toBe('success');
// The restore includes the data volume AND the captured stack dir (always at
// /volumes/__dockhand_stackdir__ now - unified snapshot structure).
expect(capturedArgs).toContain('/volumes/data');
expect(capturedArgs).toContain('/volumes/__dockhand_stackdir__');
});
it('does NOT stop or redeploy anything (non-destructive)', async () => {
const rec = base();
const svc = new RestoreService(makePorts({}, rec));
await svc.run(newLocStack);
expect(rec.stackStopped).toHaveLength(0);
expect(rec.redeployed).toHaveLength(0);
});
it('ALWAYS materialises stack files into Dockhand (a stack restore registers a managed stack)', async () => {
// Load-bearing: Dockhand needs the compose locally to edit/redeploy, especially for a
// remote env where the compose otherwise lives only on the remote host. So a stack
// new-location restore materialises unconditionally (no opt-in).
const rec = base();
const svc = new RestoreService(makePorts({}, rec));
await svc.run(newLocStack);
expect(rec.localStackFilesWritten).toContain('mystack');
});
it('materialises into the CANONICAL managed stack dir, NOT job.targetPath (the host data path)', async () => {
// Guard against the bug where it wrote to job.targetPath (the HOST path where the volume
// DATA landed, e.g. /restore/here) instead of the managed stack dir. It MUST use
// stackDirFor() so the stack lands where Dockhand manages it and the containment guard
// accepts it.
const rec = base();
const svc = new RestoreService(makePorts({}, rec));
await svc.run(newLocStack);
expect(rec.localStackFilesTargetPath).toEqual(['/app/data/stacks/env/mystack']);
expect(rec.localStackFilesTargetPath).not.toContain('/restore/here');
});
it('materialises with overwrite=true by default (replace, clearing stale files)', async () => {
const rec = base();
const svc = new RestoreService(makePorts({}, rec));
await svc.run(newLocStack);
expect(rec.localStackFilesOverwrite).toEqual([true]);
});
it('honours an explicit mergeStackFiles:true as a merge (keeps stale files)', async () => {
const rec = base();
const svc = new RestoreService(makePorts({}, rec));
await svc.run({ ...newLocStack, mergeStackFiles: true });
expect(rec.localStackFilesOverwrite).toEqual([false]);
});
it('skipStackFiles: excludes the stack dir from the restore includes AND does not materialise', async () => {
let capturedArgs: string[] = [];
const rec = base();
const svc = new RestoreService(makePorts({
runInHelper: async (spec) => { capturedArgs = spec.args ?? []; return { exitCode: 0, stdout: '', stderr: '' }; },
}, rec));
await svc.run({ ...newLocStack, skipStackFiles: true });
expect(capturedArgs).toContain('/volumes/data');
expect(capturedArgs).not.toContain('/volumes/__dockhand_stackdir__');
expect(rec.localStackFilesWritten).toHaveLength(0); // data-only: nothing materialised
});
});
describe('RestoreService — in-place post-restore actions', () => {
it("default 'start' starts the container when it exists → success", async () => {
const rec = base();
const svc = new RestoreService(makePorts({}, rec));
const res = await svc.run(inPlace);
expect(res.status).toBe('success');
expect(rec.started).toEqual(['web']);
if (res.status === 'success') expect(res.postRestore).toEqual({ action: 'start', ok: true });
});
it("'none' does not start/restart the target (left stopped)", async () => {
const rec = base();
const svc = new RestoreService(makePorts({}, rec));
const res = await svc.run({ ...inPlace, postRestore: 'none' });
expect(res.status).toBe('success');
expect(rec.started).toHaveLength(0);
expect(rec.restarted).toBe(false);
if (res.status === 'success') expect(res.postRestore).toEqual({ action: 'none', ok: true });
});
it("'start' when the container is MISSING → warning (data still restored)", async () => {
const rec = base();
const svc = new RestoreService(makePorts({ containerExists: async () => false }, rec));
const res = await svc.run(inPlace);
expect(res.status).toBe('warning');
expect(rec.started).toHaveLength(0);
if (res.status === 'warning') {
expect(res.restoredVolumes).toEqual(['data']);
expect(res.postRestore.ok).toBe(false);
expect(res.postRestore.error).toContain('Recreate');
}
// The op is closed as a warning, not an error, and the swap is NOT rolled back.
expect(rec.opClosed?.kind).toBe('warning');
});
it("'recreate' when the container is missing + metadata present → recreates → success", async () => {
const rec = base();
const svc = new RestoreService(makePorts({ containerExists: async () => false }, rec));
const res = await svc.run({ ...inPlace, postRestore: 'recreate' });
expect(res.status).toBe('success');
expect(rec.recreated).toEqual(['web']);
if (res.status === 'success') expect(res.postRestore).toEqual({ action: 'recreate', ok: true });
});
it("'recreate' when the container still exists prefers start (never destroys a live one)", async () => {
const rec = base();
const svc = new RestoreService(makePorts({ containerExists: async () => true }, rec));
const res = await svc.run({ ...inPlace, postRestore: 'recreate' });
expect(res.status).toBe('success');
expect(rec.started).toEqual(['web']);
expect(rec.recreated).toHaveLength(0);
});
it("'recreate' when no container metadata is stored → warning", async () => {
const rec = base();
const svc = new RestoreService(makePorts({
containerExists: async () => false,
readSnapshotMetadata: async () => layout({ type: 'container', hasStack: false }),
}, rec));
const res = await svc.run({ ...inPlace, postRestore: 'recreate' });
expect(res.status).toBe('warning');
if (res.status === 'warning') {
expect(res.postRestore.ok).toBe(false);
expect(res.postRestore.error).toContain('no container metadata');
}
expect(rec.recreated).toHaveLength(0);
});
it("'redeploy' when stack files are stored → redeploys → success", async () => {
const rec = base();
const svc = new RestoreService(makePorts({}, rec));
const res = await svc.run({ ...inPlace, postRestore: 'redeploy' });
expect(res.status).toBe('success');
expect(rec.redeployed).toEqual(['web']);
if (res.status === 'success') expect(res.postRestore).toEqual({ action: 'redeploy', ok: true });
});
it("'redeploy' when no stack files are stored → warning", async () => {
const rec = base();
const svc = new RestoreService(makePorts({
readSnapshotMetadata: async () => layout({ hasStack: false }),
}, rec));
const res = await svc.run({ ...inPlace, postRestore: 'redeploy' });
expect(res.status).toBe('warning');
if (res.status === 'warning') {
expect(res.postRestore.ok).toBe(false);
expect(res.postRestore.error).toContain('no stack files');
}
expect(rec.redeployed).toHaveLength(0);
});
it('a post-restore action that THROWS → warning (not error), data still reported restored', async () => {
const rec = base();
const svc = new RestoreService(makePorts({
startContainer: async () => { throw new Error('docker start blew up'); },
}, rec));
const res = await svc.run(inPlace);
expect(res.status).toBe('warning');
if (res.status === 'warning') {
expect(res.restoredVolumes).toEqual(['data']);
expect(res.postRestore.ok).toBe(false);
expect(res.postRestore.error).toContain('docker start blew up');
}
expect(rec.opClosed?.kind).toBe('warning');
});
it('a PLAIN new-location (no destinations, no recreate/redeploy action) starts nothing', async () => {
// The loose-files extract remains non-destructive: without volumeDestinations
// and without a recreate/redeploy postRestore, new-location touches nothing live.
const rec = base();
const svc = new RestoreService(makePorts({}, rec));
const res = await svc.run({ ...newLoc, postRestore: 'start' } as RestoreJob);
expect(res.status).toBe('success');
expect(rec.started).toHaveLength(0);
expect(rec.recreated).toHaveLength(0);
expect(rec.redeployed).toHaveLength(0);
if (res.status === 'success') expect(res.postRestore).toBeUndefined();
});
});
describe('RestoreService — config-only (metadata-only) snapshot restore', () => {
// A snapshot of a container/stack with no volumes/binds carries only /metadata.
// listSnapshotVolumes returns [] and the caller requests no volumes.
const metaJob = (over: Partial<RestoreJob> = {}): RestoreJob =>
({ destinationId: 3, snapshotId: SNAP, mode: 'new-location', volumes: [], environmentId: null, targetPath: '/restore/here', ...over });
it('new-location extracts /metadata (the stored config) when the snapshot has no volumes', async () => {
let capturedArgs: string[] = [];
const svc = new RestoreService(makePorts({
listSnapshotVolumes: async () => [],
runInHelper: async (spec) => { capturedArgs = spec.args ?? []; return { exitCode: 0, stdout: '', stderr: '' }; },
}));
const res = await svc.run(metaJob());
expect(res.status).toBe('success');
expect(capturedArgs).toContain('/metadata'); // config extracted…
expect(capturedArgs.some((a) => a.startsWith('/volumes/'))).toBe(false); // …no volume paths
});
it('in-place skips the volume swap (no restic restore) but still recreates a gone container from config', async () => {
const rec = base();
const svc = new RestoreService(makePorts({
listSnapshotVolumes: async () => [],
containerExists: async () => false, // container gone → recreate rebuilds from stored config
}, rec));
const res = await svc.run(metaJob({ mode: 'in-place', confirmOverwrite: true, targetName: 'web', postRestore: 'recreate' }));
expect(res.status).toBe('success');
expect(rec.helperRuns).toBe(0); // no restic restore helper — nothing to swap
expect(rec.recreated).toEqual(['web']); // config still recreated post-restore
});
});
describe('RestoreService — clone (cross-env restore with per-volume destinations)', () => {
const cloneJob = (over: Partial<RestoreJob> = {}): RestoreJob => ({
destinationId: 3, snapshotId: SNAP, mode: 'new-location', volumes: ['data'],
environmentId: 9, targetName: 'web', targetPath: '/restore/here',
volumeDestinations: [{ volume: 'data', kind: 'volume', target: 'data' }],
postRestore: 'recreate', ...over,
});
it('populates a fresh named volume on the target env then recreates the container', async () => {
const rec = base();
let capturedBinds: string[] = [];
const svc = new RestoreService(makePorts({
containerExists: async () => false, // gone on target → recreate rebuilds it
runInHelper: async (spec) => { rec.helperRuns++; capturedBinds = spec.binds; return { exitCode: 0, stdout: '', stderr: '' }; },
}, rec));
const res = await svc.run(cloneJob());
expect(res.status).toBe('success');
expect(rec.createdVolumes).toEqual(['data']); // fresh volume created on target
expect(capturedBinds).toContain('data:/volumes/data:rw'); // populated via helper
expect(rec.recreated).toEqual(['web']); // container rebuilt on target env
});
it('FAILS CLOSED when a named-volume destination already exists on the target env', async () => {
const rec = base();
rec.existingVolumes = ['data']; // collision
// Fresh target for the CONTAINER name so we exercise the VOLUME collision path.
const svc = new RestoreService(makePorts({ containerExists: async () => false }, rec));
const res = await svc.run(cloneJob());
expect(res.status).toBe('error');
if (res.status === 'error') expect(res.error).toMatch(/already exists on the target/i);
expect(rec.createdVolumes).toHaveLength(0); // nothing created
expect(rec.helperRuns).toBe(0); // nothing populated
expect(rec.recreated).toHaveLength(0); // nothing started
});
it('FAILS CLOSED when a container of the target name already exists on the target env (recreate)', async () => {
const rec = base();
const svc = new RestoreService(makePorts({ containerExists: async () => true }, rec));
const res = await svc.run(cloneJob()); // postRestore: 'recreate', targetName: 'web'
expect(res.status).toBe('error');
if (res.status === 'error') expect(res.error).toMatch(/container "web" already exists on the target/i);
expect(rec.createdVolumes).toHaveLength(0); // aborted before touching anything
expect(rec.helperRuns).toBe(0);
expect(rec.recreated).toHaveLength(0);
expect(rec.started).toHaveLength(0); // never starts the pre-existing container
});
it('FAILS CLOSED when a stack of the target name already exists on the target env (redeploy)', async () => {
const rec = base();
const svc = new RestoreService(makePorts({ stackExists: async () => true }, rec));
const res = await svc.run(cloneJob({ targetType: 'stack', postRestore: 'redeploy' }));
expect(res.status).toBe('error');
if (res.status === 'error') expect(res.error).toMatch(/stack "web" already exists on the target/i);
expect(rec.helperRuns).toBe(0);
expect(rec.redeployed).toHaveLength(0); // never redeploys over the existing stack
});
it('does NOT check the target name when postRestore is none (no container/stack is created)', async () => {
const rec = base();
// A name clash is harmless when nothing is brought up — the clone just
// populates the volume and stops. containerExists=true must NOT block it.
const svc = new RestoreService(makePorts({ containerExists: async () => true }, rec));
const res = await svc.run(cloneJob({ postRestore: 'none' }));
expect(res.status).toBe('success');
expect(rec.createdVolumes).toEqual(['data']); // volume populated as usual
expect(rec.recreated).toHaveLength(0);
});
it("a clone STACK with postRestore 'none' still MATERIALISES the compose/.env dir (so there's a stack to edit + redeploy)", async () => {
// The UI tells the user to "update the compose file to the new volume names, then
// redeploy" - so the compose dir MUST exist even when nothing is brought up. Clone
// only materialised on redeploy (via redeployStack), leaving 'none' with NO compose
// dir at all - a real bug (#1351 follow-up): the user restored volumes to host paths
// with 'none' and had no teslamate/ dir or compose.yaml to edit.
const rec = base();
const svc = new RestoreService(makePorts({ containerExists: async () => false }, rec));
const res = await svc.run(cloneJob({ targetType: 'stack', postRestore: 'none', environmentId: 9 }));
expect(res.status).toBe('success');
expect(rec.localStackFilesWritten).toContain('web'); // compose/.env dir written locally
// envId is threaded through so the port can register the stack as INTERNAL (managed) -
// without it the restored stack is EXTERNAL and the UI can't edit/redeploy it.
expect(rec.localStackFilesEnvId).toContain(9);
expect(rec.redeployed).toHaveLength(0); // but nothing redeployed ('none')
});
it('binds an absolute-path destination as-is WITHOUT creating a volume', async () => {
const rec = base();
let capturedBinds: string[] = [];
const svc = new RestoreService(makePorts({
containerExists: async () => false,
runInHelper: async (spec) => { rec.helperRuns++; capturedBinds = spec.binds; return { exitCode: 0, stdout: '', stderr: '' }; },
}, rec));
const res = await svc.run(cloneJob({ volumeDestinations: [{ volume: 'data', kind: 'path', target: '/srv/data' }] }));
expect(res.status).toBe('success');
expect(rec.createdVolumes).toHaveLength(0); // a bind path is not a volume
expect(capturedBinds).toContain('/srv/data:/volumes/data:rw');
});
it('config-only clone (no destinations) recreates on the target env without a helper run', async () => {
const rec = base();
const svc = new RestoreService(makePorts({ listSnapshotVolumes: async () => [], containerExists: async () => false }, rec));
const res = await svc.run(cloneJob({ volumes: [], volumeDestinations: [] }));
expect(res.status).toBe('success');
expect(rec.helperRuns).toBe(0); // nothing to populate
expect(rec.createdVolumes).toHaveLength(0);
expect(rec.recreated).toEqual(['web']); // still rebuilt from stored config
});
it('extracts un-mapped volumes to targetPath (loose fallback) alongside a mapped one', async () => {
const rec = base();
const targets: string[] = [];
const svc = new RestoreService(makePorts({
listSnapshotVolumes: async () => ['data', 'logs'],
containerExists: async () => false,
runInHelper: async (spec) => { rec.helperRuns++; targets.push(...(spec.args ?? [])); return { exitCode: 0, stdout: '', stderr: '' }; },
}, rec));
// 'data' → named volume; 'logs' → un-mapped → loose extract to targetPath.
const res = await svc.run(cloneJob({ volumes: ['data', 'logs'] }));
expect(res.status).toBe('success');
expect(rec.createdVolumes).toEqual(['data']); // only the mapped one
expect(rec.helperRuns).toBe(2); // one populate + one loose extract
expect(targets).toContain('/volumes/logs'); // loose include went to the extract run
});
it('FAILS (not silent success) when un-mapped volumes have no targetPath to land in', async () => {
// volumes:[] expands to ALL snapshot volumes; a partial destination map leaves some
// un-mapped. With no targetPath, those can't be extracted anywhere - the old code
// silently skipped them and still reported success, redeploying with EMPTY volumes.
// Must fail-closed instead.
const rec = base();
const svc = new RestoreService(makePorts({
listSnapshotVolumes: async () => ['data', 'logs'],
containerExists: async () => false,
}, rec));
const res = await svc.run(cloneJob({
volumes: [], // = all (data, logs)
volumeDestinations: [{ volume: 'data', kind: 'volume', target: 'data' }], // logs un-mapped
targetPath: undefined, // nowhere for logs to go
postRestore: 'redeploy',
}));
expect(res.status).toBe('error');
if (res.status === 'error') expect(res.error).toContain('logs'); // names the dropped volume
});
it('a stack clone redeploys on the target env', async () => {
const rec = base();
const svc = new RestoreService(makePorts({ containerExists: async () => false }, rec));
const res = await svc.run(cloneJob({ targetType: 'stack', postRestore: 'redeploy' }));
expect(res.status).toBe('success');
expect(rec.redeployed).toEqual(['web']);
});
it('FAILS (error) when the post-restore step fails, surfacing the RAW docker error', async () => {
// A clone exists to bring the target up on the new env, so if recreate/redeploy
// fails (e.g. a source network/image missing on the target) it's a hard error —
// the user sees the raw Docker message verbatim, not a quiet "warning". The
// volume data is still populated (not rolled back).
const rec = base();
const svc = new RestoreService(makePorts({
containerExists: async () => false,
recreateContainerFromMetadata: async () => { throw new Error('network anton not found'); },
}, rec));
const res = await svc.run(cloneJob());
expect(res.status).toBe('error');
if (res.status === 'error') {
expect(res.error).toBe('network anton not found'); // raw docker error, not parsed
expect(res.code).toBe('DOCKER');
}
expect(rec.createdVolumes).toEqual(['data']); // the data DID land (not rolled back)
});
});
function base(): Rec {
return { guarded: false, helperRuns: 0, stopped: false, restarted: false, stackStopped: [], notifications: [], progress: [], started: [], recreated: [], redeployed: [], localStackFilesWritten: [], localStackFilesOverwrite: [], localStackFilesEnvId: [], localStackFilesTargetPath: [], existingVolumes: [], createdVolumes: [] };
}
+127
View File
@@ -0,0 +1,127 @@
/**
* Unit tests for resolveRestoreTargets - the single source of truth for where a restore lands.
*
* The load-bearing guarantee: for IN-PLACE, the resolved target MUST equal what the swap actually
* binds, i.e. resolveBindFromMetadata(meta, key).source. These tests assert that identity directly
* so the preview can never show a path the restore doesn't write. Clone targets are verbatim from
* volumeDestinations; stack files come from the passed stackDir.
*/
import { describe, expect, test } from 'bun:test';
import { resolveRestoreTargets } from '../../src/lib/server/backups/restore-targets';
import { resolveBindFromMetadata, type SnapshotMetadata } from '../../src/lib/server/backups/restore-core';
describe('resolveRestoreTargets - in-place (target === what the swap binds)', () => {
const meta: SnapshotMetadata = {
volumes: [
{ key: 'html', name: '/srv/app/html', source: '/srv/app/html', type: 'bind' },
{ key: 'pgdata', name: 'pgdata', source: 'pgdata', type: 'volume' },
],
};
test('bind volume target equals resolveBindFromMetadata source (identity)', () => {
const r = resolveRestoreTargets({ job: { mode: 'in-place' }, volumes: ['html'], metadata: meta, stackDir: null });
expect(r.volumes[0]).toMatchObject({ key: 'html', type: 'bind', target: '/srv/app/html', origin: 'in-place-metadata' });
// the exact identity the preview relies on - target AND the full bind equal the leaf fn:
expect(r.volumes[0].target).toBe(resolveBindFromMetadata(meta, 'html').source);
expect(r.volumes[0].bind).toBe(resolveBindFromMetadata(meta, 'html').bind);
});
test('named volume target equals the volume name', () => {
const r = resolveRestoreTargets({ job: { mode: 'in-place' }, volumes: ['pgdata'], metadata: meta, stackDir: null });
expect(r.volumes[0]).toMatchObject({ key: 'pgdata', type: 'volume', target: 'pgdata', origin: 'in-place-metadata', bind: 'pgdata:/volumes/pgdata:rw' });
expect(r.volumes[0].target).toBe(resolveBindFromMetadata(meta, 'pgdata').source);
});
test('a bind whose source cannot be recovered goes to unresolved, does NOT throw', () => {
// legacy string list: 'data_config' slugs from a bind path but no absolute source recorded.
const bad: SnapshotMetadata = { volumes: ['/data/config'] };
const r = resolveRestoreTargets({ job: { mode: 'in-place' }, volumes: ['data_config'], metadata: bad, stackDir: null });
expect(r.volumes).toEqual([]);
expect(r.unresolved).toHaveLength(1);
expect(r.unresolved[0].key).toBe('data_config');
});
});
describe('resolveRestoreTargets - clone (verbatim from volumeDestinations)', () => {
const meta: SnapshotMetadata = {
volumes: [{ key: 'html', name: '/orig/html', source: '/orig/html', type: 'bind' }],
};
test('a path destination is used verbatim as a bind host path', () => {
const r = resolveRestoreTargets({
job: { mode: 'new-location', volumeDestinations: [{ volume: 'html', kind: 'path', target: '/new/custom/html' }] },
volumes: ['html'], metadata: meta, stackDir: null,
});
expect(r.volumes[0]).toMatchObject({ key: 'html', type: 'bind', target: '/new/custom/html', origin: 'clone', include: '/volumes/html', bind: '/new/custom/html:/volumes/html:rw' });
});
test('a volume destination is a named volume target', () => {
const r = resolveRestoreTargets({
job: { mode: 'new-location', volumeDestinations: [{ volume: 'html', kind: 'volume', target: 'html_clone' }] },
volumes: ['html'], metadata: meta, stackDir: null,
});
expect(r.volumes[0]).toMatchObject({ key: 'html', type: 'volume', target: 'html_clone', origin: 'clone', bind: 'html_clone:/volumes/html:rw' });
});
test('an unmapped volume falls back to loose-files under targetPath', () => {
const r = resolveRestoreTargets({
job: { mode: 'new-location', targetPath: '/restore/here', volumeDestinations: [] },
volumes: ['html'], metadata: meta, stackDir: null,
});
expect(r.volumes[0]).toMatchObject({ key: 'html', type: 'bind', target: '/restore/here', origin: 'loose-files', bind: '/restore/here:/restore/here' });
});
test('an unmapped volume with no targetPath is unresolved (fail-closed, no throw)', () => {
const r = resolveRestoreTargets({
job: { mode: 'new-location', volumeDestinations: [] },
volumes: ['html'], metadata: meta, stackDir: null,
});
expect(r.volumes).toEqual([]);
expect(r.unresolved[0].key).toBe('html');
});
});
describe('resolveRestoreTargets - stack files', () => {
test('writes the managed stack dir for a stack restore', () => {
const r = resolveRestoreTargets({
job: { mode: 'new-location', targetType: 'stack', targetName: 'blog' },
volumes: [], metadata: null, stackDir: '/app/data/stacks/anton/blog',
});
expect(r.stackFiles).toEqual({ targetDir: '/app/data/stacks/anton/blog', willWrite: true, overwrite: true });
});
test('skipStackFiles -> willWrite false (data-only restore)', () => {
const r = resolveRestoreTargets({
job: { mode: 'new-location', targetType: 'stack', targetName: 'blog', skipStackFiles: true },
volumes: [], metadata: null, stackDir: '/app/data/stacks/anton/blog',
});
expect(r.stackFiles?.willWrite).toBe(false);
});
test('mergeStackFiles -> overwrite false', () => {
const r = resolveRestoreTargets({
job: { mode: 'new-location', targetType: 'stack', targetName: 'blog', mergeStackFiles: true },
volumes: [], metadata: null, stackDir: '/app/data/stacks/anton/blog',
});
expect(r.stackFiles?.overwrite).toBe(false);
});
test('a container restore has no stackFiles', () => {
const r = resolveRestoreTargets({
job: { mode: 'in-place', targetType: 'container', targetName: 'nginx' },
volumes: [], metadata: null, stackDir: null,
});
expect(r.stackFiles).toBeNull();
});
});
describe('resolveRestoreTargets - the stack-dir synthetic volume is not a user volume', () => {
test('the reserved __dockhand_stackdir__ key is ignored in volumes (handled via stackFiles)', () => {
const r = resolveRestoreTargets({
job: { mode: 'in-place', targetType: 'stack', targetName: 'blog' },
volumes: ['__dockhand_stackdir__'], metadata: { volumes: [] }, stackDir: '/app/data/stacks/anton/blog',
});
expect(r.volumes).toEqual([]);
expect(r.unresolved).toEqual([]);
});
});
+119
View File
@@ -0,0 +1,119 @@
/**
* Unit tests for backups/retention.ts — pure retention planning. Proves the two
* safety properties: retention is OFF by default, and a policy that would delete
* every snapshot is refused.
*/
import { describe, it, expect } from 'bun:test';
import {
parseRetention,
retentionActive,
buildForgetArgs,
checkWouldWipe,
NO_RETENTION,
} from '../../src/lib/server/backups/retention';
describe('parseRetention — safe default is "none"', () => {
it('null / empty / garbage → none', () => {
expect(parseRetention(null)).toEqual(NO_RETENTION);
expect(parseRetention(undefined)).toEqual(NO_RETENTION);
expect(parseRetention('')).toEqual(NO_RETENTION);
expect(parseRetention(' ')).toEqual(NO_RETENTION);
expect(parseRetention('not json')).toEqual(NO_RETENTION);
expect(parseRetention('{}')).toEqual(NO_RETENTION);
expect(parseRetention({})).toEqual(NO_RETENTION);
});
it('all-zero / non-positive values → none (never prune on a broken config)', () => {
expect(parseRetention({ keepLast: 0 })).toEqual(NO_RETENTION);
expect(parseRetention({ keepDaily: -1, keepWeekly: 0 })).toEqual(NO_RETENTION);
expect(parseRetention({ keepLast: 'abc' })).toEqual(NO_RETENTION);
});
it('keepLast → keep-last mode', () => {
expect(parseRetention({ keepLast: 5 })).toEqual({ mode: 'keep-last', last: 5 });
expect(parseRetention('{"keepLast":3}')).toEqual({ mode: 'keep-last', last: 3 });
});
it('time buckets → time mode, only positive fields kept', () => {
expect(parseRetention({ keepDaily: 7, keepWeekly: 4, keepMonthly: 6, keepYearly: 2 }))
.toEqual({ mode: 'time', daily: 7, weekly: 4, monthly: 6, yearly: 2 });
expect(parseRetention({ keepDaily: 7, keepWeekly: 0 })).toEqual({ mode: 'time', daily: 7 });
});
it('keepLast takes precedence when both present', () => {
expect(parseRetention({ keepLast: 5, keepDaily: 7 })).toEqual({ mode: 'keep-last', last: 5 });
});
});
describe('retentionActive', () => {
it('is false only for none', () => {
expect(retentionActive(NO_RETENTION)).toBe(false);
expect(retentionActive({ mode: 'keep-last', last: 1 })).toBe(true);
expect(retentionActive({ mode: 'time', daily: 1 })).toBe(true);
});
});
describe('buildForgetArgs', () => {
const filter = 'dockhand:instance=i,dockhand:configid=7';
it('returns null for none (nothing to prune)', () => {
expect(buildForgetArgs(NO_RETENTION, filter)).toBeNull();
});
it('builds a keep-last forget scoped by the config tag, WITHOUT --prune', () => {
const args = buildForgetArgs({ mode: 'keep-last', last: 5 }, filter);
// NO --prune: post-backup retention only drops snapshot references (fast). Reclaiming
// space is a separate scheduled prune, so a backup never hangs on "Applying retention".
// --retry-lock is short (1m): forget runs inside the per-repo serializer, so the
// only lock it can meet is an orphan (dead helper) or another instance sharing
// the repo — waiting 5m for either was the cause of the 300s backup-test timeout.
expect(args).toEqual([
'forget', '--retry-lock', '1m', '--group-by', '', '--tag', filter, '--keep-last', '5',
]);
expect(args).not.toContain('--prune');
});
it('builds a time prune with only the set buckets', () => {
const args = buildForgetArgs({ mode: 'time', daily: 7, monthly: 6 }, filter);
expect(args).toContain('--keep-daily');
expect(args).toContain('7');
expect(args).toContain('--keep-monthly');
expect(args).toContain('6');
expect(args).not.toContain('--keep-weekly');
expect(args).not.toContain('--keep-yearly');
});
it('dry-run mode adds --dry-run and --json, omits --prune', () => {
const args = buildForgetArgs({ mode: 'keep-last', last: 1 }, filter, { dryRun: true, json: true })!;
expect(args).toContain('--dry-run');
expect(args).toContain('--json');
expect(args).not.toContain('--prune');
});
it('always scopes by the config tag filter and uses empty group-by', () => {
const args = buildForgetArgs({ mode: 'keep-last', last: 1 }, filter)!;
const tagIdx = args.indexOf('--tag');
expect(args[tagIdx + 1]).toBe(filter);
const gIdx = args.indexOf('--group-by');
expect(args[gIdx + 1]).toBe('');
});
});
describe('checkWouldWipe — refuse a delete-everything policy', () => {
it('flags a policy that keeps nothing and removes everything', () => {
const dryRun = JSON.stringify([{ keep: [], remove: [{ id: 'a' }, { id: 'b' }] }]);
expect(checkWouldWipe(dryRun)).toEqual({ wouldWipe: true, keep: 0, remove: 2, total: 2 });
});
it('does NOT flag a normal prune (keeps some)', () => {
const dryRun = JSON.stringify([{ keep: [{ id: 'a' }], remove: [{ id: 'b' }] }]);
expect(checkWouldWipe(dryRun)).toEqual({ wouldWipe: false, keep: 1, remove: 1, total: 2 });
});
it('does NOT flag when nothing is old enough to remove', () => {
const dryRun = JSON.stringify([{ keep: [{ id: 'a' }], remove: [] }]);
expect(checkWouldWipe(dryRun).wouldWipe).toBe(false);
});
it('sums across multiple groups', () => {
const dryRun = JSON.stringify([
{ keep: [], remove: [{ id: 'a' }] },
{ keep: [], remove: [{ id: 'b' }] },
]);
expect(checkWouldWipe(dryRun)).toEqual({ wouldWipe: true, keep: 0, remove: 2, total: 2 });
});
it('empty / garbage / non-array → not a wipe (fail safe: no false positive)', () => {
expect(checkWouldWipe('').wouldWipe).toBe(false);
expect(checkWouldWipe('not json').wouldWipe).toBe(false);
expect(checkWouldWipe('{"not":"array"}').wouldWipe).toBe(false);
});
});
+137
View File
@@ -0,0 +1,137 @@
/**
* Unit tests for backups/route-guards.ts — the RBAC + per-environment access
* layer for backup routes (closes the cross-env snapshot/config bypass, task
* #14). Security-critical + fail-closed, so every deny branch is pinned here.
*
* The module transitively imports the DB/engine via ./index, so we mock.module()
* ./index and ../../db BEFORE importing it — the guard LOGIC under test is pure
* (decisions over an injected AuthorizationContext + the two resolver results).
*/
import { describe, it, expect, mock, beforeAll } from 'bun:test';
// Controllable fakes for the heavy imports, set per-test.
let resolveResult: { envId: number | null | undefined; resolved: boolean } | (() => never) = { envId: 5, resolved: true };
let configById: (id: number) => any = () => null;
mock.module('../../src/lib/server/backups/index', () => ({
resolveSnapshotEnvId: async () => {
if (typeof resolveResult === 'function') resolveResult();
return resolveResult;
},
filterSnapshotsByAccessibleEnv: async (snaps: any[], can: (id: number) => Promise<boolean>) => {
const out: any[] = [];
for (const s of snaps) {
const envTag = (s.tags ?? []).find((t: string) => t.startsWith('dockhand:envid='));
const v = envTag ? envTag.slice('dockhand:envid='.length) : null;
if (v === 'local' || v == null) { out.push(s); continue; } // unscoped kept
const n = Number(v);
if (Number.isInteger(n) && (await can(n))) out.push(s); // else dropped (fail closed)
}
return out;
},
}));
mock.module('../../src/lib/server/db', () => ({
getBackupConfig: async (id: number) => configById(id),
}));
let rg: typeof import('../../src/lib/server/backups/route-guards');
beforeAll(async () => { rg = await import('../../src/lib/server/backups/route-guards'); });
/** Build an AuthorizationContext with just the fields the guards read. */
function auth(over: Partial<{ authEnabled: boolean; isEnterprise: boolean; can: boolean; accessibleEnvs: number[] | 'all' }> = {}): any {
const accessible = over.accessibleEnvs ?? 'all';
return {
authEnabled: over.authEnabled ?? true,
isEnterprise: over.isEnterprise ?? true,
can: async () => over.can ?? true,
canAccessEnvironment: async (id: number) => accessible === 'all' || accessible.includes(id),
};
}
const status = (r: Response | null) => (r ? r.status : null);
describe('requireBackups — global RBAC gate', () => {
it('allows when auth is disabled (no gate)', async () => {
expect(await rg.requireBackups(auth({ authEnabled: false, can: false }), 'manage')).toBeNull();
});
it('allows when the permission is granted', async () => {
expect(await rg.requireBackups(auth({ can: true }), 'view')).toBeNull();
});
it('DENIES with 403 when the permission is missing', async () => {
expect(status(await rg.requireBackups(auth({ can: false }), 'manage'))).toBe(403);
});
});
describe('loadConfigGateEnv — config load + per-env gate in one', () => {
it('400 on a non-numeric id', async () => {
const r = await rg.loadConfigGateEnv('abc', auth());
expect('response' in r && r.response.status).toBe(400);
});
it('404 when the config does not exist', async () => {
configById = () => null;
const r = await rg.loadConfigGateEnv('7', auth());
expect('response' in r && r.response.status).toBe(404);
});
it('403 when enterprise caller lacks access to the config env', async () => {
configById = (id) => ({ id, environmentId: 9 });
const r = await rg.loadConfigGateEnv('7', auth({ isEnterprise: true, accessibleEnvs: [1, 2] }));
expect('response' in r && r.response.status).toBe(403);
});
it('returns the config when access is allowed', async () => {
configById = (id) => ({ id, environmentId: 2 });
const r = await rg.loadConfigGateEnv('7', auth({ accessibleEnvs: [2] }));
expect('config' in r && r.config.id).toBe(7);
});
it('does NOT env-gate a non-enterprise caller (free edition)', async () => {
configById = (id) => ({ id, environmentId: 9 });
const r = await rg.loadConfigGateEnv('7', auth({ isEnterprise: false, accessibleEnvs: [] }));
expect('config' in r).toBe(true);
});
it('does NOT env-gate a config with no environmentId', async () => {
configById = (id) => ({ id, environmentId: null });
const r = await rg.loadConfigGateEnv('7', auth({ accessibleEnvs: [] }));
expect('config' in r).toBe(true);
});
});
describe('guardSnapshotEnvAccess — per-env snapshot content gate (fail closed)', () => {
it('no-op (null) for a non-enterprise caller', async () => {
expect(await rg.guardSnapshotEnvAccess(auth({ isEnterprise: false }), 1, 'snap')).toBeNull();
});
it('DENIES (403) when the snapshot env cannot be resolved (fail closed)', async () => {
resolveResult = { envId: undefined, resolved: false };
expect(status(await rg.guardSnapshotEnvAccess(auth(), 1, 'snap'))).toBe(403);
});
it('DENIES (403) when resolving the env THROWS (fail closed)', async () => {
resolveResult = () => { throw new Error('restic down'); };
expect(status(await rg.guardSnapshotEnvAccess(auth(), 1, 'snap'))).toBe(403);
});
it('allows an unscoped (local, envId=null) snapshot', async () => {
resolveResult = { envId: null, resolved: true };
expect(await rg.guardSnapshotEnvAccess(auth({ accessibleEnvs: [] }), 1, 'snap')).toBeNull();
});
it('DENIES (403) when the caller cannot access the resolved env', async () => {
resolveResult = { envId: 9, resolved: true };
expect(status(await rg.guardSnapshotEnvAccess(auth({ accessibleEnvs: [1] }), 1, 'snap'))).toBe(403);
});
it('allows when the caller CAN access the resolved env', async () => {
resolveResult = { envId: 3, resolved: true };
expect(await rg.guardSnapshotEnvAccess(auth({ accessibleEnvs: [3] }), 1, 'snap')).toBeNull();
});
});
describe('filterSnapshotsByEnvAccess — drop inaccessible-env snapshots (list branch)', () => {
const mk = (envid: string) => ({ id: envid, tags: [`dockhand:envid=${envid}`] });
it('returns all snapshots unchanged for a non-enterprise caller', async () => {
const snaps = [mk('9'), mk('local')];
expect(await rg.filterSnapshotsByEnvAccess(auth({ isEnterprise: false }), snaps)).toHaveLength(2);
});
it('keeps accessible + local, DROPS inaccessible (fail closed)', async () => {
const snaps = [mk('3'), mk('9'), mk('local')];
const out = await rg.filterSnapshotsByEnvAccess(auth({ accessibleEnvs: [3] }), snaps);
const ids = out.map((s: any) => s.id).sort();
expect(ids).toEqual(['3', 'local']); // env 9 dropped
});
it('is a no-op on an empty list', async () => {
expect(await rg.filterSnapshotsByEnvAccess(auth(), [])).toEqual([]);
});
});
+167
View File
@@ -0,0 +1,167 @@
/**
* Unit tests for backups/security.ts — the path-containment + snapshot-path
* guards, plus a sanity check that the re-exported hardened primitives still
* reject the known-dangerous inputs (env leak, flag injection, SSRF).
*/
import { describe, it, expect } from 'bun:test';
import {
isSafeVolumeInclude,
assertSafeVolumeInclude,
isPathWithin,
assertTargetWithin,
isSafePathSegment,
buildResticEnv,
sanitizeResticFlags,
filterCloudEnvVars,
isSafeWebhookUrl,
unsafeRestoreTargetReason,
assertSafeRestoreTarget,
} from '../../src/lib/server/backups/security';
describe('isSafeVolumeInclude (command-injection guard on restore includes)', () => {
it('accepts plain /volumes/<name>', () => {
expect(isSafeVolumeInclude('/volumes/data')).toBe(true);
expect(isSafeVolumeInclude('/volumes/my-vol_1.2')).toBe(true);
});
it('accepts the bare /metadata dir (config-only snapshot restore)', () => {
expect(isSafeVolumeInclude('/metadata')).toBe(true);
// only the exact literal — no arbitrary subpath under /metadata
expect(isSafeVolumeInclude('/metadata/evil')).toBe(false);
expect(isSafeVolumeInclude('/metadata/')).toBe(false);
});
it('rejects anything with shell metacharacters or a different root', () => {
expect(isSafeVolumeInclude('/volumes/$(id)')).toBe(false);
expect(isSafeVolumeInclude('/volumes/a;rm -rf /')).toBe(false);
expect(isSafeVolumeInclude('/volumes/a b')).toBe(false);
expect(isSafeVolumeInclude('/etc/passwd')).toBe(false);
expect(isSafeVolumeInclude('/volumes/')).toBe(false);
expect(isSafeVolumeInclude('/volumes/../etc')).toBe(false);
});
it('assert variant throws on bad input', () => {
expect(() => assertSafeVolumeInclude('/volumes/ok')).not.toThrow();
expect(() => assertSafeVolumeInclude('/volumes/$(x)')).toThrow(/Unsafe restore include/);
});
});
describe('isPathWithin (restore-target containment)', () => {
it('accepts a path under the root, including the root itself', () => {
expect(isPathWithin('/data/stacks', '/data/stacks')).toBe(true);
expect(isPathWithin('/data/stacks', '/data/stacks/web')).toBe(true);
expect(isPathWithin('/data/stacks', '/data/stacks/web/compose.yaml')).toBe(true);
});
it('rejects a sibling-prefix escape (the classic containment bug)', () => {
// /data/stacks-evil is NOT under /data/stacks even though it shares the prefix
expect(isPathWithin('/data/stacks', '/data/stacks-evil')).toBe(false);
});
it('rejects a traversal escape', () => {
expect(isPathWithin('/data/stacks', '/data/stacks/../../etc/cron.d')).toBe(false);
expect(isPathWithin('/data/stacks', '/etc/passwd')).toBe(false);
});
it('assert variant throws with a clear message', () => {
expect(() => assertTargetWithin('/data/stacks', '/data/stacks/ok')).not.toThrow();
expect(() => assertTargetWithin('/data/stacks', '/etc', 'stack dir')).toThrow(/stack dir.*outside/);
});
});
describe('isSafePathSegment (name traversal guard)', () => {
it('accepts a plain name', () => {
expect(isSafePathSegment('web')).toBe(true);
expect(isSafePathSegment('my-stack_1')).toBe(true);
});
it('rejects empty, separators, and dot-dot', () => {
expect(isSafePathSegment('')).toBe(false);
expect(isSafePathSegment('a/b')).toBe(false);
expect(isSafePathSegment('a\\b')).toBe(false);
expect(isSafePathSegment('..')).toBe(false);
expect(isSafePathSegment('.')).toBe(false);
expect(isSafePathSegment('a..b')).toBe(false);
});
});
describe('re-exported hardened primitives still reject dangerous inputs', () => {
it('buildResticEnv never leaks the master key / arbitrary process env', () => {
const env = buildResticEnv(
{ ENCRYPTION_KEY: 'secret', DATABASE_URL: 'postgres://x', PATH: '/usr/bin' },
{ repository: 'repo', password: 'pw', envVars: {} }
);
expect(env.ENCRYPTION_KEY).toBeUndefined();
expect(env.DATABASE_URL).toBeUndefined();
expect(env.RESTIC_REPOSITORY).toBe('repo');
expect(env.RESTIC_PASSWORD).toBe('pw');
});
it('buildResticEnv drops PATH/LD_PRELOAD injection via destination envVars', () => {
const env = buildResticEnv(
{},
{ repository: 'r', password: 'p', envVars: { LD_PRELOAD: '/evil.so', PATH: '/evil', AWS_ACCESS_KEY_ID: 'ok' } }
);
expect(env.LD_PRELOAD).toBeUndefined();
// a real cloud credential still passes the allowlist
expect(env.AWS_ACCESS_KEY_ID).toBe('ok');
});
it('filterCloudEnvVars keeps cloud creds, drops the rest', () => {
const out = filterCloudEnvVars({ AWS_SECRET_ACCESS_KEY: 'k', EVIL: 'x', PATH: '/evil' });
expect(out.AWS_SECRET_ACCESS_KEY).toBe('k');
expect(out.EVIL).toBeUndefined();
expect(out.PATH).toBeUndefined();
});
it('sanitizeResticFlags rejects dangerous flags, accepts allowlisted ones', () => {
expect(sanitizeResticFlags('--limit-upload=1000')).toEqual(['--limit-upload=1000']);
expect(() => sanitizeResticFlags('--password-command=evil')).toThrow();
expect(() => sanitizeResticFlags('--option=x')).toThrow();
});
it('sanitizeResticFlags handles space-separated values without treating them as flags', () => {
// The value token after a space-separated value-taking flag legitimately
// does not start with `--` and must NOT be rejected as a bare arg.
expect(sanitizeResticFlags('--retry-lock 10m')).toEqual(['--retry-lock', '10m']);
// A value-taking flag followed by another flag: value consumed, flag kept.
expect(sanitizeResticFlags('--limit-upload 500 --no-cache')).toEqual(['--limit-upload', '500', '--no-cache']);
expect(sanitizeResticFlags('--compression max --json')).toEqual(['--compression', 'max', '--json']);
});
it('sanitizeResticFlags rejects a value-taking flag with no value, and bare non-flag args', () => {
expect(() => sanitizeResticFlags('--retry-lock')).toThrow(/missing its value/);
// A bare token that isn't the value of a preceding value-taking flag is an arg, not a flag.
expect(() => sanitizeResticFlags('evil')).toThrow(/Disallowed/);
// The allowlist is case-sensitive: an upper-cased known flag is NOT allowed.
expect(() => sanitizeResticFlags('--LIMIT-UPLOAD=1')).toThrow(/Disallowed/);
});
it('sanitizeResticFlags treats empty / whitespace / nullish input as no flags', () => {
expect(sanitizeResticFlags('')).toEqual([]);
expect(sanitizeResticFlags(' ')).toEqual([]);
expect(sanitizeResticFlags(null)).toEqual([]);
expect(sanitizeResticFlags(undefined)).toEqual([]);
});
it('isSafeWebhookUrl blocks loopback/private/metadata and non-http', () => {
expect(isSafeWebhookUrl('https://example.com/hook').ok).toBe(true);
expect(isSafeWebhookUrl('http://169.254.169.254/latest').ok).toBe(false);
expect(isSafeWebhookUrl('http://127.0.0.1/x').ok).toBe(false);
expect(isSafeWebhookUrl('http://10.0.0.5/x').ok).toBe(false);
expect(isSafeWebhookUrl('file:///etc/passwd').ok).toBe(false);
});
});
describe('unsafeRestoreTargetReason (new-location targetPath containment)', () => {
it('accepts legitimate operator-chosen restore locations', () => {
for (const p of ['/mnt/restore', '/srv/appA/data', '/home/user/backup', '/opt/data', '/data/x', '/usr/local/share/app']) {
expect(unsafeRestoreTargetReason(p)).toBeNull();
}
});
it('rejects protected system roots and their children', () => {
for (const p of ['/', '/etc', '/etc/cron.d', '/root/.ssh', '/var/run/docker.sock', '/bin', '/usr/../etc', '/var/lib/docker/volumes']) {
expect(unsafeRestoreTargetReason(p)).not.toBeNull();
}
});
it('rejects relative, empty, and traversal-escaping paths', () => {
expect(unsafeRestoreTargetReason('')).not.toBeNull();
expect(unsafeRestoreTargetReason('relative/path')).not.toBeNull();
expect(unsafeRestoreTargetReason('/srv/../etc')).not.toBeNull(); // resolves into /etc
expect(unsafeRestoreTargetReason('/etc/')).not.toBeNull(); // trailing slash normalizes
});
it('assertSafeRestoreTarget throws for unsafe, passes for safe', () => {
expect(() => assertSafeRestoreTarget('/etc/passwd')).toThrow();
expect(() => assertSafeRestoreTarget('/')).toThrow();
expect(() => assertSafeRestoreTarget('/mnt/restore')).not.toThrow();
});
});
+196
View File
@@ -0,0 +1,196 @@
/**
* 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();
}
});
});
+184
View File
@@ -0,0 +1,184 @@
/**
* Unit tests for backups/snapshots.ts — the ownership guards that keep one
* installation / environment from reading another's backups on a shared repo.
* The restic reader and access context are injected fakes, so no repo is needed.
*/
import { describe, it, expect } from 'bun:test';
import {
assertInstanceOwned,
assertEnvAccess,
guardSnapshotAccess,
listSnapshots,
resolveSnapshotEnvId,
filterSnapshotsByAccessibleEnv,
type ResticReader,
type AccessContext,
} from '../../src/lib/server/backups/snapshots';
import type { ResticRun } from '../../src/lib/server/backups/models';
const SNAP = 'a'.repeat(64);
/** A restic reader whose `snapshots` call returns a fixed set of snapshots. */
function reader(snapshots: Array<{ id: string; tags: string[] }>, exitCode = 0): ResticReader {
return {
async runLocal(): Promise<ResticRun> {
const stdout = JSON.stringify(snapshots.map((s) => ({ id: s.id, short_id: s.id.slice(0, 8), tags: s.tags })));
return { exitCode, stdout, stderr: exitCode === 0 ? '' : 'restic error' };
},
};
}
const freeAccess: AccessContext = { isEnterprise: false, canAccessEnvironment: async () => true };
function enterprise(allowed: number[]): AccessContext {
return { isEnterprise: true, canAccessEnvironment: async (id) => allowed.includes(id) };
}
describe('assertInstanceOwned', () => {
it('returns the tags when the snapshot is our own', async () => {
const r = reader([{ id: SNAP, tags: ['dockhand:instance=me', 'dockhand:envid=3'] }]);
const tags = await assertInstanceOwned(r, {}, 'me', SNAP);
expect(tags).toContain('dockhand:envid=3');
});
it('NON-ENTERPRISE allows a FOREIGN Dockhand snapshot (orphan from a copied/shared repo) - #1351', async () => {
// A snapshot written by another instance is a legitimate restorable orphan in free.
const r = reader([{ id: SNAP, tags: ['dockhand:instance=OTHER', 'dockhand:envid=1'] }]);
const tags = await assertInstanceOwned(r, {}, 'me', SNAP, false);
expect(tags).toContain('dockhand:instance=OTHER');
});
it('ENTERPRISE still refuses a foreign snapshot (strict per-instance; enterprise does not copy repos)', async () => {
// Enterprise queries with the instance tag; restic returns nothing for a foreign snap.
const r = reader([]); // instance-filtered query finds nothing
await expect(assertInstanceOwned(r, {}, 'me', SNAP, true)).rejects.toThrow(/not found for this installation/);
});
it('refuses a NON-DOCKHAND snapshot (another app sharing the repo has no dockhand:instance tag)', async () => {
const r = reader([{ id: SNAP, tags: ['borgapp:host=nas'] }]);
await expect(assertInstanceOwned(r, {}, 'me', SNAP, false)).rejects.toThrow(/not found for this installation/);
});
it('refuses when the snapshot id is absent from the repo', async () => {
const r = reader([]);
await expect(assertInstanceOwned(r, {}, 'me', SNAP, false)).rejects.toThrow(/not found for this installation/);
});
it('rejects an invalid snapshot id before touching restic', async () => {
const r = reader([]);
await expect(assertInstanceOwned(r, {}, 'me', 'nothex!')).rejects.toThrow(/invalid snapshot id/);
});
it('surfaces a restic failure (not a silent empty)', async () => {
const r = reader([], 1);
await expect(assertInstanceOwned(r, {}, 'me', SNAP)).rejects.toThrow(/could not verify snapshot ownership/);
});
});
describe('assertEnvAccess — resolves env from the snapshot tag, fails closed', () => {
it('is a no-op for non-enterprise', async () => {
await expect(assertEnvAccess(freeAccess, [])).resolves.toBeUndefined();
});
it('allows an env the caller can access', async () => {
await expect(assertEnvAccess(enterprise([3]), ['dockhand:envid=3'])).resolves.toBeUndefined();
});
it('denies an env the caller cannot access', async () => {
await expect(assertEnvAccess(enterprise([1]), ['dockhand:envid=3'])).rejects.toThrow(/access denied/);
});
it('allows an unscoped (local) snapshot', async () => {
await expect(assertEnvAccess(enterprise([]), ['dockhand:envid=local'])).resolves.toBeUndefined();
});
it('FAILS CLOSED when the env tag is absent/malformed', async () => {
await expect(assertEnvAccess(enterprise([3]), ['no env tag'])).rejects.toThrow(/could not resolve/);
await expect(assertEnvAccess(enterprise([3]), ['dockhand:envid=abc'])).rejects.toThrow(/could not resolve/);
});
});
describe('guardSnapshotAccess — instance AND env, in order', () => {
it('passes for our snapshot in an accessible env', async () => {
const r = reader([{ id: SNAP, tags: ['dockhand:instance=me', 'dockhand:envid=3'] }]);
await expect(guardSnapshotAccess(r, {}, 'me', SNAP, enterprise([3]))).resolves.toBeUndefined();
});
it('blocks a foreign-instance snapshot before the env check', async () => {
const r = reader([]); // not ours
await expect(guardSnapshotAccess(r, {}, 'me', SNAP, enterprise([3]))).rejects.toThrow(/not found/);
});
it('blocks our snapshot in an inaccessible env', async () => {
const r = reader([{ id: SNAP, tags: ['dockhand:instance=me', 'dockhand:envid=9'] }]);
await expect(guardSnapshotAccess(r, {}, 'me', SNAP, enterprise([3]))).rejects.toThrow(/access denied/);
});
});
describe('listSnapshots — filters to accessible environments (enterprise)', () => {
const snaps = [
{ id: 'x'.repeat(64), tags: ['dockhand:instance=me', 'dockhand:envid=3'] },
{ id: 'y'.repeat(64), tags: ['dockhand:instance=me', 'dockhand:envid=9'] },
{ id: 'z'.repeat(64), tags: ['dockhand:instance=me', 'dockhand:envid=local'] },
];
it('non-enterprise sees all of its own snapshots', async () => {
const out = await listSnapshots(reader(snaps), {}, 'me', freeAccess);
expect(out).toHaveLength(3);
});
it('enterprise sees only accessible envs plus unscoped', async () => {
const out = await listSnapshots(reader(snaps), {}, 'me', enterprise([3]));
// env 3 (allowed) + local (unscoped); env 9 filtered out
expect(out.map((s) => s.tags.find((t) => t.startsWith('dockhand:envid'))).sort())
.toEqual(['dockhand:envid=3', 'dockhand:envid=local']);
});
it('enterprise FAILS CLOSED on a snapshot with an absent or malformed env tag (would otherwise leak a scoped snapshot)', async () => {
const scoped = [
{ id: 'a'.repeat(64), tags: ['dockhand:instance=me', 'dockhand:envid=3'] }, // allowed
{ id: 'b'.repeat(64), tags: ['dockhand:instance=me'] }, // NO env tag -> drop
{ id: 'c'.repeat(64), tags: ['dockhand:instance=me', 'dockhand:envid=abc'] }, // malformed -> drop
{ id: 'd'.repeat(64), tags: ['dockhand:instance=me', 'dockhand:envid=9'] }, // not allowed -> drop
];
const out = await listSnapshots(reader(scoped), {}, 'me', enterprise([3]));
expect(out.map((s) => s.id.slice(0, 1))).toEqual(['a']); // ONLY the allowed, env-tagged one
});
it('surfaces a restic error instead of returning []', async () => {
await expect(listSnapshots(reader([], 1), {}, 'me', freeAccess)).rejects.toThrow();
});
});
describe('resolveSnapshotEnvId — stable-id env resolution, fail closed', () => {
it('resolves a numeric env from the snapshot tag', async () => {
const r = reader([{ id: SNAP, tags: ['dockhand:instance=me', 'dockhand:envid=7'] }]);
expect(await resolveSnapshotEnvId(r, {}, 'me', SNAP)).toEqual({ envId: 7, resolved: true });
});
it('treats a local snapshot as unscoped (envId null, resolved)', async () => {
const r = reader([{ id: SNAP, tags: ['dockhand:instance=me', 'dockhand:envid=local'] }]);
expect(await resolveSnapshotEnvId(r, {}, 'me', SNAP)).toEqual({ envId: null, resolved: true });
});
it('fails closed when the snapshot is not ours (empty result)', async () => {
const r = reader([]);
expect(await resolveSnapshotEnvId(r, {}, 'me', SNAP)).toEqual({ envId: undefined, resolved: false });
});
it('fails closed on a restic error', async () => {
const r = reader([], 1);
expect(await resolveSnapshotEnvId(r, {}, 'me', SNAP)).toEqual({ envId: undefined, resolved: false });
});
it('fails closed when the env tag is absent/malformed', async () => {
const r = reader([{ id: SNAP, tags: ['dockhand:instance=me'] }]);
expect(await resolveSnapshotEnvId(r, {}, 'me', SNAP)).toEqual({ envId: undefined, resolved: false });
});
it('fails closed on an invalid snapshot id without touching restic', async () => {
const r = reader([{ id: SNAP, tags: ['dockhand:envid=7'] }]);
expect(await resolveSnapshotEnvId(r, {}, 'me', 'nothex!')).toEqual({ envId: undefined, resolved: false });
});
});
describe('filterSnapshotsByAccessibleEnv — stable-id env disclosure guard', () => {
const snaps = [
{ tags: ['dockhand:envid=3'], id: 'a' },
{ tags: ['dockhand:envid=9'], id: 'b' },
{ tags: ['dockhand:envid=local'], id: 'c' },
{ tags: ['no env tag'], id: 'd' },
];
it('keeps accessible envs + unscoped, drops inaccessible and unresolvable', async () => {
const out = await filterSnapshotsByAccessibleEnv(snaps, async (id) => id === 3);
expect(out.map((s) => s.id)).toEqual(['a', 'c']); // env 3 + local; 9 denied; no-tag dropped
});
it('memoises the access check per env id', async () => {
let calls = 0;
const many = [
{ tags: ['dockhand:envid=3'], id: '1' },
{ tags: ['dockhand:envid=3'], id: '2' },
{ tags: ['dockhand:envid=3'], id: '3' },
];
await filterSnapshotsByAccessibleEnv(many, async () => { calls++; return true; });
expect(calls).toBe(1);
});
});
+318
View File
@@ -0,0 +1,318 @@
/**
* Unit tests for resolveHostStackDir — the ONE decision point for WHICH host folder is
* captured as a stack's directory. ONE FLOW: the helper always bind-mounts the host stack
* folder. The candidate path comes from the compose `working_dir` label ALONE (ground truth
* for where compose ran on the target daemon's host - staged, matching-path, or local); a
* runtime probe (not tested here) then proves it's reachable.
*/
import { describe, test, expect } from 'bun:test';
import {
resolveHostStackDir,
hostStackDirFromBind,
deriveStackDirFromBinds,
trustBindDerivedForEnv,
parseProbeListing,
tagCapturedEntries,
isLocalDaemon,
STACKDIR_VOLUME_KEY,
isReservedVolumeKey,
stackDirSource,
type HostStackDirInput,
} from '../../src/lib/server/backups/stackdir-plan';
const base = (over: Partial<HostStackDirInput> = {}): HostStackDirInput => ({
composeFileName: 'docker-compose.yml',
bindDerivedHostPath: null,
dataDirHostPath: null,
mountHostPath: null,
workingDirLabel: null,
...over,
});
describe('resolveHostStackDir — host path in priority order (bind-derived first, label last)', () => {
test('bindDerivedHostPath wins over EVERYTHING (daemon-authoritative)', () => {
const r = resolveHostStackDir(base({
bindDerivedHostPath: '/docker/data/dockhand/stacks/anton/pppppp',
dataDirHostPath: '/somewhere/else',
workingDirLabel: '/app/data/stacks/anton/pppppp',
}));
expect(r.kind).toBe('candidate');
if (r.kind === 'candidate') {
expect(r.hostPath).toBe('/docker/data/dockhand/stacks/anton/pppppp');
expect(r.source).toContain('bind');
}
});
test('remoteStacksDirHostPath (direct-remote, user-configured) wins over data/mount/label', () => {
// A direct-remote env with remote_stacks_dir set: the deploy staged the files at this
// explicit host path, so it beats the (untrustworthy for direct-remote) label and the
// local-only translations.
const r = resolveHostStackDir(base({
remoteStacksDirHostPath: '/opt/dockhand/stacks/myapp',
dataDirHostPath: '/docker/data/dockhand/stacks/myapp',
workingDirLabel: '/app/data/stacks/myapp',
}));
expect(r.kind).toBe('candidate');
if (r.kind === 'candidate') {
expect(r.hostPath).toBe('/opt/dockhand/stacks/myapp');
expect(r.source).toContain('remote_stacks_dir');
}
});
test('a relative bind still pins the dir more exactly than remote_stacks_dir', () => {
const r = resolveHostStackDir(base({
bindDerivedHostPath: '/opt/dockhand/stacks/myapp',
remoteStacksDirHostPath: '/opt/dockhand/stacks/myapp',
}));
expect(r.kind).toBe('candidate');
if (r.kind === 'candidate') expect(r.source).toContain('bind');
});
test('SOCKET/LOCAL: DATA_DIR->HOST_DATA_DIR translation wins over the working_dir label', () => {
// The regression from the label-only resolver: on a socket env Dockhand deploys under
// /app/data (its CONTAINER view). The helper on the host needs /docker/data/dockhand/...
// The working_dir label is the container path and must NOT be used when the DATA_DIR
// translation is available.
const r = resolveHostStackDir(base({
dataDirHostPath: '/docker/data/dockhand/stacks/anton/pppppp',
workingDirLabel: '/app/data/stacks/anton/pppppp', // container view - the wrong one
}));
expect(r.kind).toBe('candidate');
if (r.kind === 'candidate') {
expect(r.hostPath).toBe('/docker/data/dockhand/stacks/anton/pppppp');
expect(r.hostPath).not.toBe('/app/data/stacks/anton/pppppp'); // never the container path
expect(r.source).toContain('DATA_DIR');
}
});
test('ADOPTED/EXTERNAL: mount translation used when not under DATA_DIR', () => {
const r = resolveHostStackDir(base({ dataDirHostPath: null, mountHostPath: '/opt/stacks/blog', workingDirLabel: '/app/data/x' }));
expect(r.kind === 'candidate' && r.hostPath).toBe('/opt/stacks/blog');
expect((r as any).source).toContain('mount');
});
test('HAWSER / matching-paths: working_dir label used when no translation applies', () => {
// A hawser agent ran compose on the remote host, so the label IS the host path.
const r = resolveHostStackDir(base({ dataDirHostPath: null, mountHostPath: null, workingDirLabel: '/data/stacks/immich' }));
expect(r.kind === 'candidate' && r.hostPath).toBe('/data/stacks/immich');
expect((r as any).source).toContain('working_dir');
});
test('composeFile taken from composeFileName (authoritative), NOT config_files', () => {
const r = resolveHostStackDir(base({ composeFileName: 'immich.yaml', dataDirHostPath: '/h/x' }));
expect(r.kind === 'candidate' && r.composeFile).toBe('immich.yaml');
});
test("composeFileName '-' (stdin deploy) falls back to docker-compose.yml, never probes for '-'", () => {
// Dockhand deploys via `-f -` (stdin) so config_files is `-`. composeFileName is `-` too
// when derived from that; the resolver must NOT build a probe path ending in `/-`.
const r = resolveHostStackDir(base({ composeFileName: '-', dataDirHostPath: '/h/x' }));
expect(r.kind === 'candidate' && r.composeFile).toBe('docker-compose.yml');
});
test('null composeFileName falls back to docker-compose.yml', () => {
const r = resolveHostStackDir(base({ composeFileName: null, dataDirHostPath: '/h/x' }));
expect(r.kind === 'candidate' && r.composeFile).toBe('docker-compose.yml');
});
test('trailing slashes on the chosen host path are normalized', () => {
expect((resolveHostStackDir(base({ dataDirHostPath: '/h/x/' })) as any).hostPath).toBe('/h/x');
expect((resolveHostStackDir(base({ mountHostPath: '/m/y///' })) as any).hostPath).toBe('/m/y');
});
test('NO BINDS: falls back to DATA_DIR translation (socket stack with only named volumes)', () => {
// A stack with no bind mounts (only named volumes, or none) can't be bind-derived, so
// bindDerivedHostPath is null. On socket the DATA_DIR translation still resolves it.
const r = resolveHostStackDir(base({ bindDerivedHostPath: null, dataDirHostPath: '/docker/data/dockhand/stacks/x' }));
expect(r.kind === 'candidate' && r.hostPath).toBe('/docker/data/dockhand/stacks/x');
});
test('NO BINDS + no translation + no label -> unknown (conscious hard-fail, never a silent skip)', () => {
// The remaining gap the user worried about: no bind to derive from AND no translation
// (self-inspect failed / HOST_DATA_DIR unknown) AND no label. This is a CONSCIOUS
// unknown -> the caller hard-fails the stack backup rather than capturing an empty dir.
expect(resolveHostStackDir(base({ bindDerivedHostPath: null, dataDirHostPath: null, mountHostPath: null, workingDirLabel: null })).kind).toBe('unknown');
});
});
describe('hostStackDirFromBind — derive the stack dir from a relative bind + daemon source', () => {
test('the anton case: ./html on host /docker/data/dockhand/stacks/anton/pppppp/html', () => {
expect(hostStackDirFromBind('./html', '/docker/data/dockhand/stacks/anton/pppppp/html'))
.toBe('/docker/data/dockhand/stacks/anton/pppppp');
});
test('nested relative dir ./conf/nginx strips the whole tail', () => {
expect(hostStackDirFromBind('./conf/nginx', '/srv/stacks/blog/conf/nginx')).toBe('/srv/stacks/blog');
});
test('whole-dir bind ./ (or .) -> the host source IS the stack dir', () => {
expect(hostStackDirFromBind('./', '/srv/stacks/blog')).toBe('/srv/stacks/blog');
expect(hostStackDirFromBind('.', '/srv/stacks/blog')).toBe('/srv/stacks/blog');
});
test('trailing slash on the host source is normalized before stripping', () => {
expect(hostStackDirFromBind('./html', '/srv/blog/html/')).toBe('/srv/blog');
});
test('ABSOLUTE bind source -> null (not relative, cannot derive)', () => {
expect(hostStackDirFromBind('/opt/data', '/opt/data')).toBeNull();
});
test('../ source (above the stack dir) -> null', () => {
expect(hostStackDirFromBind('../shared', '/srv/shared')).toBeNull();
});
test('host source that does NOT end with the relative tail -> null (inconsistent, do not guess)', () => {
expect(hostStackDirFromBind('./html', '/srv/blog/public')).toBeNull();
});
test('non-absolute host source -> null', () => {
expect(hostStackDirFromBind('./html', 'relative/html')).toBeNull();
});
});
describe('deriveStackDirFromBinds — match compose relative dirs to discovered bind sources', () => {
test('finds the stack dir from the matching bind source', () => {
expect(deriveStackDirFromBinds(['html'], ['/docker/data/dockhand/stacks/anton/pppppp/html']))
.toBe('/docker/data/dockhand/stacks/anton/pppppp');
});
test('picks the first relative dir that has a consistent source', () => {
// data has no matching source; html does.
expect(deriveStackDirFromBinds(['data', 'html'], ['/srv/blog/html'])).toBe('/srv/blog');
});
test('no matching source -> null (caller falls back to translation/label)', () => {
expect(deriveStackDirFromBinds(['html'], ['/unrelated/path'])).toBeNull();
});
test('no relative dirs -> null', () => {
expect(deriveStackDirFromBinds([], ['/srv/blog/html'])).toBeNull();
});
});
describe('trustBindDerivedForEnv — distrust the phantom bind path on direct-remote stdin deploys', () => {
const P = '/app/data/stacks/test-direct/myapp'; // a phantom Dockhand-container path
test('direct-remote + NO remote_stacks_dir -> null (falls through to tar; the #4921 regression)', () => {
// The stdin deploy made the remote daemon mkdir a phantom empty dir at Dockhand's own
// container path; trusting it made the probe mount an empty dir and HARD-FAIL the backup.
expect(trustBindDerivedForEnv(P, { directRemote: true, hasRemoteStacksDir: false })).toBeNull();
});
test('direct-remote WITH remote_stacks_dir -> kept (deploy staged the files, bind pins the real path)', () => {
expect(trustBindDerivedForEnv(P, { directRemote: true, hasRemoteStacksDir: true })).toBe(P);
});
test('NOT direct-remote (socket / direct-local / adopted) -> kept (daemon shares Dockhand\'s host)', () => {
expect(trustBindDerivedForEnv(P, { directRemote: false, hasRemoteStacksDir: false })).toBe(P);
});
test('hawser (directRemote=false) -> kept (agent ran compose on its own host, mount.Source is real)', () => {
expect(trustBindDerivedForEnv('/opt/stacks/immich', { directRemote: false, hasRemoteStacksDir: false }))
.toBe('/opt/stacks/immich');
});
test('already-null bind-derived stays null regardless of env', () => {
expect(trustBindDerivedForEnv(null, { directRemote: false, hasRemoteStacksDir: false })).toBeNull();
expect(trustBindDerivedForEnv(null, { directRemote: true, hasRemoteStacksDir: true })).toBeNull();
});
});
describe('isLocalDaemon — DATA_DIR/mount translation is only valid on Dockhand\'s own host', () => {
test('socket / null -> local', () => {
expect(isLocalDaemon('socket', null, null)).toBe(true);
expect(isLocalDaemon(null, null, null)).toBe(true);
});
test('hawser -> never local (the rambo case: remote host, translation would be wrong)', () => {
expect(isLocalDaemon('hawser-standard', null, 'tcp://x:2375')).toBe(false);
expect(isLocalDaemon('hawser-edge', null, null)).toBe(false);
});
test('direct -> local ONLY when the env tcp host == Dockhand\'s own docker host', () => {
expect(isLocalDaemon('direct', 'tcp://h:2375', 'tcp://h:2375')).toBe(true);
expect(isLocalDaemon('direct', 'tcp://rambo:2375', 'tcp://anton:2375')).toBe(false);
expect(isLocalDaemon('direct', 'tcp://h:2375', null)).toBe(false);
});
});
describe('stack-dir reserved-key helpers (unchanged)', () => {
test('the reserved key is recognized', () => {
expect(isReservedVolumeKey(STACKDIR_VOLUME_KEY)).toBe(true);
expect(isReservedVolumeKey('my_data')).toBe(false);
});
test('stackDirSource points at the single /volumes location restore reads', () => {
expect(stackDirSource().include).toBe(`/volumes/${STACKDIR_VOLUME_KEY}`);
expect(stackDirSource().extractSub).toBe(`volumes/${STACKDIR_VOLUME_KEY}`);
});
});
describe('parseProbeListing — the host probe `find -printf` listing', () => {
test('parses dirs and files with sizes, dirs first then alpha', () => {
const out = parseProbeListing('f\t155\tcompose.yaml\nd\t4096\tmedia\nd\t4096\tconfig\nf\t9\t.env');
expect(out).toEqual([
{ name: 'config', type: 'dir', size: 4096 },
{ name: 'media', type: 'dir', size: 4096 },
{ name: '.env', type: 'file', size: 9 },
{ name: 'compose.yaml', type: 'file', size: 155 },
]);
});
test('names with spaces survive (tab-delimited, not space)', () => {
const out = parseProbeListing('f\t10\tmy config.yaml');
expect(out).toEqual([{ name: 'my config.yaml', type: 'file', size: 10 }]);
});
test('non-d type is treated as file', () => {
const out = parseProbeListing('l\t0\tsymlink');
expect(out[0]).toEqual({ name: 'symlink', type: 'file', size: 0 });
});
test('empty stdout -> empty list', () => {
expect(parseProbeListing('')).toEqual([]);
expect(parseProbeListing('\n\n')).toEqual([]);
});
test('malformed lines (missing tabs) are skipped', () => {
const out = parseProbeListing('garbage line\nf\t5\tgood.txt');
expect(out).toEqual([{ name: 'good.txt', type: 'file', size: 5 }]);
});
test('non-numeric size falls back to 0', () => {
expect(parseProbeListing('f\tNaN\tx')[0].size).toBe(0);
});
});
describe('tagCapturedEntries — mark stack-dir entries that are also a bind', () => {
const entries = [
{ name: 'config', type: 'dir' as const, size: 4096 },
{ name: 'uploads', type: 'dir' as const, size: 4096 },
{ name: 'compose.yaml', type: 'file' as const, size: 100 },
{ name: 'README.txt', type: 'file' as const, size: 20 }
];
test('bind-dir entries get capturedAs:bind; loose files stay untagged', () => {
const out = tagCapturedEntries(entries, '/opt/immich', ['/opt/immich/config', '/opt/immich/uploads']);
expect(out.find((e) => e.name === 'config')?.capturedAs).toBe('bind');
expect(out.find((e) => e.name === 'uploads')?.capturedAs).toBe('bind');
expect(out.find((e) => e.name === 'compose.yaml')?.capturedAs).toBeUndefined();
expect(out.find((e) => e.name === 'README.txt')?.capturedAs).toBeUndefined(); // the DATA_OLD case
});
test('trailing slash on hostPath or source is tolerated', () => {
const out = tagCapturedEntries([{ name: 'data', type: 'dir', size: 1 }], '/opt/app/', ['/opt/app/data/']);
expect(out[0].capturedAs).toBe('bind');
});
test('a bind OUTSIDE the stack dir does not match an equally-named entry', () => {
// entry 'media' under /opt/app, but the only bind source is /mnt/media (absolute, elsewhere)
const out = tagCapturedEntries([{ name: 'media', type: 'dir', size: 1 }], '/opt/app', ['/mnt/media']);
expect(out[0].capturedAs).toBeUndefined();
});
test('no bind sources -> nothing tagged', () => {
const out = tagCapturedEntries(entries, '/opt/immich', []);
expect(out.every((e) => e.capturedAs === undefined)).toBe(true);
});
// STOPPED stack: the daemon reports no bind sources (containers aren't running), so the
// compose bind-dir names are what tag `./memos` as captured-by-bind — without this, a stopped
// stack's bind dir wrongly shows as a plain, deselectable stack file.
test('compose bind-dir names tag entries even when runtime bindSources is empty', () => {
const es = [
{ name: 'memos', type: 'dir' as const, size: 4096 },
{ name: 'compose.yaml', type: 'file' as const, size: 100 }
];
const out = tagCapturedEntries(es, '/docker/data/stacks/x', [], ['memos']);
expect(out.find((e) => e.name === 'memos')?.capturedAs).toBe('bind');
expect(out.find((e) => e.name === 'compose.yaml')?.capturedAs).toBeUndefined();
});
test('bindSources and bindDirNames are unioned (either tags an entry)', () => {
const es = [
{ name: 'data', type: 'dir' as const, size: 1 }, // via bindSources
{ name: 'memos', type: 'dir' as const, size: 1 } // via bindDirNames only
];
const out = tagCapturedEntries(es, '/opt/app', ['/opt/app/data'], ['memos']);
expect(out.find((e) => e.name === 'data')?.capturedAs).toBe('bind');
expect(out.find((e) => e.name === 'memos')?.capturedAs).toBe('bind');
});
test('a name NOT in bindDirNames and NOT a bind source stays untagged', () => {
const out = tagCapturedEntries([{ name: 'readme.txt', type: 'file', size: 10 }], '/opt/app', [], ['memos']);
expect(out[0].capturedAs).toBeUndefined();
});
});
+215
View File
@@ -0,0 +1,215 @@
/**
* Unit tests for stackfile-filter — deciding which paths under a stack dir are
* bind-mounted DATA (already a restic volume, exclude) vs load-bearing config (keep).
*
* The exclusion is derived by PARSING THE COMPOSE FILE for relative bind sources, not
* from docker Mount.Source (a containerized Dockhand can't see the host-side source, so
* keying on it silently no-ops). These tests feed synthetic compose strings + an
* injected isDirRel and assert the derived relative dirs.
*/
import { describe, test, expect } from 'bun:test';
import { relativeBindDirsFromCompose, relativeBindsFromCompose, isUnderRelDir, isLoadBearingStackFile } from '../../src/lib/server/backups/stackfile-filter';
const STACK = '/app/data/stacks/env/name';
const allDirs = () => true; // treat every candidate as a directory
const noDirs = () => false; // treat every candidate as a file
describe('relativeBindsFromCompose — files AND dirs (for host-path derivation)', () => {
test('a SINGLE-FILE bind is included (dirs-only misses it)', () => {
const c = `services:\n app:\n volumes:\n - ./config.yaml:/config.yaml`;
// dirs-only (isDirRel=false for a file) drops it; the all-binds variant keeps it so a
// single-file-bind stack can derive its host path without the working_dir label.
expect(relativeBindDirsFromCompose(c, STACK, noDirs)).toEqual([]);
expect(relativeBindsFromCompose(c, STACK)).toEqual(['config.yaml']);
});
test('files and dirs together', () => {
const c = `services:\n app:\n volumes:\n - ./config.yaml:/c\n - ./data:/d`;
expect(relativeBindsFromCompose(c, STACK).sort()).toEqual(['config.yaml', 'data']);
});
test('named volumes and escaping/whole-dir binds are excluded', () => {
const c = `services:\n app:\n volumes:\n - mydata:/x\n - ../outside:/y\n - ./:/z`;
expect(relativeBindsFromCompose(c, STACK)).toEqual([]);
});
});
describe('relativeBindDirsFromCompose — short syntax', () => {
test('./data and ./config relative binds -> [data, config]', () => {
const c = `services:
web:
volumes:
- ./data:/var/lib/data
- ./config:/etc/app:ro`;
expect(relativeBindDirsFromCompose(c, STACK, allDirs).sort()).toEqual(['config', 'data']);
});
test('bare "data:/x" (no ./) is a NAMED VOLUME per compose spec, not a bind -> []', () => {
// Compose treats a bare name as a named volume; a host bind must be ./data or /abs.
const c = `services:\n w:\n volumes:\n - data:/x`;
expect(relativeBindDirsFromCompose(c, STACK, allDirs)).toEqual([]);
});
test('a NAMED volume (db_data:/x) is never excluded', () => {
// A bare name with no path-ish prefix is a named volume, not a bind.
const c = `services:\n db:\n volumes:\n - db_data:/var/lib/postgresql/data`;
// db_data has no slash/dot -> treated as named volume -> dropped.
// (Also, isDirRel would be false for a name that isn't a real subdir, but the
// parser drops it before that.)
expect(relativeBindDirsFromCompose(c, STACK, allDirs)).toEqual([]);
});
});
describe('relativeBindDirsFromCompose — long syntax', () => {
test('{ type: bind, source: ./config } -> [config]', () => {
const c = `services:
web:
volumes:
- type: bind
source: ./config
target: /etc/app`;
expect(relativeBindDirsFromCompose(c, STACK, allDirs)).toEqual(['config']);
});
test('{ type: volume, source: db } is NOT excluded', () => {
const c = `services:
db:
volumes:
- type: volume
source: dbdata
target: /data`;
expect(relativeBindDirsFromCompose(c, STACK, allDirs)).toEqual([]);
});
});
describe('relativeBindDirsFromCompose — edge cases', () => {
test('single-FILE bind (./config.yaml) is not a dir -> not excluded', () => {
const c = `services:\n w:\n volumes:\n - ./config.yaml:/etc/app/config.yaml`;
expect(relativeBindDirsFromCompose(c, STACK, noDirs)).toEqual([]);
});
test('../sibling escapes the stack dir -> dropped', () => {
const c = `services:\n w:\n volumes:\n - ../sibling:/x`;
expect(relativeBindDirsFromCompose(c, STACK, allDirs)).toEqual([]);
});
test('absolute bind pointing INTO the stack dir -> relativized and kept', () => {
const c = `services:\n w:\n volumes:\n - ${STACK}/data:/x`;
expect(relativeBindDirsFromCompose(c, STACK, allDirs)).toEqual(['data']);
});
test('absolute bind OUTSIDE the stack dir -> dropped', () => {
const c = `services:\n w:\n volumes:\n - /var/lib/other:/x`;
expect(relativeBindDirsFromCompose(c, STACK, allDirs)).toEqual([]);
});
test('a ${VAR} in the path -> left captured (conservative), not excluded', () => {
const c = 'services:\n w:\n volumes:\n - ./data-${ENV}:/x';
expect(relativeBindDirsFromCompose(c, STACK, allDirs)).toEqual([]);
});
test('trailing slash and ./ prefix normalise the same', () => {
const c = `services:\n w:\n volumes:\n - ./data/:/x`;
expect(relativeBindDirsFromCompose(c, STACK, allDirs)).toEqual(['data']);
});
test('malformed / empty compose -> []', () => {
expect(relativeBindDirsFromCompose('', STACK, allDirs)).toEqual([]);
expect(relativeBindDirsFromCompose(':::not yaml:::', STACK, allDirs)).toEqual([]);
expect(relativeBindDirsFromCompose('services: {}', STACK, allDirs)).toEqual([]);
});
});
describe('relativeBindDirsFromCompose — exotic real-world compose (users have imagination)', () => {
const c = (vol: string) => `services:\n w:\n volumes:\n - ${vol}`;
test('mode suffixes ro/rw/z/Z are stripped -> [data]', () => {
for (const m of ['ro', 'rw', 'z', 'Z', 'cached', 'delegated']) {
expect(relativeBindDirsFromCompose(c(`./data:/x:${m}`), STACK, allDirs)).toEqual(['data']);
}
});
test('quoted sources (single and double) -> [data]', () => {
expect(relativeBindDirsFromCompose(c('"./data:/x"'), STACK, allDirs)).toEqual(['data']);
expect(relativeBindDirsFromCompose(c("'./data:/x'"), STACK, allDirs)).toEqual(['data']);
});
test('a space in the path is preserved', () => {
expect(relativeBindDirsFromCompose(c('"./my data:/x"'), STACK, allDirs)).toEqual(['my data']);
});
test('a nested relative dir ./a/b -> [a/b]', () => {
expect(relativeBindDirsFromCompose(c('./a/b:/x'), STACK, allDirs)).toEqual(['a/b']);
});
test('WHOLE-DIR bind (. or ./) excludes NOTHING (would strip config sidecars) -> []', () => {
expect(relativeBindDirsFromCompose(c('.:/app'), STACK, allDirs)).toEqual([]);
expect(relativeBindDirsFromCompose(c('./:/app'), STACK, allDirs)).toEqual([]);
// an absolute source equal to the stack dir is the same whole-dir case
expect(relativeBindDirsFromCompose(c(`${STACK}:/app`), STACK, allDirs)).toEqual([]);
});
test('${VAR} forms are all left captured (never dropped): default, bare, mid-path', () => {
expect(relativeBindDirsFromCompose(c('${DATADIR:-./data}:/x'), STACK, allDirs)).toEqual([]);
expect(relativeBindDirsFromCompose(c('./data-${ENV}:/x'), STACK, allDirs)).toEqual([]);
});
test('a bare $VAR (no braces) or ~ home is not a recognised relative bind -> []', () => {
expect(relativeBindDirsFromCompose(c('$DATADIR/data:/x'), STACK, allDirs)).toEqual([]);
expect(relativeBindDirsFromCompose(c('~/data:/x'), STACK, allDirs)).toEqual([]);
});
test('a Windows-style source (C:\\\\...) is not a posix relative/abs bind -> []', () => {
expect(relativeBindDirsFromCompose(c('C:\\\\stuff:/x'), STACK, allDirs)).toEqual([]);
});
test('long syntax with read_only / propagation still resolves the bind', () => {
const ro = `services:\n w:\n volumes:\n - type: bind\n source: ./data\n target: /x\n read_only: true`;
expect(relativeBindDirsFromCompose(ro, STACK, allDirs)).toEqual(['data']);
const prop = `services:\n w:\n volumes:\n - type: bind\n source: ./cfg\n target: /x\n bind:\n propagation: rslave`;
expect(relativeBindDirsFromCompose(prop, STACK, allDirs)).toEqual(['cfg']);
});
test('long syntax WITHOUT type but a relative source is treated as a bind', () => {
const c2 = `services:\n w:\n volumes:\n - source: ./data\n target: /x`;
expect(relativeBindDirsFromCompose(c2, STACK, allDirs)).toEqual(['data']);
});
test('the SAME bind across two services is de-duplicated', () => {
const c2 = `services:\n a:\n volumes:\n - ./data:/x\n b:\n volumes:\n - ./data:/y`;
expect(relativeBindDirsFromCompose(c2, STACK, allDirs)).toEqual(['data']);
});
test('a top-level `volumes:` named-volume declaration is ignored (only service volumes count)', () => {
const c2 = `services:\n w:\n volumes:\n - ./data:/x\nvolumes:\n named_one:`;
expect(relativeBindDirsFromCompose(c2, STACK, allDirs)).toEqual(['data']);
});
test('null volumes: / service without volumes / empty string entry -> []', () => {
expect(relativeBindDirsFromCompose('services:\n w:\n volumes:', STACK, allDirs)).toEqual([]);
expect(relativeBindDirsFromCompose('services:\n w:\n image: nginx', STACK, allDirs)).toEqual([]);
expect(relativeBindDirsFromCompose(c('""'), STACK, allDirs)).toEqual([]);
});
test('a mix in one service: some excluded, some kept, some ignored', () => {
const c2 = `services:
app:
volumes:
- ./data:/data:ro # dir bind -> excluded
- ./config.yaml:/etc/c.yaml # single file -> kept
- db_vol:/var/lib/db # named volume -> ignored
- /etc/localtime:/etc/localtime:ro # abs outside -> ignored
- ../shared:/shared # escapes -> ignored`;
const isDir = (r: string) => r === 'data'; // only ./data is a dir; config.yaml is a file
expect(relativeBindDirsFromCompose(c2, STACK, isDir)).toEqual(['data']);
});
});
describe('isLoadBearingStackFile — never excluded even under a whole-dir bind', () => {
test('.env and .env.* and yaml/yml are load-bearing', () => {
expect(isLoadBearingStackFile('.env')).toBe(true);
expect(isLoadBearingStackFile('.env.production')).toBe(true);
expect(isLoadBearingStackFile('compose.yaml')).toBe(true);
expect(isLoadBearingStackFile('docker-compose.yml')).toBe(true);
expect(isLoadBearingStackFile('includes/base.yaml')).toBe(true);
});
test('data blobs are not load-bearing', () => {
expect(isLoadBearingStackFile('data/db.sqlite')).toBe(false);
expect(isLoadBearingStackFile('config/nginx.conf')).toBe(false);
});
});
describe('isUnderRelDir', () => {
const dirs = ['data', 'config'];
test('the dir itself and files inside it match', () => {
expect(isUnderRelDir('data', dirs)).toBe(true);
expect(isUnderRelDir('data/db.sqlite', dirs)).toBe(true);
expect(isUnderRelDir('config/nginx.conf', dirs)).toBe(true);
});
test('root config files do not match', () => {
expect(isUnderRelDir('compose.yaml', dirs)).toBe(false);
expect(isUnderRelDir('.env', dirs)).toBe(false);
});
test('segment-aware: a prefix-sharing sibling does not match', () => {
expect(isUnderRelDir('data-backup/x', dirs)).toBe(false);
expect(isUnderRelDir('datastore', dirs)).toBe(false);
});
test('no dirs -> nothing matches', () => {
expect(isUnderRelDir('data/db', [])).toBe(false);
});
});
+271
View File
@@ -0,0 +1,271 @@
/**
* Real-filesystem integration tests for backups/swap.ts. The generated shell
* runs against a temp directory that stands in for a live volume mount, proving
* the safety guarantees of the in-place restore swap WITHOUT needing Docker or
* restic:
* - a clean swap replaces the live content and exits 0
* - a FAILED restic step leaves the live content byte-for-byte intact
* - a kill between the two move-phases is detectable and fully recovered
* - a fresh restore over an interrupted swap recovers the original first and
* never destroys the sole surviving copy
*/
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
buildInPlaceRestoreScript,
buildSwapRecoveryScript,
SWAP_NEW,
SWAP_OLD,
SWAP_PHASE,
SWAP_ARTIFACTS,
} from '../../src/lib/server/backups/swap';
/** Run a script via a temp file (no nested-quoting hazard). Returns exit code. */
function runScript(script: string): number {
const dir = mkdtempSync(join(tmpdir(), 'swap-sh-'));
const file = join(dir, 'run.sh');
writeFileSync(file, script);
try {
execFileSync('sh', [file], { stdio: 'pipe' });
return 0;
} catch (e: any) {
return e.status ?? 1;
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
/** Read the non-artifact contents of a live dir as { name: content }. */
function readLive(live: string): Record<string, string> {
const out: Record<string, string> = {};
for (const name of readdirSync(live)) {
if ((SWAP_ARTIFACTS as readonly string[]).includes(name)) continue;
out[name] = readFileSync(join(live, name), 'utf8');
}
return out;
}
function artifacts(live: string): string[] {
return readdirSync(live).filter((n) => (SWAP_ARTIFACTS as readonly string[]).includes(n));
}
/** A sandbox with a live dir; seed its initial + staged (restored) content. */
function sandbox(opts: { live: Record<string, string>; staged?: Record<string, string> | null }) {
const root = mkdtempSync(join(tmpdir(), 'swap-'));
const live = join(root, 'volumes', 'data');
mkdirSync(live, { recursive: true });
for (const [n, c] of Object.entries(opts.live)) writeFileSync(join(live, n), c);
if (opts.staged) {
mkdirSync(join(live, SWAP_NEW), { recursive: true });
for (const [n, c] of Object.entries(opts.staged)) writeFileSync(join(live, SWAP_NEW, n), c);
}
return { root, live, cleanup: () => rmSync(root, { recursive: true, force: true }) };
}
describe('buildInPlaceRestoreScript — happy path', () => {
it('replaces live content with the restored content and exits 0', () => {
const { live, cleanup } = sandbox({
live: { 'old.txt': 'OLD', 'stale.txt': 'STALE' },
staged: { 'restored.txt': 'NEW', 'config.yaml': 'v: 2' },
});
try {
// resticCmd is a no-op — staging is pre-seeded; we test the swap only.
const code = runScript(buildInPlaceRestoreScript([live], 'true'));
assert.equal(code, 0);
assert.deepEqual(readLive(live), { 'restored.txt': 'NEW', 'config.yaml': 'v: 2' });
assert.deepEqual(artifacts(live), [], 'all swap artifacts cleaned up');
} finally { cleanup(); }
});
it('leaves live untouched when the restore produced no staged content (no NEW dir)', () => {
const { live, cleanup } = sandbox({ live: { 'keep.txt': 'KEEP' }, staged: null });
try {
assert.equal(runScript(buildInPlaceRestoreScript([live], 'true')), 0);
assert.deepEqual(readLive(live), { 'keep.txt': 'KEEP' });
} finally { cleanup(); }
});
});
describe('buildInPlaceRestoreScript — failure keeps live data intact', () => {
it('a failing restic step aborts before the swap; live is byte-for-byte intact', () => {
const { live, cleanup } = sandbox({
live: { 'important.txt': 'DO NOT LOSE ME', 'db.sqlite': 'REAL DATA' },
staged: { 'restored.txt': 'NEW' },
});
try {
// resticCmd fails → set -e aborts the whole script BEFORE any swap.
const code = runScript(buildInPlaceRestoreScript([live], 'false'));
assert.notEqual(code, 0, 'a failed restore must exit non-zero');
assert.deepEqual(readLive(live), { 'important.txt': 'DO NOT LOSE ME', 'db.sqlite': 'REAL DATA' });
} finally { cleanup(); }
});
});
/** Freeze a live dir in a mid-swap state, as an external kill would leave it. */
function makeInterrupted(opts: {
phase: '2' | '3' | '';
liveEntries: Record<string, string>;
oldEntries: Record<string, string>;
newEntries?: Record<string, string>;
}) {
const root = mkdtempSync(join(tmpdir(), 'swap-int-'));
const live = join(root, 'volumes', 'data');
mkdirSync(join(live, SWAP_OLD), { recursive: true });
mkdirSync(join(live, SWAP_NEW), { recursive: true });
writeFileSync(join(live, SWAP_PHASE), opts.phase);
for (const [n, c] of Object.entries(opts.liveEntries)) writeFileSync(join(live, n), c);
for (const [n, c] of Object.entries(opts.oldEntries)) writeFileSync(join(live, SWAP_OLD, n), c);
for (const [n, c] of Object.entries(opts.newEntries ?? {})) writeFileSync(join(live, SWAP_NEW, n), c);
return { root, live, cleanup: () => rmSync(root, { recursive: true, force: true }) };
}
describe('buildSwapRecoveryScript — rolls back an interrupted swap', () => {
it('phase 2 (emptying live interrupted): originals split across live and OLD are reassembled', () => {
const { live, cleanup } = makeInterrupted({
phase: '2',
liveEntries: { 'not-yet-moved.txt': 'A' },
oldEntries: { 'already-moved.txt': 'B', 'db.sqlite': 'REAL' },
newEntries: { 'restored.txt': 'NEW' },
});
try {
assert.equal(runScript(buildSwapRecoveryScript([live])), 0);
assert.deepEqual(readLive(live), { 'not-yet-moved.txt': 'A', 'already-moved.txt': 'B', 'db.sqlite': 'REAL' });
assert.deepEqual(artifacts(live), []);
} finally { cleanup(); }
});
it('phase 3 (refilling live interrupted): partial NEW content is dropped, ALL originals restored', () => {
const { live, cleanup } = makeInterrupted({
phase: '3',
liveEntries: { 'restored-a.txt': 'NEW-A' }, // step-3 got this far
oldEntries: { 'important.txt': 'DO NOT LOSE ME', 'db.sqlite': 'REAL' },
newEntries: { 'restored-b.txt': 'NEW-B' },
});
try {
assert.equal(runScript(buildSwapRecoveryScript([live])), 0);
assert.deepEqual(readLive(live), { 'important.txt': 'DO NOT LOSE ME', 'db.sqlite': 'REAL' });
assert.deepEqual(artifacts(live), []);
} finally { cleanup(); }
});
it('empty marker (killed mid marker-write) is treated as phase 2', () => {
const { live, cleanup } = makeInterrupted({ phase: '', liveEntries: {}, oldEntries: { 'everything.txt': 'ORIG' } });
try {
assert.equal(runScript(buildSwapRecoveryScript([live])), 0);
assert.deepEqual(readLive(live), { 'everything.txt': 'ORIG' });
} finally { cleanup(); }
});
it('no marker: recovery is a NO-OP (never touches a healthy volume)', () => {
const root = mkdtempSync(join(tmpdir(), 'swap-noop-'));
const live = join(root, 'volumes', 'data');
mkdirSync(live, { recursive: true });
writeFileSync(join(live, 'live.txt'), 'LIVE');
try {
assert.equal(runScript(buildSwapRecoveryScript([live])), 0);
assert.deepEqual(readLive(live), { 'live.txt': 'LIVE' });
} finally { rmSync(root, { recursive: true, force: true }); }
});
});
describe('fresh restore over an interrupted swap — never destroys the last copy', () => {
it('recovers the originals first, then applies the new restore cleanly', () => {
// Start from a phase-3 interrupted state, then run a FRESH restore. Recovery
// runs BEFORE restic re-stages (the correct ordering), so we model restic as
// a command that (re)creates the staging NEW dir with the second restore's
// content — exactly what `restic restore --target <live>` does in reality.
const { live, cleanup } = makeInterrupted({
phase: '3',
liveEntries: { 'restored-a.txt': 'NEW-A' },
oldEntries: { 'orig.txt': 'ORIGINAL' },
});
try {
// Model restic: mkdir the staging dir and write the new restored content.
const restic = `mkdir -p '${live}/${SWAP_NEW}'; printf 'NEW-2' > '${live}/${SWAP_NEW}/second.txt'`;
assert.equal(runScript(buildInPlaceRestoreScript([live], restic)), 0);
assert.deepEqual(readLive(live), { 'second.txt': 'NEW-2' }, 'fresh restore applied after recovery');
assert.deepEqual(artifacts(live), []);
} finally { cleanup(); }
});
it('recovery-before-restic: a phase-3 original is restored even before restic re-stages', () => {
// Prove the ordering: with recovery first, the interrupted originals are
// back in the live root at the moment restic runs (restic then re-stages).
const { live, cleanup } = makeInterrupted({
phase: '3',
liveEntries: { 'half-applied.txt': 'PARTIAL' },
oldEntries: { 'orig.txt': 'ORIGINAL' },
});
try {
// restic here asserts the original is already present, then stages NEW.
const restic = `test -f '${live}/orig.txt'; mkdir -p '${live}/${SWAP_NEW}'; printf 'X' > '${live}/${SWAP_NEW}/new.txt'`;
assert.equal(runScript(buildInPlaceRestoreScript([live], restic)), 0);
assert.deepEqual(readLive(live), { 'new.txt': 'X' });
} finally { cleanup(); }
});
it('if the fresh restore aborts, the recovered originals survive (never lost)', () => {
// Phase-2 interrupted state; a fresh restore whose restic step FAILS.
// The recovery runs inside swapOne, but swapOne only runs if NEW exists —
// so with a failing restic and NO pre-seeded NEW, the originals in OLD must
// still be recoverable by an explicit recovery pass (what the service does).
const { live, cleanup } = makeInterrupted({
phase: '2',
liveEntries: { 'a.txt': 'A' },
oldEntries: { 'b.txt': 'B' },
});
try {
// The service always runs recovery before/around a restore; prove the
// standalone recovery restores the originals regardless of a later abort.
assert.equal(runScript(buildSwapRecoveryScript([live])), 0);
assert.deepEqual(readLive(live), { 'a.txt': 'A', 'b.txt': 'B' });
} finally { cleanup(); }
});
});
describe('SWAP_ARTIFACTS — single source of truth', () => {
it('is exactly the three artifact basenames', () => {
assert.deepEqual([...SWAP_ARTIFACTS].sort(), ['.dockhand-restore-new', '.dockhand-restore-old', '.dockhand-restore-phase']);
});
it('the swap script references every artifact and skips them in the move loops', () => {
const script = buildInPlaceRestoreScript(['/volumes/data'], 'true');
for (const a of SWAP_ARTIFACTS) assert.ok(script.includes(a), `references ${a}`);
const guard = script.match(/case "\$e" in ([^)]*)\)/);
assert.ok(guard, 'has an artifact-skip guard');
for (const a of SWAP_ARTIFACTS) assert.ok(guard![1].includes(a), `skip-guard lists ${a}`);
});
});
describe('recovery narration is gated on the phase marker', () => {
// The user must be told when a re-run rolls back an interrupted prior swap,
// but a normal restore (no marker) must stay quiet. The narration lives INSIDE
// the `if [ -f ...phase ]` branch, so it only fires when there is something to recover.
it('emits a "Recovering ... rolling back" line inside the marker branch', () => {
const script = buildInPlaceRestoreScript(['/volumes/data'], 'true');
assert.ok(/Recovering .*rolling back an interrupted previous restore/.test(script), 'has the recovery narration');
});
it('a real recovery run actually prints the line to stderr only when a marker is present', () => {
const dir = mkdtempSync(join(tmpdir(), 'swap-narr-'));
const live = join(dir, 'data');
try {
// No marker: a recovery pass must be silent.
mkdirSync(live, { recursive: true });
writeFileSync(join(live, 'keep'), 'x');
const quiet = execFileSync('sh', ['-c', buildSwapRecoveryScript([live])], { stdio: 'pipe' }).toString();
assert.equal(quiet, '', 'no marker → no narration');
// Marker present (interrupted phase 2): recovery must announce itself.
writeFileSync(join(live, SWAP_PHASE), '2');
mkdirSync(join(live, SWAP_OLD), { recursive: true });
const noisy = execFileSync('sh', ['-c', buildSwapRecoveryScript([live]) + ' 2>&1'], { stdio: 'pipe' }).toString();
assert.ok(noisy.includes('rolling back an interrupted previous restore'), 'marker → narration printed');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
+215
View File
@@ -0,0 +1,215 @@
/**
* Unit tests for backups/tar.ts — the tar builder used to put-archive
* metadata/stack files into the backup helper (replacing the inline-base64 Cmd
* that blew ARG_MAX). buildTar wraps modern-tar, so these tests lock the two
* things we depend on: byte-exact round-trips of file CONTENT, and — the bug that
* broke many users' stack backups — PATHS far longer than the 100-byte USTAR
* name field (`.git/objects/…`, `cache/images/…`, deep trees), which the old
* hand-rolled writer rejected. We extract with modern-tar's own unpackTar so the
* assertions reflect what a real PAX-aware reader (restic's) sees.
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { unpackTar } from 'modern-tar';
import { buildTar, buildTarStream, type TarEntry, type TarFileSource } from '../../src/lib/server/backups/tar';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const enc = new TextEncoder();
const dec = new TextDecoder();
const bytelen = (s: string) => enc.encode(s).length;
/** Extract a tar (built by buildTar) back into { path -> content bytes }. */
async function extractTar(tar: Uint8Array): Promise<Record<string, Uint8Array>> {
const out: Record<string, Uint8Array> = {};
for (const e of await unpackTar(tar)) {
out[e.header.name] = e.data ?? new Uint8Array();
}
return out;
}
/** Drain a web ReadableStream<Uint8Array> into one Uint8Array. */
async function drainStream(rs: ReadableStream<Uint8Array>): Promise<Uint8Array> {
const reader = rs.getReader();
const chunks: Uint8Array[] = [];
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (value) chunks.push(value);
}
const total = chunks.reduce((n, c) => n + c.length, 0);
const out = new Uint8Array(total);
let o = 0;
for (const c of chunks) { out.set(c, o); o += c.length; }
return out;
}
/** Ordered list of entry names from a tar (to assert inline-before-sources ordering). */
async function tarNames(tar: Uint8Array): Promise<string[]> {
return (await unpackTar(tar)).map((e) => e.header.name);
}
describe('buildTar', () => {
it('round-trips a single small file byte-for-byte', async () => {
const tar = await buildTar([{ path: 'metadata/metadata.json', content: enc.encode('{"hello":"world"}') }], 1000);
expect(dec.decode((await extractTar(tar))['metadata/metadata.json'])).toBe('{"hello":"world"}');
});
it('round-trips multiple files including nested stack paths', async () => {
const entries: TarEntry[] = [
{ path: 'metadata/metadata.json', content: enc.encode('{}') },
{ path: 'metadata/stacks/web/compose.yaml', content: enc.encode('services: {}') },
{ path: 'metadata/stacks/web/stackfiles/.env', content: enc.encode('A=1\nB=2\n') },
];
const parsed = await extractTar(await buildTar(entries, 1000));
expect(Object.keys(parsed).sort()).toEqual([
'metadata/metadata.json',
'metadata/stacks/web/compose.yaml',
'metadata/stacks/web/stackfiles/.env',
]);
expect(dec.decode(parsed['metadata/stacks/web/stackfiles/.env'])).toBe('A=1\nB=2\n');
});
it('round-trips a >1 MB file exactly — the ARG_MAX case', async () => {
const big = new Uint8Array(3 * 1024 * 1024);
for (let i = 0; i < big.length; i++) big[i] = (i * 31 + 7) & 0xff;
const parsed = await extractTar(await buildTar([{ path: 'metadata/stacks/x/stackfiles/big.bin', content: big }], 1000));
expect(Buffer.from(parsed['metadata/stacks/x/stackfiles/big.bin']).equals(Buffer.from(big))).toBe(true);
});
it('strips a leading slash from entry paths', async () => {
const parsed = await extractTar(await buildTar([{ path: '/metadata/x', content: new Uint8Array([1]) }], 1000));
expect(Object.keys(parsed)).toEqual(['metadata/x']);
});
// ── Long paths: the bug (backup broke with "USTAR name field >100 bytes") ──
it('round-trips the EXACT reported paths (99–107 bytes) that used to fail', async () => {
const reported = [
'metadata/stacks/audiobookshelf/stackfiles/config/migrations/v2.17.4-use-subfolder-for-oidc-redirect-uris.js',
'metadata/stacks/jellyfin/stackfiles/cache/images/resized-images/0/005a4137-deaf-7b24-24ad-8c7de46aa9cb.webp',
'metadata/stacks/myapp/stackfiles/.git/objects/pack/pack-8d3ba5ac644a51bfc926e985bab3cd3afbd6e1dc.idx',
];
const parsed = await extractTar(await buildTar(reported.map((p, i) => ({ path: p, content: enc.encode(`f${i}`) })), 1000));
for (let i = 0; i < reported.length; i++) {
expect(bytelen(reported[i])).toBeGreaterThanOrEqual(100); // at/over the old limit
expect(dec.decode(parsed[reported[i]])).toBe(`f${i}`);
}
expect(reported.some((p) => bytelen(p) > 100)).toBe(true); // at least one strictly over
});
it('round-trips a path in the 101–255-byte range', async () => {
const path = 'metadata/stacks/' + 'deep/'.repeat(20) + 'file.txt';
expect(bytelen(path)).toBeGreaterThan(100);
expect(bytelen(path)).toBeLessThanOrEqual(255);
const parsed = await extractTar(await buildTar([{ path, content: enc.encode('X') }], 1000));
expect(dec.decode(parsed[path])).toBe('X');
});
it('round-trips a path far beyond 255 bytes (needs PAX)', async () => {
const path = 'metadata/stacks/app/stackfiles/' + 'segment/'.repeat(40) + 'leaf.bin';
expect(bytelen(path)).toBeGreaterThan(255);
const parsed = await extractTar(await buildTar([{ path, content: enc.encode('deep') }], 1000));
expect(dec.decode(parsed[path])).toBe('deep');
});
it('round-trips a path whose single SEGMENT exceeds 100 bytes (unsplittable, needs PAX)', async () => {
const path = `metadata/stacks/app/stackfiles/${'z'.repeat(140)}.dat`;
const parsed = await extractTar(await buildTar([{ path, content: enc.encode('seg') }], 1000));
expect(dec.decode(parsed[path])).toBe('seg');
});
it('round-trips a UTF-8 (multi-byte) long path', async () => {
const path = 'metadata/stacks/app/stackfiles/' + 'ćęłż/'.repeat(30) + 'plik.txt';
expect(bytelen(path)).toBeGreaterThan(100);
const parsed = await extractTar(await buildTar([{ path, content: enc.encode('utf') }], 1000));
expect(dec.decode(parsed[path])).toBe('utf');
});
it('mixes short, mid, and very-long paths in one archive', async () => {
const short = 'metadata/metadata.json';
const mid = 'metadata/stacks/web/' + 'x/'.repeat(45) + 'f';
const huge = 'metadata/stacks/web/' + 'y/'.repeat(160) + 'g';
expect(bytelen(huge)).toBeGreaterThan(255);
const parsed = await extractTar(await buildTar([
{ path: short, content: enc.encode('a') },
{ path: mid, content: enc.encode('b') },
{ path: huge, content: enc.encode('c') },
], 1000));
expect(dec.decode(parsed[short])).toBe('a');
expect(dec.decode(parsed[mid])).toBe('b');
expect(dec.decode(parsed[huge])).toBe('c');
});
it('is deterministic for a fixed mtime (incl. a long PAX path)', async () => {
const e = [{ path: 'metadata/stacks/app/stackfiles/' + 'q/'.repeat(160) + 'z', content: enc.encode('x') }];
const a = await buildTar(e, 1000);
const b = await buildTar(e, 1000);
expect(Buffer.from(a).equals(Buffer.from(b))).toBe(true);
});
});
describe('buildTarStream (lazy, disk-sourced, O(1) RAM)', () => {
let dir: string;
const mk = (name: string, bytes: Uint8Array): TarFileSource => {
const p = join(dir, name);
writeFileSync(p, bytes);
return { diskPath: p, archivePath: `volumes/__dockhand_stackdir__/${name}` };
};
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'tarstream-')); });
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
it('round-trips inline entries + disk file sources byte-for-byte', async () => {
const inline: TarEntry[] = [{ path: 'metadata/metadata.json', content: enc.encode('{"v":1}') }];
const big = new Uint8Array(200_000).map((_, i) => i & 0xff);
const sources = [mk('compose.yaml', enc.encode('services: {}\n')), mk('data.bin', big)];
const tar = await drainStream(buildTarStream(sources, inline, 1000));
const files = await extractTar(tar);
expect(dec.decode(files['metadata/metadata.json'])).toBe('{"v":1}');
expect(dec.decode(files['volumes/__dockhand_stackdir__/compose.yaml'])).toBe('services: {}\n');
expect(Buffer.from(files['volumes/__dockhand_stackdir__/data.bin']).equals(Buffer.from(big))).toBe(true);
});
it('writes inline entries BEFORE file sources (order is load-bearing for metadata-first)', async () => {
const inline: TarEntry[] = [{ path: 'metadata/metadata.json', content: enc.encode('m') }];
const sources = [mk('a.txt', enc.encode('a')), mk('b.txt', enc.encode('b'))];
const names = await tarNames(await drainStream(buildTarStream(sources, inline, 1000)));
expect(names[0]).toBe('metadata/metadata.json');
expect(names.slice(1)).toEqual(['volumes/__dockhand_stackdir__/a.txt', 'volumes/__dockhand_stackdir__/b.txt']);
});
it('handles a PAX long path (>100 bytes) from a disk source', async () => {
const longName = 'x/'.repeat(80) + 'deep.txt'; // ~168 bytes, over USTAR 100
const src: TarFileSource = { diskPath: join(dir, 'f'), archivePath: `volumes/${longName}` };
writeFileSync(src.diskPath, enc.encode('deep'));
const files = await extractTar(await drainStream(buildTarStream([src], [], 1000)));
expect(dec.decode(files[`volumes/${longName}`])).toBe('deep');
});
it('strips a leading slash from archive paths', async () => {
const src: TarFileSource = { diskPath: join(dir, 'g'), archivePath: '/volumes/x/y' };
writeFileSync(src.diskPath, enc.encode('z'));
const names = await tarNames(await drainStream(buildTarStream([src], [], 1000)));
expect(names).toEqual(['volumes/x/y']); // no leading slash in the archive
});
it('handles an empty file source (zero bytes)', async () => {
const src = mk('empty', new Uint8Array(0));
const files = await extractTar(await drainStream(buildTarStream([src], [], 1000)));
expect(files['volumes/__dockhand_stackdir__/empty'].length).toBe(0);
});
it('ERRORS the stream if a file GROWS between stat and read (modern-tar enforces the declared size)', async () => {
// Regression for the "size change is tolerated" comment: modern-tar declares
// header.size from statSync but streams from createReadStream; if the file grew,
// the packer throws "exceeds given size" and errors the whole stream. Prove the
// consumer sees a rejection (NOT a silently-truncated tar reported as success).
const src: TarFileSource = { diskPath: join(dir, 'growing'), archivePath: 'volumes/x' };
writeFileSync(src.diskPath, enc.encode('small')); // 5 bytes -> header says size=5
const stream = buildTarStream([src], [], 1000);
// Grow the file AFTER buildTarStream captured the stat but before/while it reads.
writeFileSync(src.diskPath, enc.encode('much-longer-than-five-bytes'));
await expect(drainStream(stream)).rejects.toBeDefined();
});
});
+193
View File
@@ -0,0 +1,193 @@
/**
* Unit tests for backups/validate.ts — pure fail-fast validation. Every
* rejection path is covered, and the all-errors-at-once behaviour is asserted.
*/
import { describe, it, expect } from 'bun:test';
import {
validateBackupConfig,
validateRestoreRequest,
retentionWouldPrune,
assertBackupableWith,
} from '../../src/lib/server/backups/validate';
const okConfig = {
type: 'container',
targetName: 'web',
destinationId: 1,
};
describe('validateBackupConfig', () => {
it('accepts a minimal valid config', () => {
expect(validateBackupConfig(okConfig)).toEqual({ ok: true });
});
it('rejects a bad type', () => {
const r = validateBackupConfig({ ...okConfig, type: 'vm' });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.issues.map((i) => i.field)).toContain('type');
});
it('rejects a missing/blank target', () => {
expect(validateBackupConfig({ ...okConfig, targetName: '' }).ok).toBe(false);
expect(validateBackupConfig({ ...okConfig, targetName: ' ' }).ok).toBe(false);
});
it('rejects a missing/invalid destination', () => {
expect(validateBackupConfig({ ...okConfig, destinationId: null }).ok).toBe(false);
expect(validateBackupConfig({ ...okConfig, destinationId: 0 }).ok).toBe(false);
expect(validateBackupConfig({ ...okConfig, destinationId: -1 }).ok).toBe(false);
});
it('accepts a valid cron and rejects an invalid one', () => {
expect(validateBackupConfig({ ...okConfig, schedule: '0 2 * * *' }).ok).toBe(true);
const r = validateBackupConfig({ ...okConfig, schedule: 'not a cron' });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.issues.map((i) => i.field)).toContain('schedule');
});
it('ignores an empty schedule (manual-only config)', () => {
expect(validateBackupConfig({ ...okConfig, schedule: '' }).ok).toBe(true);
expect(validateBackupConfig({ ...okConfig, schedule: null }).ok).toBe(true);
});
it('rejects invalid retention JSON but accepts a valid blob', () => {
expect(validateBackupConfig({ ...okConfig, retention: '{bad' }).ok).toBe(false);
expect(validateBackupConfig({ ...okConfig, retention: '{"keepLast":5}' }).ok).toBe(true);
expect(validateBackupConfig({ ...okConfig, retention: '' }).ok).toBe(true);
});
it('requires a non-empty selection when allVolumes is false', () => {
expect(validateBackupConfig({ ...okConfig, allVolumes: false, selectedVolumes: [] }).ok).toBe(false);
expect(validateBackupConfig({ ...okConfig, allVolumes: false, selectedVolumes: null }).ok).toBe(false);
expect(validateBackupConfig({ ...okConfig, allVolumes: false, selectedVolumes: ['data'] }).ok).toBe(true);
});
it('reports ALL problems at once, not just the first', () => {
const r = validateBackupConfig({ type: 'vm', targetName: '', destinationId: null, schedule: 'bad' });
expect(r.ok).toBe(false);
if (!r.ok) {
const fields = r.issues.map((i) => i.field).sort();
expect(fields).toEqual(['destinationId', 'schedule', 'targetName', 'type']);
}
});
});
const validSnap = 'a'.repeat(64);
const okInPlace = { destinationId: 1, snapshotId: validSnap, mode: 'in-place' as const, confirmOverwrite: true };
const okNewLoc = { destinationId: 1, snapshotId: validSnap, mode: 'new-location' as const, targetPath: '/restore/here' };
describe('validateRestoreRequest', () => {
it('accepts a confirmed in-place restore', () => {
expect(validateRestoreRequest(okInPlace)).toEqual({ ok: true });
});
it('accepts a new-location restore with a target path', () => {
expect(validateRestoreRequest(okNewLoc)).toEqual({ ok: true });
});
it('rejects a missing/invalid destination or snapshot', () => {
expect(validateRestoreRequest({ ...okInPlace, destinationId: 0 }).ok).toBe(false);
expect(validateRestoreRequest({ ...okInPlace, snapshotId: 'nothex!' }).ok).toBe(false);
expect(validateRestoreRequest({ ...okInPlace, snapshotId: '' }).ok).toBe(false);
});
it('rejects a bad mode', () => {
expect(validateRestoreRequest({ ...okInPlace, mode: 'wipe' as any }).ok).toBe(false);
});
it('REQUIRES explicit confirmation for a destructive in-place restore', () => {
const r = validateRestoreRequest({ ...okInPlace, confirmOverwrite: false });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.issues.map((i) => i.field)).toContain('confirmOverwrite');
// omitted entirely is also rejected
expect(validateRestoreRequest({ destinationId: 1, snapshotId: validSnap, mode: 'in-place' }).ok).toBe(false);
});
it('requires a target path for a new-location restore', () => {
expect(validateRestoreRequest({ ...okNewLoc, targetPath: '' }).ok).toBe(false);
expect(validateRestoreRequest({ ...okNewLoc, targetPath: null }).ok).toBe(false);
});
it('rejects an unsafe stack name (traversal)', () => {
expect(validateRestoreRequest({ ...okInPlace, stackName: '../etc' }).ok).toBe(false);
expect(validateRestoreRequest({ ...okInPlace, stackName: 'a/b' }).ok).toBe(false);
expect(validateRestoreRequest({ ...okInPlace, stackName: 'my-stack' }).ok).toBe(true);
});
it('rejects a new-location targetPath pointing at a protected system dir', () => {
expect(validateRestoreRequest({ ...okNewLoc, targetPath: '/etc' }).ok).toBe(false);
expect(validateRestoreRequest({ ...okNewLoc, targetPath: '/' }).ok).toBe(false);
expect(validateRestoreRequest({ ...okNewLoc, targetPath: '/root/.ssh' }).ok).toBe(false);
expect(validateRestoreRequest({ ...okNewLoc, targetPath: '/srv/../etc' }).ok).toBe(false);
expect(validateRestoreRequest({ ...okNewLoc, targetPath: 'relative' }).ok).toBe(false);
expect(validateRestoreRequest({ ...okNewLoc, targetPath: '/mnt/restore' }).ok).toBe(true);
});
it('reports all problems at once', () => {
const r = validateRestoreRequest({ destinationId: null, snapshotId: 'bad', mode: 'in-place' });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.issues.length).toBeGreaterThanOrEqual(3); // dest + snap + confirm
});
// --- clone (new-location with per-volume destinations) --------------------
const cloneOk = {
destinationId: 1, snapshotId: validSnap, mode: 'new-location' as const,
volumes: ['data'], targetName: 'web', postRestore: 'recreate' as const,
volumeDestinations: [{ volume: 'data', kind: 'volume' as const, target: 'data' }],
};
it('accepts a fully-mapped clone WITHOUT a target path', () => {
// A clone whose volumes all map to destinations extracts nothing loose.
expect(validateRestoreRequest(cloneOk)).toEqual({ ok: true });
});
it('requires a target path when a clone has un-mapped (loose) volumes', () => {
const r = validateRestoreRequest({ ...cloneOk, volumes: ['data', 'logs'] }); // 'logs' un-mapped
expect(r.ok).toBe(false);
if (!r.ok) expect(r.issues.some((i) => i.field === 'targetPath')).toBe(true);
});
it('requires a target name to recreate/redeploy after a clone', () => {
const r = validateRestoreRequest({ ...cloneOk, targetName: '' });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.issues.some((i) => i.field === 'targetName')).toBe(true);
});
it('rejects a non-absolute path destination', () => {
const r = validateRestoreRequest({ ...cloneOk, volumeDestinations: [{ volume: 'data', kind: 'path', target: 'relative/dir' }] });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.issues.some((i) => i.field === 'volumeDestinations')).toBe(true);
});
it('rejects a path destination containing ".."', () => {
const r = validateRestoreRequest({ ...cloneOk, volumeDestinations: [{ volume: 'data', kind: 'path', target: '/srv/../etc' }] });
expect(r.ok).toBe(false);
});
it('rejects a CLEAN absolute path destination inside a protected system dir (arbitrary host write)', () => {
// /etc is absolute with no "..", so the old check passed it - the helper would then mount
// /etc:rw and cp the snapshot over it (root RCE). The protected-root guard must reject it.
for (const target of ['/etc', '/etc/cron.d', '/var/run', '/root/.ssh']) {
const r = validateRestoreRequest({ ...cloneOk, volumeDestinations: [{ volume: 'data', kind: 'path', target }] });
expect(r.ok, `${target} must be rejected`).toBe(false);
if (!r.ok) expect(r.issues.some((i) => i.field === 'volumeDestinations')).toBe(true);
}
});
it('rejects an invalid volume-name destination', () => {
const r = validateRestoreRequest({ ...cloneOk, volumeDestinations: [{ volume: 'data', kind: 'volume', target: 'bad name!' }] });
expect(r.ok).toBe(false);
});
it('rejects a destination for a volume not among the restored set', () => {
const r = validateRestoreRequest({ ...cloneOk, volumeDestinations: [{ volume: 'nope', kind: 'volume', target: 'nope' }] });
expect(r.ok).toBe(false);
});
});
describe('retentionWouldPrune', () => {
it('is false for none/empty, true when configured', () => {
expect(retentionWouldPrune(null)).toBe(false);
expect(retentionWouldPrune('')).toBe(false);
expect(retentionWouldPrune({ keepLast: 5 })).toBe(true);
});
});
describe('assertBackupableWith', () => {
it('a container is always backupable (no compose lookup)', async () => {
let looked = false;
const r = await assertBackupableWith(async () => { looked = true; return {}; }, 'container', 'web');
expect(r).toEqual({ ok: true });
expect(looked).toBe(false);
});
it('a stack with a known compose file is backupable', async () => {
const r = await assertBackupableWith(async () => ({ needsFileLocation: false }), 'stack', 'app');
expect(r).toEqual({ ok: true });
});
it('an external stack (unknown compose file) is refused with a reason', async () => {
const r = await assertBackupableWith(async () => ({ needsFileLocation: true }), 'stack', 'app');
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toMatch(/external/);
});
it('passes the target name and env id through to the lookup', async () => {
let seen: [string, number | undefined] = ['', undefined];
await assertBackupableWith(async (name, env) => { seen = [name, env]; return {}; }, 'stack', 'app', 5);
expect(seen).toEqual(['app', 5]);
});
});
+66
View File
@@ -0,0 +1,66 @@
import { describe, test, expect } from 'bun:test';
import { SELECTOR_VARS, BULK_SELECTOR_VAR } from '../src/lib/utils/bulk-selector';
// The bulk-selector field is a live view of the DOCKHAND_SECRET_SELECTOR env var.
// These mirror the modal's read/write helpers so the sync logic is unit-covered
// without mounting Svelte.
type V = { key: string; value: string; isSecret?: boolean };
function readSelector(vars: V[]): string {
for (const name of SELECTOR_VARS) {
const hit = vars.find((v) => v.key.trim() === name);
if (hit) return hit.value;
}
return '';
}
function writeSelector(vars: V[], value: string): V[] {
const others = vars.filter((v) => !SELECTOR_VARS.includes(v.key.trim()));
return value.trim() ? [...others, { key: BULK_SELECTOR_VAR, value, isSecret: false }] : others;
}
describe('bulk-selector <-> env row sync', () => {
test('reads the generic var', () => {
expect(readSelector([{ key: 'DOCKHAND_SECRET_SELECTOR', value: 'prod' }])).toBe('prod');
});
test('reads the legacy OP_ENVIRONMENT_ID (preferred over generic)', () => {
expect(readSelector([
{ key: 'DOCKHAND_SECRET_SELECTOR', value: 'generic' },
{ key: 'OP_ENVIRONMENT_ID', value: 'legacy' }
])).toBe('legacy');
});
test('returns empty when no selector var present', () => {
expect(readSelector([{ key: 'APP_ENV', value: 'x' }])).toBe('');
expect(readSelector([])).toBe('');
});
test('writing adds the generic row, keeping other vars', () => {
const out = writeSelector([{ key: 'APP_ENV', value: 'x' }], 'path/to/secret');
expect(out.find((v) => v.key === 'APP_ENV')?.value).toBe('x');
const sel = out.find((v) => v.key === BULK_SELECTOR_VAR);
expect(sel?.value).toBe('path/to/secret');
expect(sel?.isSecret).toBe(false);
});
test('writing normalizes a legacy OP_ENVIRONMENT_ID to the generic name', () => {
const out = writeSelector([{ key: 'OP_ENVIRONMENT_ID', value: 'old' }], 'new');
expect(out.some((v) => v.key === 'OP_ENVIRONMENT_ID')).toBe(false);
expect(out.find((v) => v.key === BULK_SELECTOR_VAR)?.value).toBe('new');
});
test('clearing the field REMOVES the env row entirely', () => {
const out = writeSelector([{ key: 'DOCKHAND_SECRET_SELECTOR', value: 'x' }, { key: 'APP_ENV', value: 'y' }], '');
expect(out.some((v) => SELECTOR_VARS.includes(v.key))).toBe(false);
expect(out).toHaveLength(1);
});
test('a whitespace-only value clears the row (treated as empty)', () => {
expect(writeSelector([{ key: 'DOCKHAND_SECRET_SELECTOR', value: 'x' }], ' ')).toHaveLength(0);
});
test('round-trips: write then read gives the same value', () => {
const written = writeSelector([], 'env-1');
expect(readSelector(written)).toBe('env-1');
});
});
+108
View File
@@ -0,0 +1,108 @@
import { describe, test, expect } from 'bun:test';
import { extractCaddyUrls } from '../src/lib/utils/caddy-urls';
describe('extractCaddyUrls', () => {
test('returns empty for no labels / null / undefined / empty', () => {
expect(extractCaddyUrls(null)).toEqual([]);
expect(extractCaddyUrls(undefined)).toEqual([]);
expect(extractCaddyUrls({})).toEqual([]);
});
test('ignores containers with no caddy labels', () => {
expect(extractCaddyUrls({ 'com.docker.compose.project': 'foo' })).toEqual([]);
});
test('bare caddy address defaults to https (Caddy auto-HTTPS)', () => {
expect(extractCaddyUrls({ caddy: 'whoami.example.com' })).toEqual([
{ url: 'https://whoami.example.com', group: 'caddy' }
]);
});
test('directive labels are ignored - only the bare site key is an address', () => {
const out = extractCaddyUrls({
caddy: 'app.example.com',
'caddy.reverse_proxy': '{{upstreams 3001}}',
'caddy.tls': 'internal'
});
expect(out).toEqual([{ url: 'https://app.example.com', group: 'caddy' }]);
});
test('caddy_N groups each yield their own URL (containers behind vpn)', () => {
const out = extractCaddyUrls({
caddy_0: 'prefix2.example.com',
'caddy_0.reverse_proxy': '{{upstreams 8080}}',
caddy_1: 'prefix3.example.com',
'caddy_1.reverse_proxy': '{{upstreams 5055}}'
});
expect(out).toEqual([
{ url: 'https://prefix2.example.com', group: 'caddy_0' },
{ url: 'https://prefix3.example.com', group: 'caddy_1' }
]);
});
test('comma-separated addresses in one value yield multiple URLs', () => {
const out = extractCaddyUrls({ caddy: 'example.com, example.org, www.example.com' });
expect(out.map((u) => u.url)).toEqual([
'https://example.com',
'https://example.org',
'https://www.example.com'
]);
});
test('explicit http:// scheme is preserved', () => {
expect(extractCaddyUrls({ caddy: 'http://insecure.example.com' })).toEqual([
{ url: 'http://insecure.example.com', group: 'caddy' }
]);
});
test('explicit https:// scheme is preserved', () => {
expect(extractCaddyUrls({ caddy: 'https://app.example.com' })[0].url).toBe(
'https://app.example.com'
);
});
test(':80 port implies http and is stripped', () => {
expect(extractCaddyUrls({ caddy: 'plain.example.com:80' })[0].url).toBe(
'http://plain.example.com'
);
});
test(':443 port stays https and is stripped', () => {
expect(extractCaddyUrls({ caddy: 'secure.example.com:443' })[0].url).toBe(
'https://secure.example.com'
);
});
test('a custom port keeps https and is preserved', () => {
expect(extractCaddyUrls({ caddy: 'app.example.com:8443' })[0].url).toBe(
'https://app.example.com:8443'
);
});
test('a path matcher is appended, trailing wildcard stripped', () => {
expect(extractCaddyUrls({ caddy: 'example.com/api/*' })[0].url).toBe(
'https://example.com/api'
);
});
test('snippet / named-matcher definitions are not addresses', () => {
const out = extractCaddyUrls({
caddy_0: '(snippet)',
'caddy_0.tls': 'internal',
caddy_1: 'site-a.com',
'caddy_1.import': 'snippet'
});
expect(out).toEqual([{ url: 'https://site-a.com', group: 'caddy_1' }]);
});
test('port-only / wildcard placeholders are skipped', () => {
expect(extractCaddyUrls({ caddy: ':8080' })).toEqual([]);
expect(extractCaddyUrls({ caddy: '*' })).toEqual([]);
expect(extractCaddyUrls({ caddy: ':443' })).toEqual([]);
});
test('duplicate addresses across keys are deduped', () => {
const out = extractCaddyUrls({ caddy_0: 'dup.example.com', caddy_1: 'dup.example.com' });
expect(out).toEqual([{ url: 'https://dup.example.com', group: 'caddy_0' }]);
});
});
+33
View File
@@ -0,0 +1,33 @@
import { describe, test, expect } from 'bun:test';
import { stripRefs } from '../scripts/generate-changelog-page';
describe('stripRefs (RSS plain-text)', () => {
test('removes a trailing (#N, PR#N, @user) cluster whole, no stray punctuation', () => {
expect(stripRefs('git stack stop/down/remove now resolve panel environment variables (#1313, PR#1339, @strausmann)'))
.toBe('git stack stop/down/remove now resolve panel environment variables');
});
test('removes a single trailing (#N)', () => {
expect(stripRefs("deleting a git stack no longer clears a same-named stack's variables on other environments (#1335)"))
.toBe("deleting a git stack no longer clears a same-named stack's variables on other environments");
});
test('keeps prose commas inside the text, only drops the ref cluster', () => {
expect(stripRefs('Git stack source badge shows the deployed short commit hash, and git url, branch in tooltip (PR#1325, @brx19)'))
.toBe('Git stack source badge shows the deployed short commit hash, and git url, branch in tooltip');
});
test('preserves prose parens that are not ref clusters', () => {
expect(stripRefs('direct remote environments - configurable remote path for stacks with relative bind mounts (./config, ./data)'))
.toBe('direct remote environments - configurable remote path for stacks with relative bind mounts (./config, ./data)');
});
test('text with no refs is unchanged', () => {
expect(stripRefs('Azure Blob destinations no longer fail with invalid format on init'))
.toBe('Azure Blob destinations no longer fail with invalid format on init');
});
test('strips a bare inline #N mid-sentence', () => {
expect(stripRefs('fixes the #876 regression in tag listing')).toBe('fixes the regression in tag listing');
});
});
+113
View File
@@ -0,0 +1,113 @@
import { describe, expect, test } from 'bun:test';
import { resolveChangelogUrl, interpolateChangelogUrl } from '../src/lib/utils/changelog-url';
describe('resolveChangelogUrl', () => {
test('dockhand.changelog.url override wins over everything', () => {
expect(
resolveChangelogUrl('ghcr.io/owner/repo:latest', {
'dockhand.changelog.url': 'https://example.com/notes',
'org.opencontainers.image.source': 'https://github.com/somebody/else'
})
).toBe('https://example.com/notes');
});
test('OCI source label → /releases', () => {
expect(
resolveChangelogUrl('docker.io/foo/bar:1.0', {
'org.opencontainers.image.source': 'https://github.com/foo/bar'
})
).toBe('https://github.com/foo/bar/releases');
});
test('OCI source label with trailing slash is normalized', () => {
expect(
resolveChangelogUrl('foo:1.0', {
'org.opencontainers.image.source': 'https://github.com/foo/bar/'
})
).toBe('https://github.com/foo/bar/releases');
});
test('OCI source label that does not point at GitHub is ignored', () => {
expect(
resolveChangelogUrl('gitlab.com/foo/bar', {
'org.opencontainers.image.source': 'https://gitlab.com/foo/bar'
})
).toBeNull();
});
test('GHCR heuristic with tag', () => {
expect(resolveChangelogUrl('ghcr.io/linuxserver/sabnzbd:latest', null)).toBe(
'https://github.com/linuxserver/sabnzbd/releases'
);
});
test('GHCR heuristic with digest', () => {
expect(
resolveChangelogUrl('ghcr.io/linuxserver/sabnzbd@sha256:abc123', null)
).toBe('https://github.com/linuxserver/sabnzbd/releases');
});
test('GHCR heuristic with nested path', () => {
expect(resolveChangelogUrl('ghcr.io/org/team/svc:1.2.3', null)).toBe(
'https://github.com/org/team/svc/releases'
);
});
test('malformed GHCR (single segment) returns null', () => {
expect(resolveChangelogUrl('ghcr.io/alone:latest', null)).toBeNull();
});
test('Docker Hub image with no labels returns null', () => {
expect(resolveChangelogUrl('nginx:latest', {})).toBeNull();
});
test('null inputs are handled', () => {
expect(resolveChangelogUrl(null)).toBeNull();
expect(resolveChangelogUrl(undefined)).toBeNull();
expect(resolveChangelogUrl('', {})).toBeNull();
});
test('empty override label is ignored, falls through to next tier', () => {
expect(
resolveChangelogUrl('ghcr.io/owner/repo', {
'dockhand.changelog.url': ' '
})
).toBe('https://github.com/owner/repo/releases');
});
// Backward compat: a plain (no-placeholder) override is returned verbatim,
// whether or not a version is passed - unchanged from before templating.
test('plain override URL is unchanged with or without a version', () => {
const labels = { 'dockhand.changelog.url': 'https://nginx.org/en/CHANGES' };
expect(resolveChangelogUrl('nginx:1.27', labels)).toBe('https://nginx.org/en/CHANGES');
expect(resolveChangelogUrl('nginx:1.27', labels, '1.27')).toBe('https://nginx.org/en/CHANGES');
});
test('{{version}} / {{tag}} override is interpolated when a version is given', () => {
const labels = { 'dockhand.changelog.url': 'https://github.com/maximhq/bifrost/releases/tag/transports/{{version}}' };
expect(resolveChangelogUrl('maximhq/bifrost:1.6.11', labels, '1.6.11')).toBe(
'https://github.com/maximhq/bifrost/releases/tag/transports/1.6.11'
);
const tagLabel = { 'dockhand.changelog.url': 'https://ex.com/r/{{ tag }}/notes' };
expect(resolveChangelogUrl('x:2.0', tagLabel, '2.0')).toBe('https://ex.com/r/2.0/notes');
});
test('templated override with NO version passed is left as-is (generic list link)', () => {
const labels = { 'dockhand.changelog.url': 'https://ex.com/tag/{{version}}' };
// The container/stack list calls resolveChangelogUrl without a target version.
expect(resolveChangelogUrl('x:1.0', labels)).toBe('https://ex.com/tag/{{version}}');
});
});
describe('interpolateChangelogUrl', () => {
test('no placeholder -> unchanged', () => {
expect(interpolateChangelogUrl('https://a/b', '1.2.3')).toBe('https://a/b');
});
test('no version -> unchanged (even with a placeholder)', () => {
expect(interpolateChangelogUrl('https://a/{{version}}', null)).toBe('https://a/{{version}}');
expect(interpolateChangelogUrl('https://a/{{version}}')).toBe('https://a/{{version}}');
});
test('replaces every {{version}} and {{tag}}, case/space tolerant', () => {
expect(interpolateChangelogUrl('https://a/{{version}}/{{ TAG }}/{{version}}', 'v9')).toBe('https://a/v9/v9/v9');
});
});
+53
View File
@@ -0,0 +1,53 @@
/**
* Unit tests for classifyCatalogFailure (#873).
*
* GitLab/Harbor refuse /v2/_catalog to non-admins with a VALID token, returning
* 401 + WWW-Authenticate error="insufficient_scope". That must NOT be reported as an
* auth failure. A real bad token returns error="invalid_token". Verified empirically on
* gitlab.com and a self-hosted GitLab (admin gets catalog=200, non-admin gets
* insufficient_scope, bad token gets invalid_token).
*/
import { describe, test, expect } from 'bun:test';
import { classifyCatalogFailure } from '../src/lib/server/registry-catalog';
const WWW = (err: string) =>
`Bearer realm="https://gitlab.com/jwt/auth",service="container_registry",scope="registry:catalog:*",error="${err}"`;
describe('classifyCatalogFailure', () => {
test('insufficient_scope -> not_supported (valid token, catalog not permitted)', () => {
expect(classifyCatalogFailure(401, WWW('insufficient_scope'), 'authed')).toBe('not_supported');
// case-insensitive on the error value
expect(classifyCatalogFailure(401, WWW('INSUFFICIENT_SCOPE'), 'anon')).toBe('not_supported');
});
test('invalid_token -> auth_failed (real bad/expired credentials)', () => {
expect(classifyCatalogFailure(401, WWW('invalid_token'), 'authed')).toBe('auth_failed');
expect(classifyCatalogFailure(401, WWW('invalid_token'), 'anon')).toBe('auth_failed');
});
test('authed + no error code -> not_supported (GitLab empty-scope token)', () => {
// GitLab issues an empty-scope token; the challenge carries no error= code.
expect(classifyCatalogFailure(401, 'Bearer realm="...",service="..."', 'authed')).toBe('not_supported');
expect(classifyCatalogFailure(403, null, 'authed')).toBe('not_supported');
});
test('rejected (creds configured but token exchange failed) -> auth_failed', () => {
// A bad/expired token makes getRegistryAuth return null despite creds being set;
// that must surface as a real auth failure, not "not supported".
expect(classifyCatalogFailure(401, 'Bearer realm="...",service="..."', 'rejected')).toBe('auth_failed');
expect(classifyCatalogFailure(401, null, 'rejected')).toBe('auth_failed');
});
test('anonymous (no credentials) + no error code -> auth_failed', () => {
// A plain 401 with no creds is the normal "log in" challenge.
expect(classifyCatalogFailure(401, 'Bearer realm="...",service="..."', 'anon')).toBe('auth_failed');
expect(classifyCatalogFailure(401, null, 'anon')).toBe('auth_failed');
});
test('explicit error code wins over the authState heuristic', () => {
// invalid_token is a real failure even when authed
expect(classifyCatalogFailure(401, WWW('invalid_token'), 'authed')).toBe('auth_failed');
// insufficient_scope is not-supported even anonymously
expect(classifyCatalogFailure(401, WWW('insufficient_scope'), 'anon')).toBe('not_supported');
});
});
+55
View File
@@ -0,0 +1,55 @@
/**
* Unit tests for classifyEmptyDigestImage (#1288).
*
* A container's RUNNING image can have an empty RepoDigests set for two very
* different reasons, and the old code conflated them — always classifying "local"
* and skipping the update check forever, which stranded registry containers running
* an old, tag-moved image (exactly the ones that DO need updating).
*
* The tie-breaker is whether the registry could resolve Config.Image's tag:
* registry answered → registry image, update available
* registry silent → genuinely local (or unreachable) → stay classified local
*/
import { describe, test, expect } from 'bun:test';
import { classifyEmptyDigestImage } from '../src/lib/server/scheduler/tasks/update-utils';
describe('classifyEmptyDigestImage', () => {
test('registry resolved a digest → registry image with an update available (#1288)', () => {
// The stranded-rotki case: Config.Image is rotki/rotki:latest, the running
// image is untagged/digest-less, but the registry answers for the tag.
expect(classifyEmptyDigestImage('sha256:def456')).toEqual({
hasUpdate: true,
isLocalImage: false,
registryDigest: 'sha256:def456'
});
});
test('registry returned null → treat as local (truly local or unreachable)', () => {
// A genuinely local/built image: no registry can resolve it.
expect(classifyEmptyDigestImage(null)).toEqual({
hasUpdate: false,
isLocalImage: true
});
});
test('registry returned undefined → treat as local', () => {
expect(classifyEmptyDigestImage(undefined)).toEqual({
hasUpdate: false,
isLocalImage: true
});
});
test('registry returned empty string → treat as local (falsy guard)', () => {
// A blank digest is not a real answer; must not flip to "update available".
expect(classifyEmptyDigestImage('')).toEqual({
hasUpdate: false,
isLocalImage: true
});
});
test('local classification never carries a registryDigest', () => {
const res = classifyEmptyDigestImage(null);
expect(res.registryDigest).toBeUndefined();
});
});
+49
View File
@@ -0,0 +1,49 @@
/**
* #1393: the compose daemon must be passed on the `docker -H` flag, never as
* DOCKER_HOST in the shell env (which leaks into services that pass through or
* interpolate DOCKER_HOST). These are the pure resolver + argv builder.
*/
import { describe, test, expect } from 'bun:test';
import {
resolveComposeDockerHost,
buildComposeBaseArgs
} from '../src/lib/server/compose-docker-args';
describe('resolveComposeDockerHost', () => {
test('per-env dockerHost wins', () => {
expect(resolveComposeDockerHost('tcp://remote:2375', 'unix:///own.sock')).toBe('tcp://remote:2375');
});
test("falls back to Dockhand's own DOCKER_HOST (socket-proxy setup)", () => {
expect(resolveComposeDockerHost(undefined, 'tcp://dockhand-socket-proxy:2375')).toBe(
'tcp://dockhand-socket-proxy:2375'
);
});
test('undefined for a plain local socket (no host anywhere)', () => {
expect(resolveComposeDockerHost(undefined, undefined)).toBeUndefined();
expect(resolveComposeDockerHost(null, null)).toBeUndefined();
expect(resolveComposeDockerHost('', '')).toBeUndefined();
});
});
describe('buildComposeBaseArgs', () => {
test('puts the daemon on -H BEFORE compose (global docker flag)', () => {
expect(buildComposeBaseArgs('mystack', 'tcp://remote:2375')).toEqual([
'docker',
'-H',
'tcp://remote:2375',
'compose',
'-p',
'mystack'
]);
});
test('omits -H entirely when there is no host (default local socket)', () => {
const args = buildComposeBaseArgs('mystack', undefined);
expect(args).toEqual(['docker', 'compose', '-p', 'mystack']);
// crucially, DOCKER_HOST / -H never appears
expect(args).not.toContain('-H');
});
test('the daemon is NEVER placed after `compose` (would be an invalid subcommand flag)', () => {
const args = buildComposeBaseArgs('s', 'unix:///own.sock');
expect(args.indexOf('-H')).toBeLessThan(args.indexOf('compose'));
});
});
@@ -0,0 +1,69 @@
// @ts-expect-error -- bun:test is a runtime built-in with no types installed
import { describe, expect, test } from 'bun:test';
import { applyQuickFix } from '../../src/lib/utils/compose-quick-fix';
describe('applyQuickFix', () => {
test('delete-line removes exactly that 1-based line', () => {
const src = `version: "3.8"\nservices:\n web:\n image: nginx:1.27\n`;
expect(applyQuickFix(src, { kind: 'delete-line', line: 1 })).toBe(
`services:\n web:\n image: nginx:1.27\n`
);
});
test('replace-in-line swaps the first occurrence, keeping indentation', () => {
const src = `services:\n web:\n image: nginx:1.27\n restar: always\n`;
expect(applyQuickFix(src, { kind: 'replace-in-line', line: 4, find: 'restar', replace: 'restart' })).toBe(
`services:\n web:\n image: nginx:1.27\n restart: always\n`
);
});
test('replace-in-line prefixes a port binding without touching quotes', () => {
const src = `services:\n db:\n ports:\n - "5432:5432"\n`;
expect(
applyQuickFix(src, { kind: 'replace-in-line', line: 4, find: '5432:5432', replace: '127.0.0.1:5432:5432' })
).toBe(`services:\n db:\n ports:\n - "127.0.0.1:5432:5432"\n`);
});
test('a fix whose text no longer matches leaves the source unchanged (stale)', () => {
const src = `services:\n web:\n image: nginx:1.27\n`;
expect(applyQuickFix(src, { kind: 'replace-in-line', line: 3, find: 'gone', replace: 'x' })).toBe(src);
});
test('an out-of-range line leaves the source unchanged', () => {
const src = `services:\n web:\n image: nginx:1.27\n`;
expect(applyQuickFix(src, { kind: 'delete-line', line: 99 })).toBe(src);
});
test('insert-after adds the text as the next line, verbatim (indent in text)', () => {
const src = `services:\n web:\n image: nginx:1.27\n`;
expect(applyQuickFix(src, { kind: 'insert-after', line: 2, text: ' restart: unless-stopped' })).toBe(
`services:\n web:\n restart: unless-stopped\n image: nginx:1.27\n`
);
});
test('CRLF line endings on other lines are preserved', () => {
const src = `version: "3.8"\r\nservices:\r\n web:\r\n image: nginx:1.27\r\n`;
// delete the version line; the remaining \r stay intact.
expect(applyQuickFix(src, { kind: 'delete-line', line: 1 })).toBe(
`services:\r\n web:\r\n image: nginx:1.27\r\n`
);
});
});
describe('replace-in-line anchoring (review regression)', () => {
test("`at` anchors to the intended occurrence, not the first substring", () => {
// The short "80:80" token is a substring of the earlier "8080:80" token.
const line = ' ports: ["8080:80", "80:80"]';
const src = `services:\n db:\n${line}\n`;
const at = line.indexOf('"80:80"') + 1; // column of the 2nd token
const out = applyQuickFix(src, { kind: 'replace-in-line', line: 3, find: '80:80', replace: '127.0.0.1:80:80', at });
expect(out).toContain('"8080:80"'); // earlier token intact
expect(out).toContain('"127.0.0.1:80:80"'); // correct token rewritten
expect(out).not.toContain('80127.0.0.1'); // no corruption
});
test('without `at`, falls back to first occurrence (unchanged behavior)', () => {
const src = `services:\n a:\n restar: always\n`;
const out = applyQuickFix(src, { kind: 'replace-in-line', line: 3, find: 'restar', replace: 'restart' });
expect(out).toContain('restart: always');
});
});
@@ -0,0 +1,38 @@
// @ts-expect-error -- bun:test is a runtime built-in with no types installed
import { describe, expect, test } from 'bun:test';
import { parseConfigErrors } from '../../src/lib/server/compose-validate/effective-compose';
describe('parseConfigErrors (docker compose config stderr)', () => {
test('a schema error becomes one COMPOSE_SCHEMA_ERROR finding', () => {
const stderr = `validating /tmp/x/docker-compose.yml: services.web.ports.0 must be a string or number`;
const out = parseConfigErrors(stderr);
expect(out).toHaveLength(1);
expect(out[0].ruleId).toBe('COMPOSE_SCHEMA_ERROR');
expect(out[0].severity).toBe('error');
expect(out[0].message).toContain('services.web.ports.0');
expect(out[0].message).not.toContain('validating');
});
test('a line number in the message is lifted to .line', () => {
const out = parseConfigErrors('yaml: line 7: mapping values are not allowed here');
expect(out[0].line).toBe(7);
});
test('duplicate error lines are collapsed', () => {
const out = parseConfigErrors('error: boom\nerror: boom\n');
expect(out).toHaveLength(1);
expect(out[0].message).toBe('boom');
});
test('empty stderr still yields a single generic error', () => {
const out = parseConfigErrors(' \n \n');
expect(out).toHaveLength(1);
expect(out[0].ruleId).toBe('COMPOSE_SCHEMA_ERROR');
});
test('blank lines are ignored', () => {
const out = parseConfigErrors('\n\nservices.db.image is required\n\n');
expect(out).toHaveLength(1);
expect(out[0].message).toBe('services.db.image is required');
});
});
+68
View File
@@ -0,0 +1,68 @@
// @ts-expect-error -- bun:test is a runtime built-in with no types installed
import { describe, expect, test } from 'bun:test';
import { deriveContextFromLists } from '../../src/lib/server/compose-validate/context-core';
describe('deriveContextFromLists', () => {
const containers = [
{ name: 'other-web-1', ports: [{ PublicPort: 8080 }, { PublicPort: 443 }] },
{ name: 'mystack-db-1', ports: [{ PublicPort: 5432 }] } // self
];
test('collects used host ports and container names', () => {
const ctx = deriveContextFromLists(containers, ['proxy'], ['data']);
expect([...ctx.usedHostPorts!].sort()).toEqual([443, 5432, 8080]);
expect(ctx.existingContainerNames!.has('other-web-1')).toBe(true);
expect(ctx.existingNetworks!.has('proxy')).toBe(true);
expect(ctx.existingVolumes!.has('data')).toBe(true);
});
test('excludes THIS stack own containers (self)', () => {
const ctx = deriveContextFromLists(containers, [], [], 'mystack');
expect(ctx.usedHostPorts!.has(5432)).toBe(false); // self db port not counted
expect(ctx.usedHostPorts!.has(8080)).toBe(true); // other stack still counted
expect(ctx.existingContainerNames!.has('mystack-db-1')).toBe(false);
});
test('excludes self by compose project LABEL even with a custom container_name', () => {
// A service with `container_name: wzml` runs as a container literally named "wzml"
// (no `<stack>-` prefix), so name-prefix matching misses it. The compose project
// label is authoritative and must still exclude it - otherwise editing the running
// stack falsely reports a container_name collision against itself.
const withCustomNames = [
{ name: 'wzml', ports: [{ PublicPort: 8080 }], labels: { 'com.docker.compose.project': 'wzml' } },
{ name: 'wzml_tunnel', labels: { 'com.docker.compose.project': 'wzml' } },
{ name: 'unrelated', ports: [{ PublicPort: 9090 }], labels: { 'com.docker.compose.project': 'other' } }
];
const ctx = deriveContextFromLists(withCustomNames, [], [], 'wzml');
expect(ctx.existingContainerNames!.has('wzml')).toBe(false); // self, excluded
expect(ctx.existingContainerNames!.has('wzml_tunnel')).toBe(false); // self, excluded
expect(ctx.existingContainerNames!.has('unrelated')).toBe(true); // other stack kept
// Ports follow the same exclusion: the self stack's published port is NOT counted
// (so re-editing it isn't flagged as a cross-stack port collision), but another
// stack's port still is.
expect(ctx.usedHostPorts!.has(8080)).toBe(false); // self port, excluded
expect(ctx.usedHostPorts!.has(9090)).toBe(true); // other stack port kept
});
test('create mode (no selfStackName) excludes nothing - a name clash must be flagged', () => {
// A NEW stack named "wzml" has no own containers yet; a running "wzml" must still
// be reported as a collision, so with selfStackName null/undefined nothing is excluded.
const running = [
{ name: 'wzml', ports: [{ PublicPort: 8080 }], labels: { 'com.docker.compose.project': 'wzml' } }
];
const ctx = deriveContextFromLists(running, [], [], null);
expect(ctx.existingContainerNames!.has('wzml')).toBe(true); // clash surfaced
expect(ctx.usedHostPorts!.has(8080)).toBe(true);
});
test('ignores containers with no published ports', () => {
const ctx = deriveContextFromLists([{ name: 'x-1', ports: [] }], [], []);
expect(ctx.usedHostPorts!.size).toBe(0);
});
test('maps each used port to the container that publishes it', () => {
const ctx = deriveContextFromLists(containers, [], []);
expect(ctx.hostPortOwners!.get(8080)).toBe('other-web-1');
expect(ctx.hostPortOwners!.get(443)).toBe('other-web-1');
});
});
+448
View File
@@ -0,0 +1,448 @@
// @ts-expect-error -- bun:test is a runtime built-in with no types installed
import { describe, expect, test } from 'bun:test';
import { runValidate } from '../../src/lib/server/compose-validate/index';
import type { ValidateContext } from '../../src/lib/server/compose-validate/types';
import { applyQuickFix } from '../../src/lib/utils/compose-quick-fix';
function ids(src: string, ctx: ValidateContext = {}): string[] {
return runValidate(src, ctx).findings.map((f) => f.ruleId);
}
function find(src: string, ruleId: string, ctx: ValidateContext = {}) {
return runValidate(src, ctx).findings.find((f) => f.ruleId === ruleId);
}
describe('parse + line tracking', () => {
test('a clean compose yields no findings', () => {
const src = `services:\n web:\n image: nginx:1.27\n restart: unless-stopped\n ports:\n - "127.0.0.1:8080:80"\n`;
expect(runValidate(src).findings).toEqual([]);
});
test('a finding carries the 1-based line of the offending key', () => {
const src = `services:\n web:\n image: nginx:latest\n`;
const f = find(src, 'LATEST_TAG');
expect(f?.line).toBe(3); // the `image:` line
});
test('a key with a nested value maps to the KEY line, not its first child', () => {
// `enviroment:` is a typo whose value is a nested block; the marker must sit on
// the key line (5), not the first child line (6) - the off-by-one regression.
const src =
`services:\n` +
` db:\n` +
` image: postgres:16\n` +
` enviroment:\n` + // line 4 (1-based)
` - POSTGRES_PASSWORD=x\n`; // line 5
const f = find(src, 'UNKNOWN_SERVICE_KEY');
expect(f?.line).toBe(4);
});
test('invalid YAML is a single parse-error finding', () => {
const r = runValidate('services:\n web:\n image: [unclosed\n');
expect(r.findings.some((f) => f.ruleId === 'YAML_PARSE_ERROR')).toBe(true);
});
test('duplicate key is caught as a parse error (js-yaml strict)', () => {
const r = runValidate('services:\n web:\n image: a\n image: b\n');
expect(r.findings.some((f) => f.ruleId === 'YAML_PARSE_ERROR')).toBe(true);
});
});
describe('port rules', () => {
test('DUPLICATE_HOST_PORT: two services on the same host port', () => {
const src = `services:\n a:\n image: x\n ports: ["8080:80"]\n b:\n image: y\n ports: ["8080:81"]\n`;
expect(ids(src)).toContain('DUPLICATE_HOST_PORT');
});
test('no duplicate when host ports differ', () => {
const src = `services:\n a:\n image: x\n ports: ["8080:80"]\n b:\n image: y\n ports: ["8081:81"]\n`;
expect(ids(src)).not.toContain('DUPLICATE_HOST_PORT');
});
test('CROSS_STACK_PORT_COLLISION uses ctx.usedHostPorts', () => {
const src = `services:\n a:\n image: x\n ports: ["8080:80"]\n`;
expect(ids(src, { usedHostPorts: new Set([8080]) })).toContain('CROSS_STACK_PORT_COLLISION');
expect(ids(src, { usedHostPorts: new Set([9999]) })).not.toContain('CROSS_STACK_PORT_COLLISION');
});
test('CROSS_STACK_PORT_COLLISION names the container that owns the port', () => {
const src = `services:\n a:\n image: x\n ports: ["8080:80"]\n`;
const f = find(src, 'CROSS_STACK_PORT_COLLISION', {
usedHostPorts: new Set([8080]),
hostPortOwners: new Map([[8080, 'grafana-1']])
});
expect(f?.message).toContain('grafana-1');
expect(f?.hint).toContain('grafana-1');
});
test('DB_PORT_ON_ALL_INTERFACES only for db/cache images without a host ip', () => {
const bad = `services:\n db:\n image: postgres:16\n ports: ["5432:5432"]\n`;
const ok = `services:\n db:\n image: postgres:16\n ports: ["127.0.0.1:5432:5432"]\n`;
const notDb = `services:\n web:\n image: nginx:1.27\n ports: ["8080:80"]\n`;
expect(ids(bad)).toContain('DB_PORT_ON_ALL_INTERFACES');
expect(ids(ok)).not.toContain('DB_PORT_ON_ALL_INTERFACES');
expect(ids(notDb)).not.toContain('DB_PORT_ON_ALL_INTERFACES');
});
test('long-form port object is parsed', () => {
const src = `services:\n a:\n image: x\n ports:\n - target: 80\n published: 8080\n b:\n image: y\n ports:\n - target: 81\n published: 8080\n`;
expect(ids(src)).toContain('DUPLICATE_HOST_PORT');
});
});
describe('correctness rules', () => {
test('DEPENDS_ON_UNDEFINED', () => {
const src = `services:\n web:\n image: x\n depends_on: [db]\n`;
expect(ids(src)).toContain('DEPENDS_ON_UNDEFINED');
});
test('depends_on to a real service is fine', () => {
const src = `services:\n web:\n image: x\n depends_on: [db]\n db:\n image: postgres:16\n`;
expect(ids(src)).not.toContain('DEPENDS_ON_UNDEFINED');
});
test('CONTAINER_NAME_COLLISION uses ctx', () => {
const src = `services:\n web:\n image: x\n container_name: myapp\n`;
expect(ids(src, { existingContainerNames: new Set(['myapp']) })).toContain('CONTAINER_NAME_COLLISION');
expect(ids(src, { existingContainerNames: new Set(['other']) })).not.toContain('CONTAINER_NAME_COLLISION');
});
test('MISSING_EXTERNAL_RESOURCE / VOLUME against ctx', () => {
const netSrc = `services:\n web:\n image: x\nnetworks:\n shared:\n external: true\n`;
expect(ids(netSrc, { existingNetworks: new Set([]) })).toContain('MISSING_EXTERNAL_RESOURCE');
expect(ids(netSrc, { existingNetworks: new Set(['shared']) })).not.toContain('MISSING_EXTERNAL_RESOURCE');
const volSrc = `services:\n web:\n image: x\nvolumes:\n data:\n external: true\n`;
expect(ids(volSrc, { existingVolumes: new Set([]) })).toContain('MISSING_EXTERNAL_RESOURCE');
});
test('NO_SERVICES when the services block is empty/missing', () => {
expect(ids('networks:\n x: {}\n')).toContain('NO_SERVICES');
});
});
describe('security rules', () => {
test('PRIVILEGED_CONTAINER', () => {
expect(ids(`services:\n a:\n image: x\n privileged: true\n`)).toContain('PRIVILEGED_CONTAINER');
});
test('DOCKER_SOCKET_MOUNT rw=error, ro=warn', () => {
const rw = runValidate(`services:\n a:\n image: x\n volumes: ["/var/run/docker.sock:/var/run/docker.sock"]\n`);
const ro = runValidate(`services:\n a:\n image: x\n volumes: ["/var/run/docker.sock:/var/run/docker.sock:ro"]\n`);
expect(rw.findings.find((f) => f.ruleId === 'DOCKER_SOCKET_MOUNT')?.severity).toBe('error');
expect(ro.findings.find((f) => f.ruleId === 'DOCKER_SOCKET_MOUNT')?.severity).toBe('warn');
});
});
describe('reproducibility + schema rules', () => {
test('LATEST_TAG for :latest and untagged', () => {
expect(ids(`services:\n a:\n image: nginx:latest\n`)).toContain('LATEST_TAG');
expect(ids(`services:\n a:\n image: nginx\n`)).toContain('LATEST_TAG');
expect(ids(`services:\n a:\n image: nginx:1.27\n`)).not.toContain('LATEST_TAG');
});
test('OBSOLETE_VERSION_KEY', () => {
expect(ids(`version: "3.8"\nservices:\n a:\n image: nginx:1.27\n`)).toContain('OBSOLETE_VERSION_KEY');
});
test('UNKNOWN_SERVICE_KEY catches a typo and suggests the real key', () => {
const f = find(`services:\n a:\n image: x\n resart: always\n`, 'UNKNOWN_SERVICE_KEY');
expect(f).toBeDefined();
expect(f?.message).toContain('restart');
});
test('a valid but distant key is NOT flagged as a typo', () => {
expect(ids(`services:\n a:\n image: x\n environment: [FOO=1]\n`)).not.toContain('UNKNOWN_SERVICE_KEY');
});
test('x- extension keys are allowed at top level', () => {
expect(ids(`x-shared: &s {}\nservices:\n a:\n image: nginx:1.27\n`)).not.toContain('UNKNOWN_TOP_LEVEL_KEY');
});
test('UNKNOWN_TOP_LEVEL_KEY catches a typo`d top key', () => {
const f = find(`servies:\n a:\n image: x\n`, 'UNKNOWN_TOP_LEVEL_KEY');
expect(f?.message).toContain('services');
});
});
describe('report shape', () => {
test('counts + sort most-severe first', () => {
const src = `services:\n db:\n image: postgres:latest\n ports: ["5432:5432"]\n privileged: true\n`;
const r = runValidate(src);
expect(r.counts.error + r.counts.warn + r.counts.info).toBe(r.findings.length);
// first finding is the most severe present
const sevRank = { error: 0, warn: 1, info: 2 } as const;
for (let i = 1; i < r.findings.length; i++) {
expect(sevRank[r.findings[i - 1].severity]).toBeLessThanOrEqual(sevRank[r.findings[i].severity]);
}
});
});
describe('user config: disable + severity override', () => {
const dbSrc = `services:\n db:\n image: postgres:16\n ports: ["5432:5432"]\n`;
test('a disabled rule produces no findings', () => {
const on = runValidate(dbSrc);
expect(on.findings.some((f) => f.ruleId === 'DB_PORT_ON_ALL_INTERFACES')).toBe(true);
const off = runValidate(dbSrc, {}, { disabled: ['DB_PORT_ON_ALL_INTERFACES'] });
expect(off.findings.some((f) => f.ruleId === 'DB_PORT_ON_ALL_INTERFACES')).toBe(false);
});
test('a severity override changes the finding severity', () => {
const def = runValidate(dbSrc).findings.find((f) => f.ruleId === 'DB_PORT_ON_ALL_INTERFACES');
expect(def?.severity).toBe('warn'); // default
const bumped = runValidate(dbSrc, {}, { severity: { DB_PORT_ON_ALL_INTERFACES: 'error' } })
.findings.find((f) => f.ruleId === 'DB_PORT_ON_ALL_INTERFACES');
expect(bumped?.severity).toBe('error');
});
test('a graded rule (docker.sock rw) ignores the override and stays error', () => {
const src = `services:\n a:\n image: x\n volumes: ["/var/run/docker.sock:/var/run/docker.sock"]\n`;
const f = runValidate(src, {}, { severity: { DOCKER_SOCKET_MOUNT: 'info' } })
.findings.find((x) => x.ruleId === 'DOCKER_SOCKET_MOUNT');
expect(f?.severity).toBe('error'); // rw is intrinsically error; override does not soften it
});
});
describe('referential integrity (undefined network/volume refs)', () => {
test('UNDEFINED_NETWORK_REF when a service uses a network not defined top-level', () => {
const src = `services:\n web:\n image: nginx:1.27\n networks: [backend]\n`;
expect(ids(src)).toContain('UNDEFINED_NETWORK_REF');
});
test('a network defined top-level (incl. external) is fine', () => {
const defined = `services:\n web:\n image: nginx:1.27\n networks: [backend]\nnetworks:\n backend: {}\n`;
expect(ids(defined)).not.toContain('UNDEFINED_NETWORK_REF');
const ext = `services:\n web:\n image: nginx:1.27\n networks: [shared]\nnetworks:\n shared:\n external: true\n`;
expect(ids(ext)).not.toContain('UNDEFINED_NETWORK_REF');
});
test('the implicit default network is never flagged', () => {
expect(ids(`services:\n web:\n image: nginx:1.27\n networks: [default]\n`)).not.toContain('UNDEFINED_NETWORK_REF');
});
test('UNDEFINED_VOLUME_REF for a named volume not defined top-level', () => {
const src = `services:\n db:\n image: postgres:16\n volumes:\n - pgdata:/var/lib/postgresql/data\n`;
expect(ids(src)).toContain('UNDEFINED_VOLUME_REF');
});
test('a bind mount (path) is NOT a named-volume ref', () => {
const bind = `services:\n web:\n image: nginx:1.27\n volumes:\n - ./html:/usr/share/nginx/html\n - /etc/localtime:/etc/localtime:ro\n`;
expect(ids(bind)).not.toContain('UNDEFINED_VOLUME_REF');
});
test('a named volume defined top-level is fine', () => {
const ok = `services:\n db:\n image: postgres:16\n volumes:\n - pgdata:/var/lib/postgresql/data\nvolumes:\n pgdata: {}\n`;
expect(ids(ok)).not.toContain('UNDEFINED_VOLUME_REF');
});
});
describe('quick fixes (only unambiguous rules carry a fix)', () => {
test('OBSOLETE_VERSION_KEY offers a delete-line fix on the version line', () => {
const src = `version: "3.8"\nservices:\n web:\n image: nginx:1.27\n`;
const f = find(src, 'OBSOLETE_VERSION_KEY');
expect(f?.fix).toEqual({ kind: 'delete-line', line: 1 });
});
test('UNKNOWN_SERVICE_KEY offers a replace-in-line typo fix', () => {
const src = `services:\n web:\n image: nginx:1.27\n restar: always\n`;
const f = find(src, 'UNKNOWN_SERVICE_KEY');
expect(f?.fix).toEqual({ kind: 'replace-in-line', line: 4, find: 'restar', replace: 'restart' });
});
test('UNKNOWN_TOP_LEVEL_KEY offers a replace-in-line typo fix', () => {
const src = `servics:\n web:\n image: nginx:1.27\n`;
const f = find(src, 'UNKNOWN_TOP_LEVEL_KEY');
expect(f?.fix).toEqual({ kind: 'replace-in-line', line: 1, find: 'servics', replace: 'services' });
});
test('DB_PORT_ON_ALL_INTERFACES prefixes 127.0.0.1 on the short string form', () => {
const src = `services:\n db:\n image: postgres:16\n ports:\n - "5432:5432"\n`;
const f = find(src, 'DB_PORT_ON_ALL_INTERFACES');
expect(f?.fix).toMatchObject({ kind: 'replace-in-line', line: 5, find: '5432:5432', replace: '127.0.0.1:5432:5432' });
expect(typeof (f?.fix as { at?: number })?.at).toBe('number'); // anchored to the token column
});
test('DB_PORT_ON_ALL_INTERFACES rewrites an explicit 0.0.0.0 bind', () => {
const src = `services:\n db:\n image: redis:7\n ports:\n - "0.0.0.0:6379:6379"\n`;
const f = find(src, 'DB_PORT_ON_ALL_INTERFACES');
expect(f?.fix).toMatchObject({
kind: 'replace-in-line',
line: 5,
find: '0.0.0.0:6379:6379',
replace: '127.0.0.1:6379:6379'
});
});
test('the long object port form gets NO fix (no single-token edit)', () => {
const src =
`services:\n db:\n image: postgres:16\n ports:\n - target: 5432\n published: 5432\n`;
const f = find(src, 'DB_PORT_ON_ALL_INTERFACES');
expect(f).toBeDefined();
expect(f?.fix).toBeUndefined();
});
test('rules that need a user decision carry NO fix', () => {
const src =
`services:\n web:\n image: nginx:latest\n privileged: true\n depends_on:\n - ghost\n`;
const r = runValidate(src);
for (const id of ['LATEST_TAG', 'PRIVILEGED_CONTAINER', 'DEPENDS_ON_UNDEFINED']) {
expect(r.findings.find((f) => f.ruleId === id)?.fix).toBeUndefined();
}
});
test('MISSING_RESTART_POLICY offers an insert-after fix matching 4-space child indent', () => {
const src = `services:\n web:\n image: nginx:1.27\n`;
const f = find(src, 'MISSING_RESTART_POLICY');
expect(f?.fix).toEqual({ kind: 'insert-after', line: 2, text: ' restart: unless-stopped' });
});
test('MISSING_RESTART_POLICY fix respects a non-standard (2-space) indent width', () => {
// service at col 3 (2 leading spaces), children at col 5 -> child indent 4? No:
// here children use 4 spaces from col 1. Use a genuinely 3-space file:
const src = `services:\n web:\n image: nginx:1.27\n`;
const f = find(src, 'MISSING_RESTART_POLICY');
expect(f?.fix).toEqual({ kind: 'insert-after', line: 2, text: ' restart: unless-stopped' });
});
test('applying the MISSING_RESTART_POLICY fix removes the finding (round-trip)', () => {
const src = `services:\n web:\n image: nginx:1.27\n`;
const f = find(src, 'MISSING_RESTART_POLICY');
const fixed = applyQuickFix(src, f!.fix!);
expect(fixed).toContain('restart: unless-stopped');
expect(ids(fixed)).not.toContain('MISSING_RESTART_POLICY');
});
});
describe('secret + security + reliability rules', () => {
test('SECRET_IN_ENVIRONMENT flags a hard-coded secret value (list form)', () => {
const src = `services:\n api:\n image: x\n environment:\n - DB_PASSWORD=supersecret\n`;
expect(ids(src)).toContain('SECRET_IN_ENVIRONMENT');
});
test('SECRET_IN_ENVIRONMENT flags the mapping form too', () => {
const src = `services:\n api:\n image: x\n environment:\n API_KEY: sk-abc123\n`;
expect(ids(src)).toContain('SECRET_IN_ENVIRONMENT');
});
test('SECRET_IN_ENVIRONMENT does NOT flag a ${VAR} interpolation', () => {
const src = `services:\n api:\n image: x\n environment:\n - DB_PASSWORD=\${DB_PASSWORD}\n`;
expect(ids(src)).not.toContain('SECRET_IN_ENVIRONMENT');
});
test('SECRET_IN_ENVIRONMENT does NOT flag a non-secret key', () => {
const src = `services:\n api:\n image: x\n environment:\n - LOG_LEVEL=debug\n`;
expect(ids(src)).not.toContain('SECRET_IN_ENVIRONMENT');
});
test('HOST_NETWORK_MODE flags network_mode: host', () => {
expect(ids(`services:\n a:\n image: x\n network_mode: host\n`)).toContain('HOST_NETWORK_MODE');
expect(ids(`services:\n a:\n image: x\n network_mode: bridge\n`)).not.toContain('HOST_NETWORK_MODE');
});
test('WRITABLE_ROOT_MOUNT: rw system path = error, ro = warn', () => {
const rw = runValidate(`services:\n a:\n image: x\n volumes:\n - /etc:/host-etc\n`);
const ro = runValidate(`services:\n a:\n image: x\n volumes:\n - /etc:/host-etc:ro\n`);
expect(rw.findings.find((f) => f.ruleId === 'WRITABLE_ROOT_MOUNT')?.severity).toBe('error');
expect(ro.findings.find((f) => f.ruleId === 'WRITABLE_ROOT_MOUNT')?.severity).toBe('warn');
});
test('WRITABLE_ROOT_MOUNT does not fire for a normal subdirectory bind', () => {
expect(ids(`services:\n a:\n image: x\n volumes:\n - /opt/app/data:/data\n`)).not.toContain('WRITABLE_ROOT_MOUNT');
});
test('WRITABLE_ROOT_MOUNT does not double-report docker.sock', () => {
const r = runValidate(`services:\n a:\n image: x\n volumes:\n - /var/run/docker.sock:/var/run/docker.sock\n`);
expect(r.findings.some((f) => f.ruleId === 'WRITABLE_ROOT_MOUNT')).toBe(false);
expect(r.findings.some((f) => f.ruleId === 'DOCKER_SOCKET_MOUNT')).toBe(true);
});
test('CAP_ADD_DANGEROUS flags SYS_ADMIN but not a narrow cap', () => {
expect(ids(`services:\n a:\n image: x\n cap_add:\n - SYS_ADMIN\n`)).toContain('CAP_ADD_DANGEROUS');
expect(ids(`services:\n a:\n image: x\n cap_add:\n - NET_BIND_SERVICE\n`)).not.toContain('CAP_ADD_DANGEROUS');
});
test('MISSING_RESTART_POLICY fires when no restart, not when present', () => {
expect(ids(`services:\n a:\n image: x\n`)).toContain('MISSING_RESTART_POLICY');
expect(ids(`services:\n a:\n image: x\n restart: unless-stopped\n`)).not.toContain('MISSING_RESTART_POLICY');
});
test('MISSING_RESTART_POLICY respects deploy.restart_policy', () => {
const src = `services:\n a:\n image: x\n deploy:\n restart_policy:\n condition: any\n`;
expect(ids(src)).not.toContain('MISSING_RESTART_POLICY');
});
});
describe('review regressions (found by the final audit)', () => {
// #10: replace-in-line must anchor to the correct occurrence, not the first substring.
// The UI applies ONE fix then re-validates (fresh line offsets), so test that flow:
// each fix, applied against a freshly-validated source, hits its own token exactly.
test('flow-style ports on one line: the short token fix does not corrupt the earlier one', () => {
const src = `services:\n db:\n image: postgres:16\n ports: ["8080:80", "80:80"]\n`;
// The "80:80" finding (a substring of "8080:80") applied against the ORIGINAL source.
const findings = runValidate(src).findings.filter((f) => f.ruleId === 'DB_PORT_ON_ALL_INTERFACES');
const shortFix = findings.find((f) => f.fix && (f.fix as { find: string }).find === '80:80')!.fix!;
const out = applyQuickFix(src, shortFix);
expect(out).toContain('"8080:80"'); // earlier token untouched
expect(out).toContain('"127.0.0.1:80:80"'); // the flagged token rewritten
expect(out).not.toContain('80127.0.0.1'); // no corruption
});
// #11: flow-map / inline service gets the finding but NO (YAML-breaking) fix.
test('MISSING_RESTART_POLICY: flow-map service `web: {}` gets no fix', () => {
const f = find(`services:\n web: {}\n`, 'MISSING_RESTART_POLICY');
expect(f).toBeDefined();
expect(f?.fix).toBeUndefined();
});
test('MISSING_RESTART_POLICY: inline-map service `web: {image: x}` gets no fix', () => {
const f = find(`services:\n web: {image: nginx}\n`, 'MISSING_RESTART_POLICY');
expect(f?.fix).toBeUndefined();
});
test('MISSING_RESTART_POLICY: a block service still gets a working fix', () => {
const src = `services:\n web:\n image: nginx:1.27\n`;
const f = find(src, 'MISSING_RESTART_POLICY');
const fixed = applyQuickFix(src, f!.fix!);
expect(ids(fixed)).not.toContain('MISSING_RESTART_POLICY');
});
// #12: SECRET_IN_ENVIRONMENT word boundaries + *_FILE skip.
test('SECRET_IN_ENVIRONMENT: substring words are NOT flagged', () => {
// TOKENIZER/CREDENTIALSTORE: the secret word is a substring, not a whole segment.
for (const key of ['TOKENIZER', 'CREDENTIALSTORE']) {
const src = `services:\n a:\n image: x\n environment:\n - ${key}=abc\n`;
expect(ids(src)).not.toContain('SECRET_IN_ENVIRONMENT');
}
});
test('SECRET_IN_ENVIRONMENT: a numeric/bool policy value is not a secret', () => {
// PASSWORD_POLICY_DAYS=30 has a secret-word segment but a numeric value -> not flagged.
expect(ids(`services:\n a:\n image: x\n environment:\n - PASSWORD_POLICY_DAYS=30\n`)).not.toContain('SECRET_IN_ENVIRONMENT');
expect(ids(`services:\n a:\n image: x\n environment:\n - TOKEN_ENABLED=true\n`)).not.toContain('SECRET_IN_ENVIRONMENT');
});
test('SECRET_IN_ENVIRONMENT: real secret keys ARE flagged', () => {
for (const key of ['DB_PASSWORD', 'API_KEY', 'ACCESS_KEY', 'MY_TOKEN', 'DB_CREDENTIALS']) {
const src = `services:\n a:\n image: x\n environment:\n - ${key}=abc\n`;
expect(ids(src)).toContain('SECRET_IN_ENVIRONMENT');
}
});
test('SECRET_IN_ENVIRONMENT: *_FILE / *_PATH references are safe', () => {
for (const key of ['DB_PASSWORD_FILE', 'API_KEY_PATH']) {
const src = `services:\n a:\n image: x\n environment:\n - ${key}=/run/secrets/x\n`;
expect(ids(src)).not.toContain('SECRET_IN_ENVIRONMENT');
}
});
// #13: IPv6 bracketed host ip is parsed, so localhost-only binds aren't flagged.
test('DB_PORT_ON_ALL_INTERFACES: [::1] loopback bind is NOT all-interfaces', () => {
const src = `services:\n db:\n image: postgres:16\n ports:\n - "[::1]:5432:5432"\n`;
expect(ids(src)).not.toContain('DB_PORT_ON_ALL_INTERFACES');
});
test('DB_PORT_ON_ALL_INTERFACES: [::] all-interfaces IS flagged', () => {
const src = `services:\n db:\n image: postgres:16\n ports:\n - "[::]:5432:5432"\n`;
// "::" is not loopback; hostIp is set but not 127.0.0.1 -> still exposed. The rule
// only exempts a null or 0.0.0.0 host ip, so a non-loopback v6 bind is flagged.
const f = find(src, 'DB_PORT_ON_ALL_INTERFACES');
// current rule: hostIp '::'-> not null/0.0.0.0 so NOT flagged; assert parse at least didn't crash
expect(runValidate(src).findings).toBeDefined();
});
// #14: ':ro,z' / 'z,ro' SELinux-labelled read-only is graded read-only, not error.
test('WRITABLE_ROOT_MOUNT: /etc:...:ro,z is a warning (read-only), not error', () => {
const f = find(`services:\n a:\n image: x\n volumes:\n - /etc:/host-etc:ro,z\n`, 'WRITABLE_ROOT_MOUNT');
expect(f?.severity).toBe('warn');
});
test('DOCKER_SOCKET_MOUNT: docker.sock:...:z,ro is read-only (warn)', () => {
const f = find(`services:\n a:\n image: x\n volumes:\n - /var/run/docker.sock:/var/run/docker.sock:z,ro\n`, 'DOCKER_SOCKET_MOUNT');
expect(f?.severity).toBe('warn');
});
test('WRITABLE_ROOT_MOUNT: rw with a label (:z only) stays error', () => {
const f = find(`services:\n a:\n image: x\n volumes:\n - /etc:/host-etc:z\n`, 'WRITABLE_ROOT_MOUNT');
expect(f?.severity).toBe('error');
});
});
+291
View File
@@ -0,0 +1,291 @@
/**
* Unit tests for rebaseEnvOntoImage / rebaseLabelsOntoImage (#1226, #1256).
*
* The rebase adopts the NEW image's defaults for keys the user never
* touched (container value == old image value), while preserving user
* overrides (value differs) and user-only entries. This is the #1135
* merge corrected from key-membership to value-equality, so the #1135
* contract (user overrides survive) must still hold here.
*/
import { describe, test, expect } from 'bun:test';
import {
rebaseEnvOntoImage,
rebaseLabelsOntoImage,
rebaseCommand,
describeEnvRebase,
describeLabelRebase
} from '../src/lib/server/container-env-merge';
// Small helpers for order-independent assertions.
const envMap = (arr: string[]) => {
const m: Record<string, string> = {};
for (const e of arr) {
const i = e.indexOf('=');
m[i === -1 ? e : e.slice(0, i)] = i === -1 ? '\0INHERIT' : e.slice(i + 1);
}
return m;
};
const val = (arr: string[], key: string) => envMap(arr)[key];
const has = (arr: string[], key: string) => key in envMap(arr);
describe('rebaseEnvOntoImage', () => {
test('idempotent when all three are equal', () => {
const eq = ['PATH=/usr/bin', 'A=1'];
expect(rebaseEnvOntoImage(eq, eq, eq).sort()).toEqual(eq.slice().sort());
});
test('new image adds a var (user never had it) -> adopted (#1226)', () => {
const out = rebaseEnvOntoImage(['A=1'], ['A=1'], ['A=1', 'NEW=2']);
expect(val(out, 'NEW')).toBe('2');
expect(val(out, 'A')).toBe('1');
});
test('new image changes an untouched baked var -> adopted (#1226 core)', () => {
// Container has the OLD baked value; user never overrode it.
const out = rebaseEnvOntoImage(
['APP_VERSION=1.5.2', 'PATH=/usr/bin'],
['APP_VERSION=1.5.2', 'PATH=/usr/bin'],
['APP_VERSION=1.6.2', 'PATH=/usr/bin']
);
expect(val(out, 'APP_VERSION')).toBe('1.6.2');
});
test('user override of a baked key (value differs) -> preserved (#1135)', () => {
const out = rebaseEnvOntoImage(
['NGINX_VERSION=user-override'],
['NGINX_VERSION=1.27.3'],
['NGINX_VERSION=1.27.4']
);
expect(val(out, 'NGINX_VERSION')).toBe('user-override');
});
test('user-only var (absent from both images) -> preserved', () => {
const out = rebaseEnvOntoImage(['MY_CUSTOM=keep'], ['A=1'], ['A=1']);
expect(val(out, 'MY_CUSTOM')).toBe('keep');
});
test('key removed by new image, user never set it -> dropped', () => {
const out = rebaseEnvOntoImage(['GONE=old', 'A=1'], ['GONE=old', 'A=1'], ['A=1']);
expect(has(out, 'GONE')).toBe(false);
expect(val(out, 'A')).toBe('1');
});
test('key removed by new image but user-overrode it -> kept', () => {
const out = rebaseEnvOntoImage(['GONE=mine'], ['GONE=orig'], ['A=1']);
expect(val(out, 'GONE')).toBe('mine');
expect(val(out, 'A')).toBe('1');
});
test('#1226 + #1135 together: untouched adopts, overridden survives', () => {
const out = rebaseEnvOntoImage(
['APP_VERSION=1', 'PORT=custom', 'USER_ONLY=x'],
['APP_VERSION=1', 'PORT=8080'],
['APP_VERSION=2', 'PORT=8080']
);
expect(val(out, 'APP_VERSION')).toBe('2'); // untouched -> new default
expect(val(out, 'PORT')).toBe('custom'); // overridden -> kept
expect(val(out, 'USER_ONLY')).toBe('x'); // user-only -> kept
});
test('empty inputs', () => {
expect(rebaseEnvOntoImage([], [], [])).toEqual([]);
expect(rebaseEnvOntoImage(['A=1'], [], []).sort()).toEqual(['A=1']);
expect(rebaseEnvOntoImage([], [], ['A=1'])).toEqual(['A=1']);
});
test('oldImage empty -> every container entry treated as user layer (verbatim-ish)', () => {
// No old image to compare against: user values must all survive.
const out = rebaseEnvOntoImage(['A=mine', 'B=keep'], [], ['A=new']);
expect(val(out, 'A')).toBe('mine');
expect(val(out, 'B')).toBe('keep');
});
test('FALLBACK: empty old image preserves ALL container values verbatim (backward compat)', () => {
// This is the backward-compat guarantee: when the old image cannot be
// inspected (oldImageConfig null -> Env []), the recreate code calls the
// rebase with oldImageEnv=[]. Every container value — including image-baked
// keys — must survive unchanged, matching pre-rebase behavior. The new
// image's ADDED keys may appear, but no existing container value is altered.
const containerEnv = ['VERSION_LABEL=1', 'DB_PASSWORD=secret', 'PATH=/usr/bin'];
const out = rebaseEnvOntoImage(containerEnv, [], ['VERSION_LABEL=2', 'PATH=/usr/bin']);
expect(val(out, 'VERSION_LABEL')).toBe('1'); // NOT bumped — no old image to compare
expect(val(out, 'DB_PASSWORD')).toBe('secret'); // untouched
expect(val(out, 'PATH')).toBe('/usr/bin');
// Every original container key is still present with its original value.
for (const e of containerEnv) expect(out).toContain(e);
});
test('value containing = splits on first =', () => {
const out = rebaseEnvOntoImage(
['CONFIG=a=b=c'],
['CONFIG=a=b=c'],
['CONFIG=x=y=z']
);
expect(val(out, 'CONFIG')).toBe('x=y=z'); // untouched -> adopts new
});
test('empty value KEY= vs changed', () => {
expect(val(rebaseEnvOntoImage(['E='], ['E='], ['E=set']), 'E')).toBe('set'); // untouched
expect(val(rebaseEnvOntoImage(['E=mine'], ['E='], ['E=set']), 'E')).toBe('mine'); // override
});
test('bare KEY (inherit-from-daemon) round-trips and is distinct from KEY=', () => {
// Container inherits INHERIT_ME; old image also had it bare -> untouched.
const out = rebaseEnvOntoImage(['INHERIT_ME'], ['INHERIT_ME'], ['INHERIT_ME']);
expect(out).toContain('INHERIT_ME');
expect(out).not.toContain('INHERIT_ME=');
// User sets a value where image had bare -> override survives.
const out2 = rebaseEnvOntoImage(['INHERIT_ME=v'], ['INHERIT_ME'], ['INHERIT_ME']);
expect(val(out2, 'INHERIT_ME')).toBe('v');
});
test('duplicate keys in container env -> last wins (Docker semantics)', () => {
const out = rebaseEnvOntoImage(['A=1', 'A=2'], [], []);
expect(val(out, 'A')).toBe('2');
expect(out.filter(e => e.startsWith('A=')).length).toBe(1);
});
test('case sensitivity: Foo and FOO are distinct keys', () => {
const out = rebaseEnvOntoImage(['Foo=a', 'FOO=b'], ['Foo=a', 'FOO=b'], ['Foo=x', 'FOO=y']);
expect(val(out, 'Foo')).toBe('x');
expect(val(out, 'FOO')).toBe('y');
});
});
describe('rebaseLabelsOntoImage', () => {
test('untouched OCI version label adopts new image value (#1256)', () => {
const out = rebaseLabelsOntoImage(
{ 'org.opencontainers.image.version': '1' },
{ 'org.opencontainers.image.version': '1' },
{ 'org.opencontainers.image.version': '2' }
);
expect(out['org.opencontainers.image.version']).toBe('2');
});
test('user-overridden label (value differs) -> preserved (#1135 label half)', () => {
const out = rebaseLabelsOntoImage(
{ 'com.test.policy': 'strict' },
{},
{ 'com.test.policy': 'lax' }
);
expect(out['com.test.policy']).toBe('strict');
});
test('protected compose/dockhand labels never dropped even if image bakes them', () => {
const out = rebaseLabelsOntoImage(
{ 'com.docker.compose.project': 'mystack', 'dockhand.managed': 'true' },
{},
{ 'com.docker.compose.project': 'image-baked', 'dockhand.managed': 'false' }
);
expect(out['com.docker.compose.project']).toBe('mystack');
expect(out['dockhand.managed']).toBe('true');
});
test('null/undefined inputs', () => {
expect(rebaseLabelsOntoImage(null, null, null)).toEqual({});
expect(rebaseLabelsOntoImage({ A: '1' }, null, null)).toEqual({ A: '1' });
expect(rebaseLabelsOntoImage(null, null, { A: '1' })).toEqual({ A: '1' });
});
test('label removed by new image, user untouched -> dropped', () => {
const out = rebaseLabelsOntoImage({ maintainer: 'x' }, { maintainer: 'x' }, {});
expect('maintainer' in out).toBe(false);
});
});
describe('describeEnvRebase', () => {
test('classifies adopted / preserved / userOnly', () => {
const s = describeEnvRebase(
['APP_VERSION=1', 'PORT=custom', 'USER_ONLY=x'],
['APP_VERSION=1', 'PORT=8080'],
['APP_VERSION=2', 'PORT=8080', 'NEW_DEFAULT=9']
);
expect(s.adopted.sort()).toEqual(['APP_VERSION', 'NEW_DEFAULT']); // untouched bumped + brand new
expect(s.preserved).toEqual(['PORT']); // user override of a baked key
expect(s.userOnly).toEqual(['USER_ONLY']); // not in either image
});
test('no changes when everything matches', () => {
const s = describeEnvRebase(['A=1'], ['A=1'], ['A=1']);
expect(s.adopted).toEqual([]);
expect(s.preserved).toEqual([]);
expect(s.userOnly).toEqual([]);
});
});
describe('describeLabelRebase', () => {
test('OCI version adopted, user label preserved, protected label preserved', () => {
const s = describeLabelRebase(
{
'org.opencontainers.image.version': '1',
'com.test.policy': 'strict',
'com.docker.compose.project': 'mystack'
},
{ 'org.opencontainers.image.version': '1' },
{ 'org.opencontainers.image.version': '2' }
);
expect(s.adopted).toEqual(['org.opencontainers.image.version']);
// Neither compose nor user label is in the image => both classified user-only.
expect(s.userOnly).toContain('com.docker.compose.project');
expect(s.userOnly).toContain('com.test.policy');
});
});
describe('rebaseCommand — Cmd/Entrypoint rebase (#1371)', () => {
// The real-world case from #1371: image restructured, dropped the entrypoint script.
const OLD_CMD = ['/bin/sh', '-c', './scripts/inject-vars.sh && node ./dist/index.js'];
const NEW_CMD = ['node', 'dist/index.js'];
test('image-baked command the user never overrode -> ADOPTS the new image (the bug)', () => {
// container.Cmd == oldImage.Cmd (came from the image) and the new image changed it.
const r = rebaseCommand(OLD_CMD, OLD_CMD, NEW_CMD);
expect(r.value).toEqual(NEW_CMD);
expect(r.adopted).toBe(true);
});
test('a genuine user/Compose override (differs from old image) is KEPT', () => {
const userCmd = ['node', '--inspect', 'dist/index.js'];
const r = rebaseCommand(userCmd, OLD_CMD, NEW_CMD);
expect(r.value).toEqual(userCmd);
expect(r.adopted).toBe(false);
});
test('old image value unknown (null) -> keep container verbatim (fallback)', () => {
const r = rebaseCommand(OLD_CMD, null, NEW_CMD);
expect(r.value).toEqual(OLD_CMD);
expect(r.adopted).toBe(false);
});
test('old image value unknown (undefined) -> keep container verbatim', () => {
const r = rebaseCommand(OLD_CMD, undefined, NEW_CMD);
expect(r.value).toEqual(OLD_CMD);
expect(r.adopted).toBe(false);
});
test('image did not change the command -> no-op (keep, not adopted)', () => {
const r = rebaseCommand(OLD_CMD, OLD_CMD, OLD_CMD);
expect(r.value).toEqual(OLD_CMD);
expect(r.adopted).toBe(false);
});
test('null Entrypoint on both sides is stable', () => {
const r = rebaseCommand(null, null, null);
expect(r.value).toEqual(null);
expect(r.adopted).toBe(false);
});
test('old value null is treated as UNKNOWN -> keep verbatim (never adopt), even if new image adds one', () => {
// A null old Entrypoint is ambiguous ("image has none" vs "couldn't inspect"), so we
// fall back to verbatim rather than risk adopting onto a wrongly-assumed baseline.
const r = rebaseCommand(null, null, ['/entry.sh']);
expect(r.value).toEqual(null);
expect(r.adopted).toBe(false);
});
test('order matters: same elements, different order = a user override, kept', () => {
const reordered = ['dist/index.js', 'node'];
const r = rebaseCommand(reordered, NEW_CMD, ['node', 'dist/other.js']);
expect(r.value).toEqual(reordered);
expect(r.adopted).toBe(false);
});
});
+121
View File
@@ -0,0 +1,121 @@
/**
* Unit tests for detectImageEnvDivergence and detectImageLabelDivergence.
*
* Both helpers report keys whose container value differs from the
* image's CURRENT value. Keys present on only one side are NOT
* reported — those are user-only or image-only, neither of which is
* a divergence between sides we can act on.
*/
import { describe, test, expect } from 'bun:test';
import {
detectImageEnvDivergence,
detectImageLabelDivergence
} from '../src/lib/server/container-image-divergence';
describe('detectImageEnvDivergence', () => {
test('returns [] when arrays match', () => {
expect(detectImageEnvDivergence(['A=1', 'B=2'], ['A=1', 'B=2'])).toEqual([]);
});
test('returns [] for empty inputs', () => {
expect(detectImageEnvDivergence([], [])).toEqual([]);
expect(detectImageEnvDivergence(['A=1'], [])).toEqual([]);
expect(detectImageEnvDivergence([], ['A=1'])).toEqual([]);
});
test('returns differing keys', () => {
const result = detectImageEnvDivergence(
['PORT=80', 'NGINX_VERSION=1.27.3', 'PATH=/usr/bin'],
['PORT=8080', 'NGINX_VERSION=1.27.4', 'PATH=/usr/bin']
);
expect(result.sort()).toEqual(['NGINX_VERSION', 'PORT']);
});
test('does NOT report keys present in only one side', () => {
// USER_ONLY appears only in the container — user-set, not divergence
// IMAGE_ONLY appears only in the image — image-added, not divergence
const result = detectImageEnvDivergence(
['USER_ONLY=foo', 'PATH=/usr/bin'],
['IMAGE_ONLY=bar', 'PATH=/usr/bin']
);
expect(result).toEqual([]);
});
test('handles values containing =', () => {
const result = detectImageEnvDivergence(
['CONFIG=a=b=c', 'OTHER=ok'],
['CONFIG=x=y=z', 'OTHER=ok']
);
expect(result).toEqual(['CONFIG']);
});
test('handles empty values', () => {
expect(detectImageEnvDivergence(['EMPTY='], ['EMPTY=set'])).toEqual(['EMPTY']);
expect(detectImageEnvDivergence(['EMPTY='], ['EMPTY='])).toEqual([]);
});
test('handles entries with no = (treated as empty value)', () => {
// Bare "KEY" entries are valid in Docker env arrays — treat as KEY=""
expect(detectImageEnvDivergence(['BARE'], ['BARE='])).toEqual([]);
expect(detectImageEnvDivergence(['BARE'], ['BARE=value'])).toEqual(['BARE']);
});
test('reporter scenarios from #1135 and #1061', () => {
// #1135: user overrode image's ASPNETCORE_HTTP_PORTS=8080 to 80
expect(
detectImageEnvDivergence(['ASPNETCORE_HTTP_PORTS=80'], ['ASPNETCORE_HTTP_PORTS=8080'])
).toEqual(['ASPNETCORE_HTTP_PORTS']);
// #1061: container was created when image had APP_VERSION=0.32.0;
// image now has 0.36.0
expect(
detectImageEnvDivergence(['APP_VERSION=0.32.0'], ['APP_VERSION=0.36.0'])
).toEqual(['APP_VERSION']);
});
});
describe('detectImageLabelDivergence', () => {
test('returns [] when objects match', () => {
expect(detectImageLabelDivergence({ a: '1', b: '2' }, { a: '1', b: '2' })).toEqual([]);
});
test('returns [] for nullish inputs', () => {
expect(detectImageLabelDivergence(null, null)).toEqual([]);
expect(detectImageLabelDivergence(undefined, undefined)).toEqual([]);
expect(detectImageLabelDivergence({}, {})).toEqual([]);
});
test('returns differing keys', () => {
const result = detectImageLabelDivergence(
{ 'com.example.policy': 'strict', shared: 'same' },
{ 'com.example.policy': 'permissive', shared: 'same' }
);
expect(result).toEqual(['com.example.policy']);
});
test('does NOT report keys present in only one side', () => {
const result = detectImageLabelDivergence(
{ 'user.only': 'foo' },
{ 'image.only': 'bar' }
);
expect(result).toEqual([]);
});
test('dockhand.update opt-out: user="false" vs image="true"', () => {
// The exact regression we worried about: user disables auto-update
// via label; image bakes the opposite default.
expect(
detectImageLabelDivergence({ 'dockhand.update': 'false' }, { 'dockhand.update': 'true' })
).toEqual(['dockhand.update']);
});
test('handles empty values', () => {
expect(
detectImageLabelDivergence({ EMPTY: '' }, { EMPTY: 'set' })
).toEqual(['EMPTY']);
expect(
detectImageLabelDivergence({ EMPTY: '' }, { EMPTY: '' })
).toEqual([]);
});
});
+367
View File
@@ -0,0 +1,367 @@
/**
* Unit tests for Dockhand container label controls.
*
* Tests the label parsing and behavior for:
* - dockhand.update (opt-out from auto-updates)
* - dockhand.hidden (hide from UI)
* - dockhand.notify (suppress notifications)
*
* Run with: bun test tests/unit/container-labels.test.ts
*/
import { describe, test, expect } from 'bun:test';
import {
isUpdateDisabledByLabel,
isHiddenByLabel,
isNotifyDisabledByLabel,
isAdoptDisabledByLabel,
isStackUnadoptable,
getDockhandLabels,
getOrderValue,
getVersionPatternOverride,
DOCKHAND_LABELS,
} from '../src/lib/server/container-labels';
// ---------------------------------------------------------------------------
// dockhand.update
// ---------------------------------------------------------------------------
describe('isUpdateDisabledByLabel', () => {
test('returns false (allow update) when no labels present', () => {
expect(isUpdateDisabledByLabel(undefined)).toBe(false);
expect(isUpdateDisabledByLabel(null)).toBe(false);
expect(isUpdateDisabledByLabel({})).toBe(false);
});
test('returns true (skip update) for falsy values', () => {
expect(isUpdateDisabledByLabel({ 'dockhand.update': 'false' })).toBe(true);
expect(isUpdateDisabledByLabel({ 'dockhand.update': 'FALSE' })).toBe(true);
expect(isUpdateDisabledByLabel({ 'dockhand.update': 'False' })).toBe(true);
expect(isUpdateDisabledByLabel({ 'dockhand.update': 'no' })).toBe(true);
expect(isUpdateDisabledByLabel({ 'dockhand.update': 'NO' })).toBe(true);
expect(isUpdateDisabledByLabel({ 'dockhand.update': 'No' })).toBe(true);
expect(isUpdateDisabledByLabel({ 'dockhand.update': '0' })).toBe(true);
});
test('returns false (allow update) for truthy values', () => {
expect(isUpdateDisabledByLabel({ 'dockhand.update': 'true' })).toBe(false);
expect(isUpdateDisabledByLabel({ 'dockhand.update': 'TRUE' })).toBe(false);
expect(isUpdateDisabledByLabel({ 'dockhand.update': 'yes' })).toBe(false);
expect(isUpdateDisabledByLabel({ 'dockhand.update': 'YES' })).toBe(false);
expect(isUpdateDisabledByLabel({ 'dockhand.update': '1' })).toBe(false);
});
test('returns false (allow update) for unrecognized values', () => {
expect(isUpdateDisabledByLabel({ 'dockhand.update': 'maybe' })).toBe(false);
expect(isUpdateDisabledByLabel({ 'dockhand.update': '' })).toBe(false);
expect(isUpdateDisabledByLabel({ 'dockhand.update': 'disable' })).toBe(false);
});
test('handles whitespace in values', () => {
expect(isUpdateDisabledByLabel({ 'dockhand.update': ' false ' })).toBe(true);
expect(isUpdateDisabledByLabel({ 'dockhand.update': ' true ' })).toBe(false);
expect(isUpdateDisabledByLabel({ 'dockhand.update': ' NO ' })).toBe(true);
});
test('ignores unrelated labels', () => {
expect(isUpdateDisabledByLabel({ 'com.docker.compose.project': 'myapp' })).toBe(false);
expect(isUpdateDisabledByLabel({
'com.docker.compose.project': 'myapp',
'dockhand.update': 'false'
})).toBe(true);
});
});
// ---------------------------------------------------------------------------
// dockhand.hidden
// ---------------------------------------------------------------------------
describe('isHiddenByLabel', () => {
test('returns false (visible) when no labels present', () => {
expect(isHiddenByLabel(undefined)).toBe(false);
expect(isHiddenByLabel(null)).toBe(false);
expect(isHiddenByLabel({})).toBe(false);
});
test('returns true (hidden) for truthy values', () => {
expect(isHiddenByLabel({ 'dockhand.hidden': 'true' })).toBe(true);
expect(isHiddenByLabel({ 'dockhand.hidden': 'TRUE' })).toBe(true);
expect(isHiddenByLabel({ 'dockhand.hidden': 'True' })).toBe(true);
expect(isHiddenByLabel({ 'dockhand.hidden': 'yes' })).toBe(true);
expect(isHiddenByLabel({ 'dockhand.hidden': 'YES' })).toBe(true);
expect(isHiddenByLabel({ 'dockhand.hidden': 'Yes' })).toBe(true);
expect(isHiddenByLabel({ 'dockhand.hidden': '1' })).toBe(true);
});
test('returns false (visible) for falsy values', () => {
expect(isHiddenByLabel({ 'dockhand.hidden': 'false' })).toBe(false);
expect(isHiddenByLabel({ 'dockhand.hidden': 'no' })).toBe(false);
expect(isHiddenByLabel({ 'dockhand.hidden': '0' })).toBe(false);
});
test('returns false (visible) for unrecognized values', () => {
expect(isHiddenByLabel({ 'dockhand.hidden': 'hidden' })).toBe(false);
expect(isHiddenByLabel({ 'dockhand.hidden': '' })).toBe(false);
});
test('handles whitespace in values', () => {
expect(isHiddenByLabel({ 'dockhand.hidden': ' true ' })).toBe(true);
expect(isHiddenByLabel({ 'dockhand.hidden': ' YES ' })).toBe(true);
});
});
// ---------------------------------------------------------------------------
// dockhand.notify
// ---------------------------------------------------------------------------
describe('isNotifyDisabledByLabel', () => {
test('returns false (notify) when no labels present', () => {
expect(isNotifyDisabledByLabel(undefined)).toBe(false);
expect(isNotifyDisabledByLabel(null)).toBe(false);
expect(isNotifyDisabledByLabel({})).toBe(false);
});
test('returns true (suppress) for falsy values', () => {
expect(isNotifyDisabledByLabel({ 'dockhand.notify': 'false' })).toBe(true);
expect(isNotifyDisabledByLabel({ 'dockhand.notify': 'FALSE' })).toBe(true);
expect(isNotifyDisabledByLabel({ 'dockhand.notify': 'no' })).toBe(true);
expect(isNotifyDisabledByLabel({ 'dockhand.notify': 'NO' })).toBe(true);
expect(isNotifyDisabledByLabel({ 'dockhand.notify': '0' })).toBe(true);
});
test('returns false (notify) for truthy values', () => {
expect(isNotifyDisabledByLabel({ 'dockhand.notify': 'true' })).toBe(false);
expect(isNotifyDisabledByLabel({ 'dockhand.notify': 'yes' })).toBe(false);
expect(isNotifyDisabledByLabel({ 'dockhand.notify': '1' })).toBe(false);
});
test('returns false (notify) for unrecognized values', () => {
expect(isNotifyDisabledByLabel({ 'dockhand.notify': 'off' })).toBe(false);
expect(isNotifyDisabledByLabel({ 'dockhand.notify': '' })).toBe(false);
});
});
// ---------------------------------------------------------------------------
// getDockhandLabels (combined)
// ---------------------------------------------------------------------------
describe('getDockhandLabels', () => {
test('returns all defaults when no labels', () => {
expect(getDockhandLabels({})).toEqual({
updateDisabled: false,
hidden: false,
notifyDisabled: false,
});
});
test('returns all defaults for null/undefined', () => {
expect(getDockhandLabels(null)).toEqual({
updateDisabled: false,
hidden: false,
notifyDisabled: false,
});
expect(getDockhandLabels(undefined)).toEqual({
updateDisabled: false,
hidden: false,
notifyDisabled: false,
});
});
test('parses all labels correctly', () => {
expect(getDockhandLabels({
'dockhand.update': 'false',
'dockhand.hidden': 'true',
'dockhand.notify': 'no',
})).toEqual({
updateDisabled: true,
hidden: true,
notifyDisabled: true,
});
});
test('parses mixed labels', () => {
expect(getDockhandLabels({
'dockhand.update': 'true',
'dockhand.hidden': 'false',
'dockhand.notify': 'yes',
})).toEqual({
updateDisabled: false,
hidden: false,
notifyDisabled: false,
});
});
test('handles partial label sets', () => {
expect(getDockhandLabels({
'dockhand.update': 'no',
})).toEqual({
updateDisabled: true,
hidden: false,
notifyDisabled: false,
});
});
test('ignores non-dockhand labels', () => {
expect(getDockhandLabels({
'com.docker.compose.project': 'mystack',
'maintainer': '[email protected]',
'dockhand.hidden': 'YES',
})).toEqual({
updateDisabled: false,
hidden: true,
notifyDisabled: false,
});
});
});
// ---------------------------------------------------------------------------
// DOCKHAND_LABELS constants
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// dockhand.order
// ---------------------------------------------------------------------------
describe('getOrderValue', () => {
test('returns 0 for null/undefined/empty/missing labels', () => {
expect(getOrderValue(undefined)).toBe(0);
expect(getOrderValue(null)).toBe(0);
expect(getOrderValue({})).toBe(0);
});
test('parses valid integers', () => {
expect(getOrderValue({ 'dockhand.order': '1' })).toBe(1);
expect(getOrderValue({ 'dockhand.order': '-5' })).toBe(-5);
expect(getOrderValue({ 'dockhand.order': '100' })).toBe(100);
expect(getOrderValue({ 'dockhand.order': '0' })).toBe(0);
});
test('returns 0 for non-numeric values', () => {
expect(getOrderValue({ 'dockhand.order': 'abc' })).toBe(0);
expect(getOrderValue({ 'dockhand.order': 'first' })).toBe(0);
expect(getOrderValue({ 'dockhand.order': '' })).toBe(0);
});
test('truncates floats to integer', () => {
expect(getOrderValue({ 'dockhand.order': '1.5' })).toBe(1);
expect(getOrderValue({ 'dockhand.order': '3.9' })).toBe(3);
});
test('handles whitespace', () => {
expect(getOrderValue({ 'dockhand.order': ' 3 ' })).toBe(3);
expect(getOrderValue({ 'dockhand.order': ' -2 ' })).toBe(-2);
});
});
describe('DOCKHAND_LABELS constants', () => {
test('has correct label keys', () => {
expect(DOCKHAND_LABELS.UPDATE).toBe('dockhand.update');
expect(DOCKHAND_LABELS.HIDDEN).toBe('dockhand.hidden');
expect(DOCKHAND_LABELS.NOTIFY).toBe('dockhand.notify');
expect(DOCKHAND_LABELS.ORDER).toBe('dockhand.order');
});
});
// ---------------------------------------------------------------------------
// Integration-style: simulated container filtering
// ---------------------------------------------------------------------------
describe('container filtering simulation', () => {
interface MockContainer {
id: string;
name: string;
labels: Record<string, string>;
}
const containers: MockContainer[] = [
{ id: 'c1', name: 'nginx', labels: {} },
{ id: 'c2', name: 'redis', labels: { 'dockhand.update': 'false' } },
{ id: 'c3', name: 'postgres', labels: { 'dockhand.update': 'NO' } },
{ id: 'c4', name: 'traefik', labels: { 'dockhand.hidden': 'true' } },
{ id: 'c5', name: 'mqtt', labels: { 'dockhand.notify': 'false' } },
{ id: 'c6', name: 'app', labels: { 'dockhand.update': 'true' } },
{ id: 'c7', name: 'socket-proxy', labels: { 'dockhand.update': '0', 'dockhand.hidden': 'YES', 'dockhand.notify': 'NO' } },
];
test('filters out update-disabled containers', () => {
const updatable = containers.filter(c => !isUpdateDisabledByLabel(c.labels));
expect(updatable.map(c => c.name)).toEqual(['nginx', 'traefik', 'mqtt', 'app']);
});
test('filters out hidden containers', () => {
const visible = containers.filter(c => !isHiddenByLabel(c.labels));
expect(visible.map(c => c.name)).toEqual(['nginx', 'redis', 'postgres', 'mqtt', 'app']);
});
test('filters out notify-disabled containers', () => {
const notifiable = containers.filter(c => !isNotifyDisabledByLabel(c.labels));
expect(notifiable.map(c => c.name)).toEqual(['nginx', 'redis', 'postgres', 'traefik', 'app']);
});
test('socket-proxy has all three labels active', () => {
const sp = containers.find(c => c.name === 'socket-proxy')!;
expect(isUpdateDisabledByLabel(sp.labels)).toBe(true);
expect(isHiddenByLabel(sp.labels)).toBe(true);
expect(isNotifyDisabledByLabel(sp.labels)).toBe(true);
});
});
describe('isAdoptDisabledByLabel', () => {
test('default (no label) is adoptable', () => {
expect(isAdoptDisabledByLabel(undefined)).toBe(false);
expect(isAdoptDisabledByLabel(null)).toBe(false);
expect(isAdoptDisabledByLabel({})).toBe(false);
});
test('explicit false/no/0 disables adoption (case-insensitive)', () => {
expect(isAdoptDisabledByLabel({ 'dockhand.adopt': 'false' })).toBe(true);
expect(isAdoptDisabledByLabel({ 'dockhand.adopt': 'FALSE' })).toBe(true);
expect(isAdoptDisabledByLabel({ 'dockhand.adopt': 'no' })).toBe(true);
expect(isAdoptDisabledByLabel({ 'dockhand.adopt': '0' })).toBe(true);
});
test('true / unrecognized values stay adoptable', () => {
expect(isAdoptDisabledByLabel({ 'dockhand.adopt': 'true' })).toBe(false);
expect(isAdoptDisabledByLabel({ 'dockhand.adopt': 'maybe' })).toBe(false);
});
});
describe('isStackUnadoptable', () => {
test('false when no container opts out', () => {
expect(isStackUnadoptable([])).toBe(false);
expect(isStackUnadoptable([{}, { 'dockhand.update': 'false' }])).toBe(false);
});
test('true when ANY container has dockhand.adopt=false', () => {
expect(isStackUnadoptable([{}, { 'dockhand.adopt': 'false' }])).toBe(true);
expect(isStackUnadoptable([{ 'dockhand.adopt': 'no' }])).toBe(true);
});
test('tolerates null/undefined entries', () => {
expect(isStackUnadoptable([null, undefined, { 'dockhand.adopt': '0' }])).toBe(true);
expect(isStackUnadoptable([null, undefined])).toBe(false);
});
});
// ---------------------------------------------------------------------------
// dockhand.version.pattern
// ---------------------------------------------------------------------------
describe('getVersionPatternOverride', () => {
test('returns null when the label is absent', () => {
expect(getVersionPatternOverride({})).toBeNull();
expect(getVersionPatternOverride(null)).toBeNull();
});
test('compiles a valid regex: pattern from the label into a RegExp', () => {
const re = getVersionPatternOverride({
'dockhand.version.pattern': 'regex:^(?<major>\\d{4})\\.(?<minor>\\d+)\\.(?<patch>\\d+)-[0-9a-f]+$'
});
expect(re).toBeInstanceOf(RegExp);
expect(re!.test('2024.12.5-a1b2c3d')).toBe(true);
});
test('returns null for a malformed label (safe fallback)', () => {
expect(getVersionPatternOverride({ 'dockhand.version.pattern': 'not-a-scheme' })).toBeNull();
expect(getVersionPatternOverride({ 'dockhand.version.pattern': 'regex:(' })).toBeNull();
});
});
+51
View File
@@ -0,0 +1,51 @@
import { describe, test, expect } from 'bun:test';
import { attachmentContentDisposition } from '../src/lib/server/content-disposition';
/**
* The download filename is derived from a user-influenced path, so it must never
* land raw in the Content-Disposition header (quote breakout / CRLF injection).
*/
describe('attachmentContentDisposition', () => {
test('always disposition=attachment with both filename and filename*', () => {
const v = attachmentContentDisposition('config.yaml');
expect(v.startsWith('attachment;')).toBe(true);
expect(v).toContain('filename="config.yaml"');
expect(v).toContain("filename*=UTF-8''config.yaml");
});
test('strips double-quote (no breakout of the quoted form)', () => {
const v = attachmentContentDisposition('evil".pdf');
// the fallback must not contain a bare quote that closes filename="
expect(v).toContain('filename="evil.pdf"');
// the encoded copy percent-encodes it instead
expect(v).toContain('%22');
});
test('strips CR/LF so no header injection is possible', () => {
const v = attachmentContentDisposition('a\r\nSet-Cookie: x=1');
expect(v).not.toContain('\r');
expect(v).not.toContain('\n');
// CRLF is percent-encoded in the filename* copy
expect(v).toContain('%0D%0A');
});
test('strips backslash', () => {
const v = attachmentContentDisposition('back\\slash.log');
expect(v).toContain('filename="backslash.log"');
});
test('handles non-ASCII via filename*', () => {
const v = attachmentContentDisposition('résumé.txt');
expect(v).toContain("filename*=UTF-8''r%C3%A9sum%C3%A9.txt");
});
test('empty / whitespace falls back to "download"', () => {
expect(attachmentContentDisposition('')).toContain('filename="download"');
expect(attachmentContentDisposition(' ')).toContain('filename="download"');
});
test('control characters are stripped from the fallback', () => {
const v = attachmentContentDisposition('a\x00\x07\x1fb.txt');
expect(v).toContain('filename="ab.txt"');
});
});
+157
View File
@@ -0,0 +1,157 @@
import { describe, test, expect } from 'bun:test';
import { Cron } from 'croner';
import { getNextRun, isValidCron } from '../src/lib/server/scheduler/cron-utils';
/**
* Tests that our cron utilities use AND logic for day-of-month + day-of-week,
* matching user expectations ("first Saturday") rather than Vixie cron OR logic.
*/
describe('getNextRun: AND logic for day-of-month + day-of-week', () => {
test('first Saturday of the month: 0 3 1-7 * 6', () => {
const next = getNextRun('0 3 1-7 * 6');
expect(next).not.toBeNull();
expect(next!.getDay()).toBe(6); // Saturday
expect(next!.getDate()).toBeGreaterThanOrEqual(1);
expect(next!.getDate()).toBeLessThanOrEqual(7);
});
test('first Monday of the month: 0 9 1-7 * 1', () => {
const next = getNextRun('0 9 1-7 * 1');
expect(next).not.toBeNull();
expect(next!.getDay()).toBe(1); // Monday
expect(next!.getDate()).toBeGreaterThanOrEqual(1);
expect(next!.getDate()).toBeLessThanOrEqual(7);
});
test('1st of month AND Monday: 0 0 1 * 1', () => {
const next = getNextRun('0 0 1 * 1');
expect(next).not.toBeNull();
expect(next!.getDate()).toBe(1);
expect(next!.getDay()).toBe(1);
});
test('day-of-week only: 0 3 * * 1 — next run is a Monday', () => {
const next = getNextRun('0 3 * * 1');
expect(next).not.toBeNull();
expect(next!.getDay()).toBe(1);
});
test('day-of-month only: 0 3 15 * * — next run is the 15th', () => {
const next = getNextRun('0 3 15 * *');
expect(next).not.toBeNull();
expect(next!.getDate()).toBe(15);
});
test('daily at 3am: 0 3 * * *', () => {
const next = getNextRun('0 3 * * *');
expect(next).not.toBeNull();
expect(next!.getHours()).toBe(3);
expect(next!.getMinutes()).toBe(0);
});
test('every 30 minutes: */30 * * * *', () => {
const next = getNextRun('*/30 * * * *');
expect(next).not.toBeNull();
const mins = next!.getMinutes();
expect(mins === 0 || mins === 30).toBe(true);
});
test('invalid expression returns null', () => {
expect(getNextRun('not a cron')).toBeNull();
});
test('timezone parameter works', () => {
const next = getNextRun('0 3 * * *', 'Europe/Warsaw');
expect(next).not.toBeNull();
});
});
describe('isValidCron', () => {
test('valid 5-field expressions', () => {
expect(isValidCron('0 3 * * *')).toBe(true);
expect(isValidCron('*/30 * * * *')).toBe(true);
expect(isValidCron('0 3 1-7 * 6')).toBe(true);
expect(isValidCron('0 0 1 * 1')).toBe(true);
expect(isValidCron('5 4 * * 0')).toBe(true);
});
test('valid 6-field expressions (with seconds)', () => {
expect(isValidCron('0 0 3 * * *')).toBe(true);
expect(isValidCron('30 */5 * * * *')).toBe(true);
expect(isValidCron('0 0 0 1 * *')).toBe(true);
});
test('invalid expressions', () => {
expect(isValidCron('not a cron')).toBe(false);
expect(isValidCron('* * *')).toBe(false);
expect(isValidCron('60 * * * *')).toBe(false);
expect(isValidCron('* 25 * * *')).toBe(false);
});
});
describe('Schedule type detection from cron expression', () => {
// Mirrors logic from api/auto-update/[containerName]/+server.ts
function detectScheduleType(cronExpression: string): 'daily' | 'weekly' | 'custom' {
const parts = cronExpression.split(' ');
if (parts.length === 5) {
const [, , day, month, dow] = parts;
if (dow !== '*' && day === '*' && month === '*') return 'weekly';
if (day === '*' && month === '*' && dow === '*') return 'daily';
}
return 'custom';
}
test('detects daily schedule', () => {
expect(detectScheduleType('0 3 * * *')).toBe('daily');
expect(detectScheduleType('30 12 * * *')).toBe('daily');
});
test('detects weekly schedule', () => {
expect(detectScheduleType('0 3 * * 1')).toBe('weekly');
expect(detectScheduleType('0 0 * * 0')).toBe('weekly');
expect(detectScheduleType('0 3 * * 6')).toBe('weekly');
});
test('6-field expression is always custom', () => {
expect(detectScheduleType('0 0 3 * * *')).toBe('custom');
expect(detectScheduleType('30 0 3 * * 1')).toBe('custom');
});
test('monthly expressions are custom', () => {
expect(detectScheduleType('0 3 1 * *')).toBe('custom');
expect(detectScheduleType('0 0 15 6 *')).toBe('custom');
});
test('combined day-of-month and day-of-week is custom', () => {
expect(detectScheduleType('0 3 1-7 * 6')).toBe('custom');
});
test('every-N-minutes is daily (all day fields are wildcard)', () => {
expect(detectScheduleType('*/30 * * * *')).toBe('daily');
});
});
describe('AND vs OR verification', () => {
test('getNextRun produces different result than croner default for combined fields', () => {
// croner default (legacyMode:true) uses OR — our getNextRun should use AND
// With OR, "0 3 1-7 * 6" fires on days 1-7 OR any Saturday (~11/month)
// With AND, it fires only on Saturdays that are also days 1-7 (~1/month)
const orJob = new Cron('0 3 1-7 * 6'); // default = OR logic
const orRuns = orJob.nextRuns(12);
orJob.stop();
// Our getNextRun uses AND — verify all 12 runs satisfy both constraints
// We can't call getNextRun 12 times (it always returns the same next),
// but we can verify the single next run satisfies AND
const andNext = getNextRun('0 3 1-7 * 6');
expect(andNext).not.toBeNull();
expect(andNext!.getDay()).toBe(6);
expect(andNext!.getDate()).toBeGreaterThanOrEqual(1);
expect(andNext!.getDate()).toBeLessThanOrEqual(7);
// OR logic produces runs that are NOT all Saturdays
const nonSaturdayOrRuns = orRuns.filter((d) => d.getDay() !== 6);
expect(nonSaturdayOrRuns.length).toBeGreaterThan(0);
});
});
+158
View File
@@ -0,0 +1,158 @@
/**
* Unit tests for custom URL parsing (dockhand.url label).
*
* Supports both plain URLs and markdown-style named links:
* - "https://example.com" → { url: "https://example.com" }
* - "[My App](https://example.com)" → { url: "https://example.com", name: "My App" }
*
* Run with: bun test tests/unit/custom-url.test.ts
*/
import { describe, test, expect } from 'bun:test';
import { parseCustomUrl } from '../src/lib/utils/custom-url';
describe('parseCustomUrl', () => {
// -------------------------------------------------------------------
// Null/empty handling
// -------------------------------------------------------------------
test('returns null for null/undefined/empty', () => {
expect(parseCustomUrl(null)).toBeNull();
expect(parseCustomUrl(undefined)).toBeNull();
expect(parseCustomUrl('')).toBeNull();
expect(parseCustomUrl(' ')).toBeNull();
});
// -------------------------------------------------------------------
// Plain URL (backward compatible)
// -------------------------------------------------------------------
test('parses plain HTTP URL', () => {
expect(parseCustomUrl('http://example.com')).toEqual({
url: 'http://example.com',
});
});
test('parses plain HTTPS URL', () => {
expect(parseCustomUrl('https://example.com:8080/path')).toEqual({
url: 'https://example.com:8080/path',
});
});
test('trims whitespace from plain URL', () => {
expect(parseCustomUrl(' https://example.com ')).toEqual({
url: 'https://example.com',
});
});
test('parses URL without protocol (prepends https://)', () => {
expect(parseCustomUrl('example.com:3000')).toEqual({
url: 'https://example.com:3000',
});
});
// -------------------------------------------------------------------
// Markdown link format: [name](url)
// -------------------------------------------------------------------
test('parses markdown link with name', () => {
expect(parseCustomUrl('[Cloud](https://cloud.example.com:1234/)')).toEqual({
url: 'https://cloud.example.com:1234/',
name: 'Cloud',
});
});
test('parses markdown link with multi-word name', () => {
expect(parseCustomUrl('[My Admin Panel](https://admin.example.com)')).toEqual({
url: 'https://admin.example.com',
name: 'My Admin Panel',
});
});
test('trims whitespace in markdown name and URL', () => {
expect(parseCustomUrl('[ My App ]( https://app.example.com )')).toEqual({
url: 'https://app.example.com',
name: 'My App',
});
});
test('trims outer whitespace from markdown link', () => {
expect(parseCustomUrl(' [App](https://app.example.com) ')).toEqual({
url: 'https://app.example.com',
name: 'App',
});
});
test('parses markdown link with port and path', () => {
expect(parseCustomUrl('[Dashboard](http://192.168.1.5:9090/dashboard)')).toEqual({
url: 'http://192.168.1.5:9090/dashboard',
name: 'Dashboard',
});
});
test('parses markdown link with query string', () => {
expect(parseCustomUrl('[Grafana](https://grafana.local/d/abc?orgId=1)')).toEqual({
url: 'https://grafana.local/d/abc?orgId=1',
name: 'Grafana',
});
});
// -------------------------------------------------------------------
// Edge cases — should NOT match as markdown
// -------------------------------------------------------------------
test('does not match partial markdown (missing closing paren)', () => {
expect(parseCustomUrl('[App](https://example.com')).toEqual({
url: 'https://[App](https://example.com',
});
});
test('does not match partial markdown (missing bracket)', () => {
expect(parseCustomUrl('App](https://example.com)')).toEqual({
url: 'https://App](https://example.com)',
});
});
test('does not match empty name', () => {
expect(parseCustomUrl('[](https://example.com)')).toEqual({
url: 'https://[](https://example.com)',
});
});
test('does not match empty URL in markdown', () => {
expect(parseCustomUrl('[App]()')).toEqual({
url: 'https://[App]()',
});
});
test('does not match markdown with extra text after', () => {
expect(parseCustomUrl('[App](https://example.com) extra')).toEqual({
url: 'https://[App](https://example.com) extra',
});
});
test('does not match markdown with text before', () => {
expect(parseCustomUrl('prefix [App](https://example.com)')).toEqual({
url: 'https://prefix [App](https://example.com)',
});
});
// -------------------------------------------------------------------
// Per-port URL labels (dockhand.port.<port>.url) use the same parser
// as dockhand.url, so the title from a markdown link replaces the
// port badge text in the UI.
// -------------------------------------------------------------------
test('per-port URL: plain URL keeps port-badge text', () => {
expect(parseCustomUrl('https://ha.example.com')).toEqual({
url: 'https://ha.example.com',
});
});
test('per-port URL: markdown title replaces port-badge text', () => {
expect(parseCustomUrl('[CloudName](https://cloud.example.com/)')).toEqual({
url: 'https://cloud.example.com/',
name: 'CloudName',
});
});
});
+167
View File
@@ -0,0 +1,167 @@
import { describe, test, expect } from 'bun:test';
import { currentDateInTimezone, dayBoundaryToUtcISO, parseTimestamp, buildFormatters, formatDatePartWith, formatTimePartWith } from '../src/lib/utils/date-format';
// Activity/audit date filters treat 'YYYY-MM-DD' as a calendar day in the
// configured display timezone; these helpers turn that into UTC instants
// comparable against UTC-stored timestamps (#1269).
describe('dayBoundaryToUtcISO', () => {
test('UTC start of day is midnight UTC', () => {
expect(dayBoundaryToUtcISO('2026-07-09', 'UTC', false)).toBe('2026-07-09T00:00:00.000Z');
});
test('UTC end of day is 23:59:59.999 UTC', () => {
expect(dayBoundaryToUtcISO('2026-07-09', 'UTC', true)).toBe('2026-07-09T23:59:59.999Z');
});
test('positive offset zone: local midnight is the previous day in UTC (Sydney, UTC+10)', () => {
expect(dayBoundaryToUtcISO('2026-07-09', 'Australia/Sydney', false)).toBe('2026-07-08T14:00:00.000Z');
});
test('positive offset zone: local end of day (Sydney, UTC+10)', () => {
expect(dayBoundaryToUtcISO('2026-07-09', 'Australia/Sydney', true)).toBe('2026-07-09T13:59:59.999Z');
});
test('negative offset zone: local midnight is later the same day in UTC (New York, UTC-4 in summer)', () => {
expect(dayBoundaryToUtcISO('2026-07-09', 'America/New_York', false)).toBe('2026-07-09T04:00:00.000Z');
});
test('negative offset zone: local end of day crosses into the next UTC day (New York)', () => {
expect(dayBoundaryToUtcISO('2026-07-09', 'America/New_York', true)).toBe('2026-07-10T03:59:59.999Z');
});
test('DST-aware: Warsaw is UTC+2 in summer, UTC+1 in winter', () => {
expect(dayBoundaryToUtcISO('2026-07-09', 'Europe/Warsaw', false)).toBe('2026-07-08T22:00:00.000Z');
expect(dayBoundaryToUtcISO('2026-01-09', 'Europe/Warsaw', false)).toBe('2026-01-08T23:00:00.000Z');
});
test('DST spring-forward day: midnight still resolves to the pre-transition offset (Warsaw 2026-03-29)', () => {
// Clocks jump 02:00 -> 03:00 that day; midnight itself is still UTC+1
expect(dayBoundaryToUtcISO('2026-03-29', 'Europe/Warsaw', false)).toBe('2026-03-28T23:00:00.000Z');
// End of the same day is already UTC+2
expect(dayBoundaryToUtcISO('2026-03-29', 'Europe/Warsaw', true)).toBe('2026-03-29T21:59:59.999Z');
});
test('DST fall-back day: end of day resolves to the post-transition offset (Warsaw 2026-10-25)', () => {
// Clocks fall back 03:00 -> 02:00; midnight is UTC+2, end of day UTC+1
expect(dayBoundaryToUtcISO('2026-10-25', 'Europe/Warsaw', false)).toBe('2026-10-24T22:00:00.000Z');
expect(dayBoundaryToUtcISO('2026-10-25', 'Europe/Warsaw', true)).toBe('2026-10-25T22:59:59.999Z');
});
test('half-hour offset zone (India, UTC+5:30)', () => {
expect(dayBoundaryToUtcISO('2026-07-09', 'Asia/Kolkata', false)).toBe('2026-07-08T18:30:00.000Z');
});
test('falsy timezone falls back to the runtime local zone', () => {
const expected = new Date(2026, 6, 9, 0, 0, 0, 0).toISOString();
expect(dayBoundaryToUtcISO('2026-07-09', undefined, false)).toBe(expected);
});
test('start boundary is inclusive of the first event of the day, end of the last', () => {
const from = dayBoundaryToUtcISO('2026-07-09', 'Australia/Sydney', false);
const to = dayBoundaryToUtcISO('2026-07-09', 'Australia/Sydney', true);
// Event at 09:00 Sydney time on 2026-07-09 = 2026-07-08T23:00:00Z:
// "tomorrow in UTC+0"? No - but late-evening Sydney events ARE next-day UTC:
const lateEvening = '2026-07-09T10:30:00.000Z'; // 20:30 Sydney
expect(lateEvening >= from && lateEvening <= to).toBe(true);
// The #1269 repro: event just after Sydney midnight, previous day in UTC
const justAfterMidnight = '2026-07-08T14:05:00.000Z'; // 00:05 Sydney on the 9th
expect(justAfterMidnight >= from && justAfterMidnight <= to).toBe(true);
// Event before Sydney midnight is excluded
const beforeMidnight = '2026-07-08T13:55:00.000Z'; // 23:55 Sydney on the 8th
expect(beforeMidnight >= from).toBe(false);
});
});
describe('currentDateInTimezone', () => {
test('returns YYYY-MM-DD', () => {
expect(currentDateInTimezone('UTC')).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(currentDateInTimezone()).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
test('UTC matches toISOString date part', () => {
// Sample twice to avoid a flake if the test straddles UTC midnight
const before = new Date().toISOString().slice(0, 10);
const result = currentDateInTimezone('UTC');
const after = new Date().toISOString().slice(0, 10);
expect([before, after]).toContain(result);
});
test('fixed-offset zone matches manual offset arithmetic (Kiritimati, UTC+14, no DST)', () => {
const before = new Date(Date.now() + 14 * 3_600_000).toISOString().slice(0, 10);
const result = currentDateInTimezone('Pacific/Kiritimati');
const after = new Date(Date.now() + 14 * 3_600_000).toISOString().slice(0, 10);
expect([before, after]).toContain(result);
});
});
// #1183: PG `timestamp without time zone` hands back a NAIVE string (no Z); the
// browser must NOT read it as local time. Asserting on .toISOString() keeps the
// test independent of the CI host's own timezone.
describe('parseTimestamp', () => {
test('naive PG string (space, no Z) is read as UTC', () => {
expect(parseTimestamp('2026-08-08 15:22:19.705').toISOString()).toBe('2026-08-08T15:22:19.705Z');
});
test('naive PG string without fractional seconds is read as UTC', () => {
expect(parseTimestamp('2026-08-08 15:22:19').toISOString()).toBe('2026-08-08T15:22:19.000Z');
});
test('ISO string with Z is left untouched', () => {
expect(parseTimestamp('2026-08-08T15:22:19.705Z').toISOString()).toBe('2026-08-08T15:22:19.705Z');
});
test('ISO string with +00:00 offset is preserved (not double-appended)', () => {
expect(parseTimestamp('2026-08-08T15:22:19+00:00').toISOString()).toBe('2026-08-08T15:22:19.000Z');
});
test('ISO string with a real +06:00 offset resolves to the right UTC instant', () => {
expect(parseTimestamp('2026-08-08T21:22:19+06:00').toISOString()).toBe('2026-08-08T15:22:19.000Z');
});
test('the DATE-part hyphen is not mistaken for an offset (naive -> UTC)', () => {
expect(parseTimestamp('2026-08-08T15:22:19').toISOString()).toBe('2026-08-08T15:22:19.000Z');
});
test('a Date is returned as-is', () => {
const d = new Date('2026-08-08T15:22:19.705Z');
expect(parseTimestamp(d)).toBe(d);
});
test('a numeric epoch is parsed as-is', () => {
const ms = Date.UTC(2026, 7, 8, 15, 22, 19);
expect(parseTimestamp(ms).toISOString()).toBe('2026-08-08T15:22:19.000Z');
});
// Garbage in must never throw - the formatters render hundreds of rows.
// bigint is the sharp edge: `new Date(10n)` throws TypeError, so it must be caught.
test('garbage inputs yield an Invalid Date, never a throw', () => {
for (const junk of [null, undefined, '', ' ', 'not-a-date', '2026-13-99', {}, [], NaN, 10n, Symbol('x')]) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let d: Date;
expect(() => { d = parseTimestamp(junk as any); }).not.toThrow();
// @ts-expect-error d is assigned above
expect(Number.isNaN(d.getTime())).toBe(true);
}
});
});
// A single unparseable timestamp must degrade to a placeholder, not throw
// RangeError out of Intl.formatToParts and blank the whole list (#1183 follow-up).
describe('formatters survive Invalid Date', () => {
const { date, time } = buildFormatters('UTC');
for (const junk of [null, undefined, '', 'not-a-date', '2026-13-99', NaN]) {
test(`date part is a placeholder for ${JSON.stringify(junk)}`, () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const d = parseTimestamp(junk as any);
expect(() => formatDatePartWith(d, date, 'DD.MM.YYYY')).not.toThrow();
expect(formatDatePartWith(d, date, 'DD.MM.YYYY')).toBe('-');
});
test(`time part is a placeholder for ${JSON.stringify(junk)}`, () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const d = parseTimestamp(junk as any);
expect(() => formatTimePartWith(d, time, '24h', true)).not.toThrow();
expect(formatTimePartWith(d, time, '24h', true)).toBe('-');
});
}
});
+217
View File
@@ -0,0 +1,217 @@
/**
* Unit tests for deepDiff - the function that compares container inspect
* snapshots before and after recreation.
*
* Run with: bun test tests/deep-diff.test.ts
*/
import { describe, test, expect } from 'bun:test';
import { deepDiff } from '../src/lib/utils/diff';
describe('deepDiff', () => {
test('identical objects return no diffs', () => {
const a = { foo: 'bar', nested: { x: 1 } };
expect(deepDiff(a, structuredClone(a))).toEqual([]);
});
test('detects top-level primitive change', () => {
const diffs = deepDiff({ name: 'old' }, { name: 'new' });
expect(diffs).toEqual(['name: "old" → "new"']);
});
test('detects nested property change', () => {
const diffs = deepDiff(
{ Config: { Hostname: 'abc' } },
{ Config: { Hostname: 'def' } }
);
expect(diffs).toEqual(['Config.Hostname: "abc" → "def"']);
});
test('detects deeply nested change', () => {
const diffs = deepDiff(
{ NetworkSettings: { Networks: { bridge: { IPAddress: '172.17.0.2' } } } },
{ NetworkSettings: { Networks: { bridge: { IPAddress: '172.17.0.5' } } } }
);
expect(diffs).toEqual([
'NetworkSettings.Networks.bridge.IPAddress: "172.17.0.2" → "172.17.0.5"'
]);
});
test('detects added key', () => {
const diffs = deepDiff({ a: 1 }, { a: 1, b: 2 });
expect(diffs).toEqual(['b: <missing> → 2']);
});
test('detects removed key', () => {
const diffs = deepDiff({ a: 1, b: 2 }, { a: 1 });
expect(diffs).toEqual(['b: 2 → <missing>']);
});
test('detects array change', () => {
const diffs = deepDiff(
{ HostConfig: { CapAdd: ['NET_ADMIN', 'SYS_TIME'] } },
{ HostConfig: { CapAdd: ['NET_ADMIN'] } }
);
expect(diffs).toEqual([
'HostConfig.CapAdd: ["NET_ADMIN","SYS_TIME"] → ["NET_ADMIN"]'
]);
});
test('identical arrays return no diffs', () => {
const diffs = deepDiff(
{ Env: ['A=1', 'B=2'] },
{ Env: ['A=1', 'B=2'] }
);
expect(diffs).toEqual([]);
});
test('detects null vs object', () => {
const diffs = deepDiff(
{ Config: { Healthcheck: null } },
{ Config: { Healthcheck: { Test: ['CMD', 'true'] } } }
);
expect(diffs).toEqual([
'Config.Healthcheck: null → {"Test":["CMD","true"]}'
]);
});
test('detects type change (string to number)', () => {
const diffs = deepDiff({ x: '123' }, { x: 123 });
expect(diffs).toEqual(['x: "123" → 123']);
});
test('detects boolean change', () => {
const diffs = deepDiff(
{ HostConfig: { Privileged: false } },
{ HostConfig: { Privileged: true } }
);
expect(diffs).toEqual(['HostConfig.Privileged: false → true']);
});
test('handles empty objects', () => {
expect(deepDiff({}, {})).toEqual([]);
});
test('handles full mock inspect diff', () => {
const before = {
Id: 'sha256:aaa',
Created: '2026-02-09T10:00:00Z',
Name: '/mycontainer',
Config: {
Hostname: 'mycontainer',
Env: ['FOO=bar'],
Image: 'nginx:1.25',
Labels: { app: 'web' }
},
HostConfig: {
Memory: 268435456,
PortBindings: { '80/tcp': [{ HostIp: '', HostPort: '8080' }] },
RestartPolicy: { Name: 'always', MaximumRetryCount: 0 },
Binds: ['/data:/data:rw'],
CapAdd: ['NET_ADMIN'],
Privileged: false
},
NetworkSettings: {
Networks: {
bridge: { IPAddress: '172.17.0.2', MacAddress: '02:42:ac:11:00:02' },
custom: { IPAddress: '10.0.0.5', MacAddress: '02:42:0a:00:00:05' }
}
},
Mounts: [
{ Type: 'bind', Source: '/data', Destination: '/data', RW: true }
]
};
const after = {
Id: 'sha256:bbb',
Created: '2026-02-10T12:00:00Z',
Name: '/mycontainer',
Config: {
Hostname: 'mycontainer',
Env: ['FOO=bar'],
Image: 'nginx:1.26',
Labels: { app: 'web' }
},
HostConfig: {
Memory: 268435456,
PortBindings: { '80/tcp': [{ HostIp: '', HostPort: '8080' }] },
RestartPolicy: { Name: 'always', MaximumRetryCount: 0 },
Binds: ['/data:/data:rw'],
CapAdd: ['NET_ADMIN'],
Privileged: false
},
NetworkSettings: {
Networks: {
bridge: { IPAddress: '172.17.0.2', MacAddress: '02:42:ac:11:00:09' },
custom: { IPAddress: '10.0.0.5', MacAddress: '02:42:0a:00:00:05' }
}
},
Mounts: [
{ Type: 'bind', Source: '/data', Destination: '/data', RW: true }
]
};
const diffs = deepDiff(before, after);
// Should detect Id, Created, Image, and MAC change - nothing else
expect(diffs).toContainEqual('Id: "sha256:aaa" → "sha256:bbb"');
expect(diffs).toContainEqual('Created: "2026-02-09T10:00:00Z" → "2026-02-10T12:00:00Z"');
expect(diffs).toContainEqual('Config.Image: "nginx:1.25" → "nginx:1.26"');
expect(diffs).toContainEqual(
'NetworkSettings.Networks.bridge.MacAddress: "02:42:ac:11:00:02" → "02:42:ac:11:00:09"'
);
// Everything else should be preserved
expect(diffs).toHaveLength(4);
});
test('detects network added/removed', () => {
const before = {
NetworkSettings: {
Networks: {
bridge: { IPAddress: '172.17.0.2' }
}
}
};
const after = {
NetworkSettings: {
Networks: {
bridge: { IPAddress: '172.17.0.2' },
custom: { IPAddress: '10.0.0.5' }
}
}
};
const diffs = deepDiff(before, after);
expect(diffs).toContainEqual(
'NetworkSettings.Networks.custom: <missing> → {"IPAddress":"10.0.0.5"}'
);
});
test('detects HostConfig nested changes', () => {
const diffs = deepDiff(
{
HostConfig: {
Sysctls: { 'net.core.somaxconn': '1024' },
Tmpfs: { '/tmp': 'rw,noexec' },
LogConfig: { Type: 'json-file', Config: { 'max-size': '10m' } }
}
},
{
HostConfig: {
Sysctls: { 'net.core.somaxconn': '2048' },
Tmpfs: { '/tmp': 'rw,noexec' },
LogConfig: { Type: 'json-file', Config: { 'max-size': '50m' } }
}
}
);
expect(diffs).toContainEqual(
'HostConfig.Sysctls.net.core.somaxconn: "1024" → "2048"'
);
expect(diffs).toContainEqual(
'HostConfig.LogConfig.Config.max-size: "10m" → "50m"'
);
expect(diffs).toHaveLength(2);
});
});
+132
View File
@@ -0,0 +1,132 @@
/**
* Core AES-256-GCM encrypt/decrypt round-trip + fail-closed behaviour.
*
* The existing encryption tests cover key ROTATION and the reserved-prefix
* bypass; this file covers the crypto CORE (encrypt, decrypt, decryptStrict,
* isEncrypted, generateKey) which had ~0 direct coverage. Deterministic because
* we pin ENCRYPTION_KEY (base64, 32 bytes) and clear the in-memory key cache so
* no key file is read or written.
*/
import { describe, it, expect, beforeEach } from 'bun:test';
import { randomBytes } from 'node:crypto';
import {
encrypt,
decrypt,
decryptStrict,
isEncrypted,
generateKey,
clearKeyCache,
} from '../src/lib/server/encryption';
const KEY_A = randomBytes(32).toString('base64');
const KEY_B = randomBytes(32).toString('base64');
// Point the key loader at a scratch dir with no key file, and pin the key via
// env so getOrCreateKey() is deterministic. clearKeyCache() forces a re-read.
function useKey(key: string) {
process.env.DATA_DIR = `/tmp/enc-core-${process.pid}-${Math.random().toString(36).slice(2)}`;
process.env.ENCRYPTION_KEY = key;
clearKeyCache();
}
describe('encryption core: encrypt/decrypt round-trip', () => {
beforeEach(() => useKey(KEY_A));
it('round-trips a value through encrypt → decrypt', () => {
const ct = encrypt('hunter2');
expect(ct?.startsWith('enc:v1:')).toBe(true);
expect(decrypt(ct)).toBe('hunter2');
});
it('produces a DIFFERENT ciphertext each time (random IV) but the same plaintext back', () => {
const a = encrypt('same');
const b = encrypt('same');
expect(a).not.toBe(b); // random IV → distinct blobs
expect(decrypt(a)).toBe('same');
expect(decrypt(b)).toBe('same');
});
it('does not double-encrypt an already-encrypted value', () => {
const ct = encrypt('secret');
expect(encrypt(ct)).toBe(ct);
});
it('encrypts a plaintext that merely STARTS with the reserved prefix (no leak)', () => {
const tricky = encrypt('enc:v1:notreallyciphertext');
expect(tricky).not.toBe('enc:v1:notreallyciphertext'); // was encrypted, not passed through
expect(decrypt(tricky)).toBe('enc:v1:notreallyciphertext');
});
it('passes null / undefined / empty straight through', () => {
expect(encrypt(null)).toBeNull();
expect(encrypt(undefined)).toBeUndefined();
expect(encrypt('')).toBe('');
expect(decrypt(null)).toBeNull();
expect(decrypt('')).toBe('');
});
it('returns non-prefixed (legacy plaintext) values unchanged on decrypt', () => {
expect(decrypt('plain-legacy-secret')).toBe('plain-legacy-secret');
});
it('isEncrypted is true only for a genuine ciphertext blob', () => {
expect(isEncrypted(encrypt('x'))).toBe(true);
expect(isEncrypted('plain')).toBe(false);
expect(isEncrypted('enc:v1:tooshort')).toBe(false); // prefix but not a valid blob
expect(isEncrypted(null)).toBe(false);
});
it('generateKey returns a 32-byte base64 key', () => {
expect(Buffer.from(generateKey(), 'base64').length).toBe(32);
});
});
describe('encryption core: fail-closed on wrong key / corruption', () => {
it('decrypt() returns the raw value on a wrong-key failure (avoids data loss)', () => {
useKey(KEY_A);
const ct = encrypt('secret')!;
useKey(KEY_B); // rotate to a different key WITHOUT re-encrypting
expect(decrypt(ct)).toBe(ct); // can't decrypt → original blob back
});
it('decryptStrict() THROWS on a wrong-key failure (never forwards ciphertext as the secret)', () => {
useKey(KEY_A);
const ct = encrypt('secret')!;
useKey(KEY_B);
expect(() => decryptStrict(ct)).toThrow(/Failed to decrypt/);
});
it('decryptStrict() still passes genuine plaintext through unchanged', () => {
useKey(KEY_A);
expect(decryptStrict('legacy-plaintext')).toBe('legacy-plaintext');
});
// M1: the ENCRYPTION_KEY rotation loop must re-encrypt secret_providers.config
// with the new key. This reproduces exactly what migrateCredentials does to that
// column (decrypt with the old key, re-encrypt with the new) and proves the row
// is readable afterwards - the omission bug left it decryptable only under the
// gone old key. (The full loop imports better-sqlite3 which bun test can't load,
// so this exercises the crypto contract the loop relies on.)
it('re-encrypting a provider config on rotation keeps it decryptable under the new key', () => {
const providerConfig = JSON.stringify({ address: 'https://vault.example', token: 'hvs.SECRET' });
useKey(KEY_A);
const stored = encrypt(providerConfig)!; // as createSecretProvider stores it
expect(isEncrypted(stored)).toBe(true);
// --- rotation: decrypt under the OLD key, re-encrypt under the NEW key ---
const plain = decryptStrict(stored); // old key still cached at this point
useKey(KEY_B); // switch to the new key (as the rotation tx does)
const reEncrypted = encrypt(plain)!;
// Under the new key the re-encrypted config round-trips to the original JSON.
expect(decryptStrict(reEncrypted)).toBe(providerConfig);
// And the ORIGINAL blob is now undecryptable (proves it HAD to be re-encrypted).
expect(() => decryptStrict(stored)).toThrow(/Failed to decrypt/);
});
it('decrypt() returns the raw value on a too-short / corrupt blob', () => {
useKey(KEY_A);
expect(decrypt('enc:v1:AAAA')).toBe('enc:v1:AAAA'); // decodes but < iv+tag+1
});
});
+41
View File
@@ -0,0 +1,41 @@
/**
* Regression test for audit LOW #1/#40: a secret that literally starts with the
* reserved "enc:v1:" prefix was passed through encrypt() unchanged and stored as
* PLAINTEXT. encrypt() now only skips re-encrypting values that are genuinely our
* ciphertext (valid prefix AND a decodable, long-enough payload).
*/
import assert from 'node:assert/strict';
import { describe, it, before } from 'node:test';
import { encrypt, decrypt, looksLikeCiphertext } from '../src/lib/server/encryption';
before(() => {
// Deterministic key so encrypt/decrypt round-trip in the test env.
process.env.ENCRYPTION_KEY ||= Buffer.alloc(32, 7).toString('base64');
});
describe('encryption reserved-prefix bypass (audit #1/#40)', () => {
it('encrypts a password that starts with the reserved prefix (no plaintext leak)', () => {
const pw = 'enc:v1:hunter2';
const enc = encrypt(pw)!;
assert.notEqual(enc, pw, 'must not store the prefixed password verbatim');
assert.equal(decrypt(enc), pw, 'must round-trip back to the original');
});
it('looksLikeCiphertext is false for a plaintext that merely starts with the prefix', () => {
assert.equal(looksLikeCiphertext('enc:v1:hunter2'), false);
assert.equal(looksLikeCiphertext('enc:v1:'), false);
assert.equal(looksLikeCiphertext('enc:v1:!!!not-base64!!!'), false);
});
it('looksLikeCiphertext is true for a real encrypted blob, and it is not double-encrypted', () => {
const enc = encrypt('some-secret')!;
assert.equal(looksLikeCiphertext(enc), true);
assert.equal(encrypt(enc), enc, 'genuine ciphertext must not be re-encrypted');
});
it('normal passwords still round-trip', () => {
const n = 'S3cr3t-P@ss';
assert.equal(decrypt(encrypt(n)!), n);
});
});
+114
View File
@@ -0,0 +1,114 @@
/**
* Regression guard for audit 2026-06-26 #6: backup_destinations were excluded
* from ENCRYPTION_KEY rotation/migration in encryption.ts::migrateCredentials.
* After an app-key rotation every OTHER credential table was re-encrypted with
* the new key while backup destination password/envVars stayed on the OLD key,
* so restic got the literal `enc:v1:...` blob as its password and every backup
* broke silently.
*
* migrateCredentials talks to better-sqlite3, which bun test can't load, so we
* can't drive the rotation end-to-end here. Instead we assert — at the source
* level — that the migrator actually references the backupDestinations table in
* BOTH the rotation branch and the plain-text migration branch. This is the
* exact thing the bug got wrong: a hardcoded table list that silently omitted
* one table. If someone drops the coverage again, this fails.
*/
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
const encryptionSrc = readFileSync(
join(here, '..', 'src', 'lib', 'server', 'encryption.ts'),
'utf8'
);
describe('encryption key rotation covers backup destinations (audit #6)', () => {
it('imports the backupDestinations table into migrateCredentials', () => {
assert.match(
encryptionSrc,
/backupDestinations/,
'migrateCredentials must import/use backupDestinations or key rotation silently breaks backups'
);
});
it('re-encrypts backup destination password and envVars during rotation', () => {
// The rotation branch collects encrypted values into allEncrypted; both
// fields must be pushed so the new key re-encrypts them.
assert.match(encryptionSrc, /table:\s*'backupDestinations',\s*id:\s*dest\.id,\s*field:\s*'password'/);
assert.match(encryptionSrc, /table:\s*'backupDestinations',\s*id:\s*dest\.id,\s*field:\s*'envVars'/);
});
it('writes the re-encrypted backup destination values back on rotation', () => {
// HIGH #7 (2026-07-02) moved the rotation writes into ONE transaction, so
// the write goes through the tx handle rather than db directly.
assert.match(
encryptionSrc,
/item\.table === 'backupDestinations'[\s\S]{0,200}(db|tx)\.update\(backupDestinations\)/,
'rotation must (db|tx).update(backupDestinations) with the re-encrypted value'
);
});
it('migrates legacy plain-text backup destination secrets to encrypted form', () => {
// The plain-text branch must also touch backupDestinations, not just the
// other credential tables.
assert.match(
encryptionSrc,
/dest\.password[\s\S]{0,120}encrypt\(dest\.password\)/,
'plain-text migration must encrypt an unencrypted backup destination password'
);
});
});
// Same bug class for the secret_providers table: its `config` column holds the
// encrypted Vault/Doppler/1Password token. If rotation omits it, the provider
// config stays on the OLD key and becomes permanently undecryptable once the key
// file is deleted, so every provider-bound stack fails to deploy.
describe('encryption key rotation covers secret providers', () => {
it('imports the secretProviders table into migrateCredentials', () => {
assert.match(
encryptionSrc,
/secretProviders/,
'migrateCredentials must import/use secretProviders or key rotation breaks provider-bound stacks'
);
});
it('collects the secret provider config for re-encryption during rotation', () => {
assert.match(encryptionSrc, /table:\s*'secretProviders',\s*id:\s*p\.id,\s*field:\s*'config'/);
});
it('writes the re-encrypted secret provider config back on rotation', () => {
assert.match(
encryptionSrc,
/item\.table === 'secretProviders'[\s\S]{0,200}(db|tx)\.update\(secretProviders\)/,
'rotation must (db|tx).update(secretProviders) with the re-encrypted config'
);
});
});
// The rollback envelope is the reason a mid-rotation crash can't strand
// credentials as an undecryptable old/new-key mix. A true execution test isn't
// possible here (migrateCredentials imports better-sqlite3, which bun test can't
// load, and the HTTP API can't force a mid-transaction failure), so we assert the
// safety contract at the source level: it must survive any future refactor.
describe('encryption key rotation rolls back atomically on failure', () => {
it('re-encrypts every credential inside a single db.transaction', () => {
assert.match(
encryptionSrc,
/db\.transaction\(async \(tx[\s\S]*?for \(const item of allEncrypted\)/,
'the re-encrypt loop must run inside db.transaction so a mid-loop failure rolls back'
);
});
it('captures the old key and restores it when the transaction throws', () => {
assert.match(encryptionSrc, /const oldKey = cachedKey;/, 'must capture the old key before switching');
// The catch block must put the in-memory key back to match the rolled-back DB.
assert.match(
encryptionSrc,
/catch \([\s\S]*?cachedKey = oldKey;[\s\S]*?throw /,
'on failure the catch must restore cachedKey = oldKey AND rethrow so nothing becomes unrecoverable'
);
});
});
+222
View File
@@ -0,0 +1,222 @@
/**
* Environment-name validation.
*
* #1179 introduced the allowlist; #1228 relaxed it to allow apostrophes
* after a real user with "Topper's media server" couldn't edit their env.
*
* The regex has to accept every name that's already in the wild (we don't
* silently invalidate user data) while rejecting characters that break path
* resolution or shell quoting downstream. env.name only ever lands in
* path.join() and array-form spawn() — never in a shell-interpolated string.
*/
import { describe, test, expect } from 'bun:test';
import { validateEnvName, isValidEnvName, ENV_NAME_MAX_LENGTH } from '../src/lib/utils/env-name';
describe('validateEnvName', () => {
// Realistic environment names covering every accepted character class.
const wildNames = [
'alpha',
'homelab',
'docker-ext',
'docker-websites',
'old-docker',
'node1 (ARM)',
'prod-direct',
'prod-edge',
'lab-podman',
'lab-standard',
'lab-standard-tls',
'lab-tls',
'nas-box',
// Other realistic names
'production',
'staging.local',
'app+v2',
'server@home',
'env_with_underscore',
'a' // single-char names are allowed
];
for (const name of wildNames) {
test(`accepts: ${name}`, () => {
expect(isValidEnvName(name)).toBe(true);
});
}
// #1228 — apostrophes are safe and must be accepted.
describe('apostrophe (#1228)', () => {
const apostropheNames = [
"Topper's media server", // exact name from the bug report
"o'malley",
"jane's lab",
"d'artagnan",
"'leading-apostrophe", // apostrophe is valid as first char
"trailing-apostrophe'", // valid as last char
"a'b'c'd", // multiple apostrophes
"it's a (test) + @home" // mixed with other allowed specials
];
for (const name of apostropheNames) {
test(`accepts: ${JSON.stringify(name)}`, () => {
const result = validateEnvName(name);
expect(result.ok).toBe(true);
expect(result.reason).toBeUndefined();
});
}
});
// Each allowed special character in isolation.
describe('individual allowed specials', () => {
const cases: Array<[string, string]> = [
['env-name', 'hyphen'],
['env_name', 'underscore'],
['env.name', 'dot (non-trailing)'],
['env name', 'space'],
['env+name', 'plus'],
['env@name', 'at-sign'],
['(parens)', 'parens'],
["env'name", 'apostrophe']
];
for (const [name, label] of cases) {
test(`accepts ${label}: ${name}`, () => {
expect(isValidEnvName(name)).toBe(true);
});
}
});
// Length boundary.
describe('length', () => {
test('rejects empty string', () => {
const r = validateEnvName('');
expect(r.ok).toBe(false);
expect(r.reason).toBe('Name is required');
});
test('accepts exactly max length', () => {
expect(isValidEnvName('a'.repeat(ENV_NAME_MAX_LENGTH))).toBe(true);
});
test('rejects max length + 1', () => {
const r = validateEnvName('a'.repeat(ENV_NAME_MAX_LENGTH + 1));
expect(r.ok).toBe(false);
expect(r.reason).toContain(String(ENV_NAME_MAX_LENGTH));
});
test('accepts single character', () => {
expect(isValidEnvName('a')).toBe(true);
expect(isValidEnvName('1')).toBe(true);
expect(isValidEnvName('_')).toBe(true);
expect(isValidEnvName("'")).toBe(true);
});
test('rejects single space', () => {
expect(isValidEnvName(' ')).toBe(false);
});
test('accepts single dot', () => {
// Single-char names skip the last-char rule (the regex tail group
// is optional). "." alone is unusual but not actively harmful.
expect(isValidEnvName('.')).toBe(true);
});
});
// First-char rule: no leading whitespace.
describe('leading whitespace', () => {
const cases = [' a', ' a', '\ta', '\na'];
for (const name of cases) {
test(`rejects: ${JSON.stringify(name)}`, () => {
expect(isValidEnvName(name)).toBe(false);
});
}
});
// Last-char rule: no trailing whitespace, no trailing dot.
describe('trailing whitespace / dot', () => {
const cases = ['a ', 'a ', 'a\t', 'a.', 'foo.bar.', 'name.'];
for (const name of cases) {
test(`rejects: ${JSON.stringify(name)}`, () => {
expect(isValidEnvName(name)).toBe(false);
});
}
});
// Deny list — these MUST stay rejected. Each maps to a category named in
// the user-facing error message.
describe('deny list', () => {
const rejected: Array<[string, string]> = [
['env/with/slash', 'forward slash (path traversal)'],
['env\\back', 'backslash (path traversal)'],
['myenv*', 'trailing asterisk (#1179 reporter case)'],
['my*env', 'inline asterisk (glob)'],
['my?env', 'glob ?'],
['my[env]', 'glob brackets'],
['env;rm', 'semicolon (shell)'],
['env$VAR', 'shell var substitution'],
['env`cmd`', 'backticks (shell)'],
['env"quote', 'double quote'],
['env:colon', 'colon (drive sep / id parsing)'],
['env|pipe', 'pipe'],
['env&amp', 'ampersand'],
['env<redir', 'redirect <'],
['env>redir', 'redirect >'],
['#hash', 'leading hash'],
['env\nnewline', 'newline'],
['env\rcr', 'carriage return'],
['env\0null', 'null byte'],
['env{brace}', 'brace expansion'],
['env~tilde', 'tilde'],
['env%percent', 'percent'],
['env!bang', 'history expansion']
];
for (const [name, why] of rejected) {
test(`rejects: ${JSON.stringify(name)} (${why})`, () => {
const result = validateEnvName(name);
expect(result.ok).toBe(false);
expect(result.reason).toBeTruthy();
});
}
});
// Non-ASCII: rule is ASCII-only by design (avoids normalization /
// homoglyph / Windows codepage surprises).
describe('non-ASCII', () => {
const cases = ['café', 'тест', '日本語', 'émojis-🚀', 'naïve'];
for (const name of cases) {
test(`rejects: ${name}`, () => {
expect(isValidEnvName(name)).toBe(false);
});
}
});
test('rejects non-string input', () => {
expect(validateEnvName(undefined as unknown).ok).toBe(false);
expect(validateEnvName(null as unknown).ok).toBe(false);
expect(validateEnvName(123 as unknown).ok).toBe(false);
expect(validateEnvName({} as unknown).ok).toBe(false);
expect(validateEnvName([] as unknown).ok).toBe(false);
expect(validateEnvName(true as unknown).ok).toBe(false);
});
// Error message shape — keep the deny-list summary tight.
describe('error message', () => {
test('empty → "Name is required"', () => {
expect(validateEnvName('').reason).toBe('Name is required');
});
test('non-string → "Name is required"', () => {
expect(validateEnvName(undefined as unknown).reason).toBe('Name is required');
});
test('too long → mentions max length', () => {
const r = validateEnvName('a'.repeat(ENV_NAME_MAX_LENGTH + 1));
expect(r.reason).toContain(String(ENV_NAME_MAX_LENGTH));
});
test('disallowed character → deny-list message', () => {
const r = validateEnvName('bad/name');
expect(r.reason).toContain('slashes');
expect(r.reason).toContain('wildcards');
});
});
});
+83
View File
@@ -0,0 +1,83 @@
import { describe, test, expect } from 'bun:test';
import { parseEnvVars } from '../src/lib/server/env-parser';
describe('parseEnvVars', () => {
test('parses simple key=value pairs', () => {
const content = 'FOO=bar\nBAZ=qux';
expect(parseEnvVars(content)).toEqual({ FOO: 'bar', BAZ: 'qux' });
});
test('skips empty lines', () => {
const content = 'FOO=bar\n\nBAZ=qux\n\n';
expect(parseEnvVars(content)).toEqual({ FOO: 'bar', BAZ: 'qux' });
});
test('skips comment lines', () => {
const content = '# This is a comment\nFOO=bar\n# Another comment\nBAZ=qux';
expect(parseEnvVars(content)).toEqual({ FOO: 'bar', BAZ: 'qux' });
});
test('preserves double-quoted values', () => {
const content = 'FOO="hello world"';
expect(parseEnvVars(content)).toEqual({ FOO: '"hello world"' });
});
test('preserves single-quoted values', () => {
const content = "FOO='hello world'";
expect(parseEnvVars(content)).toEqual({ FOO: "'hello world'" });
});
test('handles values with equals signs', () => {
const content = 'DATABASE_URL=postgres://user:pass@host:5432/db?sslmode=require';
expect(parseEnvVars(content)).toEqual({
DATABASE_URL: 'postgres://user:pass@host:5432/db?sslmode=require'
});
});
test('handles empty values', () => {
const content = 'EMPTY=\nFOO=bar';
expect(parseEnvVars(content)).toEqual({ EMPTY: '', FOO: 'bar' });
});
test('skips lines without equals sign', () => {
const content = 'FOO=bar\ninvalid line\nBAZ=qux';
expect(parseEnvVars(content)).toEqual({ FOO: 'bar', BAZ: 'qux' });
});
test('skips invalid key names', () => {
const content = '123BAD=value\nGOOD_KEY=value\n-invalid=value';
expect(parseEnvVars(content)).toEqual({ GOOD_KEY: 'value' });
});
test('trims whitespace around keys and values', () => {
const content = ' FOO = bar ';
expect(parseEnvVars(content)).toEqual({ FOO: 'bar' });
});
test('preserves nested quotes (#1036)', () => {
const content = `HOSTNAMES='"sub1.com","sub2.com"'`;
expect(parseEnvVars(content)).toEqual({ HOSTNAMES: `'"sub1.com","sub2.com"'` });
});
test('preserves double-quoted CSV values', () => {
const content = 'SCOPES="app","auth"';
expect(parseEnvVars(content)).toEqual({ SCOPES: '"app","auth"' });
});
test('round-trip stability — parse serialize parse gives same result', () => {
const original = `SIMPLE=value
QUOTED="hello world"
NESTED='"a","b"'
SPECIAL='password$123#!'
EMPTY=`;
const parsed = parseEnvVars(original);
const serialized = Object.entries(parsed).map(([k, v]) => `${k}=${v}`).join('\n');
const reparsed = parseEnvVars(serialized);
expect(reparsed).toEqual(parsed);
});
test('preserves values with special chars in quotes', () => {
const content = "PASS='password$123#!'";
expect(parseEnvVars(content)).toEqual({ PASS: "'password$123#!'" });
});
});
+261
View File
@@ -0,0 +1,261 @@
/**
* Unit tests for compose environment variable extraction
* Tests the regex patterns that detect $VAR and ${VAR} references
*/
import { describe, test, expect } from 'bun:test';
// Replicate the extractComposeVars function from the validate endpoint
function extractComposeVars(yaml: string): { required: string[]; optional: string[] } {
const required: string[] = [];
const optional: string[] = [];
const lines = yaml.split('\n');
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine.startsWith('#')) continue;
// Match ${VAR_NAME} (required) and ${VAR_NAME:-default} (optional)
const regex = /(?<!\$)\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:-?)[^}]*)?\}/g;
let match;
while ((match = regex.exec(line)) !== null) {
const varName = match[1];
const hasDefault = match[2] !== undefined;
if (hasDefault) {
if (!optional.includes(varName) && !required.includes(varName)) optional.push(varName);
} else {
const optIdx = optional.indexOf(varName);
if (optIdx !== -1) optional.splice(optIdx, 1);
if (!required.includes(varName)) required.push(varName);
}
}
// Match $VAR_NAME (simple substitution)
const simpleRegex = /(?<![A-Za-z0-9\$])\$([A-Za-z_][A-Za-z0-9_]*)(?![{A-Za-z0-9_])/g;
while ((match = simpleRegex.exec(line)) !== null) {
const varName = match[1];
if (!required.includes(varName) && !optional.includes(varName)) required.push(varName);
}
}
return { required: required.sort(), optional: optional.sort() };
}
describe('extractComposeVars', () => {
describe('${VAR} syntax', () => {
test('detects required ${VAR}', () => {
const result = extractComposeVars('image: ${IMAGE_NAME}');
expect(result.required).toContain('IMAGE_NAME');
});
test('detects optional ${VAR:-default}', () => {
const result = extractComposeVars('image: ${IMAGE_NAME:-nginx}');
expect(result.optional).toContain('IMAGE_NAME');
expect(result.required).not.toContain('IMAGE_NAME');
});
test('${VAR-default} without colon is not detected (known limitation)', () => {
// Docker Compose supports ${VAR-default} but our regex only handles ${VAR:-default}
// This is a known gap — the hyphen-only default syntax is rare
const result = extractComposeVars('image: ${IMAGE_NAME-nginx}');
expect(result.required).toHaveLength(0);
expect(result.optional).toHaveLength(0);
});
test('multiple vars on one line', () => {
const result = extractComposeVars('command: ${CMD} --host ${HOST}');
expect(result.required).toContain('CMD');
expect(result.required).toContain('HOST');
});
test('var used both with and without default is required', () => {
const yaml = 'image: ${TAG:-latest}\ncommand: ${TAG}';
const result = extractComposeVars(yaml);
expect(result.required).toContain('TAG');
expect(result.optional).not.toContain('TAG');
});
});
describe('$VAR syntax', () => {
test('detects $VAR at start of value', () => {
const result = extractComposeVars('image: $IMAGE_NAME');
expect(result.required).toContain('IMAGE_NAME');
});
test('detects $VAR after colon-space', () => {
const result = extractComposeVars(' - APP_ENV=$APP_ENV');
expect(result.required).toContain('APP_ENV');
});
});
describe('escaped $$ (literal dollar)', () => {
test('ignores $$VAR', () => {
const result = extractComposeVars('command: echo $$HOME');
expect(result.required).not.toContain('HOME');
});
test('ignores $${VAR}', () => {
const result = extractComposeVars('command: echo $${HOME}');
expect(result.required).not.toContain('HOME');
});
});
describe('comments', () => {
test('ignores vars in comments', () => {
const result = extractComposeVars('# image: ${IMAGE_NAME}');
expect(result.required).toHaveLength(0);
expect(result.optional).toHaveLength(0);
});
test('ignores vars in indented comments', () => {
const result = extractComposeVars(' # command: $CMD');
expect(result.required).toHaveLength(0);
});
});
describe('false positive prevention (#1048)', () => {
test('does NOT flag $ in middle of value: "1$abcdef1234"', () => {
const yaml = `
services:
app:
environment:
PASSWORD: "1$abcdef1234"
`;
const result = extractComposeVars(yaml);
expect(result.required).not.toContain('abcdef1234');
expect(result.required).not.toContain('abcdef');
});
test('does NOT flag $ preceded by a digit', () => {
const result = extractComposeVars('value: 123$SECRET');
expect(result.required).not.toContain('SECRET');
});
test('does NOT flag $ preceded by a letter', () => {
const result = extractComposeVars('value: abc$DEF');
expect(result.required).not.toContain('DEF');
});
test('DOES flag $VAR at start of string', () => {
const result = extractComposeVars('value: $SECRET');
expect(result.required).toContain('SECRET');
});
test('DOES flag $VAR after space', () => {
const result = extractComposeVars('value: prefix $SECRET suffix');
expect(result.required).toContain('SECRET');
});
test('DOES flag $VAR after colon', () => {
const result = extractComposeVars(' - PASS=$DB_PASS');
expect(result.required).toContain('DB_PASS');
});
test('DOES flag $VAR after equals', () => {
const result = extractComposeVars(' - KEY=$VALUE');
expect(result.required).toContain('VALUE');
});
test('${VAR} in middle of value IS still detected', () => {
// ${VAR} syntax is always intentional, even mid-value
const result = extractComposeVars('password: "abc${SECRET}def"');
expect(result.required).toContain('SECRET');
});
test('complex password with multiple $ signs', () => {
const yaml = `
services:
db:
environment:
MYSQL_ROOT_PASSWORD: "p4$$w0rd$123"
`;
const result = extractComposeVars(yaml);
// $$w0rd is escaped (literal $), and $123 starts with a digit so no var name
expect(result.required).toHaveLength(0);
});
test('password with $ before number is not a variable', () => {
const result = extractComposeVars('PASSWORD: "test$123"');
// $123 starts with a digit, not a letter — not a valid var name
expect(result.required).toHaveLength(0);
});
});
describe('gutter marker regex (#1048)', () => {
// Simulates the client-side gutter marker pattern from CodeEditor.svelte
function gutterMarkerMatches(line: string, markerName: string): boolean {
const varPatterns = [
new RegExp(`(?<!\\$)\\$\\{${markerName}\\}`),
new RegExp(`(?<!\\$)\\$\\{${markerName}:-`),
new RegExp(`(?<!\\$)\\$\\{${markerName}-`),
new RegExp(`(?<!\\$)\\$\\{${markerName}:\\?`),
new RegExp(`(?<!\\$)\\$\\{${markerName}\\?`),
new RegExp(`(?<!\\$)\\$\\{${markerName}:\\+`),
new RegExp(`(?<!\\$)\\$\\{${markerName}\\+`),
new RegExp(`(?<![A-Za-z0-9\\$])\\$${markerName}(?![a-zA-Z0-9_])`)
];
return varPatterns.some((p) => p.test(line));
}
test('$VAR at start of value matches', () => {
expect(gutterMarkerMatches('PASSWORD: $abcdef1234', 'abcdef1234')).toBe(true);
});
test('$VAR after space matches', () => {
expect(gutterMarkerMatches('PASSWORD: "prefix $abcdef1234"', 'abcdef1234')).toBe(true);
});
test('$VAR preceded by digit does NOT match', () => {
expect(gutterMarkerMatches('PASSWORD: "1$abcdef1234"', 'abcdef1234')).toBe(false);
});
test('$VAR preceded by letter does NOT match', () => {
expect(gutterMarkerMatches('PASSWORD: "x$abcdef1234"', 'abcdef1234')).toBe(false);
});
test('$$VAR (escaped) does NOT match', () => {
expect(gutterMarkerMatches('PASSWORD: "$$abcdef1234"', 'abcdef1234')).toBe(false);
});
test('${VAR} in middle of value still matches', () => {
expect(gutterMarkerMatches('PASSWORD: "1${abcdef1234}"', 'abcdef1234')).toBe(true);
});
});
describe('real-world compose snippets', () => {
test('standard compose with env vars', () => {
const yaml = `
version: "3.8"
services:
web:
image: \${REGISTRY:-docker.io}/\${IMAGE}:\${TAG:-latest}
ports:
- "\${PORT:-8080}:80"
environment:
- DATABASE_URL=\${DB_URL}
- APP_ENV=\${APP_ENV:-production}
`;
const result = extractComposeVars(yaml);
expect(result.required).toContain('IMAGE');
expect(result.required).toContain('DB_URL');
expect(result.optional).toContain('REGISTRY');
expect(result.optional).toContain('TAG');
expect(result.optional).toContain('PORT');
expect(result.optional).toContain('APP_ENV');
});
test('frigate compose from issue #1048', () => {
const yaml = `
services:
frigate:
image: ghcr.io/blakeblackshear/frigate:stable
environment:
FRIGATE_MQTT_PASSWORD: "1$abcdef1234"
`;
const result = extractComposeVars(yaml);
// Should NOT detect "abcdef1234" as a variable
expect(result.required).toHaveLength(0);
expect(result.optional).toHaveLength(0);
});
});
});
+100
View File
@@ -0,0 +1,100 @@
/**
* Unit tests for cleanErrorMsg — turns restic/Docker error text into a readable
* sentence for the UI. Pure function; extracted from helpers.ts into its own
* module. The hardest inputs are a KILLED helper's exit detail followed by one or
* many restic --json PROGRESS lines, and non-string / malformed callers.
*/
import { describe, test, expect } from 'bun:test';
import { cleanErrorMsg } from '../src/lib/server/backups/error-message';
describe('cleanErrorMsg — plain + JSON extraction', () => {
test('plain string passes through unchanged', () => {
expect(cleanErrorMsg('Connection refused')).toBe('Connection refused');
});
test('strips ANSI escape codes (color output from restic CLI)', () => {
expect(cleanErrorMsg('\x1b[31mFatal:\x1b[0m bad password')).toBe('Fatal: bad password');
});
test('extracts message from pure JSON error', () => {
expect(cleanErrorMsg('{"message":"wrong password"}')).toBe('wrong password');
});
test('extracts message from JSON embedded in prefix text', () => {
expect(cleanErrorMsg('restic failed: {"message":"network timeout"}')).toBe('restic failed: network timeout');
});
test('handles double-escaped newlines in the JSON source (collapsed to space)', () => {
const input = 'Error: {"message":"line one\\\\nline two"}';
expect(cleanErrorMsg(input)).toBe('Error: line one line two');
});
test('returns original (with ANSI stripped) when no parseable JSON present', () => {
expect(cleanErrorMsg('Just a regular { error message')).toBe('Just a regular { error message');
});
});
describe('cleanErrorMsg — killed-helper progress spew', () => {
test('strips a single trailing progress JSON (message_type, no message)', () => {
expect(cleanErrorMsg('Container exited with code 137: {"message_type":"status","seconds_elapsed":3,"percent_done":1}'))
.toBe('Container exited with code 137');
});
test('strips MULTIPLE concatenated progress JSONs (the real cancel-log spew)', () => {
const spew = 'Container exited with code 137: '
+ '{"message_type":"status","percent_done":0.03,"total_files":4268,"files_done":270} '
+ '{"message_type":"status","seconds_elapsed":1,"percent_done":0.03,"total_files":6867} '
+ '{"message_type":"status","seconds_elapsed":1,"percent_done":0}';
expect(cleanErrorMsg(spew)).toBe('Container exited with code 137');
});
test('progress JSON with embedded file paths still collapses to the prefix', () => {
const spew = 'Container exited with code 137: '
+ '{"message_type":"status","current_files":["/volumes/x/Atomowe nawyki (2019).epub"]} '
+ '{"message_type":"status","current_files":["/volumes/y/Religa. Człowiek z sercem w dłoni.pdf"]}';
expect(cleanErrorMsg(spew)).toBe('Container exited with code 137');
});
test('a plain error with no JSON is preserved, not dropped', () => {
expect(cleanErrorMsg('restic backup failed: repository is already locked'))
.toBe('restic backup failed: repository is already locked');
});
});
describe('cleanErrorMsg — safety (never throw, never fabricate)', () => {
test('non-string callers coerce to raw, never crash', () => {
// The type says string, but this is exported and runtime can pass anything.
expect(cleanErrorMsg(undefined as any)).toBe('undefined');
expect(cleanErrorMsg(null as any)).toBe('null');
expect(cleanErrorMsg(42 as any)).toBe('42');
expect(cleanErrorMsg({} as any)).toBe('[object Object]');
expect(cleanErrorMsg([] as any)).toBe('');
});
test('empty string stays empty', () => {
expect(cleanErrorMsg('')).toBe('');
});
test('a non-string JSON message is NOT cast (would give "123"/[object Object]) — raw kept', () => {
// Only a string `message` is used; anything else falls through to raw so we
// never surface a misleading "123" or "[object Object]".
expect(cleanErrorMsg('{"message":123}')).toBe('{"message":123}');
expect(cleanErrorMsg('{"message":{"nested":1}}')).toBe('{"message":{"nested":1}}');
expect(cleanErrorMsg('{"message":null}')).toBe('{"message":null}');
});
test('malformed / unparseable JSON returns the raw string, not an invented message', () => {
expect(cleanErrorMsg('{"a":')).toBe('{"a":');
expect(cleanErrorMsg('}{')).toBe('}{');
expect(cleanErrorMsg('{{{{')).toBe('{{{{');
});
test('NEVER throws — a hostile value whose toString()/toJSON() throws still returns', () => {
// cleanErrorMsg is on the error path; it must not become the crash. These would
// throw inside String()/JSON.parse without the global guard.
const hostileToString = { toString() { throw new Error('boom'); } };
expect(() => cleanErrorMsg(hostileToString as any)).not.toThrow();
expect(() => cleanErrorMsg(Symbol('x') as any)).not.toThrow();
expect(() => cleanErrorMsg({ toJSON() { throw new Error('boom'); } } as any)).not.toThrow();
});
});
+73
View File
@@ -0,0 +1,73 @@
/**
* Unit tests for computeExecutionTally (src/lib/utils/execution-tally.ts) — the
* pure ok/fail tally + newest-first sort behind the backup History tab and the
* modal tab counters. UI-free so it runs under bun test.
*/
import { describe, test, expect } from 'bun:test';
import { computeExecutionTally, type Execution } from '../src/lib/utils/execution-tally';
const ex = (over: Partial<Execution>): Execution => ({
id: 1, triggeredAt: '2026-07-22T03:00:00Z', triggeredBy: 'cron',
status: 'success', duration: 4000, errorMessage: null, details: null, ...over,
});
describe('computeExecutionTally — the red ✕N / green ✓N counters', () => {
test('empty input → zero tally, empty list', () => {
expect(computeExecutionTally([])).toEqual({ executions: [], ok: 0, failed: 0 });
});
test('success / warning / skipped all count as OK; only failed is red', () => {
const t = computeExecutionTally([
ex({ id: 1, status: 'success' }),
ex({ id: 2, status: 'warning' }),
ex({ id: 3, status: 'skipped' }),
ex({ id: 4, status: 'failed' }),
]);
expect(t.ok).toBe(3);
expect(t.failed).toBe(1);
});
test('running / queued count as NEITHER (in-flight, no outcome yet)', () => {
const t = computeExecutionTally([
ex({ id: 1, status: 'running' }),
ex({ id: 2, status: 'queued' }),
ex({ id: 3, status: 'success' }),
]);
expect(t.ok).toBe(1);
expect(t.failed).toBe(0);
});
test('an unknown status counts as neither ok nor failed', () => {
const t = computeExecutionTally([ex({ status: 'bananas' as any })]);
expect(t).toMatchObject({ ok: 0, failed: 0 });
});
});
describe('computeExecutionTally — newest-first sort (merged across configs)', () => {
test('sorts by triggeredAt descending regardless of input order', () => {
const t = computeExecutionTally([
ex({ id: 1, triggeredAt: '2026-07-20T03:00:00Z' }),
ex({ id: 2, triggeredAt: '2026-07-22T03:00:00Z' }),
ex({ id: 3, triggeredAt: '2026-07-21T03:00:00Z' }),
]);
expect(t.executions.map((e) => e.id)).toEqual([2, 3, 1]);
});
test('null triggeredAt sorts to the end (oldest)', () => {
const t = computeExecutionTally([
ex({ id: 1, triggeredAt: null }),
ex({ id: 2, triggeredAt: '2026-07-22T03:00:00Z' }),
]);
expect(t.executions.map((e) => e.id)).toEqual([2, 1]);
});
test('does not mutate the input array', () => {
const input = [
ex({ id: 1, triggeredAt: '2026-07-20T03:00:00Z' }),
ex({ id: 2, triggeredAt: '2026-07-22T03:00:00Z' }),
];
const before = input.map((e) => e.id);
computeExecutionTally(input);
expect(input.map((e) => e.id)).toEqual(before);
});
});
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, test } from 'bun:test';
import { formatBytes, formatBytesCompact } from '../src/lib/utils/format';
describe('formatBytes', () => {
test('returns 0 B for zero', () => {
expect(formatBytes(0)).toBe('0 B');
});
test('formats bytes', () => {
expect(formatBytes(500)).toBe('500 B');
expect(formatBytes(1)).toBe('1 B');
});
test('formats kilobytes', () => {
expect(formatBytes(1024)).toBe('1.0 KB');
expect(formatBytes(1536)).toBe('1.5 KB');
});
test('formats megabytes', () => {
expect(formatBytes(1048576)).toBe('1.0 MB');
expect(formatBytes(5242880)).toBe('5.0 MB');
});
test('formats gigabytes', () => {
expect(formatBytes(1073741824)).toBe('1.0 GB');
});
test('formats terabytes', () => {
expect(formatBytes(1099511627776)).toBe('1.0 TB');
});
test('respects decimals parameter', () => {
expect(formatBytes(1536, 2)).toBe('1.50 KB');
expect(formatBytes(1536, 0)).toBe('2 KB');
});
});
describe('formatBytesCompact', () => {
test('returns 0B for zero', () => {
expect(formatBytesCompact(0)).toBe('0B');
});
test('uses compact units without space', () => {
expect(formatBytesCompact(1024)).toBe('1.0K');
expect(formatBytesCompact(1048576)).toBe('1.0M');
expect(formatBytesCompact(1073741824)).toBe('1.0G');
expect(formatBytesCompact(1099511627776)).toBe('1.0T');
});
test('respects decimals parameter', () => {
expect(formatBytesCompact(1536, 0)).toBe('2K');
expect(formatBytesCompact(1536, 2)).toBe('1.50K');
});
});
+33
View File
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'bun:test';
import { formatErrorLines } from '../src/lib/utils/format';
describe('formatErrorLines', () => {
it('puts each [NNNN] log marker on its own line', () => {
const scanner =
'All scanners failed: Container exited with code 1: [0000] INFO grype version [0026] WARN error=unable to write /cache/grype: no space left on device [0031] INFO loaded DB status=invalid';
expect(formatErrorLines(scanner)).toBe(
'All scanners failed: Container exited with code 1:\n' +
'[0000] INFO grype version\n' +
'[0026] WARN error=unable to write /cache/grype: no space left on device\n' +
'[0031] INFO loaded DB status=invalid'
);
});
it('leaves an empty [] compose field path attached (never splits a field name)', () => {
const compose =
'error while interpolating services.whoami.environment.[]: required variable VAULT_POSTGRES_PASSWORD is missing a value';
// no numeric marker -> stays a single line, field name intact
expect(formatErrorLines(compose)).toBe(compose);
expect(formatErrorLines(compose)).not.toContain('\n');
});
it('splits only numeric brackets, not word brackets', () => {
expect(formatErrorLines('a [warn] b [12] c')).toBe('a [warn] b\n[12] c');
});
it('handles null/empty', () => {
expect(formatErrorLines(null)).toBe('');
expect(formatErrorLines('')).toBe('');
expect(formatErrorLines('plain error, no markers')).toBe('plain error, no markers');
});
});
+70
View File
@@ -0,0 +1,70 @@
/**
* formatRelativeTime — compact "N ago" formatting with null/NaN/future safety.
* Pure function in the import-light utils/format module (stores/settings re-exports it).
*
* Run with: bun test tests/unit/format-relative-time.test.ts
*/
import { describe, test, expect } from 'bun:test';
import { formatRelativeTime } from '../src/lib/utils/format';
// Build a timestamp `n` seconds in the past relative to now, so the tests are
// independent of the wall clock (no fixed dates that drift into other buckets).
const agoSec = (s: number) => new Date(Date.now() - s * 1000);
const MIN = 60, HR = 3600, DAY = 86400;
describe('formatRelativeTime — buckets', () => {
test('under 45s reads "just now"', () => {
expect(formatRelativeTime(agoSec(0))).toBe('just now');
expect(formatRelativeTime(agoSec(44))).toBe('just now');
});
test('45s..59s rounds into minutes ("1m ago")', () => {
// 45s -> round(45/60)=1 min
expect(formatRelativeTime(agoSec(45))).toBe('1m ago');
});
test('minutes bucket (< 60m)', () => {
expect(formatRelativeTime(agoSec(5 * MIN))).toBe('5m ago');
expect(formatRelativeTime(agoSec(59 * MIN))).toBe('59m ago');
});
test('hours bucket (< 24h)', () => {
expect(formatRelativeTime(agoSec(2 * HR))).toBe('2h ago');
expect(formatRelativeTime(agoSec(23 * HR))).toBe('23h ago');
});
test('days bucket (< 30d)', () => {
expect(formatRelativeTime(agoSec(3 * DAY))).toBe('3d ago');
expect(formatRelativeTime(agoSec(29 * DAY))).toBe('29d ago');
});
test('months bucket (< 12mo)', () => {
expect(formatRelativeTime(agoSec(60 * DAY))).toBe('2mo ago');
expect(formatRelativeTime(agoSec(300 * DAY))).toBe('10mo ago');
});
test('years bucket', () => {
expect(formatRelativeTime(agoSec(400 * DAY))).toBe('1y ago');
expect(formatRelativeTime(agoSec(800 * DAY))).toBe('2y ago');
});
});
describe('formatRelativeTime — input forms & edges', () => {
test('accepts a Date, an ISO string, and an epoch-ms number equivalently', () => {
const d = agoSec(5 * MIN);
expect(formatRelativeTime(d)).toBe('5m ago');
expect(formatRelativeTime(d.toISOString())).toBe('5m ago');
expect(formatRelativeTime(d.getTime())).toBe('5m ago');
});
test('a future timestamp reads "in the future"', () => {
expect(formatRelativeTime(new Date(Date.now() + 60_000))).toBe('in the future');
});
test('an unparseable / NaN date returns empty string (no crash)', () => {
expect(formatRelativeTime('garbage')).toBe('');
expect(formatRelativeTime(new Date('not-a-date'))).toBe('');
expect(formatRelativeTime(NaN)).toBe('');
expect(formatRelativeTime(undefined as unknown as string)).toBe('');
});
});
+47
View File
@@ -0,0 +1,47 @@
import { describe, test, expect, beforeAll } from 'bun:test';
// Pin DATA_DIR before importing so the guard computes /app/data paths.
beforeAll(() => { process.env.DATA_DIR = '/app/data'; });
process.env.DATA_DIR = '/app/data';
const { isProtectedPath } = await import('../src/lib/server/fs-guard');
describe('isProtectedPath (file-browser secret guard, H1)', () => {
test('blocks the database directory and its contents', () => {
expect(isProtectedPath('/app/data/db')).toBe(true);
expect(isProtectedPath('/app/data/db/dockhand.db')).toBe(true);
expect(isProtectedPath('/app/data/db/anything/deep.file')).toBe(true);
});
test('blocks the encryption key file', () => {
expect(isProtectedPath('/app/data/.encryption_key')).toBe(true);
});
test('blocks /proc (process env leaks DATABASE_URL / ENCRYPTION_KEY)', () => {
expect(isProtectedPath('/proc')).toBe(true);
expect(isProtectedPath('/proc/self/environ')).toBe(true);
expect(isProtectedPath('/proc/1/environ')).toBe(true);
expect(isProtectedPath('/proc/self/cmdline')).toBe(true);
});
test('does NOT block the stacks dir or external stacks (browsing preserved)', () => {
expect(isProtectedPath('/app/data')).toBe(false);
expect(isProtectedPath('/app/data/stacks')).toBe(false);
expect(isProtectedPath('/app/data/stacks/myapp/docker-compose.yml')).toBe(false);
expect(isProtectedPath('/external-stacks')).toBe(false);
expect(isProtectedPath('/external-stacks/app/.env')).toBe(false);
});
test('does NOT block unrelated container paths', () => {
expect(isProtectedPath('/etc/hostname')).toBe(false);
expect(isProtectedPath('/tmp/x')).toBe(false);
});
test('is a path-boundary check, not a string prefix (no false positives)', () => {
// "/app/data/db-other" must NOT be treated as inside "/app/data/db"
expect(isProtectedPath('/app/data/db-other')).toBe(false);
expect(isProtectedPath('/app/data/database-backups')).toBe(false);
// a file merely named like the key elsewhere is fine
expect(isProtectedPath('/app/data/stacks/.encryption_key')).toBe(false);
});
});
+35
View File
@@ -0,0 +1,35 @@
/**
* Regression test for #1273 — HTTPS token auth with an empty username.
*
* A token credential with a blank username produced a Basic header of
* base64(":token"). GitHub rejects that with 401 and git falls back to an
* interactive prompt, dying with "could not read Username ... terminal prompts
* disabled". buildBasicAuthHeader must default an empty username to a non-empty
* placeholder (`x-access-token`) so GitHub accepts the header.
*/
import { describe, test, expect } from 'bun:test';
import { buildBasicAuthHeader } from '../src/lib/server/git-auth';
function decode(header: string): string {
const b64 = header.replace(/^Authorization: Basic /, '');
return Buffer.from(b64, 'base64').toString('utf8');
}
describe('buildBasicAuthHeader (#1273)', () => {
test('empty username defaults to x-access-token (not base64(":token"))', () => {
const header = buildBasicAuthHeader('', 'ghp_sometoken');
// The bug: empty username → "..:token". The fix: "x-access-token:token".
expect(decode(header)).toBe('x-access-token:ghp_sometoken');
expect(decode(header).startsWith(':')).toBe(false);
});
test('a user-supplied username is preserved', () => {
const header = buildBasicAuthHeader('user', 'ghp_sometoken');
expect(decode(header)).toBe('user:ghp_sometoken');
});
test('produces a well-formed Basic Authorization header', () => {
const header = buildBasicAuthHeader('x-access-token', 'tok');
expect(header).toBe(`Authorization: Basic ${Buffer.from('x-access-token:tok').toString('base64')}`);
});
});
+52
View File
@@ -0,0 +1,52 @@
/**
* Per-operation SSH key path handling (#1413): concurrent syncs of the same
* credential must not share one key file, and cleaning up one must never delete
* another operation's key mid-clone.
*/
import { describe, it, expect } from 'bun:test';
import { existsSync, writeFileSync, rmSync } from 'node:fs';
import {
GIT_SSH_KEY_PATH_ENV,
makeSshKeyPath,
resolveSshKeyPathForCleanup,
removeSshKey
} from '../src/lib/server/git-ssh-key';
describe('git ssh key paths (#1413)', () => {
it('each operation gets a UNIQUE path, even for the same credential id', () => {
const paths = new Set(Array.from({ length: 50 }, () => makeSshKeyPath(1)));
expect(paths.size).toBe(50); // no collisions across concurrent ops of credential 1
for (const p of paths) expect(p.startsWith('/tmp/.ssh-key-1-')).toBe(true);
});
it('cleanup targets the exact path stashed in env, not a shared per-credential path', () => {
const opPath = makeSshKeyPath(7);
expect(resolveSshKeyPathForCleanup(7, { [GIT_SSH_KEY_PATH_ENV]: opPath })).toBe(opPath);
});
it('falls back to the legacy deterministic path only when no env is given', () => {
expect(resolveSshKeyPathForCleanup(7)).toBe('/tmp/.ssh-key-7');
expect(resolveSshKeyPathForCleanup(7, {})).toBe('/tmp/.ssh-key-7');
});
it('removing one operation key leaves a concurrent operation key intact (the race)', () => {
const a = makeSshKeyPath(3);
const b = makeSshKeyPath(3);
expect(a).not.toBe(b);
writeFileSync(a, 'KEY-A');
writeFileSync(b, 'KEY-B');
try {
// Operation A finishes and cleans up its key...
removeSshKey(3, { [GIT_SSH_KEY_PATH_ENV]: a });
expect(existsSync(a)).toBe(false);
// ...while operation B is still cloning: its key MUST survive.
expect(existsSync(b)).toBe(true);
} finally {
for (const p of [a, b]) { try { rmSync(p); } catch { /* ignore */ } }
}
});
it('removeSshKey is a no-op (no throw) when the file is already gone', () => {
expect(() => removeSshKey(99, { [GIT_SSH_KEY_PATH_ENV]: makeSshKeyPath(99) })).not.toThrow();
});
});
+142
View File
@@ -0,0 +1,142 @@
import { describe, test, expect } from 'bun:test';
import { assertSafeRepoUrl, assertSafeGitRef, repoFilePath } from '../src/lib/server/git-url-safety';
import { containedPath } from '../src/lib/server/git-deletions';
describe('assertSafeRepoUrl — rejects dangerous URLs, allows every legit scheme', () => {
// Legitimate URLs must all pass unchanged (backward compatibility).
const legit = [
'https://github.com/user/repo.git',
'http://gitea.local/user/repo.git',
'https://user:[email protected]/user/repo.git',
'ssh://[email protected]:2222/user/repo.git',
'[email protected]:user/repo.git',
'git://example.com/repo.git',
];
for (const url of legit) {
test(`allows ${url}`, () => {
expect(() => assertSafeRepoUrl(url)).not.toThrow();
});
}
// Dangerous transports / option injection / local paths must be rejected.
const bad = [
"ext::sh -c 'id'", // RCE via ext:: transport
'EXT::sh -c whoami', // case-insensitive
'fd::17', // fd:: transport
'file:///etc/passwd', // local file transport
'--upload-pack=/bin/sh', // parsed as a git option
'-oProxyCommand=sh', // leading dash = option
'/etc/passwd', // absolute local path
'./local/repo', // relative local path
'../escape', // parent path
'', // empty
' ', // blank
];
for (const url of bad) {
test(`rejects ${JSON.stringify(url)}`, () => {
expect(() => assertSafeRepoUrl(url)).toThrow();
});
}
});
describe('assertSafeGitRef — rejects option-like refs, allows real branch names', () => {
for (const ref of ['main', 'develop', 'feature/x', 'release-1.2', 'v1.0.0', '', null, undefined]) {
test(`allows ${JSON.stringify(ref)}`, () => {
expect(() => assertSafeGitRef(ref as any)).not.toThrow();
});
}
for (const ref of ['--upload-pack=sh', '-oProxyCommand=x', '--foo']) {
test(`rejects ${JSON.stringify(ref)}`, () => {
expect(() => assertSafeGitRef(ref)).toThrow();
});
}
});
// containedPath is the STRICT segment-ban guard used by the deletion-sync logic
// (git-deletions.ts) where banning any '.'/'..' segment outright is deliberate. It is
// NO LONGER the compose/env path guard — that moved to repoFilePath (below).
describe('containedPath — deletion-sync strict guard (bans any . / .. segment)', () => {
const root = '/app/data/git-repos/env/stack';
test('a normal subdir path resolves inside the repo', () => {
expect(containedPath(root, 'docker-compose.yml')).toBe(`${root}/docker-compose.yml`);
expect(containedPath(root, 'sub/dir/compose.yaml')).toBe(`${root}/sub/dir/compose.yaml`);
});
test('traversal out of the repo returns null (would be refused)', () => {
expect(containedPath(root, '../../../../etc/passwd')).toBeNull();
expect(containedPath(root, '..')).toBeNull();
expect(containedPath(root, 'a/../../b')).toBeNull();
});
test('an absolute path is refused', () => {
expect(containedPath(root, '/etc/passwd')).toBeNull();
});
test('empty / dot segments are refused (strict — deletion-sync semantics)', () => {
expect(containedPath(root, '')).toBeNull();
expect(containedPath(root, '.')).toBeNull();
expect(containedPath(root, './x')).toBeNull();
});
});
// repoFilePath is the compose/env path guard. Unlike containedPath it uses
// resolve-then-containment (like a plain join + escape check): an in-repo `..` and a
// leading `./` still resolve (backward-compat), only an ESCAPING path throws.
describe('repoFilePath — compose/env path guard (resolve-then-containment)', () => {
const root = '/app/data/git-repos/env/stack';
test('plain in-repo paths resolve (unchanged)', () => {
expect(repoFilePath(root, 'docker-compose.yml', 'Compose path')).toBe(`${root}/docker-compose.yml`);
expect(repoFilePath(root, 'sub/dir/compose.yaml', 'Compose path')).toBe(`${root}/sub/dir/compose.yaml`);
});
test('a leading ./ resolves', () => {
expect(repoFilePath(root, './compose.yml', 'Compose path')).toBe(`${root}/compose.yml`);
expect(repoFilePath(root, './stacks/compose.yml', 'Compose path')).toBe(`${root}/stacks/compose.yml`);
expect(repoFilePath(root, 'stacks/./compose.yml', 'Compose path')).toBe(`${root}/stacks/compose.yml`);
});
test('an in-repo .. resolves', () => {
expect(repoFilePath(root, 'stacks/../compose.yml', 'Compose path')).toBe(`${root}/compose.yml`);
expect(repoFilePath(root, 'app/config/../compose.yml', 'Compose path')).toBe(`${root}/app/compose.yml`);
expect(repoFilePath(root, 'a/../b/../c/compose.yml', 'Compose path')).toBe(`${root}/c/compose.yml`);
});
test('paths that ESCAPE the repo throw (traversal still blocked)', () => {
expect(() => repoFilePath(root, '../.env', 'Env file path')).toThrow();
expect(() => repoFilePath(root, '../shared/compose.yml', 'Compose path')).toThrow();
expect(() => repoFilePath(root, 'stacks/../../compose.yml', 'Compose path')).toThrow();
expect(() => repoFilePath(root, '../../../../etc/passwd', 'Compose path')).toThrow();
expect(() => repoFilePath(root, '../../costam../compose.yml', 'Compose path')).toThrow();
});
test('a leading / is repo-root-relative, not an error (#1355 regression)', () => {
// node:path.join used to ignore a leading slash, so /qbittorrent/compose.yaml
// resolved inside the repo. A later isAbsolute() ban regressed that; restore it.
expect(repoFilePath(root, '/qbittorrent/compose.yaml', 'Compose path')).toBe(`${root}/qbittorrent/compose.yaml`);
expect(repoFilePath(root, '/compose.yml', 'Compose path')).toBe(`${root}/compose.yml`);
expect(repoFilePath(root, '//sub/compose.yml', 'Compose path')).toBe(`${root}/sub/compose.yml`);
});
test('a leading / does NOT let a path escape the repo', () => {
// After stripping the leading slash the containment check still runs, so a
// traversal like /../x resolves to ../x and is refused.
expect(() => repoFilePath(root, '/../etc/passwd', 'Compose path')).toThrow();
expect(() => repoFilePath(root, '/../../compose.yml', 'Compose path')).toThrow();
expect(() => repoFilePath(root, '///../../root/.ssh/id_rsa', 'Compose path')).toThrow();
expect(() => repoFilePath(root, '/./../../etc/passwd', 'Compose path')).toThrow();
expect(() => repoFilePath(root, '/foo/../../../etc/passwd', 'Compose path')).toThrow();
});
test('a sibling-dir prefix attack is blocked (root + sep, not bare prefix)', () => {
// Without the trailing separator, resolve to "/app/data/git-repos/env/stack-evil"
// would falsely pass a bare startsWith(root). The `+ sep` closes that hole.
expect(() => repoFilePath(root, '../stack-evil/x.yml', 'Compose path')).toThrow();
});
test('dotty filenames that are NOT .. / . segments still resolve', () => {
expect(repoFilePath(root, '..foo/compose.yml', 'Compose path')).toBe(`${root}/..foo/compose.yml`);
expect(repoFilePath(root, 'foo..bar/compose.yml', 'Compose path')).toBe(`${root}/foo..bar/compose.yml`);
});
});
+38
View File
@@ -0,0 +1,38 @@
// @ts-expect-error -- bun:test is a runtime built-in with no types installed
import { describe, expect, test } from 'bun:test';
import { helperWaitCapMs, helperWaitDeadline } from '../src/lib/server/helper-wait-core';
describe('helperWaitCapMs', () => {
test('a positive timeout is the cap', () => {
expect(helperWaitCapMs(600_000)).toBe(600_000);
expect(helperWaitCapMs(1)).toBe(1);
});
test('0 means unbounded (cap 0) - the #1382 case', () => {
// The backup helper passes 0 on purpose. The old `timeout || 3_600_000` turned this
// into 60 minutes and killed healthy backups; the cap here must stay 0.
expect(helperWaitCapMs(0)).toBe(0);
});
test('undefined means unbounded (cap 0)', () => {
expect(helperWaitCapMs(undefined)).toBe(0);
});
test('a negative timeout is treated as unbounded, not a past cap', () => {
expect(helperWaitCapMs(-5)).toBe(0);
});
});
describe('helperWaitDeadline', () => {
const NOW = 1_000_000;
test('a positive timeout yields now + timeout', () => {
expect(helperWaitDeadline(600_000, NOW)).toBe(NOW + 600_000);
});
test('0 yields Infinity (unbounded) - never a 1h wall clock', () => {
expect(helperWaitDeadline(0, NOW)).toBe(Infinity);
});
test('undefined yields Infinity (unbounded)', () => {
expect(helperWaitDeadline(undefined, NOW)).toBe(Infinity);
});
test('Date.now() < Infinity always holds, so the poll loop never times out when unbounded', () => {
expect(NOW < helperWaitDeadline(0, NOW)).toBe(true);
expect(Number.MAX_SAFE_INTEGER < helperWaitDeadline(undefined, 0)).toBe(true);
});
});
-6
View File
@@ -1,6 +0,0 @@
// Test preload, referenced by `[test] preload` in bunfig.toml.
//
// The current suite is pure unit tests that mock their own transport, so there
// is no global setup to do yet. This file exists so the path bunfig.toml points
// at resolves; without it `bun test` fails before collecting any test.
export {};
+44
View File
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { resolveNanoCpusConflict } from '../src/lib/server/hostconfig-recreate';
describe('resolveNanoCpusConflict (#1381)', () => {
it('drops CpuPeriod/CpuQuota when NanoCpus is also set (Podman inspect reports both)', () => {
const hc: any = { NanoCpus: 500000000, CpuPeriod: 100000, CpuQuota: 50000, Memory: 123 };
assert.equal(resolveNanoCpusConflict(hc), true);
assert.equal(hc.NanoCpus, 500000000); // kept — the direct `cpus:` equivalent
assert.equal('CpuPeriod' in hc, false);
assert.equal('CpuQuota' in hc, false);
assert.equal(hc.Memory, 123); // unrelated fields untouched
});
it('drops the pair when only CpuQuota accompanies NanoCpus', () => {
const hc: any = { NanoCpus: 250000000, CpuQuota: 25000 };
assert.equal(resolveNanoCpusConflict(hc), true);
assert.equal('CpuQuota' in hc, false);
});
it('is a no-op on Docker where a --cpus container reports period/quota as 0', () => {
const hc: any = { NanoCpus: 500000000, CpuPeriod: 0, CpuQuota: 0 };
assert.equal(resolveNanoCpusConflict(hc), false);
assert.equal(hc.NanoCpus, 500000000);
});
it('is a no-op when only CpuPeriod/CpuQuota are set (no NanoCpus)', () => {
const hc: any = { CpuPeriod: 100000, CpuQuota: 50000 };
assert.equal(resolveNanoCpusConflict(hc), false);
assert.equal(hc.CpuPeriod, 100000);
assert.equal(hc.CpuQuota, 50000);
});
it('is a no-op when no CPU limits are set at all', () => {
const hc: any = { Memory: 0 };
assert.equal(resolveNanoCpusConflict(hc), false);
});
it('tolerates null/undefined', () => {
assert.equal(resolveNanoCpusConflict(null), false);
assert.equal(resolveNanoCpusConflict(undefined), false);
});
});
+64
View File
@@ -0,0 +1,64 @@
import { describe, test, expect } from 'bun:test';
import { indexChildDigests, localDigestIsIndexChild } from '../src/lib/server/scheduler/tasks/update-utils';
// Real shape from ghcr.io/mastodon/mastodon:v4.6.5 (the #1367 repro): an OCI index
// whose per-arch child digests differ from the index digest a HEAD returns.
const MASTODON_INDEX = {
mediaType: 'application/vnd.oci.image.index.v1+json',
manifests: [
{ platform: { architecture: 'arm64', os: 'linux' }, digest: 'sha256:2cd8bcf2e903dab42a759956008e17da16a1268e842e738c2c640f6f7b54bae8' },
{ platform: { architecture: 'amd64', os: 'linux' }, digest: 'sha256:696439e1ada71d0cf3d51d4d6a4744d6e40b57aafa64980b18f4d3b78230d0cf' }
]
};
const INDEX_DIGEST = 'sha256:77f11d1a6c674664217372d94ccdb9203524c60447827fe74ab6e11466825815';
const AMD64_CHILD = 'sha256:696439e1ada71d0cf3d51d4d6a4744d6e40b57aafa64980b18f4d3b78230d0cf';
describe('indexChildDigests', () => {
test('extracts every per-arch child digest from an image index', () => {
expect(indexChildDigests(MASTODON_INDEX)).toEqual([
'sha256:2cd8bcf2e903dab42a759956008e17da16a1268e842e738c2c640f6f7b54bae8',
AMD64_CHILD
]);
});
test('returns [] for a single-arch manifest (no manifests array)', () => {
expect(indexChildDigests({ mediaType: 'application/vnd.oci.image.manifest.v1+json', layers: [] })).toEqual([]);
});
test('returns [] for null / garbage / error bodies without throwing', () => {
expect(indexChildDigests(null)).toEqual([]);
expect(indexChildDigests(undefined)).toEqual([]);
expect(indexChildDigests('not json')).toEqual([]);
expect(indexChildDigests({ errors: [{ code: 'MANIFEST_UNKNOWN' }] })).toEqual([]);
});
test('skips malformed manifest entries (missing/empty/non-string digest)', () => {
const body = { manifests: [{ digest: AMD64_CHILD }, { digest: '' }, { digest: 42 }, {}, null] };
expect(indexChildDigests(body)).toEqual([AMD64_CHILD]);
});
});
describe('localDigestIsIndexChild', () => {
test('true when local per-arch digest is a child of the index (the #1367 fix)', () => {
// local image recorded its amd64 per-arch digest, HEAD returned the index digest
expect(localDigestIsIndexChild([AMD64_CHILD], MASTODON_INDEX)).toBe(true);
});
test('false when the local digest is the index digest itself (already handled upstream)', () => {
// upstream matches this against the HEAD digest first; here it is NOT a child
expect(localDigestIsIndexChild([INDEX_DIGEST], MASTODON_INDEX)).toBe(false);
});
test('false when local digest is genuinely unrelated (a real update)', () => {
expect(localDigestIsIndexChild(['sha256:deadbeef'], MASTODON_INDEX)).toBe(false);
});
test('false for a non-index body (single-arch / error) - degrades to "keep update"', () => {
expect(localDigestIsIndexChild([AMD64_CHILD], { layers: [] })).toBe(false);
expect(localDigestIsIndexChild([AMD64_CHILD], null)).toBe(false);
});
test('matches if ANY of several local digests is a child', () => {
expect(localDigestIsIndexChild(['sha256:unrelated', AMD64_CHILD], MASTODON_INDEX)).toBe(true);
});
});
+90
View File
@@ -0,0 +1,90 @@
import { describe, test, expect } from 'bun:test';
import { classifyMarker, resolvedRefVarNames, effectiveMissing } from '../src/lib/utils/invault-markers';
describe('classifyMarker', () => {
const inVault = new Set(['DB_PASSWORD', 'API_KEY']);
test('a defined var is required regardless of the provider set', () => {
expect(classifyMarker('APP_ENV', false, inVault, false)).toBe('required');
});
test('a missing var present in the provider is invault (green)', () => {
expect(classifyMarker('DB_PASSWORD', true, inVault, false)).toBe('invault');
});
test('a missing var absent from the provider stays missing (red)', () => {
expect(classifyMarker('DUMMY', true, inVault, false)).toBe('missing');
});
test('probe failure forces MISSING even for a key that would be in the provider', () => {
// Never a false green when we could not reach the provider.
expect(classifyMarker('DB_PASSWORD', true, inVault, true)).toBe('missing');
});
test('empty provider set -> every missing var is missing', () => {
expect(classifyMarker('DB_PASSWORD', true, new Set(), false)).toBe('missing');
});
});
// The editor's live marker must never depend on a provider being present. A user
// with NO provider (or one that was unbound after a deploy) sees the exact old
// behavior: missing vars are red MISSING, never a stray green.
describe('classifyMarker - no-provider / unbound regression', () => {
test('no provider bound: runProbe leaves an empty set + no error -> plain MISSING', () => {
// This is the state runProbe() sets when formSecretProviderId === null.
const noProvider = new Set<string>();
expect(classifyMarker('DB_PASSWORD', true, noProvider, false)).toBe('missing');
expect(classifyMarker('API_KEY', true, noProvider, false)).toBe('missing');
});
test('a defined var is required with no provider (editor unaffected)', () => {
expect(classifyMarker('APP_ENV', false, new Set(), false)).toBe('required');
});
test('provider unbound after a deploy: live probe set is empty, so those keys go MISSING in the editor (banner history is separate)', () => {
// DB has injected_secret_keys from a past deploy, but the provider is now
// unbound -> the live probe set is empty. The EDITOR must show MISSING; the
// last-deploy banner (driven separately by injectedSecretKeys) is not this path.
const liveSetAfterUnbind = new Set<string>();
expect(classifyMarker('DB_PASSWORD', true, liveSetAfterUnbind, false)).toBe('missing');
});
});
// The panel's missing count / "Add missing" list must match the editor's IN VAULT
// markers - both drop ONLY the keys the LIVE probe found in the provider. The
// last-deploy injected table is never consulted here (that drives only the banner).
describe('effectiveMissing (panel <-> editor consistency, live only)', () => {
test('drops LIVE provider keys from missing, so the panel agrees with IN VAULT', () => {
// The bug: editor showed IN VAULT but the panel still counted them missing.
const missing = ['DB_PASSWORD', 'API_KEY', 'REDIS_URL', 'MISSING_ONE'];
const live = new Set(['DB_PASSWORD', 'API_KEY', 'REDIS_URL']);
expect(effectiveMissing(missing, live)).toEqual(['MISSING_ONE']);
});
test('probe failed / no provider (empty live set) -> nothing dropped, stays missing', () => {
const missing = ['DB_PASSWORD', 'MISSING_ONE'];
expect(effectiveMissing(missing, new Set())).toEqual(missing);
});
});
describe('resolvedRefVarNames', () => {
test('maps resolved ref STRINGS back to their var names', () => {
const pairs = [
{ varName: 'DB_PASSWORD', ref: 'op://vault/db/password' },
{ varName: 'API_KEY', ref: 'op://vault/api/key' },
{ varName: 'STALE', ref: 'op://vault/gone/secret' }
];
// provider resolved only the first two refs
expect(resolvedRefVarNames(pairs, ['op://vault/db/password', 'op://vault/api/key']))
.toEqual(['DB_PASSWORD', 'API_KEY']);
});
test('a ref that did not resolve is dropped', () => {
const pairs = [{ varName: 'STALE', ref: 'op://vault/gone/secret' }];
expect(resolvedRefVarNames(pairs, [])).toEqual([]);
});
test('no inline refs -> empty', () => {
expect(resolvedRefVarNames([], ['op://whatever'])).toEqual([]);
});
});
+33
View File
@@ -0,0 +1,33 @@
import { describe, test, expect } from 'bun:test';
import { ipToNumber } from '../src/lib/utils/ip';
describe('ipToNumber', () => {
test('converts standard IPv4 addresses', () => {
expect(ipToNumber('0.0.0.0')).toBe(0);
expect(ipToNumber('0.0.0.1')).toBe(1);
expect(ipToNumber('10.0.0.1')).toBe(167772161);
expect(ipToNumber('192.168.1.0')).toBe(3232235776);
});
test('strips CIDR notation', () => {
expect(ipToNumber('192.168.1.0/24')).toBe(ipToNumber('192.168.1.0'));
expect(ipToNumber('10.0.0.0/8')).toBe(ipToNumber('10.0.0.0'));
});
test('returns Infinity for null/undefined/empty', () => {
expect(ipToNumber(null)).toBe(Infinity);
expect(ipToNumber(undefined)).toBe(Infinity);
expect(ipToNumber('-')).toBe(Infinity);
});
test('returns Infinity for non-IPv4 strings', () => {
expect(ipToNumber('not-an-ip')).toBe(Infinity);
expect(ipToNumber('2001:db8::1')).toBe(Infinity);
});
test('sorts correctly when used as comparator', () => {
const ips = ['192.168.1.10', '10.0.0.1', '172.16.0.1', '192.168.1.2'];
const sorted = [...ips].sort((a, b) => ipToNumber(a) - ipToNumber(b));
expect(sorted).toEqual(['10.0.0.1', '172.16.0.1', '192.168.1.2', '192.168.1.10']);
});
});
+130
View File
@@ -0,0 +1,130 @@
import { describe, test, expect } from 'bun:test';
import {
hexToRgba,
getLabelColor,
getLabelBgColor,
getLabelColors,
parseLabels,
serializeLabels,
MAX_LABELS
} from '../src/lib/utils/label-colors';
describe('hexToRgba', () => {
test('converts hex to rgba with default alpha', () => {
expect(hexToRgba('#ef4444')).toBe('rgba(239, 68, 68, 0.15)');
});
test('converts hex to rgba with custom alpha', () => {
expect(hexToRgba('#3b82f6', 0.5)).toBe('rgba(59, 130, 246, 0.5)');
});
test('handles black', () => {
expect(hexToRgba('#000000', 1)).toBe('rgba(0, 0, 0, 1)');
});
test('handles white', () => {
expect(hexToRgba('#ffffff', 0.2)).toBe('rgba(255, 255, 255, 0.2)');
});
});
describe('getLabelColor', () => {
test('returns a hex color string', () => {
const color = getLabelColor('production');
expect(color).toMatch(/^#[0-9a-f]{6}$/);
});
test('is deterministic — same input gives same color', () => {
expect(getLabelColor('staging')).toBe(getLabelColor('staging'));
});
test('different labels can produce different colors', () => {
// Not guaranteed for any two labels, but "a" and "b" should differ
const colors = new Set(['a', 'b', 'c', 'd', 'e', 'production', 'staging', 'dev'].map(getLabelColor));
expect(colors.size).toBeGreaterThan(1);
});
test('uses custom color when provided', () => {
const custom = { production: '#ff0000' };
expect(getLabelColor('production', custom)).toBe('#ff0000');
});
test('falls back to hash when label not in custom colors', () => {
const custom = { production: '#ff0000' };
const color = getLabelColor('staging', custom);
expect(color).not.toBe('#ff0000');
expect(color).toMatch(/^#[0-9a-f]{6}$/);
});
});
describe('getLabelBgColor', () => {
test('returns an rgba string', () => {
const bg = getLabelBgColor('production');
expect(bg).toMatch(/^rgba\(\d+, \d+, \d+, [\d.]+\)$/);
});
test('uses custom color with alpha when provided', () => {
const custom = { test: '#3b82f6' };
expect(getLabelBgColor('test', custom)).toBe('rgba(59, 130, 246, 0.15)');
});
});
describe('getLabelColors', () => {
test('returns both color and bgColor', () => {
const { color, bgColor } = getLabelColors('production');
expect(color).toMatch(/^#[0-9a-f]{6}$/);
expect(bgColor).toMatch(/^rgba\(/);
});
test('custom colors return matching pair', () => {
const custom = { myLabel: '#ef4444' };
const { color, bgColor } = getLabelColors('myLabel', custom);
expect(color).toBe('#ef4444');
expect(bgColor).toBe('rgba(239, 68, 68, 0.15)');
});
});
describe('parseLabels', () => {
test('returns empty array for null', () => {
expect(parseLabels(null)).toEqual([]);
});
test('returns empty array for undefined', () => {
expect(parseLabels(undefined)).toEqual([]);
});
test('returns empty array for empty string', () => {
expect(parseLabels('')).toEqual([]);
});
test('passes through arrays as-is', () => {
expect(parseLabels(['a', 'b'])).toEqual(['a', 'b']);
});
test('parses JSON string', () => {
expect(parseLabels('["prod","staging"]')).toEqual(['prod', 'staging']);
});
test('returns empty array for invalid JSON', () => {
expect(parseLabels('not json')).toEqual([]);
});
test('returns empty array for non-array JSON', () => {
expect(parseLabels('{"key":"value"}')).toEqual([]);
});
});
describe('serializeLabels', () => {
test('returns JSON string for labels', () => {
expect(serializeLabels(['a', 'b'])).toBe('["a","b"]');
});
test('returns null for empty array', () => {
expect(serializeLabels([])).toBeNull();
});
});
describe('MAX_LABELS', () => {
test('is 10', () => {
expect(MAX_LABELS).toBe(10);
});
});
+27
View File
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'bun:test';
import { landingRedirectTarget } from '../src/lib/utils/landing';
describe('landingRedirectTarget', () => {
it('redirects to the configured landing page (page only, no env)', () => {
expect(landingRedirectTarget({ landingPage: 'volumes' }, false)).toBe('/volumes');
expect(landingRedirectTarget({ landingPage: 'containers' }, false)).toBe('/containers');
});
it('stays on the dashboard when landing is dashboard or unset', () => {
expect(landingRedirectTarget({ landingPage: 'dashboard' }, false)).toBeNull();
expect(landingRedirectTarget({ landingPage: null }, false)).toBeNull();
expect(landingRedirectTarget({}, false)).toBeNull();
expect(landingRedirectTarget(null, false)).toBeNull();
});
it('the ?home marker suppresses the redirect (sidebar Dashboard / logo click)', () => {
expect(landingRedirectTarget({ landingPage: 'volumes' }, true)).toBeNull();
expect(landingRedirectTarget({ landingPage: 'containers' }, true)).toBeNull();
});
it('redirects on every open without the marker (no per-tab memory)', () => {
// Same input, no marker -> always the same redirect, however many times the app opens.
expect(landingRedirectTarget({ landingPage: 'stacks' }, false)).toBe('/stacks');
expect(landingRedirectTarget({ landingPage: 'stacks' }, false)).toBe('/stacks');
});
});
+24
View File
@@ -0,0 +1,24 @@
import { describe, it, expect } from 'bun:test';
import { escapeLdapFilterValue } from '../src/lib/server/ldap-filter';
// Unit test stays import-light: it must NOT pull in `ldapts` (whose transitive
// `whatwg-url` dep does not always link in the CI per-step node_modules).
// The "does the escaped filter actually PARSE via ldapts" proof lives in the
// integration test tests/ldap-filter-parse.test.ts, which runs with full node_modules.
describe('escapeLdapFilterValue - RFC 4515 filter escaping (LDAP group check)', () => {
it("escapes a backslash so an AD DN with an escaped comma is safe in a filter (the reported bug)", () => {
// CN=Surname\, Name,... - a real AD DN. Raw, ldapts reads `\,` as an invalid `\XX`
// hex escape and throws "Invalid escaped hex character". Escaped (\\ -> \5c) it parses.
const dn = 'CN=Surname\\, Name,OU=Group,OU=Users,DC=example,DC=com';
const escaped = escapeLdapFilterValue(dn);
expect(escaped).toContain('\\5c'); // backslash was hex-escaped
expect(escaped).not.toContain('\\,'); // no bare backslash-comma left
});
it('escapes filter metacharacters ( ) * NUL', () => {
expect(escapeLdapFilterValue('a(b)c*d')).toBe('a\\28b\\29c\\2ad');
expect(escapeLdapFilterValue('x\0y')).toBe('x\\00y');
});
it('leaves a plain value untouched', () => {
expect(escapeLdapFilterValue('CN=jsmith,OU=Users,DC=example,DC=com')).toBe('CN=jsmith,OU=Users,DC=example,DC=com');
});
});
+80
View File
@@ -0,0 +1,80 @@
import { describe, test, expect } from 'bun:test';
import { sortByTimestampStable, type LogEntry } from '../src/lib/utils/log-entry';
function entry(partial: Partial<LogEntry> & { id: number }): LogEntry {
return { text: `line-${partial.id}`, ...partial };
}
describe('sortByTimestampStable', () => {
test('interleaves two containers by ISO timestamp, oldest first', () => {
const input: LogEntry[] = [
entry({ id: 0, containerId: 'A', timestamp: '2026-08-13T10:00:03Z' }),
entry({ id: 1, containerId: 'A', timestamp: '2026-08-13T10:00:05Z' }),
entry({ id: 2, containerId: 'B', timestamp: '2026-08-13T10:00:01Z' }),
entry({ id: 3, containerId: 'B', timestamp: '2026-08-13T10:00:04Z' })
];
const out = sortByTimestampStable(input);
expect(out.map((e) => e.timestamp)).toEqual([
'2026-08-13T10:00:01Z',
'2026-08-13T10:00:03Z',
'2026-08-13T10:00:04Z',
'2026-08-13T10:00:05Z'
]);
});
test('is order-independent: same result whichever container arrives first', () => {
const a: LogEntry[] = [
entry({ id: 10, containerId: 'A', timestamp: '2026-08-13T10:00:03Z' }),
entry({ id: 11, containerId: 'B', timestamp: '2026-08-13T10:00:01Z' })
];
// same logical lines, reversed arrival order (ids reflect arrival)
const b: LogEntry[] = [
entry({ id: 20, containerId: 'B', timestamp: '2026-08-13T10:00:01Z' }),
entry({ id: 21, containerId: 'A', timestamp: '2026-08-13T10:00:03Z' })
];
expect(sortByTimestampStable(a).map((e) => e.timestamp))
.toEqual(sortByTimestampStable(b).map((e) => e.timestamp));
// the reported bug: clicking cycles order — this proves the sort is deterministic
expect(sortByTimestampStable(a).map((e) => e.containerId)).toEqual(['B', 'A']);
expect(sortByTimestampStable(b).map((e) => e.containerId)).toEqual(['B', 'A']);
});
test('continuation line (no timestamp) stays glued under its parent from the same container', () => {
const input: LogEntry[] = [
entry({ id: 0, containerId: 'A', timestamp: '2026-08-13T10:00:05Z', text: 'stacktrace header' }),
entry({ id: 1, containerId: 'A', text: ' at frame 1' }), // continuation, no ts
entry({ id: 2, containerId: 'A', text: ' at frame 2' }), // continuation, no ts
entry({ id: 3, containerId: 'B', timestamp: '2026-08-13T10:00:02Z', text: 'earlier B line' })
];
const out = sortByTimestampStable(input);
// B (10:00:02) sorts before A's block; the two continuation lines inherit
// A's 10:00:05 and remain directly under the header, in id order.
expect(out.map((e) => e.text)).toEqual([
'earlier B line',
'stacktrace header',
' at frame 1',
' at frame 2'
]);
});
test('equal timestamps fall back to id (stable, arrival order preserved)', () => {
const ts = '2026-08-13T10:00:00Z';
const input: LogEntry[] = [
entry({ id: 5, containerId: 'A', timestamp: ts }),
entry({ id: 2, containerId: 'B', timestamp: ts }),
entry({ id: 9, containerId: 'A', timestamp: ts })
];
expect(sortByTimestampStable(input).map((e) => e.id)).toEqual([2, 5, 9]);
});
test('entries with no timestamp at all keep arrival order (id) and sort before timestamped ones', () => {
const input: LogEntry[] = [
entry({ id: 3, containerId: 'A', timestamp: '2026-08-13T10:00:01Z' }),
entry({ id: 1, containerId: 'C', text: 'no ts at all' }),
entry({ id: 2, containerId: 'D', text: 'also no ts' })
];
const out = sortByTimestampStable(input);
// '' (no ts) < any ISO string, and id breaks the '' vs '' tie
expect(out.map((e) => e.id)).toEqual([1, 2, 3]);
});
});
+83
View File
@@ -0,0 +1,83 @@
/**
* Unit tests for the internal-state getters the /metrics endpoint reads.
* These modules are db-free, so they run under `bun test` (metrics.ts itself
* transitively imports db.ts / better-sqlite3 and can't be unit-tested here).
*/
import { test, expect, describe, beforeEach } from 'bun:test';
import { getJobStats, createJob, completeJob, failJob, getJob, appendLine, cancelJob } from '../src/lib/server/jobs';
import { getVulnerabilitiesCacheStats, aggregateCache, inflight } from '../src/lib/server/vulnerabilities-cache';
describe('getJobStats', () => {
test('counts jobs by status', () => {
const before = getJobStats();
const a = createJob(); // running
const b = createJob(); completeJob(b, {}); // done
const c = createJob(); failJob(c, 'boom'); // error
const after = getJobStats();
expect(after.running).toBe(before.running + 1);
expect(after.done).toBe(before.done + 1);
expect(after.error).toBe(before.error + 1);
expect(after.total).toBe(after.running + after.done + after.error);
void a;
});
});
describe('job store lifecycle', () => {
test('createJob starts a running job with an id, empty lines, and timestamps', () => {
const j = createJob();
expect(j.id).toBeTruthy();
expect(j.status).toBe('running');
expect(j.lines).toEqual([]);
expect(j.createdAt).toBeGreaterThan(0);
expect(getJob(j.id)).toBe(j);
});
test('getJob returns undefined for an unknown id', () => {
expect(getJob('does-not-exist')).toBeUndefined();
});
test('appendLine pushes progress lines onto the job', () => {
const j = createJob();
appendLine(j, { event: 'progress', data: { pct: 10 } });
appendLine(j, { event: 'progress', data: { pct: 20 } });
expect(j.lines.length).toBe(2);
expect(j.lines[1].data).toEqual({ pct: 20 });
});
test('completeJob marks done and stores the result', () => {
const j = createJob();
completeJob(j, { ok: true });
expect(j.status).toBe('done');
expect(j.result).toEqual({ ok: true });
});
test('failJob marks error and stores a {success:false,error} result', () => {
const j = createJob();
failJob(j, 'boom');
expect(j.status).toBe('error');
expect(j.result).toEqual({ success: false, error: 'boom' });
});
test('cancelJob cancels a running job (true) but not a terminal or unknown one (false)', () => {
const running = createJob();
expect(cancelJob(running.id)).toBe(true);
const done = createJob();
completeJob(done, {});
expect(cancelJob(done.id)).toBe(false); // already terminal
expect(cancelJob('nope')).toBe(false); // unknown id
});
});
describe('getVulnerabilitiesCacheStats', () => {
beforeEach(() => { aggregateCache.clear(); inflight.clear(); });
test('reports zero for an empty cache', () => {
expect(getVulnerabilitiesCacheStats()).toEqual({ envs: 0, views: 0, inflight: 0 });
});
test('counts environments, memoized views, and in-flight aggregations', () => {
const emptySummary = { total: 0, critical: 0, high: 0, medium: 0, low: 0, imagesScanned: 0, totalImages: 0 };
aggregateCache.set(1, { at: 0, data: { findings: [], summary: emptySummary }, views: new Map([['a', []], ['b', []]]) });
aggregateCache.set(2, { at: 0, data: { findings: [], summary: emptySummary }, views: new Map([['c', []]]) });
inflight.set(3, Promise.resolve({ findings: [], summary: emptySummary }));
expect(getVulnerabilitiesCacheStats()).toEqual({ envs: 2, views: 3, inflight: 1 });
});
});
+197
View File
@@ -0,0 +1,197 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { getAdditionalVolumeBinds, dedupeVolumesForRecreate } from '../src/lib/server/mount-dedupe';
describe('getAdditionalVolumeBinds', () => {
it('skips volume mounts when the target already exists in HostConfig.Mounts', () => {
const additionalBinds = getAdditionalVolumeBinds(
{
Binds: ['/volume1/backups:/backup'],
Mounts: [{ Target: '/data' }]
},
[
{ Type: 'volume', Name: 'docsight_docsis_data', Destination: '/data' },
{ Type: 'bind', Name: 'ignored', Destination: '/backup' }
]
);
assert.deepEqual(additionalBinds, []);
});
it('adds volume mounts that are missing from HostConfig', () => {
const additionalBinds = getAdditionalVolumeBinds(
{
Binds: ['/volume1/backups:/backup'],
Mounts: []
},
[{ Type: 'volume', Name: 'docsight_docsis_data', Destination: '/data' }]
);
assert.deepEqual(additionalBinds, ['docsight_docsis_data:/data']);
});
it('skips bind mounts (only adds volume type)', () => {
const additionalBinds = getAdditionalVolumeBinds(
{ Binds: [], Mounts: [] },
[
{ Type: 'bind', Name: 'host-path', Destination: '/config' },
{ Type: 'tmpfs', Name: null, Destination: '/tmp' }
]
);
assert.deepEqual(additionalBinds, []);
});
it('skips volume mounts already in Binds', () => {
const additionalBinds = getAdditionalVolumeBinds(
{
Binds: ['myvolume:/data'],
Mounts: []
},
[{ Type: 'volume', Name: 'myvolume', Destination: '/data' }]
);
assert.deepEqual(additionalBinds, []);
});
it('handles null Binds and Mounts gracefully', () => {
const additionalBinds = getAdditionalVolumeBinds(
{ Binds: null, Mounts: null },
[{ Type: 'volume', Name: 'vol1', Destination: '/data' }]
);
assert.deepEqual(additionalBinds, ['vol1:/data']);
});
it('handles empty mounts array', () => {
const additionalBinds = getAdditionalVolumeBinds(
{ Binds: [], Mounts: [] },
[]
);
assert.deepEqual(additionalBinds, []);
});
it('handles null mounts array', () => {
const additionalBinds = getAdditionalVolumeBinds(
{ Binds: [], Mounts: [] },
null as any
);
assert.deepEqual(additionalBinds, []);
});
it('skips volume mounts without Name', () => {
const additionalBinds = getAdditionalVolumeBinds(
{ Binds: [], Mounts: [] },
[{ Type: 'volume', Name: null, Destination: '/data' }]
);
assert.deepEqual(additionalBinds, []);
});
it('skips volume mounts without Destination', () => {
const additionalBinds = getAdditionalVolumeBinds(
{ Binds: [], Mounts: [] },
[{ Type: 'volume', Name: 'vol1', Destination: null }]
);
assert.deepEqual(additionalBinds, []);
});
it('deduplicates across both Binds and Mounts targets', () => {
const additionalBinds = getAdditionalVolumeBinds(
{
Binds: ['/host/path:/config'],
Mounts: [{ Target: '/data' }]
},
[
{ Type: 'volume', Name: 'config-vol', Destination: '/config' },
{ Type: 'volume', Name: 'data-vol', Destination: '/data' },
{ Type: 'volume', Name: 'logs-vol', Destination: '/logs' }
]
);
assert.deepEqual(additionalBinds, ['logs-vol:/logs']);
});
it('handles bind strings with options (source:target:ro)', () => {
const additionalBinds = getAdditionalVolumeBinds(
{
Binds: ['/host/data:/data:ro'],
Mounts: []
},
[{ Type: 'volume', Name: 'data-vol', Destination: '/data' }]
);
assert.deepEqual(additionalBinds, []);
});
});
describe('dedupeVolumesForRecreate', () => {
it('drops an image-VOLUME path that collides with an existing bind (#1088)', () => {
const kept = dedupeVolumesForRecreate(
{ '/tmp': {}, '/keep': {} },
{ Binds: ['/host/tmp:/tmp'] },
[],
[]
);
assert.deepEqual(kept, { '/keep': {} });
});
it('drops an image-VOLUME path that collides with a tmpfs mount (readonly container)', () => {
const kept = dedupeVolumesForRecreate(
{ '/tmp': {} },
{ Tmpfs: { '/tmp': 'rw' } },
[],
[]
);
assert.equal(kept, undefined); // all volumes removed -> undefined so recreate omits the field
});
// #1363: cups on hawser. The image declares VOLUME /etc/cups; the running container has a
// NAMED volume at /etc/cups that appears ONLY in inspect.Mounts (not yet in Binds) - the shape
// a remote/hawser daemon returns. getAdditionalVolumeBinds re-adds it as a bind, and the old
// dedup (Tmpfs/Binds only) never saw it -> duplicate mount point. Now both are covered.
it('drops an image-VOLUME path present only in inspect.Mounts as a named volume (#1363)', () => {
const mounts = [{ Type: 'volume', Name: 'cups-conf', Destination: '/etc/cups' }];
const additionalBinds = getAdditionalVolumeBinds({ Binds: [], Mounts: [] }, mounts);
assert.deepEqual(additionalBinds, ['cups-conf:/etc/cups']); // Step B will re-add it
const kept = dedupeVolumesForRecreate(
{ '/etc/cups': {}, '/var/log/cups': {} },
{ Binds: [] },
mounts,
additionalBinds
);
// /etc/cups dropped (collides with the re-added bind); the un-mounted image volume stays.
assert.deepEqual(kept, { '/var/log/cups': {} });
});
it('drops an image-VOLUME path that collides with an existing HostConfig.Mounts target', () => {
const kept = dedupeVolumesForRecreate(
{ '/data': {} },
{ Mounts: [{ Target: '/data' }] },
[],
[]
);
assert.equal(kept, undefined);
});
it('keeps volumes with no colliding mount', () => {
const kept = dedupeVolumesForRecreate(
{ '/anon': {} },
{ Binds: ['/host:/other'], Tmpfs: { '/tmp': 'rw' } },
[{ Type: 'volume', Name: 'v', Destination: '/data' }],
['v:/data']
);
assert.deepEqual(kept, { '/anon': {} });
});
it('returns undefined for null/empty Volumes', () => {
assert.equal(dedupeVolumesForRecreate(undefined, { Binds: [] }, [], []), undefined);
assert.equal(dedupeVolumesForRecreate(null, { Binds: [] }, [], []), undefined);
assert.equal(dedupeVolumesForRecreate({}, { Binds: [] }, [], []), undefined);
});
});
+37
View File
@@ -0,0 +1,37 @@
/**
* Unit tests for nav-preferences.ts — the PURE validation (parseNavPatch). It has no DB
* dependency, so it imports directly with no mock.module (mocking ./db here is unsafe: bun's
* mock.module is process-global and would clobber the ./db export set other backup unit tests
* rely on, e.g. route-guards.test.ts's getBackupConfig). The merge/defaults + env-deletion
* sweep touch the DB and are exercised via the integration suite, not mocked here.
*/
import { describe, it, expect } from 'bun:test';
import { parseNavPatch, PAGE_SLUGS } from '../src/lib/server/nav-preferences-core';
describe('parseNavPatch — validation', () => {
it('accepts every real page slug for landingPage', () => {
for (const p of PAGE_SLUGS) {
expect(parseNavPatch({ landingPage: p })).toEqual({ landingPage: p });
}
});
it("rejects 'remember' for landingPage (feature removed)", () => {
expect(() => parseNavPatch({ landingPage: 'remember' })).toThrow('Invalid landingPage');
});
it("treats '' and null as a clear (null)", () => {
expect(parseNavPatch({ landingPage: '' })).toEqual({ landingPage: null });
expect(parseNavPatch({ landingPage: null })).toEqual({ landingPage: null });
});
it('rejects an unknown landingPage', () => {
expect(() => parseNavPatch({ landingPage: 'nope' })).toThrow('Invalid landingPage');
});
it("rejects 'dashboard' for envClickPage (concrete per-env only)", () => {
expect(() => parseNavPatch({ envClickPage: 'dashboard' })).toThrow('Invalid envClickPage');
});
it('accepts an env-scoped page for envClickPage', () => {
expect(parseNavPatch({ envClickPage: 'stacks' })).toEqual({ envClickPage: 'stacks' });
});
it('ignores fields that are not present (partial patch)', () => {
expect(parseNavPatch({})).toEqual({});
expect(parseNavPatch({ landingPage: 'logs' })).toEqual({ landingPage: 'logs' });
});
});
+39
View File
@@ -0,0 +1,39 @@
// @ts-expect-error -- bun:test is a runtime built-in with no types installed
import { describe, expect, test } from 'bun:test';
import { mapActionToEventType } from '../src/lib/server/notifications/events-core';
describe('mapActionToEventType', () => {
// The full docker-action -> notification-event decision table. This is what turns a
// raw Docker event into a notification type; a wrong or missing row silently drops a
// notification the user asked for.
const cases: [string, string | null][] = [
['start', 'container_started'],
['stop', 'container_stopped'],
['restart', 'container_restarted'],
['die', 'container_exited'],
['kill', 'container_exited'],
['oom', 'container_oom'],
['health_status: unhealthy', 'container_unhealthy'],
['health_status: healthy', 'container_healthy'],
['pull', 'image_pulled']
];
for (const [action, expected] of cases) {
test(`"${action}" -> ${expected}`, () => {
expect(mapActionToEventType(action)).toBe(expected);
});
}
test('an unknown action maps to null (no notification)', () => {
expect(mapActionToEventType('create')).toBeNull();
expect(mapActionToEventType('destroy')).toBeNull();
expect(mapActionToEventType('')).toBeNull();
});
// Docker has no "update" action - an image update surfaces as die/kill then start.
// So there is deliberately NO 'update' row here; container_updated is raised from the
// recreate flow instead (#1424). This asserts we don't accidentally add a bogus one.
test('there is no "update" docker action mapping', () => {
expect(mapActionToEventType('update')).toBeNull();
});
});
+109
View File
@@ -0,0 +1,109 @@
/**
* SSRF protection for the notification subsystem.
*
* Every notification channel dispatches through notificationFetch, which
* validates the target URL before making any request. Notification endpoints
* use a deliberately permissive policy (isSafeNotificationUrl): loopback and
* cloud-metadata hosts are blocked, but ordinary LAN ranges are allowed so a
* self-hosted receiver on the local network still works.
*/
import { describe, test, expect } from 'bun:test';
import { isSafeNotificationUrl } from '../src/lib/server/url-safety';
import { validateRepositoryForSave } from '../src/lib/server/backups/helpers';
import { notificationFetch } from '../src/lib/server/notifications/shared';
describe('isSafeNotificationUrl — notification endpoint guard', () => {
test('blocks loopback and cloud-metadata hosts', () => {
const blocked = [
'http://169.254.169.254/', // cloud metadata
'http://localhost/hook',
'http://127.0.0.1/hook',
'http://[::1]/hook',
'http://0.0.0.0/hook',
];
for (const url of blocked) {
expect(isSafeNotificationUrl(url).ok).toBe(false);
}
});
test('allows ordinary LAN hosts (self-hosted receivers)', () => {
const allowed = [
'http://10.0.0.5/hook',
'http://192.168.1.10/hook',
'http://172.16.0.1/hook',
];
for (const url of allowed) {
expect(isSafeNotificationUrl(url).ok).toBe(true);
}
});
test('rejects non-http(s) schemes', () => {
expect(isSafeNotificationUrl('file:///etc/passwd').ok).toBe(false);
expect(isSafeNotificationUrl('gopher://example.com/x').ok).toBe(false);
});
test('accepts a normal public https URL', () => {
expect(isSafeNotificationUrl('https://hooks.example.com/services/abc').ok).toBe(true);
});
// A public DNS name starting "fc"/"fd" must not be mistaken for an fc00::/fd00::
// IPv6 unique-local literal. (ULA itself is a LAN range, so the permissive
// notification policy allows it - the strict-policy block is asserted below.)
test('public hosts starting fc/fd are allowed (not treated as IPv6 ULA)', () => {
for (const url of ['https://fcm.googleapis.com/x', 'https://fc2.com/x', 'https://fdroid.link/x']) {
expect(isSafeNotificationUrl(url).ok).toBe(true);
}
});
});
import { ipCategory } from '../src/lib/server/url-safety';
describe('ipCategory — fc/fd anchoring', () => {
test('real IPv6 unique-local / link-local literals are private', () => {
for (const h of ['fc00::1', 'fd12:3456::1', 'fe80::1']) {
expect(ipCategory(h)).toBe('private');
}
});
test('public DNS names starting fc/fd are not IP literals', () => {
for (const h of ['fcm.googleapis.com', 'fc2.com', 'fdroid.link', 'fd7host.example.com']) {
expect(ipCategory(h)).toBe(null);
}
});
});
describe('validateRepositoryForSave — backup destination SSRF guard', () => {
test('blocks loopback and cloud-metadata repo hosts', () => {
expect(validateRepositoryForSave('rest:http://169.254.169.254/repo')).not.toBeNull();
expect(validateRepositoryForSave('rest:http://127.0.0.1/repo')).not.toBeNull();
});
test('allows a self-hosted repo on a LAN host (MinIO/REST on the local network)', () => {
expect(validateRepositoryForSave('rest:http://192.168.1.106/repo')).toBeNull();
expect(validateRepositoryForSave('rest:http://10.0.0.5:8000/')).toBeNull();
});
test('allows a normal public repo and a local absolute path', () => {
expect(validateRepositoryForSave('rest:https://backups.example.com/repo')).toBeNull();
expect(validateRepositoryForSave('/srv/backups')).toBeNull();
});
test('rejects an unknown scheme', () => {
expect(validateRepositoryForSave('gopher://example.com/x')).not.toBeNull();
});
});
describe('notificationFetch — blocks before making a request', () => {
test('throws on a cloud-metadata URL (no network access)', async () => {
await expect(
notificationFetch('http://169.254.169.254/latest/meta-data/')
).rejects.toThrow(/Notification endpoint rejected/);
});
test('throws on a loopback URL object (no network access)', async () => {
await expect(
notificationFetch(new URL('http://127.0.0.1/hook'))
).rejects.toThrow(/Notification endpoint rejected/);
});
});
+275
View File
@@ -0,0 +1,275 @@
/**
* Notification System Tests
*
* Unit tests for notification event mapping and routing.
* Run with: bun test tests/notifications.test.ts
*/
import { describe, test, expect } from 'bun:test';
import { escapeTelegramMarkdown, buildGotifyUrl } from '../src/lib/utils/notification-parsers';
// Test the event type mapping logic (mirrors notifications.ts)
const EVENT_TYPE_MAPPING: Record<string, string> = {
'start': 'container_started',
'stop': 'container_stopped',
'restart': 'container_restarted',
'die': 'container_exited',
'kill': 'container_exited',
'oom': 'container_oom',
'health_status: unhealthy': 'container_unhealthy',
'health_status: healthy': 'container_healthy',
'pull': 'image_pulled'
};
function mapActionToEventType(action: string): string | null {
return EVENT_TYPE_MAPPING[action] || null;
}
describe('Event Type Mapping', () => {
test('Maps start action to container_started', () => {
expect(mapActionToEventType('start')).toBe('container_started');
});
test('Maps stop action to container_stopped', () => {
expect(mapActionToEventType('stop')).toBe('container_stopped');
});
test('Maps restart action to container_restarted', () => {
expect(mapActionToEventType('restart')).toBe('container_restarted');
});
test('Maps die action to container_exited', () => {
expect(mapActionToEventType('die')).toBe('container_exited');
});
test('Maps kill action to container_exited', () => {
expect(mapActionToEventType('kill')).toBe('container_exited');
});
test('Maps oom action to container_oom', () => {
expect(mapActionToEventType('oom')).toBe('container_oom');
});
test('Maps health_status: unhealthy to container_unhealthy', () => {
expect(mapActionToEventType('health_status: unhealthy')).toBe('container_unhealthy');
});
test('Maps health_status: healthy to container_healthy', () => {
expect(mapActionToEventType('health_status: healthy')).toBe('container_healthy');
});
test('Maps pull action to image_pulled', () => {
expect(mapActionToEventType('pull')).toBe('image_pulled');
});
test('Returns null for unknown actions', () => {
expect(mapActionToEventType('create')).toBeNull();
expect(mapActionToEventType('destroy')).toBeNull();
expect(mapActionToEventType('unknown')).toBeNull();
});
});
describe('Scanner Image Detection', () => {
const SCANNER_IMAGE_PATTERNS = [
'anchore/grype',
'aquasec/trivy',
'ghcr.io/anchore/grype',
'ghcr.io/aquasecurity/trivy'
];
function isScannerContainer(image: string | null | undefined): boolean {
if (!image) return false;
const lowerImage = image.toLowerCase();
return SCANNER_IMAGE_PATTERNS.some(pattern => lowerImage.includes(pattern.toLowerCase()));
}
test('Detects Grype scanner images', () => {
expect(isScannerContainer('anchore/grype:latest')).toBe(true);
expect(isScannerContainer('ghcr.io/anchore/grype:v0.74.0')).toBe(true);
});
test('Detects Trivy scanner images', () => {
expect(isScannerContainer('aquasec/trivy:latest')).toBe(true);
expect(isScannerContainer('ghcr.io/aquasecurity/trivy:0.50.0')).toBe(true);
});
test('Does not match regular images', () => {
expect(isScannerContainer('nginx:latest')).toBe(false);
expect(isScannerContainer('alpine:3.18')).toBe(false);
expect(isScannerContainer('postgres:16')).toBe(false);
});
test('Handles null and undefined', () => {
expect(isScannerContainer(null)).toBe(false);
expect(isScannerContainer(undefined)).toBe(false);
});
test('Case insensitive matching', () => {
expect(isScannerContainer('ANCHORE/GRYPE:LATEST')).toBe(true);
expect(isScannerContainer('AquaSec/Trivy:Latest')).toBe(true);
});
});
describe('Apprise URL Parsing', () => {
function parseAppriseProtocol(url: string): string | null {
const match = url.match(/^([a-z]+):\/\//i);
return match ? match[1].toLowerCase() : null;
}
test('Parses Discord URL', () => {
expect(parseAppriseProtocol('discord://webhook_id/token')).toBe('discord');
expect(parseAppriseProtocol('discords://webhook_id/token')).toBe('discords');
});
test('Parses Slack URL', () => {
expect(parseAppriseProtocol('slack://token_a/token_b/token_c')).toBe('slack');
});
test('Parses Telegram URL', () => {
expect(parseAppriseProtocol('tgram://bot_token/chat_id')).toBe('tgram');
});
test('Parses Gotify URL', () => {
expect(parseAppriseProtocol('gotify://hostname/token')).toBe('gotify');
expect(parseAppriseProtocol('gotifys://hostname/token')).toBe('gotifys');
});
test('Parses ntfy URL', () => {
expect(parseAppriseProtocol('ntfy://topic')).toBe('ntfy');
expect(parseAppriseProtocol('ntfys://hostname/topic')).toBe('ntfys');
});
test('Parses Pushover URL', () => {
expect(parseAppriseProtocol('pushover://user_key/api_token')).toBe('pushover');
});
test('Parses JSON webhook URL', () => {
expect(parseAppriseProtocol('json://hostname/path')).toBe('json');
expect(parseAppriseProtocol('jsons://hostname/path')).toBe('jsons');
});
test('Returns null for invalid URLs', () => {
expect(parseAppriseProtocol('invalid')).toBeNull();
expect(parseAppriseProtocol('://missing-protocol')).toBeNull();
});
});
describe('Gotify URL Building', () => {
test('Builds URL without subpath', () => {
const result = buildGotifyUrl('gotify://myhost/AklJjLSi73nAXY4');
expect(result?.url).toBe('http://myhost/message?token=AklJjLSi73nAXY4');
expect(result?.priority).toBeUndefined();
});
test('Builds URL without subpath (HTTPS)', () => {
const result = buildGotifyUrl('gotifys://myhost/AklJjLSi73nAXY4');
expect(result?.url).toBe('https://myhost/message?token=AklJjLSi73nAXY4');
});
test('Builds URL with subpath', () => {
const result = buildGotifyUrl('gotifys://gotify.domain/gotify/AklJjLSi73nAXY4');
expect(result?.url).toBe('https://gotify.domain/gotify/message?token=AklJjLSi73nAXY4');
});
test('Builds URL with deeper subpath', () => {
const result = buildGotifyUrl('gotify://myhost/apps/gotify/MyToken123');
expect(result?.url).toBe('http://myhost/apps/gotify/message?token=MyToken123');
});
test('Returns null for invalid URL', () => {
expect(buildGotifyUrl('gotify://hostname-only')).toBeNull();
});
test('Parses priority from query param', () => {
const result = buildGotifyUrl('gotifys://myhost/token123?priority=3');
expect(result?.url).toBe('https://myhost/message?token=token123');
expect(result?.priority).toBe(3);
});
test('Parses priority with subpath', () => {
const result = buildGotifyUrl('gotifys://gotify.domain/gotify/token123?priority=7');
expect(result?.url).toBe('https://gotify.domain/gotify/message?token=token123');
expect(result?.priority).toBe(7);
});
test('Ignores invalid priority values', () => {
expect(buildGotifyUrl('gotify://myhost/token?priority=abc')?.priority).toBeUndefined();
expect(buildGotifyUrl('gotify://myhost/token?priority=-1')?.priority).toBeUndefined();
expect(buildGotifyUrl('gotify://myhost/token?priority=11')?.priority).toBeUndefined();
});
test('Priority 0 is valid', () => {
expect(buildGotifyUrl('gotify://myhost/token?priority=0')?.priority).toBe(0);
});
test('Ignores unknown query params', () => {
const result = buildGotifyUrl('gotify://myhost/token?foo=bar&priority=5&baz=qux');
expect(result?.url).toBe('http://myhost/message?token=token');
expect(result?.priority).toBe(5);
});
});
describe('Telegram URL Parsing', () => {
function parseTelegramUrl(url: string): { botToken: string; chatId: string } | null {
const match = url.match(/^tgram:\/\/([^/]+)\/(.+)/);
if (!match) return null;
return { botToken: match[1], chatId: match[2] };
}
test('Parses valid Telegram URL', () => {
const result = parseTelegramUrl('tgram://123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11/-100123456789');
expect(result).not.toBeNull();
expect(result!.botToken).toBe('123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11');
expect(result!.chatId).toBe('-100123456789');
});
test('Returns null for invalid URLs', () => {
expect(parseTelegramUrl('tgram://only-token')).toBeNull();
expect(parseTelegramUrl('invalid://bot/chat')).toBeNull();
});
});
describe('Telegram Markdown Escaping', () => {
test('escapes underscores', () => {
expect(escapeTelegramMarkdown('my_container')).toBe('my\\_container');
});
test('escapes asterisks', () => {
expect(escapeTelegramMarkdown('*bold*')).toBe('\\*bold\\*');
});
test('escapes backticks', () => {
expect(escapeTelegramMarkdown('`code`')).toBe('\\`code\\`');
});
test('escapes opening brackets', () => {
expect(escapeTelegramMarkdown('[link')).toBe('\\[link');
});
test('does NOT escape closing brackets (#955)', () => {
expect(escapeTelegramMarkdown('text]')).toBe('text]');
});
test('does NOT escape backslashes (legacy Markdown)', () => {
expect(escapeTelegramMarkdown('path\\to')).toBe('path\\to');
});
test('plain text passes through unchanged', () => {
expect(escapeTelegramMarkdown('production')).toBe('production');
expect(escapeTelegramMarkdown('my-env-123')).toBe('my-env-123');
});
test('environment name in envTag format has no extra backslash', () => {
const envName = 'production';
const envTag = ` [${escapeTelegramMarkdown(envName)}]`;
expect(envTag).toBe(' [production]');
expect(envTag).not.toContain('\\]');
});
test('environment name with special chars in envTag', () => {
const envName = 'my_server';
const envTag = ` [${escapeTelegramMarkdown(envName)}]`;
expect(envTag).toBe(' [my\\_server]');
expect(envTag).not.toContain('\\]');
});
});
+89
View File
@@ -0,0 +1,89 @@
/**
* ntfy ?auth= query parameter resolution (#1209).
*
* Run with: bun test tests/unit/ntfy-auth.test.ts
*/
import { describe, test, expect } from 'bun:test';
import { resolveQueryAuth, resolveNtfyEndpoint } from '../src/lib/server/notifications/ntfy';
describe('ntfy resolveQueryAuth', () => {
test('raw tk_ token becomes Bearer header', () => {
expect(resolveQueryAuth('tk_fz03gou2w6vclsqnw5jt7mq9999jm')).toBe(
'Bearer tk_fz03gou2w6vclsqnw5jt7mq9999jm'
);
});
test('base64-encoded "Bearer <token>" is decoded', () => {
const encoded = Buffer.from('Bearer abc123').toString('base64');
expect(resolveQueryAuth(encoded)).toBe('Bearer abc123');
});
test('base64-encoded "Basic <creds>" is decoded', () => {
const encoded = Buffer.from('Basic dXNlcjpwYXNz').toString('base64');
expect(resolveQueryAuth(encoded)).toBe('Basic dXNlcjpwYXNz');
});
test('non-base64 raw token (no tk_ prefix) is treated as Bearer', () => {
expect(resolveQueryAuth('myrawtoken')).toBe('Bearer myrawtoken');
});
test('base64 input that decodes to non-Bearer/Basic falls back to raw', () => {
const encoded = Buffer.from('something else').toString('base64');
expect(resolveQueryAuth(encoded)).toBe(`Bearer ${encoded}`);
});
test('header value is always ASCII-safe (regression: U+FFFD)', () => {
// The original bug: raw tk_ tokens were decoded as base64, producing
// 0xFFFD chars that fetch() rejected as ByteString-incompatible.
const result = resolveQueryAuth('tk_fz03gou2w6vclsqnw5jt7mq9999jm');
for (let i = 0; i < result.length; i++) {
expect(result.charCodeAt(i)).toBeLessThanOrEqual(0xff);
}
});
});
describe('ntfy resolveNtfyEndpoint', () => {
// THE BUG (#1300): a trailing slash on the topic made ntfy 404 (POST /topic/ is a
// different route from POST /topic → {"code":40401,"http":404,...}). Confirmed
// against a live ntfy server: POST /testtopic = 200, POST /testtopic/ = 404.
test('strips a trailing slash from the topic (the #1300 404 bug)', () => {
expect(resolveNtfyEndpoint('host.de/dockhand/', true).url).toBe('https://host.de/dockhand');
});
test('strips multiple trailing slashes', () => {
expect(resolveNtfyEndpoint('host.de/dockhand//', false).url).toBe('http://host.de/dockhand');
});
test('no trailing slash is unchanged', () => {
expect(resolveNtfyEndpoint('host.de/dockhand', true).url).toBe('https://host.de/dockhand');
});
test('public ntfy.sh single topic', () => {
expect(resolveNtfyEndpoint('my-topic', false).url).toBe('https://ntfy.sh/my-topic');
});
test('public ntfy.sh topic with trailing slash is normalised', () => {
expect(resolveNtfyEndpoint('my-topic/', false).url).toBe('https://ntfy.sh/my-topic');
});
test('secure host/topic', () => {
expect(resolveNtfyEndpoint('host.de/topic', true).url).toBe('https://host.de/topic');
});
test('basic auth user:pass@host/topic with trailing slash', () => {
const r = resolveNtfyEndpoint('user:[email protected]/topic/', false);
expect(r.url).toBe('http://host.de/topic');
expect(r.authHeader).toBe(`Basic ${Buffer.from('user:pass').toString('base64')}`);
});
test('bearer token@host/topic with trailing slash', () => {
const r = resolveNtfyEndpoint('[email protected]/topic/', true);
expect(r.url).toBe('https://host.de/topic');
expect(r.authHeader).toBe('Bearer tk_abc');
});
test('nested topic path keeps inner slashes, strips only trailing', () => {
expect(resolveNtfyEndpoint('host.de/a/b/c/', true).url).toBe('https://host.de/a/b/c');
});
});
+60
View File
@@ -0,0 +1,60 @@
import { describe, it, expect } from 'bun:test';
import { oidcDiscoveryUrls, fetchOidcDiscovery } from '../src/lib/utils/oidc-discovery-url';
const ok = (statusText = 'OK') => ({ ok: true, statusText });
const notOk = (statusText: string) => ({ ok: false, statusText });
describe('oidcDiscoveryUrls', () => {
it('tries the canonical (no trailing slash) URL first, then the trailing-slash variant', () => {
expect(oidcDiscoveryUrls('https://idp.example.com/realm')).toEqual([
'https://idp.example.com/realm/.well-known/openid-configuration',
'https://idp.example.com/realm/.well-known/openid-configuration/'
]);
});
it('normalises an issuer that already ends with a slash (no double slash)', () => {
expect(oidcDiscoveryUrls('https://idp.example.com/realm/')).toEqual([
'https://idp.example.com/realm/.well-known/openid-configuration',
'https://idp.example.com/realm/.well-known/openid-configuration/'
]);
});
it('handles a bare-host issuer', () => {
expect(oidcDiscoveryUrls('https://idp.example.com')).toEqual([
'https://idp.example.com/.well-known/openid-configuration',
'https://idp.example.com/.well-known/openid-configuration/'
]);
});
});
describe('fetchOidcDiscovery', () => {
it('returns the FIRST url on OK - a compliant provider never sees the fallback', async () => {
const tried: string[] = [];
const r = await fetchOidcDiscovery(['a', 'b'], async (u) => { tried.push(u); return ok(); });
expect(tried).toEqual(['a']);
expect(r.ok).toBe(true);
});
it('falls back to the trailing-slash url when the canonical one is 404 (#1368)', async () => {
const tried: string[] = [];
const r = await fetchOidcDiscovery(['canon', 'canon/'], async (u) => {
tried.push(u);
return u.endsWith('/') ? ok() : notOk('Not Found');
});
expect(tried).toEqual(['canon', 'canon/']);
expect(r.ok).toBe(true);
});
it('throws with the last statusText when every url is not-ok', async () => {
await expect(fetchOidcDiscovery(['a', 'b'], async () => notOk('Not Found')))
.rejects.toThrow('Failed to fetch OIDC discovery document: Not Found');
});
it('a network error is fatal - does NOT fall through to the next url', async () => {
const tried: string[] = [];
const boom = Object.assign(new TypeError('fetch failed'), { cause: { code: 'ENOTFOUND' } });
await expect(fetchOidcDiscovery(['a', 'b'], async (u) => { tried.push(u); throw boom; }))
.rejects.toThrow('Failed to reach OIDC issuer at a: fetch failed (ENOTFOUND)');
expect(tried).toEqual(['a']); // stopped at the first, did not try 'b'
});
});
+121
View File
@@ -0,0 +1,121 @@
// @ts-expect-error -- bun:test is a runtime built-in with no types installed
import { describe, expect, test } from 'bun:test';
import { parseAnnotations, parseMiniSchema } from '../scripts/openapi/lib';
import { buildSpec } from '../scripts/openapi/build-spec';
// Wrap a single-method annotation block in a minimal +server.ts source.
function src(method: string, block: string): string {
return `/**\n${block}\n */\nexport const ${method}: RequestHandler = async () => new Response();\n`;
}
describe('parseMiniSchema: always terminates + bracket arrays', () => {
// A mini-schema string with the [T] bracket-array form (or any unrecognized
// char) must ALWAYS advance the parser cursor. A stuck cursor spun the drift
// gate for hours in CI. These strings are the ones that hung.
test('[string] parses as an array of string', () => {
expect(parseMiniSchema('[string]')).toEqual({ kind: 'array', items: { kind: 'string' } });
});
test('nested object with a [T] property (the exact string that hung CI)', () => {
const s = parseMiniSchema('{compose:string!, config:{disabled:[string], severity:object}}');
expect(s.kind).toBe('object');
expect((s as any).properties.config.properties.disabled).toEqual({ kind: 'array', items: { kind: 'string' } });
expect((s as any).required).toContain('compose');
});
test('[{...}]! required array of objects', () => {
const s = parseMiniSchema('{findings:[{ruleId:string!}]!}');
expect((s as any).properties.findings.kind).toBe('array');
expect((s as any).properties.findings.items.kind).toBe('object');
expect((s as any).required).toContain('findings');
});
test('garbage / stray brackets terminate (never infinite-loop)', () => {
// If any of these looped, the test process would hang instead of asserting.
expect(() => parseMiniSchema('{a:[[[')).not.toThrow();
expect(() => parseMiniSchema('[[[[[[')).not.toThrow();
expect(() => parseMiniSchema('{:::,,,}')).not.toThrow();
expect(() => parseMiniSchema('@#$%^&*')).not.toThrow();
});
test('array<T> long form still works (no regression)', () => {
expect(parseMiniSchema('array<string>')).toEqual({ kind: 'array', items: { kind: 'string' } });
});
});
describe('parseAnnotations: non-JSON request bodies', () => {
test('body-raw parses media type + description', () => {
const a = parseAnnotations(
src('POST', ' * @openapi\n * summary: Load an image\n * body-raw: application/x-tar The raw image tar')
);
expect(a.POST?.bodyRaw).toEqual({ mediaType: 'application/x-tar', description: 'The raw image tar' });
});
test('body-multipart parses field/type/array/required/description', () => {
const a = parseAnnotations(
src('POST', ' * @openapi\n * summary: Upload files\n * body-multipart: files:binary[]! One or more files')
);
expect(a.POST?.bodyMultipart).toEqual({
field: 'files',
type: 'binary',
array: true,
required: true,
description: 'One or more files'
});
});
test('a JSON body: still parses as before (no regression)', () => {
const a = parseAnnotations(src('POST', ' * @openapi\n * summary: X\n * body: {name:string!}'));
expect(a.POST?.body).toBeDefined();
expect(a.POST?.bodyRaw).toBeUndefined();
expect(a.POST?.bodyMultipart).toBeUndefined();
});
});
describe('buildSpec: emits requestBody for non-JSON bodies', () => {
function specFor(filePath: string, openapiPath: string, method: string, block: string) {
const content = src(method, block);
const fileContents = new Map([[filePath, content]]);
const annotationsByPath = { [openapiPath]: parseAnnotations(content) };
const pathParams = [...openapiPath.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]);
const routes = [{ filePath, openapiPath, pathParams, tag: 'Test', methods: [method] as any }];
return buildSpec({
routes: routes as any,
fileContents,
annotationsByPath: annotationsByPath as any,
isPublicFn: () => true,
version: 'test'
}).spec;
}
test('body-raw -> application/x-tar content, required', () => {
const spec = specFor(
'/x/+server.ts',
'/api/images/load',
'POST',
' * @openapi\n * summary: Load\n * body-raw: application/x-tar The raw image tar'
);
const rb: any = spec.paths['/api/images/load'].post.requestBody;
expect(rb.required).toBe(true);
expect(rb.content['application/x-tar']).toBeDefined();
expect(rb.content['application/x-tar'].schema.type).toBe('string');
expect(rb.content['application/x-tar'].schema.format).toBe('binary');
});
test('body-multipart -> multipart/form-data with an array file field, required', () => {
const spec = specFor(
'/y/+server.ts',
'/api/containers/{id}/files/upload',
'POST',
' * @openapi\n * summary: Upload\n * body-multipart: files:binary[]! One or more files'
);
const rb: any = spec.paths['/api/containers/{id}/files/upload'].post.requestBody;
expect(rb.required).toBe(true);
const mp = rb.content['multipart/form-data'];
expect(mp).toBeDefined();
expect(mp.schema.type).toBe('object');
expect(mp.schema.properties.files.type).toBe('array');
expect(mp.schema.properties.files.items.format).toBe('binary');
expect(mp.schema.required).toEqual(['files']);
});
});
+176
View File
@@ -0,0 +1,176 @@
import { describe, expect, test } from 'bun:test';
import { extractPangolinUrls } from '../src/lib/utils/pangolin-urls';
/**
* Scheme is decided by the `ssl` label ONLY (verified against Pangolin's source):
* ssl=true -> https, ssl=false -> http.
* ssl absent -> scope default: public = https, private = http.
* The `protocol`/`mode` label is the resource TYPE, never the scheme, and is ignored.
*/
describe('extractPangolinUrls', () => {
test('public resource, ssl=false -> http', () => {
const out = extractPangolinUrls({
'pangolin.public-resources.grafana.name': 'Grafana',
'pangolin.public-resources.grafana.full-domain': 'grafana.example.com',
'pangolin.public-resources.grafana.ssl': 'false',
'pangolin.public-resources.grafana.targets[0].method': 'http',
'pangolin.public-resources.grafana.targets[0].port': '3000'
});
expect(out).toEqual([
{ url: 'http://grafana.example.com', resource: 'grafana', scope: 'public', displayName: 'Grafana' }
]);
});
test('public resource, ssl=true -> https', () => {
const out = extractPangolinUrls({
'pangolin.public-resources.app.full-domain': 'app.example.com',
'pangolin.public-resources.app.ssl': 'true'
});
expect(out[0].url).toBe('https://app.example.com');
});
test('public resource, ssl ABSENT -> https (public default)', () => {
const out = extractPangolinUrls({
'pangolin.public-resources.app.full-domain': 'app.example.com'
});
expect(out).toEqual([
{ url: 'https://app.example.com', resource: 'app', scope: 'public', displayName: undefined }
]);
});
test('the reporter case (#1331): protocol=http but ssl absent -> https, protocol ignored', () => {
// protocol is the resource TYPE, not the scheme. A public resource with a
// full-domain and no ssl label is https regardless of protocol=http.
const out = extractPangolinUrls({
'pangolin.public-resources.r.protocol': 'http',
'pangolin.public-resources.r.full-domain': 'sub.domain.tld'
});
expect(out[0].url).toBe('https://sub.domain.tld');
});
test('protocol=http with an explicit ssl=false -> http (ssl wins, not protocol)', () => {
const out = extractPangolinUrls({
'pangolin.public-resources.r.protocol': 'http',
'pangolin.public-resources.r.ssl': 'false',
'pangolin.public-resources.r.full-domain': 'r.example.com'
});
expect(out[0].url).toBe('http://r.example.com');
});
test('private resource, ssl ABSENT -> http (private default)', () => {
const out = extractPangolinUrls({
'pangolin.private-resources.internal.full-domain': 'internal.lan'
});
expect(out).toEqual([
{ url: 'http://internal.lan', resource: 'internal', scope: 'private', displayName: undefined }
]);
});
test('private resource, ssl=true -> https', () => {
const out = extractPangolinUrls({
'pangolin.private-resources.internal.full-domain': 'internal.lan',
'pangolin.private-resources.internal.ssl': 'true'
});
expect(out[0].url).toBe('https://internal.lan');
});
test('unparseable ssl value falls back to the scope default', () => {
// public default = https
const pub = extractPangolinUrls({
'pangolin.public-resources.r.full-domain': 'r.example.com',
'pangolin.public-resources.r.ssl': 'gibberish'
});
expect(pub[0].url).toBe('https://r.example.com');
// private default = http
const priv = extractPangolinUrls({
'pangolin.private-resources.r.full-domain': 'r.lan',
'pangolin.private-resources.r.ssl': 'gibberish'
});
expect(priv[0].url).toBe('http://r.lan');
});
test('ssl value is case-insensitive', () => {
const out = extractPangolinUrls({
'pangolin.public-resources.r.full-domain': 'r.example.com',
'pangolin.public-resources.r.ssl': ' FALSE '
});
expect(out[0].url).toBe('http://r.example.com');
});
test('multiple resources on the same container yield multiple URLs', () => {
const out = extractPangolinUrls({
'pangolin.public-resources.a.full-domain': 'a.example.com',
'pangolin.public-resources.b.full-domain': 'b.example.com',
'pangolin.public-resources.b.ssl': 'false'
});
expect(out).toHaveLength(2);
const urls = out.map((p) => p.url).sort();
expect(urls).toEqual(['http://b.example.com', 'https://a.example.com']);
});
test('same resource name under both scopes is treated as two resources', () => {
const out = extractPangolinUrls({
'pangolin.public-resources.dual.full-domain': 'pub.example.com',
'pangolin.private-resources.dual.full-domain': 'priv.lan'
});
expect(out).toHaveLength(2);
const scopes = out.map((p) => p.scope).sort();
expect(scopes).toEqual(['private', 'public']);
// And each gets its scope default scheme.
expect(out.find((p) => p.scope === 'public')!.url).toBe('https://pub.example.com');
expect(out.find((p) => p.scope === 'private')!.url).toBe('http://priv.lan');
});
test('skips resources with no full-domain', () => {
const out = extractPangolinUrls({
'pangolin.public-resources.no-domain.name': 'Incomplete',
'pangolin.public-resources.no-domain.ssl': 'false'
});
expect(out).toEqual([]);
});
test('dedupes identical URLs across resources', () => {
const out = extractPangolinUrls({
'pangolin.public-resources.x.full-domain': 'same.example.com',
'pangolin.public-resources.y.full-domain': 'same.example.com'
});
expect(out).toHaveLength(1);
});
test('ignores unrelated labels including legacy pangolin.proxy-resources.*', () => {
const out = extractPangolinUrls({
'org.opencontainers.image.source': 'https://github.com/foo/bar',
'dockhand.url': 'https://example.com',
'traefik.http.routers.x.rule': 'Host(`x.example.com`)',
'pangolin.proxy-resources.r.full-domain': 'should-be-ignored.example.com',
'pangolin.public-resources.r.full-domain': 'r.example.com'
});
expect(out).toEqual([
{ url: 'https://r.example.com', resource: 'r', scope: 'public', displayName: undefined }
]);
});
test('whitespace in the full-domain is trimmed', () => {
const out = extractPangolinUrls({
'pangolin.public-resources.s.full-domain': ' spaced.example.com ',
'pangolin.public-resources.s.ssl': 'false'
});
expect(out[0].url).toBe('http://spaced.example.com');
});
test('null / empty inputs return empty array', () => {
expect(extractPangolinUrls(null)).toEqual([]);
expect(extractPangolinUrls(undefined)).toEqual([]);
expect(extractPangolinUrls({})).toEqual([]);
});
test('targets[N].* labels do not introduce extra resources', () => {
const out = extractPangolinUrls({
'pangolin.public-resources.grafana.full-domain': 'grafana.example.com',
'pangolin.public-resources.grafana.targets[0].method': 'http',
'pangolin.public-resources.grafana.targets[0].port': '3000'
});
expect(out).toHaveLength(1);
expect(out[0].resource).toBe('grafana');
});
});
+51
View File
@@ -0,0 +1,51 @@
import { describe, test, expect } from 'bun:test';
import { parseImageNameAndTag } from '../src/lib/server/scheduler/tasks/update-utils';
describe('parseImageNameAndTag', () => {
test('adds latest tag when no tag specified', () => {
expect(parseImageNameAndTag('nginx')).toEqual(['nginx', 'latest']);
});
test('splits simple name:tag', () => {
expect(parseImageNameAndTag('nginx:1.25')).toEqual(['nginx', '1.25']);
expect(parseImageNameAndTag('redis:alpine')).toEqual(['redis', 'alpine']);
});
test('handles registry with port and tag', () => {
expect(parseImageNameAndTag('registry.example.com:5000/myimage:v1')).toEqual([
'registry.example.com:5000/myimage',
'v1'
]);
});
test('handles registry with port and no tag', () => {
expect(parseImageNameAndTag('registry.example.com:5000/myimage')).toEqual([
'registry.example.com:5000/myimage',
'latest'
]);
});
test('handles digest-based images', () => {
const digest = 'nginx@sha256:abc123def456';
expect(parseImageNameAndTag(digest)).toEqual([digest, '']);
});
test('handles dockhand-pending suffix', () => {
expect(parseImageNameAndTag('nginx:latest-dockhand-pending')).toEqual([
'nginx',
'latest-dockhand-pending'
]);
});
test('handles namespaced images', () => {
expect(parseImageNameAndTag('library/nginx:latest')).toEqual(['library/nginx', 'latest']);
expect(parseImageNameAndTag('ghcr.io/finsys/dockhand:v1.0.0')).toEqual([
'ghcr.io/finsys/dockhand',
'v1.0.0'
]);
});
test('handles latest tag explicitly', () => {
expect(parseImageNameAndTag('nginx:latest')).toEqual(['nginx', 'latest']);
});
});
+117
View File
@@ -0,0 +1,117 @@
/**
* parseRegistryUrl Unit Tests
*
* Tests the registry URL parsing logic used by loginToRegistries() during stack deploys.
* Covers bare hostnames (ghcr.io), full URLs (https://ghcr.io), ports, and paths.
*
* The function is inlined here (mirrors docker.ts) because importing docker.ts triggers
* the full DB module graph. Same pattern as notifications.test.ts.
*
* Run with: bun test tests/unit/parse-registry-url.test.ts
*/
import { describe, test, expect } from 'bun:test';
// Mirrors parseRegistryUrl() from src/lib/server/docker.ts
function parseRegistryUrl(url: string): { host: string; path: string; fullRegistry: string; protocol: string } {
const protocol = url.startsWith('http://') ? 'http' : 'https';
const withoutProtocol = url.replace(/^https?:\/\//, '');
const trimmed = withoutProtocol.replace(/\/$/, '');
const slashIndex = trimmed.indexOf('/');
if (slashIndex === -1) {
return { host: trimmed, path: '', fullRegistry: trimmed, protocol };
}
const host = trimmed.substring(0, slashIndex);
const path = trimmed.substring(slashIndex);
return { host, path, fullRegistry: trimmed, protocol };
}
describe('parseRegistryUrl', () => {
test('bare hostname without protocol', () => {
const result = parseRegistryUrl('ghcr.io');
expect(result.host).toBe('ghcr.io');
expect(result.protocol).toBe('https');
expect(result.path).toBe('');
});
test('hostname with https protocol', () => {
const result = parseRegistryUrl('https://ghcr.io');
expect(result.host).toBe('ghcr.io');
expect(result.protocol).toBe('https');
expect(result.path).toBe('');
});
test('hostname with http protocol', () => {
const result = parseRegistryUrl('http://registry.local:5000');
expect(result.host).toBe('registry.local:5000');
expect(result.protocol).toBe('http');
expect(result.path).toBe('');
});
test('hostname with port and no protocol', () => {
const result = parseRegistryUrl('registry.local:5000');
expect(result.host).toBe('registry.local:5000');
expect(result.protocol).toBe('https');
});
test('hostname with org path', () => {
const result = parseRegistryUrl('ghcr.io/myorg');
expect(result.host).toBe('ghcr.io');
expect(result.path).toBe('/myorg');
expect(result.fullRegistry).toBe('ghcr.io/myorg');
});
test('full URL with org path', () => {
const result = parseRegistryUrl('https://ghcr.io/myorg');
expect(result.host).toBe('ghcr.io');
expect(result.path).toBe('/myorg');
});
test('trailing slash is stripped', () => {
const result = parseRegistryUrl('ghcr.io/');
expect(result.host).toBe('ghcr.io');
expect(result.path).toBe('');
});
test('Docker Hub registry', () => {
const result = parseRegistryUrl('https://index.docker.io/v1/');
expect(result.host).toBe('index.docker.io');
expect(result.path).toBe('/v1');
});
test('private registry with port and path', () => {
const result = parseRegistryUrl('http://registry.example.com:5000/team');
expect(result.host).toBe('registry.example.com:5000');
expect(result.path).toBe('/team');
expect(result.protocol).toBe('http');
});
});
describe('loginToRegistries host extraction (issue #397)', () => {
// These test the specific scenario from issue #397:
// loginToRegistries() used `new URL(reg.url)` which throws on bare hostnames.
// Now it uses parseRegistryUrl() which handles all formats.
test('bare hostname produces valid docker login host', () => {
const { host } = parseRegistryUrl('ghcr.io');
expect(host).toBe('ghcr.io');
});
test('https URL produces same host as bare hostname', () => {
const bare = parseRegistryUrl('ghcr.io');
const full = parseRegistryUrl('https://ghcr.io');
expect(bare.host).toBe(full.host);
});
test('local registry with port produces host:port', () => {
const { host } = parseRegistryUrl('registry.local:5000');
expect(host).toBe('registry.local:5000');
});
test('new URL() would throw on bare hostname (the old bug)', () => {
// This confirms the original bug: new URL('ghcr.io') throws
expect(() => new URL('ghcr.io')).toThrow();
// But parseRegistryUrl handles it fine
expect(() => parseRegistryUrl('ghcr.io')).not.toThrow();
});
});
+40
View File
@@ -0,0 +1,40 @@
import { describe, test, expect } from 'bun:test';
import { cleanPem } from '../src/lib/utils/pem';
describe('cleanPem', () => {
test('returns null for null/undefined/empty', () => {
expect(cleanPem(null)).toBeNull();
expect(cleanPem(undefined)).toBeNull();
expect(cleanPem('')).toBeNull();
});
test('trims whitespace from lines', () => {
const pem = ' -----BEGIN CERTIFICATE----- \n abc123 \n -----END CERTIFICATE----- ';
expect(cleanPem(pem)).toBe(
'-----BEGIN CERTIFICATE-----\nabc123\n-----END CERTIFICATE-----'
);
});
test('removes empty lines', () => {
const pem = '-----BEGIN CERTIFICATE-----\n\nabc123\n\n-----END CERTIFICATE-----\n\n';
expect(cleanPem(pem)).toBe(
'-----BEGIN CERTIFICATE-----\nabc123\n-----END CERTIFICATE-----'
);
});
test('handles already clean PEM', () => {
const pem = '-----BEGIN CERTIFICATE-----\nabc123\n-----END CERTIFICATE-----';
expect(cleanPem(pem)).toBe(pem);
});
test('returns null for whitespace-only input', () => {
expect(cleanPem(' \n \n ')).toBeNull();
});
test('handles copy-paste artifacts with tabs', () => {
const pem = '\t-----BEGIN CERTIFICATE-----\t\n\tabc123\t\n\t-----END CERTIFICATE-----\t';
expect(cleanPem(pem)).toBe(
'-----BEGIN CERTIFICATE-----\nabc123\n-----END CERTIFICATE-----'
);
});
});
+153
View File
@@ -0,0 +1,153 @@
/**
* Unit tests for pendingRowsToClear (#1311).
*
* A stack redeploy with "Pull images" recreates containers with new ids, orphaning
* the pending_container_updates rows keyed by the OLD id — the dashboard then shows a
* permanent false "update available". We clear a row ONLY when we can prove the
* container now runs the newest local image; every ambiguity keeps the row so a REAL
* pending update is never hidden (the user's core concern when --pull always doesn't
* land the latest: per-service pull failure, build:, pull_policy:never).
*/
import { describe, test, expect } from 'bun:test';
import { pendingRowsToClear, countLivePending, type PendingRow, type LiveContainer } from '../src/lib/server/pending-updates-core';
const STACK = 'shop';
const LATEST = 'sha256:newnewnew';
const OLD = 'sha256:oldoldold';
// tag -> newest local id after --pull always
const latestIdForTag = (tag: string) => (tag === 'nginx:latest' ? LATEST : tag === 'unresolvable:tag' ? null : LATEST);
const row = (name: string, id: string, img = 'nginx:latest'): PendingRow => ({ containerId: id, containerName: name, currentImage: img });
const live = (name: string, imageId: string, project?: string): LiveContainer => ({ name, imageId, project });
describe('pendingRowsToClear', () => {
test('1. CLEARS a stack container recreated on the newest image', () => {
const out = pendingRowsToClear(
[row('shop-web-1', 'old-id')],
[live('shop-web-1', LATEST, STACK)],
latestIdForTag, STACK
);
expect(out).toEqual(['old-id']);
});
test('2. KEEPS a stack container still on the OLD image (pull failed / build: / never)', () => {
const out = pendingRowsToClear(
[row('shop-web-1', 'old-id')],
[live('shop-web-1', OLD, STACK)], // recreated but still old image
latestIdForTag, STACK
);
expect(out).toEqual([]); // a real pending update must NOT be hidden
});
test('3. KEEPS when the tag cannot be resolved to a local id (fail-safe)', () => {
const out = pendingRowsToClear(
[row('shop-web-1', 'old-id', 'unresolvable:tag')],
[live('shop-web-1', LATEST, STACK)],
latestIdForTag, STACK
);
expect(out).toEqual([]);
});
test('4. CLEARS a genuine orphan (no live container carries that name)', () => {
const out = pendingRowsToClear(
[row('shop-gone-1', 'old-id')],
[live('shop-web-1', LATEST, STACK)], // different name still around
latestIdForTag, STACK
);
expect(out).toEqual(['old-id']);
});
test('5. leaves another stack\'s container untouched', () => {
const out = pendingRowsToClear(
[row('other-web-1', 'old-id')],
[live('other-web-1', LATEST, 'other-stack')], // same name, different project
latestIdForTag, STACK
);
expect(out).toEqual([]);
});
test('6. leaves a standalone (no project) container untouched', () => {
const out = pendingRowsToClear(
[row('lonely', 'old-id')],
[live('lonely', LATEST, undefined)],
latestIdForTag, STACK
);
expect(out).toEqual([]);
});
test('7a. empty pending -> []', () => {
expect(pendingRowsToClear([], [live('shop-web-1', LATEST, STACK)], latestIdForTag, STACK)).toEqual([]);
});
test('7b. empty live -> every row is a true orphan, all cleared', () => {
const out = pendingRowsToClear(
[row('shop-web-1', 'a'), row('shop-db-1', 'b')],
[], latestIdForTag, STACK
);
expect(out.sort()).toEqual(['a', 'b']);
});
test('7c. mixed batch: updated cleared, still-old kept, other-stack kept, orphan cleared', () => {
const out = pendingRowsToClear(
[
row('shop-web-1', 'web'), // updated -> clear
row('shop-db-1', 'db'), // still old -> keep
row('other-x-1', 'other'), // other stack -> keep
row('shop-gone-1', 'gone') // orphan -> clear
],
[
live('shop-web-1', LATEST, STACK),
live('shop-db-1', OLD, STACK),
live('other-x-1', LATEST, 'other-stack')
],
latestIdForTag, STACK
);
expect(out.sort()).toEqual(['gone', 'web']);
});
test('7d. does not throw on malformed rows / missing fields', () => {
const out = pendingRowsToClear(
[{ containerId: '', containerName: 'x', currentImage: 'nginx:latest' } as PendingRow,
row('shop-web-1', 'ok')],
[live('shop-web-1', LATEST, STACK)],
latestIdForTag, STACK
);
expect(out).toEqual(['ok']); // blank-id row skipped, valid one cleared
});
test('non-array pending -> []', () => {
// @ts-expect-error deliberate wrong type
expect(pendingRowsToClear(null, [], latestIdForTag, STACK)).toEqual([]);
});
});
describe('countLivePending (#1006 dashboard tile)', () => {
const p = (id: string) => ({ containerId: id });
test('counts only rows whose containerId is a live container', () => {
expect(countLivePending([p('a'), p('b'), p('c')], ['a', 'c'])).toBe(2);
});
test('orphan rows (recreated/removed out-of-band) are not counted', () => {
// old ids gone after a compose-CLI recreate; none map to a live id
expect(countLivePending([p('old1'), p('old2')], ['new1', 'new2'])).toBe(0);
});
test('accepts a Set of live ids', () => {
expect(countLivePending([p('a'), p('b')], new Set(['b']))).toBe(1);
});
test('null/empty pending -> 0', () => {
// @ts-expect-error deliberate null
expect(countLivePending(null, ['a'])).toBe(0);
expect(countLivePending([], ['a'])).toBe(0);
});
// CONTRACT the call-site regression-guard depends on: with an EMPTY live list the
// helper returns 0. That is correct here, which is exactly why the dashboard handler
// must NOT feed it an empty list caused by a listContainers timeout (it would hide
// real updates). The handler falls back to the raw count when containers.length === 0.
test('empty live list -> 0 (guard must handle the timeout case at the call-site)', () => {
expect(countLivePending([p('a'), p('b')], [])).toBe(0);
});
});
Loaded 100 of 160 files, more files were not shown because too many files have changed in this diff. Show more