add missing unit test suite
This commit is contained in:
160 files changed
+19260
-1164
No files matched your search
+28
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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)));
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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: [] });
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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: [] };
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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('-');
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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&', '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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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#!'" });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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')}`);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 {};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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('\\]');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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-----'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in new issue
Block a user