From 5f9e6bde3c9c550656b5420c11a48ee216117205 Mon Sep 17 00:00:00 2001 From: Jarek Krochmalski Date: Sat, 22 Aug 2026 09:09:50 +0200 Subject: [PATCH] add missing unit test suite --- .dockerignore | 29 +- .github/workflows/ci.yml | 28 + bunfig.toml | 8 +- tests/audit-diff.test.ts | 104 +++ tests/backup-helpers.test.ts | 784 ++++++++++++++++++ tests/backup-image-setting.test.ts | 45 + tests/backup-medlow-audit.test.ts | 142 ++++ tests/backup-mount-normalize.test.ts | 335 ++++++++ tests/backup-restic-hardening.test.ts | 235 ++++++ tests/backup-test-summary.test.ts | 49 ++ tests/backups/backup-script.test.ts | 268 ++++++ tests/backups/backup-service.test.ts | 662 +++++++++++++++ tests/backups/browse-core.test.ts | 103 +++ tests/backups/discovery-core.test.ts | 168 ++++ tests/backups/format-restic-lines.test.ts | 51 ++ tests/backups/helpers.test.ts | 118 +++ tests/backups/identity-core.test.ts | 63 ++ tests/backups/locks.test.ts | 158 ++++ tests/backups/models.test.ts | 184 ++++ tests/backups/operations-core.test.ts | 91 ++ tests/backups/parse-restic-diff.test.ts | 48 ++ tests/backups/probe-classify.test.ts | 34 + tests/backups/put-archive-transport.test.ts | 71 ++ tests/backups/reap-core.test.ts | 34 + tests/backups/redeploy-stack-core.test.ts | 53 ++ tests/backups/repo.test.ts | 210 +++++ tests/backups/request-timeout.test.ts | 33 + tests/backups/restic-script.test.ts | 291 +++++++ tests/backups/restore-bind.test.ts | 109 +++ tests/backups/restore-script.test.ts | 301 +++++++ tests/backups/restore-service.test.ts | 708 ++++++++++++++++ tests/backups/restore-targets.test.ts | 127 +++ tests/backups/retention.test.ts | 119 +++ tests/backups/route-guards.test.ts | 137 +++ tests/backups/security.test.ts | 167 ++++ tests/backups/snapshot-layout.test.ts | 196 +++++ tests/backups/snapshots.test.ts | 184 ++++ tests/backups/stackdir-plan.test.ts | 318 +++++++ tests/backups/stackfile-filter.test.ts | 215 +++++ tests/backups/swap.test.ts | 271 ++++++ tests/backups/tar.test.ts | 215 +++++ tests/backups/validate.test.ts | 193 +++++ tests/bulk-selector.test.ts | 66 ++ tests/caddy-urls.test.ts | 108 +++ tests/changelog-strip-refs.test.ts | 33 + tests/changelog-url.test.ts | 113 +++ tests/classify-catalog-failure.test.ts | 53 ++ tests/classify-empty-digest-image.test.ts | 55 ++ tests/compose-docker-args.test.ts | 49 ++ .../compose-validate/apply-quick-fix.test.ts | 69 ++ tests/compose-validate/config-errors.test.ts | 38 + tests/compose-validate/context.test.ts | 68 ++ tests/compose-validate/rules.test.ts | 448 ++++++++++ tests/container-env-merge.test.ts | 291 +++++++ tests/container-image-divergence.test.ts | 121 +++ tests/container-labels.test.ts | 367 ++++++++ tests/content-disposition.test.ts | 51 ++ tests/cron-logic.test.ts | 157 ++++ tests/custom-url.test.ts | 158 ++++ tests/date-format-tz.test.ts | 167 ++++ tests/deep-diff.test.ts | 217 +++++ tests/encryption-core.test.ts | 132 +++ tests/encryption-prefix-bypass.test.ts | 41 + tests/encryption-rotation-coverage.test.ts | 114 +++ tests/env-name.test.ts | 222 +++++ tests/env-parser.test.ts | 83 ++ tests/env-var-validation.test.ts | 261 ++++++ tests/error-message.test.ts | 100 +++ tests/execution-tally.test.ts | 73 ++ tests/format-bytes.test.ts | 54 ++ tests/format-error-lines.test.ts | 33 + tests/format-relative-time.test.ts | 70 ++ tests/fs-guard.test.ts | 47 ++ tests/git-basic-auth-header.test.ts | 35 + tests/git-ssh-key.test.ts | 52 ++ tests/git-url-safety.test.ts | 142 ++++ tests/helper-wait-core.test.ts | 38 + tests/helpers/preload.ts | 6 - tests/hostconfig-recreate.test.ts | 44 + tests/index-child-digest.test.ts | 64 ++ tests/invault-markers.test.ts | 90 ++ tests/ip-sort.test.ts | 33 + tests/label-colors.test.ts | 130 +++ tests/landing.test.ts | 27 + tests/ldap-filter-escape.test.ts | 24 + tests/log-entry-sort.test.ts | 80 ++ tests/metrics-stats.test.ts | 83 ++ tests/mount-dedupe.test.ts | 197 +++++ tests/nav-preferences.test.ts | 37 + tests/notification-events.test.ts | 39 + tests/notifications-ssrf.test.ts | 109 +++ tests/notifications.test.ts | 275 ++++++ tests/ntfy-auth.test.ts | 89 ++ tests/oidc-discovery-url.test.ts | 60 ++ tests/openapi-body-annotations.test.ts | 121 +++ tests/pangolin-urls.test.ts | 176 ++++ tests/parse-image.test.ts | 51 ++ tests/parse-registry-url.test.ts | 117 +++ tests/pem-clean.test.ts | 40 + tests/pending-updates-core.test.ts | 153 ++++ tests/pinned-ref.test.ts | 77 ++ tests/podman-userns-recreate.test.ts | 45 + tests/port-format.test.ts | 235 ++++++ tests/port-parse.test.ts | 192 +++++ tests/provider-json-response.test.ts | 121 +++ tests/registry-auth-encode.test.ts | 171 ++++ tests/remote-staging-plan.test.ts | 124 +++ tests/repo-predicates.test.ts | 57 ++ tests/s3-region.test.ts | 51 ++ tests/safe-redirect.test.ts | 52 ++ tests/sarif-export.test.ts | 100 +++ tests/sarif-schema.test.ts | 106 +++ tests/scanner-dns-network.test.ts | 143 ++++ tests/scanner-json.test.ts | 146 ++++ tests/secret-masking.test.ts | 339 ++++++++ tests/secret-provider-bitwarden.test.ts | 285 ------- tests/secret-provider-config-redact.test.ts | 55 ++ tests/secret-provider-connect.test.ts | 318 ------- tests/secret-provider-infisical.test.ts | 180 ---- tests/secret-provider-onepassword.test.ts | 95 +++ tests/secret-provider-precedence.test.ts | 46 + tests/secret-provider-ssrf-guard.test.ts | 125 +++ tests/secret-provider-vault.test.ts | 199 ----- tests/select-volumes-to-prune.test.ts | 58 ++ tests/self-inspect-candidate.test.ts | 52 ++ tests/semver-find-newer.test.ts | 164 ++++ tests/semver-generic-v2.test.ts | 65 ++ tests/semver-image-ref.test.ts | 65 ++ tests/semver-manifest-artifact.test.ts | 48 ++ tests/semver-release-notes.test.ts | 121 +++ tests/semver-release-source.test.ts | 62 ++ tests/semver-tag-parser.test.ts | 173 ++++ tests/settings-formatters.test.ts | 116 +++ tests/sse-parser.test.ts | 76 ++ tests/stack-delete-guard.test.ts | 46 + tests/stack-filename.test.ts | 65 ++ tests/stack-injected-secret-keys.test.ts | 60 ++ tests/stack-name-normalize.test.ts | 36 + tests/stack-order.test.ts | 88 ++ tests/stack-paths.test.ts | 45 + tests/stack-status.test.ts | 45 + tests/stream-pump.test.ts | 68 ++ tests/system-container-filter.test.ts | 155 ++++ tests/tar-extract.test.ts | 78 ++ tests/template-compose-ssrf.test.ts | 42 + tests/traefik-urls.test.ts | 107 +++ tests/unbackupable-mounts.test.ts | 52 ++ tests/url-format.test.ts | 24 + tests/version.test.ts | 63 ++ tests/volume-identity.test.ts | 79 ++ tests/vuln-criteria.test.ts | 113 +++ tests/vuln-export-helpers.test.ts | 252 ++++++ tests/vulnerabilities-cache.test.ts | 118 +++ tests/webhook-signature.test.ts | 81 ++ tests/windowed-list.test.ts | 192 +++++ tests/workflows-notifications.test.ts | 179 ++++ tests/ws-auth-cookie.test.ts | 40 + tests/ws-exec-permission.test.ts | 47 ++ tests/xss-rendering.test.ts | 85 ++ tests/zabbix-notifications.test.ts | 172 ---- 160 files changed, 19260 insertions(+), 1164 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/audit-diff.test.ts create mode 100644 tests/backup-helpers.test.ts create mode 100644 tests/backup-image-setting.test.ts create mode 100644 tests/backup-medlow-audit.test.ts create mode 100644 tests/backup-mount-normalize.test.ts create mode 100644 tests/backup-restic-hardening.test.ts create mode 100644 tests/backup-test-summary.test.ts create mode 100644 tests/backups/backup-script.test.ts create mode 100644 tests/backups/backup-service.test.ts create mode 100644 tests/backups/browse-core.test.ts create mode 100644 tests/backups/discovery-core.test.ts create mode 100644 tests/backups/format-restic-lines.test.ts create mode 100644 tests/backups/helpers.test.ts create mode 100644 tests/backups/identity-core.test.ts create mode 100644 tests/backups/locks.test.ts create mode 100644 tests/backups/models.test.ts create mode 100644 tests/backups/operations-core.test.ts create mode 100644 tests/backups/parse-restic-diff.test.ts create mode 100644 tests/backups/probe-classify.test.ts create mode 100644 tests/backups/put-archive-transport.test.ts create mode 100644 tests/backups/reap-core.test.ts create mode 100644 tests/backups/redeploy-stack-core.test.ts create mode 100644 tests/backups/repo.test.ts create mode 100644 tests/backups/request-timeout.test.ts create mode 100644 tests/backups/restic-script.test.ts create mode 100644 tests/backups/restore-bind.test.ts create mode 100644 tests/backups/restore-script.test.ts create mode 100644 tests/backups/restore-service.test.ts create mode 100644 tests/backups/restore-targets.test.ts create mode 100644 tests/backups/retention.test.ts create mode 100644 tests/backups/route-guards.test.ts create mode 100644 tests/backups/security.test.ts create mode 100644 tests/backups/snapshot-layout.test.ts create mode 100644 tests/backups/snapshots.test.ts create mode 100644 tests/backups/stackdir-plan.test.ts create mode 100644 tests/backups/stackfile-filter.test.ts create mode 100644 tests/backups/swap.test.ts create mode 100644 tests/backups/tar.test.ts create mode 100644 tests/backups/validate.test.ts create mode 100644 tests/bulk-selector.test.ts create mode 100644 tests/caddy-urls.test.ts create mode 100644 tests/changelog-strip-refs.test.ts create mode 100644 tests/changelog-url.test.ts create mode 100644 tests/classify-catalog-failure.test.ts create mode 100644 tests/classify-empty-digest-image.test.ts create mode 100644 tests/compose-docker-args.test.ts create mode 100644 tests/compose-validate/apply-quick-fix.test.ts create mode 100644 tests/compose-validate/config-errors.test.ts create mode 100644 tests/compose-validate/context.test.ts create mode 100644 tests/compose-validate/rules.test.ts create mode 100644 tests/container-env-merge.test.ts create mode 100644 tests/container-image-divergence.test.ts create mode 100644 tests/container-labels.test.ts create mode 100644 tests/content-disposition.test.ts create mode 100644 tests/cron-logic.test.ts create mode 100644 tests/custom-url.test.ts create mode 100644 tests/date-format-tz.test.ts create mode 100644 tests/deep-diff.test.ts create mode 100644 tests/encryption-core.test.ts create mode 100644 tests/encryption-prefix-bypass.test.ts create mode 100644 tests/encryption-rotation-coverage.test.ts create mode 100644 tests/env-name.test.ts create mode 100644 tests/env-parser.test.ts create mode 100644 tests/env-var-validation.test.ts create mode 100644 tests/error-message.test.ts create mode 100644 tests/execution-tally.test.ts create mode 100644 tests/format-bytes.test.ts create mode 100644 tests/format-error-lines.test.ts create mode 100644 tests/format-relative-time.test.ts create mode 100644 tests/fs-guard.test.ts create mode 100644 tests/git-basic-auth-header.test.ts create mode 100644 tests/git-ssh-key.test.ts create mode 100644 tests/git-url-safety.test.ts create mode 100644 tests/helper-wait-core.test.ts delete mode 100644 tests/helpers/preload.ts create mode 100644 tests/hostconfig-recreate.test.ts create mode 100644 tests/index-child-digest.test.ts create mode 100644 tests/invault-markers.test.ts create mode 100644 tests/ip-sort.test.ts create mode 100644 tests/label-colors.test.ts create mode 100644 tests/landing.test.ts create mode 100644 tests/ldap-filter-escape.test.ts create mode 100644 tests/log-entry-sort.test.ts create mode 100644 tests/metrics-stats.test.ts create mode 100644 tests/mount-dedupe.test.ts create mode 100644 tests/nav-preferences.test.ts create mode 100644 tests/notification-events.test.ts create mode 100644 tests/notifications-ssrf.test.ts create mode 100644 tests/notifications.test.ts create mode 100644 tests/ntfy-auth.test.ts create mode 100644 tests/oidc-discovery-url.test.ts create mode 100644 tests/openapi-body-annotations.test.ts create mode 100644 tests/pangolin-urls.test.ts create mode 100644 tests/parse-image.test.ts create mode 100644 tests/parse-registry-url.test.ts create mode 100644 tests/pem-clean.test.ts create mode 100644 tests/pending-updates-core.test.ts create mode 100644 tests/pinned-ref.test.ts create mode 100644 tests/podman-userns-recreate.test.ts create mode 100644 tests/port-format.test.ts create mode 100644 tests/port-parse.test.ts create mode 100644 tests/provider-json-response.test.ts create mode 100644 tests/registry-auth-encode.test.ts create mode 100644 tests/remote-staging-plan.test.ts create mode 100644 tests/repo-predicates.test.ts create mode 100644 tests/s3-region.test.ts create mode 100644 tests/safe-redirect.test.ts create mode 100644 tests/sarif-export.test.ts create mode 100644 tests/sarif-schema.test.ts create mode 100644 tests/scanner-dns-network.test.ts create mode 100644 tests/scanner-json.test.ts create mode 100644 tests/secret-masking.test.ts delete mode 100644 tests/secret-provider-bitwarden.test.ts create mode 100644 tests/secret-provider-config-redact.test.ts delete mode 100644 tests/secret-provider-connect.test.ts delete mode 100644 tests/secret-provider-infisical.test.ts create mode 100644 tests/secret-provider-onepassword.test.ts create mode 100644 tests/secret-provider-precedence.test.ts create mode 100644 tests/secret-provider-ssrf-guard.test.ts delete mode 100644 tests/secret-provider-vault.test.ts create mode 100644 tests/select-volumes-to-prune.test.ts create mode 100644 tests/self-inspect-candidate.test.ts create mode 100644 tests/semver-find-newer.test.ts create mode 100644 tests/semver-generic-v2.test.ts create mode 100644 tests/semver-image-ref.test.ts create mode 100644 tests/semver-manifest-artifact.test.ts create mode 100644 tests/semver-release-notes.test.ts create mode 100644 tests/semver-release-source.test.ts create mode 100644 tests/semver-tag-parser.test.ts create mode 100644 tests/settings-formatters.test.ts create mode 100644 tests/sse-parser.test.ts create mode 100644 tests/stack-delete-guard.test.ts create mode 100644 tests/stack-filename.test.ts create mode 100644 tests/stack-injected-secret-keys.test.ts create mode 100644 tests/stack-name-normalize.test.ts create mode 100644 tests/stack-order.test.ts create mode 100644 tests/stack-paths.test.ts create mode 100644 tests/stack-status.test.ts create mode 100644 tests/stream-pump.test.ts create mode 100644 tests/system-container-filter.test.ts create mode 100644 tests/tar-extract.test.ts create mode 100644 tests/template-compose-ssrf.test.ts create mode 100644 tests/traefik-urls.test.ts create mode 100644 tests/unbackupable-mounts.test.ts create mode 100644 tests/url-format.test.ts create mode 100644 tests/version.test.ts create mode 100644 tests/volume-identity.test.ts create mode 100644 tests/vuln-criteria.test.ts create mode 100644 tests/vuln-export-helpers.test.ts create mode 100644 tests/vulnerabilities-cache.test.ts create mode 100644 tests/webhook-signature.test.ts create mode 100644 tests/windowed-list.test.ts create mode 100644 tests/workflows-notifications.test.ts create mode 100644 tests/ws-auth-cookie.test.ts create mode 100644 tests/ws-exec-permission.test.ts create mode 100644 tests/xss-rendering.test.ts delete mode 100644 tests/zabbix-notifications.test.ts diff --git a/.dockerignore b/.dockerignore index d7fddf0..097bf2d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d5a2cd5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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/ diff --git a/bunfig.toml b/bunfig.toml index 34ac71c..2bac5c0 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -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. diff --git a/tests/audit-diff.test.ts b/tests/audit-diff.test.ts new file mode 100644 index 0000000..634bf4e --- /dev/null +++ b/tests/audit-diff.test.ts @@ -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'); + }); +}); diff --git a/tests/backup-helpers.test.ts b/tests/backup-helpers.test.ts new file mode 100644 index 0000000..c46a41b --- /dev/null +++ b/tests/backup-helpers.test.ts @@ -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(''); + }); +}); + +// ============================================================================ +// 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(); + }); +}); diff --git a/tests/backup-image-setting.test.ts b/tests/backup-image-setting.test.ts new file mode 100644 index 0000000..b7f50a1 --- /dev/null +++ b/tests/backup-image-setting.test.ts @@ -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); + }); +}); diff --git a/tests/backup-medlow-audit.test.ts b/tests/backup-medlow-audit.test.ts new file mode 100644 index 0000000..d5d485b --- /dev/null +++ b/tests/backup-medlow-audit.test.ts @@ -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'), ''); + }); +}); + +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'); + }); +}); diff --git a/tests/backup-mount-normalize.test.ts b/tests/backup-mount-normalize.test.ts new file mode 100644 index 0000000..98eef42 --- /dev/null +++ b/tests/backup-mount-normalize.test.ts @@ -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(); + }); +}); diff --git a/tests/backup-restic-hardening.test.ts b/tests/backup-restic-hardening.test.ts new file mode 100644 index 0000000..e9285b1 --- /dev/null +++ b/tests/backup-restic-hardening.test.ts @@ -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[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))); + }); +}); diff --git a/tests/backup-test-summary.test.ts b/tests/backup-test-summary.test.ts new file mode 100644 index 0000000..379689a --- /dev/null +++ b/tests/backup-test-summary.test.ts @@ -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'); + }); +}); diff --git a/tests/backups/backup-script.test.ts b/tests/backups/backup-script.test.ts new file mode 100644 index 0000000..4a81dd8 --- /dev/null +++ b/tests/backups/backup-script.test.ts @@ -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 > "). 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 }); + } + }); + }); +}); diff --git a/tests/backups/backup-service.test.ts b/tests/backups/backup-service.test.ts new file mode 100644 index 0000000..5300fc9 --- /dev/null +++ b/tests/backups/backup-service.test.ts @@ -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 = {}, 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 ... ` → 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 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((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]); + }); +}); diff --git a/tests/backups/browse-core.test.ts b/tests/backups/browse-core.test.ts new file mode 100644 index 0000000..b3b15fd --- /dev/null +++ b/tests/backups/browse-core.test.ts @@ -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 ` 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 /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']); + }); +}); diff --git a/tests/backups/discovery-core.test.ts b/tests/backups/discovery-core.test.ts new file mode 100644 index 0000000..3453a9f --- /dev/null +++ b/tests/backups/discovery-core.test.ts @@ -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 => + 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 => { + 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 => + id === 'c1' ? [vol('keep')] : [vol('drop')]; + const r = await discoverVolumes(containers, null, ['keep'], inspect); + expect(r.volumes.map((v) => v.name)).toEqual(['keep']); + }); +}); diff --git a/tests/backups/format-restic-lines.test.ts b/tests/backups/format-restic-lines.test.ts new file mode 100644 index 0000000..a7326c3 --- /dev/null +++ b/tests/backups/format-restic-lines.test.ts @@ -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([]); + }); +}); diff --git a/tests/backups/helpers.test.ts b/tests/backups/helpers.test.ts new file mode 100644 index 0000000..6e233e1 --- /dev/null +++ b/tests/backups/helpers.test.ts @@ -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); + }); +}); diff --git a/tests/backups/identity-core.test.ts b/tests/backups/identity-core.test.ts new file mode 100644 index 0000000..6094036 --- /dev/null +++ b/tests/backups/identity-core.test.ts @@ -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; setCount: number } { + const raw = new Map(); + 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 | 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); + }); +}); diff --git a/tests/backups/locks.test.ts b/tests/backups/locks.test.ts new file mode 100644 index 0000000..447e845 --- /dev/null +++ b/tests/backups/locks.test.ts @@ -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/ 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'); + }); +}); diff --git a/tests/backups/models.test.ts b/tests/backups/models.test.ts new file mode 100644 index 0000000..f2c90cf --- /dev/null +++ b/tests/backups/models.test.ts @@ -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); + }); +}); diff --git a/tests/backups/operations-core.test.ts b/tests/backups/operations-core.test.ts new file mode 100644 index 0000000..d37fe6b --- /dev/null +++ b/tests/backups/operations-core.test.ts @@ -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); + } + }); +}); diff --git a/tests/backups/parse-restic-diff.test.ts b/tests/backups/parse-restic-diff.test.ts new file mode 100644 index 0000000..2efab26 --- /dev/null +++ b/tests/backups/parse-restic-diff.test.ts @@ -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 (" ")', () => { + 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: [] }); + }); +}); diff --git a/tests/backups/probe-classify.test.ts b/tests/backups/probe-classify.test.ts new file mode 100644 index 0000000..dfca1da --- /dev/null +++ b/tests/backups/probe-classify.test.ts @@ -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'); + }); +}); diff --git a/tests/backups/put-archive-transport.test.ts b/tests/backups/put-archive-transport.test.ts new file mode 100644 index 0000000..ff0f6d7 --- /dev/null +++ b/tests/backups/put-archive-transport.test.ts @@ -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('/ac"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((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 + }); +}); diff --git a/tests/backups/reap-core.test.ts b/tests/backups/reap-core.test.ts new file mode 100644 index 0000000..dda7ca1 --- /dev/null +++ b/tests/backups/reap-core.test.ts @@ -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); + }); +}); diff --git a/tests/backups/redeploy-stack-core.test.ts b/tests/backups/redeploy-stack-core.test.ts new file mode 100644 index 0000000..6e3fa29 --- /dev/null +++ b/tests/backups/redeploy-stack-core.test.ts @@ -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 = {}) { + 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'); + }); +}); diff --git a/tests/backups/repo.test.ts b/tests/backups/repo.test.ts new file mode 100644 index 0000000..51a9756 --- /dev/null +++ b/tests/backups/repo.test.ts @@ -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) { + 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); + }); +}); diff --git a/tests/backups/request-timeout.test.ts b/tests/backups/request-timeout.test.ts new file mode 100644 index 0000000..7f575c8 --- /dev/null +++ b/tests/backups/request-timeout.test.ts @@ -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(); + }); +}); diff --git a/tests/backups/restic-script.test.ts b/tests/backups/restic-script.test.ts new file mode 100644 index 0000000..ee4c5bf --- /dev/null +++ b/tests/backups/restic-script.test.ts @@ -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('( ')).toBe(false)); + test('relative path without leading slash', () => + expect(isSafeRedirect('dashboard')).toBe(false)); + test('empty string', () => expect(isSafeRedirect('')).toBe(false)); + test('whitespace-only', () => expect(isSafeRedirect(' ')).toBe(false)); +}); + +describe('isSafeRedirect — rejects non-string inputs', () => { + test('null', () => expect(isSafeRedirect(null)).toBe(false)); + test('undefined', () => expect(isSafeRedirect(undefined)).toBe(false)); + test('number', () => expect(isSafeRedirect(42)).toBe(false)); + test('object', () => expect(isSafeRedirect({ url: '/x' })).toBe(false)); + test('array', () => expect(isSafeRedirect(['/x'])).toBe(false)); + test('boolean', () => expect(isSafeRedirect(true)).toBe(false)); +}); + +describe('safeRedirectOrRoot — falls back to / on unsafe input', () => { + test('safe → returned as-is', () => + expect(safeRedirectOrRoot('/dashboard')).toBe('/dashboard')); + test('absolute URL → /', () => + expect(safeRedirectOrRoot('https://evil.com')).toBe('/')); + test('protocol-relative → /', () => + expect(safeRedirectOrRoot('//evil.com')).toBe('/')); + test('null → /', () => expect(safeRedirectOrRoot(null)).toBe('/')); + test('undefined → /', () => expect(safeRedirectOrRoot(undefined)).toBe('/')); + test('empty string → /', () => expect(safeRedirectOrRoot('')).toBe('/')); + test('non-string → /', () => + expect(safeRedirectOrRoot({ x: 1 })).toBe('/')); +}); diff --git a/tests/sarif-export.test.ts b/tests/sarif-export.test.ts new file mode 100644 index 0000000..275d308 --- /dev/null +++ b/tests/sarif-export.test.ts @@ -0,0 +1,100 @@ +import { describe, test, expect } from 'bun:test'; +import { findingsToSarif } from '../src/lib/utils/sarif'; +import type { Finding } from '../src/lib/utils/vulnerability'; + +function finding(over: Partial = {}): Finding { + return { + key: 'k', + cve: 'CVE-2024-0001', + package: 'openssl', + severity: 'high', + installedVersion: '1.1.1', + fixedVersion: '1.1.1w', + imageId: 'sha256:abc', + imageName: 'nginx:latest', + description: 'A flaw in openssl', + link: 'https://nvd.nist.gov/vuln/detail/CVE-2024-0001', + scannedAt: '2026-07-01T00:00:00.000Z', + ...over + }; +} + +describe('findingsToSarif', () => { + test('emits a valid SARIF 2.1.0 skeleton', () => { + const log = findingsToSarif([finding()]); + expect(log.version).toBe('2.1.0'); + expect(log.$schema).toContain('sarif-2.1.0'); + expect(log.runs).toHaveLength(1); + expect(log.runs[0].tool.driver.name).toBe('Dockhand'); + }); + + test('maps severity to SARIF level', () => { + const log = findingsToSarif([ + finding({ cve: 'C1', severity: 'critical' }), + finding({ cve: 'C2', severity: 'high' }), + finding({ cve: 'C3', severity: 'medium' }), + finding({ cve: 'C4', severity: 'low' }), + finding({ cve: 'C5', severity: 'unknown' }) + ]); + const levels = log.runs[0].results!.map((r) => r.level); + expect(levels).toEqual(['error', 'error', 'warning', 'note', 'none']); + }); + + test('creates one rule per distinct CVE (deduped)', () => { + const log = findingsToSarif([ + finding({ cve: 'CVE-1', package: 'a' }), + finding({ cve: 'CVE-1', package: 'b' }), // same CVE, different package + finding({ cve: 'CVE-2', package: 'c' }) + ]); + const rules = log.runs[0].tool.driver.rules!; + expect(rules.map((r) => r.id).sort()).toEqual(['CVE-1', 'CVE-2']); + // but every finding is still its own result + expect(log.runs[0].results).toHaveLength(3); + }); + + test('rule carries helpUri and security-severity property', () => { + const log = findingsToSarif([finding({ cve: 'CVE-X', severity: 'critical', link: 'https://example.test/x' })]); + const rule = log.runs[0].tool.driver.rules![0]; + expect(rule.helpUri).toBe('https://example.test/x'); + expect((rule.properties as any)['security-severity']).toBe('9.5'); + }); + + test('falls back to NVD helpUri when finding has no link', () => { + const log = findingsToSarif([finding({ cve: 'CVE-NOLINK', link: undefined })]); + expect(log.runs[0].tool.driver.rules![0].helpUri).toBe('https://nvd.nist.gov/vuln/detail/CVE-NOLINK'); + }); + + test('result has a stable fingerprint and image/package metadata', () => { + const log = findingsToSarif([finding({ imageId: 'sha256:xyz', cve: 'CVE-9', package: 'zlib', installedVersion: '1.2' })]); + const res = log.runs[0].results![0]; + expect(res.partialFingerprints!.dockhandFinding).toBe('sha256:xyz|CVE-9|zlib|1.2'); + expect((res.properties as any).imageName).toBe('nginx:latest'); + // URI is repo-relative and encodes the colon so `nginx:` isn't a scheme. + expect(res.locations![0].physicalLocation!.artifactLocation!.uri).toBe('images/nginx%3Alatest/zlib'); + }); + + test('artifactLocation.uri is a RELATIVE reference (image ref colon not a scheme)', () => { + // GitHub code scanning drops results whose uri is an absolute URI. An image + // ref like `nginx:latest` or `reg:5000/app@sha256:...` must stay relative. + for (const imageName of ['nginx:latest', 'myreg:5000/app', 'app@sha256:deadbeef']) { + const log = findingsToSarif([finding({ imageName, package: 'openssl' })]); + const uri = log.runs[0].results![0].locations![0].physicalLocation!.artifactLocation!.uri!; + // A relative reference resolved against a base keeps the base's scheme. + const resolved = new URL(uri, 'file:///src/'); + expect(resolved.protocol).toBe('file:'); + expect(resolved.pathname.startsWith('/src/images/')).toBe(true); + } + }); + + test('handles empty findings', () => { + const log = findingsToSarif([]); + expect(log.runs[0].results).toHaveLength(0); + expect(log.runs[0].tool.driver.rules).toHaveLength(0); + }); + + test('respects custom tool name/version', () => { + const log = findingsToSarif([finding()], { toolName: 'CI', toolVersion: '2.0.0' }); + expect(log.runs[0].tool.driver.name).toBe('CI'); + expect(log.runs[0].tool.driver.version).toBe('2.0.0'); + }); +}); diff --git a/tests/sarif-schema.test.ts b/tests/sarif-schema.test.ts new file mode 100644 index 0000000..2191f19 --- /dev/null +++ b/tests/sarif-schema.test.ts @@ -0,0 +1,106 @@ +import { describe, test, expect } from 'bun:test'; +import { findingsToSarif } from '../src/lib/utils/sarif'; +import type { Finding } from '../src/lib/utils/vulnerability'; + +// Structural validation of the generated SARIF 2.1.0 so the generator can't drift +// out of spec. This used to compile the official OASIS schema with ajv-draft-04, +// but that pulls in ajv as an OPTIONAL peer dependency which bun resolves +// unreliably in CI (files on disk, yet require('ajv/dist/core') throws) — a +// recurring flake that failed the whole unit-test step. We assert the same +// load-bearing SARIF invariants directly, with zero external dependencies. + +const SARIF_LEVELS = new Set(['none', 'note', 'warning', 'error']); + +function finding(over: Partial = {}): Finding { + return { + key: 'k', cve: 'CVE-2024-0001', package: 'openssl', severity: 'high', + installedVersion: '1.1.1', fixedVersion: '1.1.1w', + imageId: 'sha256:abc', imageName: 'nginx:latest', + description: 'A flaw', link: 'https://nvd.nist.gov/vuln/detail/CVE-2024-0001', + scannedAt: '2026-07-01T00:00:00.000Z', containers: [{ id: 'c', name: 'web-1' }], stacks: ['frontend'], + ...over + }; +} + +/** + * Assert the document is a spec-shaped SARIF 2.1.0 log. Checks the required + * structure the OASIS schema enforces: the version/`runs` envelope, each run's + * tool.driver + rules, each result's ruleId/level/message, the `level` enum, and + * referential integrity (every result.ruleId is declared in driver.rules). + */ +function expectValid(doc: any) { + // Envelope + expect(doc).toBeTypeOf('object'); + expect(doc.version).toBe('2.1.0'); + expect(typeof doc.$schema).toBe('string'); + expect(Array.isArray(doc.runs)).toBe(true); + + for (const run of doc.runs) { + // tool.driver is required, with a name and a rules array + expect(run.tool).toBeTypeOf('object'); + const driver = run.tool.driver; + expect(driver).toBeTypeOf('object'); + expect(typeof driver.name).toBe('string'); + expect(driver.name.length).toBeGreaterThan(0); + expect(Array.isArray(driver.rules)).toBe(true); + + const ruleIds = new Set(); + for (const rule of driver.rules) { + expect(typeof rule.id).toBe('string'); + expect(rule.id.length).toBeGreaterThan(0); + expect(rule.shortDescription?.text).toBeTypeOf('string'); + // defaultConfiguration.level, when present, must be a valid SARIF level + if (rule.defaultConfiguration?.level !== undefined) { + expect(SARIF_LEVELS.has(rule.defaultConfiguration.level)).toBe(true); + } + expect(ruleIds.has(rule.id)).toBe(false); // no duplicate rule ids + ruleIds.add(rule.id); + } + + // results + expect(Array.isArray(run.results)).toBe(true); + for (const res of run.results) { + expect(typeof res.ruleId).toBe('string'); + expect(SARIF_LEVELS.has(res.level)).toBe(true); + expect(res.message?.text).toBeTypeOf('string'); + // referential integrity: a result must reference a declared rule + expect(ruleIds.has(res.ruleId)).toBe(true); + } + } +} + +describe('findingsToSarif schema validity', () => { + test('empty findings produce schema-valid SARIF', () => { + const doc = findingsToSarif([]); + expectValid(doc); + expect(doc.runs[0].results).toEqual([]); + }); + + test('single finding is schema-valid', () => { + expectValid(findingsToSarif([finding()])); + }); + + test('all severities produce schema-valid SARIF', () => { + expectValid(findingsToSarif([ + finding({ cve: 'C1', severity: 'critical' }), + finding({ cve: 'C2', severity: 'high' }), + finding({ cve: 'C3', severity: 'medium' }), + finding({ cve: 'C4', severity: 'low' }), + finding({ cve: 'C5', severity: 'negligible' }), + finding({ cve: 'C6', severity: 'unknown' }) + ])); + }); + + test('findings with no fix / no link / no containers are schema-valid', () => { + expectValid(findingsToSarif([ + finding({ fixedVersion: '', link: undefined, containers: undefined, stacks: undefined, description: undefined }) + ])); + }); + + test('duplicate CVEs across packages stay schema-valid', () => { + expectValid(findingsToSarif([ + finding({ cve: 'CVE-DUP', package: 'a' }), + finding({ cve: 'CVE-DUP', package: 'b' }) + ])); + }); +}); diff --git a/tests/scanner-dns-network.test.ts b/tests/scanner-dns-network.test.ts new file mode 100644 index 0000000..e5571d5 --- /dev/null +++ b/tests/scanner-dns-network.test.ts @@ -0,0 +1,143 @@ +/** + * Scanner Advanced settings — network mode + DNS resolver (#1219). + * + * Verifies the pure helper that decides which networkMode and DNS values to + * pass to the scanner container, given (auto-detected, user-override) inputs. + * + * Run with: bun test tests/unit/scanner-dns-network.test.ts + */ + +import { describe, test, expect } from 'bun:test'; +import { resolveScannerOverrides } from '../src/lib/utils/scanner-overrides'; + +describe('resolveScannerOverrides', () => { + test('backward-compat baseline: undefined inputs → empty result', () => { + // Pre-feature behavior: nothing set on HostConfig. + expect(resolveScannerOverrides({}, {})).toEqual({ + networkMode: undefined, + dns: undefined, + extraHosts: undefined + }); + }); + + test('backward-compat with auto-detect: returns auto-detected network', () => { + // User has not set the new fields. Auto-detection produced 'bridge'. + // Result must match today's behavior (bridge passed through). + const result = resolveScannerOverrides( + { networkMode: 'bridge', extraHosts: ['internal-registry:10.0.0.5'] }, + {} + ); + expect(result.networkMode).toBe('bridge'); + expect(result.extraHosts).toEqual(['internal-registry:10.0.0.5']); + expect(result.dns).toBeUndefined(); + }); + + test('user network=host overrides auto-detected network', () => { + // The #1219 case: user explicitly sets host, beats auto-detected. + const result = resolveScannerOverrides( + { networkMode: 'bridge' }, + { networkMode: 'host' } + ); + expect(result.networkMode).toBe('host'); + }); + + test('user network="" (UI default) falls back to auto-detected', () => { + // Select default value is empty string; must not override. + const result = resolveScannerOverrides( + { networkMode: 'bridge' }, + { networkMode: '' } + ); + expect(result.networkMode).toBe('bridge'); + }); + + test('user network whitespace-only falls back to auto-detected', () => { + const result = resolveScannerOverrides( + { networkMode: 'bridge' }, + { networkMode: ' ' } + ); + expect(result.networkMode).toBe('bridge'); + }); + + test('user DNS set is passed through', () => { + const result = resolveScannerOverrides( + {}, + { dns: ['1.1.1.1', '8.8.8.8'] } + ); + expect(result.dns).toEqual(['1.1.1.1', '8.8.8.8']); + }); + + test('user DNS empty array → undefined (not empty array)', () => { + // Empty array would cause `if (options.dns)` to skip the field too, but we + // want a clean `undefined` for type symmetry and to avoid edge cases. + const result = resolveScannerOverrides({}, { dns: [] }); + expect(result.dns).toBeUndefined(); + }); + + test('user DNS undefined → undefined', () => { + const result = resolveScannerOverrides({}, {}); + expect(result.dns).toBeUndefined(); + }); + + test('user DNS trims whitespace and drops empty entries', () => { + const result = resolveScannerOverrides( + {}, + { dns: [' 1.1.1.1 ', '', ' ', '8.8.8.8'] } + ); + expect(result.dns).toEqual(['1.1.1.1', '8.8.8.8']); + }); + + test('user DNS all-empty after trimming → undefined', () => { + const result = resolveScannerOverrides( + {}, + { dns: ['', ' ', ''] } + ); + expect(result.dns).toBeUndefined(); + }); + + test('user network + dns: both applied', () => { + const result = resolveScannerOverrides( + {}, + { networkMode: 'host', dns: ['1.1.1.1'] } + ); + expect(result).toEqual({ + networkMode: 'host', + dns: ['1.1.1.1'], + extraHosts: undefined + }); + }); + + test('extraHosts preserved from auto-detect when present', () => { + const result = resolveScannerOverrides( + { networkMode: 'bridge', extraHosts: ['foo:1.2.3.4'] }, + { networkMode: 'host' } + ); + expect(result.extraHosts).toEqual(['foo:1.2.3.4']); + expect(result.networkMode).toBe('host'); + }); + + test('extraHosts empty array → undefined (no field set on HostConfig)', () => { + const result = resolveScannerOverrides( + { networkMode: 'bridge', extraHosts: [] }, + {} + ); + expect(result.extraHosts).toBeUndefined(); + }); + + test('garbage DNS strings kept (Docker daemon validates)', () => { + // We don't validate IP format strictly — the Docker daemon rejects bad + // input with a clear error. This test pins that behavior so we don't + // accidentally add over-strict validation later. + const result = resolveScannerOverrides( + {}, + { dns: ['not-an-ip', '1.2.3.4'] } + ); + expect(result.dns).toEqual(['not-an-ip', '1.2.3.4']); + }); + + test('custom network name as user override (not just host/bridge/none)', () => { + // Free-form is allowed — users with custom Docker networks should be able + // to put the scanner on a named bridge. + const result = resolveScannerOverrides({}, { networkMode: 'my-internal-net' }); + expect(result.networkMode).toBe('my-internal-net'); + }); +}); diff --git a/tests/scanner-json.test.ts b/tests/scanner-json.test.ts new file mode 100644 index 0000000..b016a32 --- /dev/null +++ b/tests/scanner-json.test.ts @@ -0,0 +1,146 @@ +import { describe, test, expect } from 'bun:test'; + +// extractJson and sanitizeJsonString are pure functions defined in scanner.ts, +// but that module has heavy side-effect imports (docker, db). We replicate the +// function bodies here to keep unit tests dependency-free. + +function extractJson(output: string): string { + const firstBrace = output.indexOf('{'); + const lastBrace = output.lastIndexOf('}'); + if (firstBrace === -1 || lastBrace === -1 || lastBrace <= firstBrace) { + throw new Error('No JSON object found in scanner output'); + } + return output.slice(firstBrace, lastBrace + 1); +} + +function sanitizeJsonString(json: string): string { + let result = ''; + let inString = false; + let escaped = false; + let sanitized = 0; + + for (let i = 0; i < json.length; i++) { + const ch = json.charCodeAt(i); + + if (escaped) { + const ch2 = json[i]; + if ('"\\\/bfnrtu'.includes(ch2)) { + result += ch2; + } else if (ch < 0x20) { + result += '\\'; + if (ch === 0x0A) result += '\\n'; + else if (ch === 0x0D) result += '\\r'; + else if (ch === 0x09) result += '\\t'; + else result += `\\u${ch.toString(16).padStart(4, '0')}`; + sanitized++; + } else { + result += '\\' + ch2; + sanitized++; + } + escaped = false; + continue; + } + + if (inString) { + if (ch === 0x5C) { + result += json[i]; + escaped = true; + } else if (ch === 0x22) { + result += json[i]; + inString = false; + } else if (ch < 0x20) { + if (ch === 0x0A) result += '\\n'; + else if (ch === 0x0D) result += '\\r'; + else if (ch === 0x09) result += '\\t'; + else result += `\\u${ch.toString(16).padStart(4, '0')}`; + sanitized++; + } else { + result += json[i]; + } + } else { + if (ch === 0x22) { + inString = true; + } + result += json[i]; + } + } + + return result; +} + +describe('extractJson', () => { + test('extracts JSON from clean output', () => { + expect(extractJson('{"key": "value"}')).toBe('{"key": "value"}'); + }); + + test('extracts JSON with leading noise', () => { + const output = 'Scanning image...\nPulling layers...\n{"results": []}'; + expect(extractJson(output)).toBe('{"results": []}'); + }); + + test('extracts JSON with trailing noise', () => { + const output = '{"results": []}\nDone in 5.2s'; + expect(extractJson(output)).toBe('{"results": []}'); + }); + + test('extracts JSON surrounded by noise', () => { + const output = 'INFO: starting\n{"data": true}\nINFO: done'; + expect(extractJson(output)).toBe('{"data": true}'); + }); + + test('throws for output with no JSON', () => { + expect(() => extractJson('no json here')).toThrow('No JSON object found'); + }); + + test('throws for empty input', () => { + expect(() => extractJson('')).toThrow('No JSON object found'); + }); + + test('handles nested braces', () => { + const json = '{"outer": {"inner": {"deep": 1}}}'; + expect(extractJson('prefix ' + json + ' suffix')).toBe(json); + }); +}); + +describe('sanitizeJsonString', () => { + test('passes clean JSON through unchanged', () => { + const json = '{"key": "value", "num": 42}'; + expect(sanitizeJsonString(json)).toBe(json); + }); + + test('escapes raw newlines inside strings', () => { + const dirty = '{"desc": "line1\nline2"}'; + const result = sanitizeJsonString(dirty); + expect(() => JSON.parse(result)).not.toThrow(); + const parsed = JSON.parse(result); + expect(parsed.desc).toBe('line1\nline2'); + }); + + test('escapes raw tabs inside strings', () => { + const dirty = '{"desc": "col1\tcol2"}'; + const result = sanitizeJsonString(dirty); + expect(() => JSON.parse(result)).not.toThrow(); + }); + + test('handles already-escaped sequences', () => { + const clean = '{"desc": "line1\\nline2"}'; + const result = sanitizeJsonString(clean); + expect(result).toBe(clean); + }); + + test('escapes null bytes inside strings', () => { + const dirty = '{"desc": "has\x00null"}'; + const result = sanitizeJsonString(dirty); + expect(() => JSON.parse(result)).not.toThrow(); + }); + + test('does not modify control chars outside strings', () => { + const json = '{\n "key": "value"\n}'; + expect(sanitizeJsonString(json)).toBe(json); + }); + + test('handles empty strings', () => { + const json = '{"key": ""}'; + expect(sanitizeJsonString(json)).toBe(json); + }); +}); diff --git a/tests/secret-masking.test.ts b/tests/secret-masking.test.ts new file mode 100644 index 0000000..85d89d3 --- /dev/null +++ b/tests/secret-masking.test.ts @@ -0,0 +1,339 @@ +/** + * Secret Masking Logic Tests + * + * Unit tests for the env var masking logic used in container inspect endpoints. + * Tests key-based matching and compose interpolation parsing. + * + * Run with: bun test tests/unit/secret-masking.test.ts + */ + +import { describe, test, expect } from 'bun:test'; +import { parseEnvInterpolation } from '../src/lib/server/env-interpolation'; + +/** + * Mirrors the masking logic from containers/[id]/inspect/+server.ts + */ +function maskEnvVars(envEntries: string[], secretKeys: Set): string[] { + return envEntries.map((entry) => { + const eqIdx = entry.indexOf('='); + if (eqIdx === -1) return entry; + const key = entry.substring(0, eqIdx); + if (secretKeys.has(key)) { + return `${key}=***`; + } + return entry; + }); +} + +/** + * Simulates getSecretKeysToMask: combines DB secret keys with provider-injected + * key names and interpolation mappings. Mirrors the real merge order in + * db.ts::getSecretKeysToMask (DB secrets + injected, THEN interpolation). + */ +function buildMaskSet( + dbSecretKeys: string[], + composeContent?: string, + injectedProviderKeys: string[] = [] +): Set { + const keys = new Set(dbSecretKeys); + for (const k of injectedProviderKeys) keys.add(k); + if (composeContent) { + const interpolated = parseEnvInterpolation(composeContent); + for (const [containerKey, varName] of interpolated) { + if (keys.has(varName)) { + keys.add(containerKey); + } + } + } + return keys; +} + +// ─── parseEnvInterpolation ───────────────────────────────────────── + +describe('parseEnvInterpolation', () => { + test('extracts simple interpolation', () => { + const compose = `services: + app: + environment: + - MYSQL_PASSWORD=\${db_secret} +`; + const result = parseEnvInterpolation(compose); + expect(result).toEqual([['MYSQL_PASSWORD', 'db_secret']]); + }); + + test('extracts interpolation with default value', () => { + const compose = `services: + app: + environment: + - DB_HOST=\${database_host:-localhost} +`; + const result = parseEnvInterpolation(compose); + expect(result).toEqual([['DB_HOST', 'database_host']]); + }); + + test('extracts interpolation with error message', () => { + const compose = `services: + app: + environment: + - API_KEY=\${secret_key?API key is required} +`; + const result = parseEnvInterpolation(compose); + expect(result).toEqual([['API_KEY', 'secret_key']]); + }); + + test('extracts interpolation with alternate value', () => { + const compose = `services: + app: + environment: + - USE_SSL=\${ssl_enabled:+true} +`; + const result = parseEnvInterpolation(compose); + expect(result).toEqual([['USE_SSL', 'ssl_enabled']]); + }); + + test('extracts interpolation with short fallback (no colon)', () => { + const compose = `services: + app: + environment: + - DB_HOST=\${database_host-localhost} +`; + const result = parseEnvInterpolation(compose); + expect(result).toEqual([['DB_HOST', 'database_host']]); + }); + + test('extracts interpolation with required error (colon question)', () => { + const compose = `services: + app: + environment: + - API_KEY=\${secret_key:?API key must be set} +`; + const result = parseEnvInterpolation(compose); + expect(result).toEqual([['API_KEY', 'secret_key']]); + }); + + test('skips same-name interpolation', () => { + const compose = `services: + app: + environment: + - DB_PASS=\${DB_PASS} +`; + const result = parseEnvInterpolation(compose); + expect(result).toEqual([]); + }); + + test('extracts multiple interpolations', () => { + const compose = `services: + app: + environment: + - MYSQL_USER=\${db_user} + - MYSQL_PASSWORD=\${db_pass} + - APP_NAME=myapp +`; + const result = parseEnvInterpolation(compose); + expect(result).toEqual([ + ['MYSQL_USER', 'db_user'], + ['MYSQL_PASSWORD', 'db_pass'] + ]); + }); + + test('ignores non-environment lines', () => { + const compose = `services: + app: + image: \${IMAGE_NAME} + command: echo \${GREETING} + environment: + - REAL_VAR=\${secret_ref} +`; + const result = parseEnvInterpolation(compose); + expect(result).toEqual([['REAL_VAR', 'secret_ref']]); + }); + + test('handles multiple services', () => { + const compose = `services: + web: + environment: + - WEB_SECRET=\${shared_secret} + worker: + environment: + - WORKER_TOKEN=\${api_token} +`; + const result = parseEnvInterpolation(compose); + expect(result).toEqual([ + ['WEB_SECRET', 'shared_secret'], + ['WORKER_TOKEN', 'api_token'] + ]); + }); + + test('handles empty compose', () => { + expect(parseEnvInterpolation('')).toEqual([]); + }); + + test('handles compose without environment section', () => { + const compose = `services: + app: + image: nginx:alpine + ports: + - "80:80" +`; + expect(parseEnvInterpolation(compose)).toEqual([]); + }); + + test('extracts multiple vars from one line', () => { + const compose = `services: + app: + environment: + - DB_URL=postgres://\${db_user}:\${db_pass}@host/db +`; + const result = parseEnvInterpolation(compose); + expect(result).toEqual([ + ['DB_URL', 'db_user'], + ['DB_URL', 'db_pass'] + ]); + }); +}); + +// ─── buildMaskSet (simulates getSecretKeysToMask) ────────────────── + +describe('buildMaskSet: key + interpolation', () => { + test('direct key match only (no compose)', () => { + const keys = buildMaskSet(['DB_PASS', 'API_KEY']); + expect(keys.has('DB_PASS')).toBe(true); + expect(keys.has('API_KEY')).toBe(true); + expect(keys.has('OTHER')).toBe(false); + }); + + test('adds interpolated container keys from compose', () => { + const compose = `services: + app: + environment: + - MYSQL_PASSWORD=\${db_secret} + - APP_TOKEN=\${api_key} +`; + const keys = buildMaskSet(['db_secret', 'api_key'], compose); + // Direct keys + expect(keys.has('db_secret')).toBe(true); + expect(keys.has('api_key')).toBe(true); + // Interpolated container keys + expect(keys.has('MYSQL_PASSWORD')).toBe(true); + expect(keys.has('APP_TOKEN')).toBe(true); + }); + + test('does not add interpolated keys for non-secret vars', () => { + const compose = `services: + app: + environment: + - HOST_NAME=\${hostname} + - DB_PASS=\${secret_pw} +`; + // Only secret_pw is a secret, not hostname + const keys = buildMaskSet(['secret_pw'], compose); + expect(keys.has('DB_PASS')).toBe(true); + expect(keys.has('HOST_NAME')).toBe(false); + }); + + test('handles compose without interpolation', () => { + const compose = `services: + app: + environment: + - APP_NAME=myapp + - PORT=3000 +`; + const keys = buildMaskSet(['DB_PASS'], compose); + expect(keys.has('DB_PASS')).toBe(true); + expect(keys.size).toBe(1); + }); +}); + +// ─── Provider-injected keys (H1) ─────────────────────────────────── +// Bulk-pulled keys (Vault/Doppler) and promoted op:// refs live only in the +// provider, never in stack_env_vars, so getSecretKeysToMask merges the names +// persisted at deploy. Without that merge they leak plaintext in inspect. + +describe('buildMaskSet: provider-injected keys', () => { + test('masks bulk-pulled keys that are NOT in the DB', () => { + // No DB secret rows at all - keys came entirely from a Vault bulk pull. + const keys = buildMaskSet([], undefined, ['DB_PASSWORD', 'REDIS_URL']); + expect(keys.has('DB_PASSWORD')).toBe(true); + expect(keys.has('REDIS_URL')).toBe(true); + }); + + test('masks a promoted op:// ref key alongside DB secrets', () => { + const keys = buildMaskSet(['DB_PASS'], undefined, ['API_KEY']); + expect(keys.has('DB_PASS')).toBe(true); // DB secret + expect(keys.has('API_KEY')).toBe(true); // promoted from .env op:// ref + }); + + test('injected keys still drive compose interpolation masking', () => { + // A bulk key `db_secret` is referenced by a container var via ${db_secret}. + const compose = `services: + app: + environment: + - MYSQL_PASSWORD=\${db_secret} +`; + const keys = buildMaskSet([], compose, ['db_secret']); + expect(keys.has('db_secret')).toBe(true); + expect(keys.has('MYSQL_PASSWORD')).toBe(true); + }); + + test('the leak case: WITHOUT injected keys a Vault secret is exposed', () => { + // Same container env, but the injected-keys merge is skipped (old behavior). + const leaky = buildMaskSet([], undefined, []); + const masked = maskEnvVars(['DB_PASSWORD=fromVault'], leaky); + expect(masked).toEqual(['DB_PASSWORD=fromVault']); // plaintext leaks + // With the merge, it is masked. + const safe = buildMaskSet([], undefined, ['DB_PASSWORD']); + expect(maskEnvVars(['DB_PASSWORD=fromVault'], safe)).toEqual(['DB_PASSWORD=***']); + }); +}); + +// ─── Full masking pipeline ───────────────────────────────────────── + +describe('maskEnvVars with interpolation-aware keys', () => { + test('masks direct secret keys', () => { + const keys = buildMaskSet(['DB_PASS']); + const result = maskEnvVars( + ['DB_PASS=secret123', 'APP_NAME=myapp'], + keys + ); + expect(result).toEqual(['DB_PASS=***', 'APP_NAME=myapp']); + }); + + test('masks interpolated secret keys', () => { + const compose = `services: + app: + environment: + - MYSQL_PASSWORD=\${db_secret} +`; + const keys = buildMaskSet(['db_secret'], compose); + const result = maskEnvVars( + ['MYSQL_PASSWORD=hunter2', 'APP_NAME=myapp', 'db_secret=hunter2'], + keys + ); + expect(result).toEqual(['MYSQL_PASSWORD=***', 'APP_NAME=myapp', 'db_secret=***']); + }); + + test('does not mask unrelated vars', () => { + const compose = `services: + app: + environment: + - MYSQL_PASSWORD=\${db_secret} +`; + const keys = buildMaskSet(['db_secret'], compose); + const result = maskEnvVars( + ['PATH=/usr/bin', 'NODE_ENV=production', 'PORT=3000'], + keys + ); + expect(result).toEqual(['PATH=/usr/bin', 'NODE_ENV=production', 'PORT=3000']); + }); + + test('no false positives on matching values', () => { + // Even though PORT has value "3000" which could appear elsewhere, + // it should NOT be masked because there's no key or interpolation match + const keys = buildMaskSet(['SECRET_KEY']); + const result = maskEnvVars( + ['PORT=3000', 'SECRET_KEY=3000'], + keys + ); + expect(result).toEqual(['PORT=3000', 'SECRET_KEY=***']); + }); +}); diff --git a/tests/secret-provider-bitwarden.test.ts b/tests/secret-provider-bitwarden.test.ts deleted file mode 100644 index d5a827b..0000000 --- a/tests/secret-provider-bitwarden.test.ts +++ /dev/null @@ -1,285 +0,0 @@ -/** Focused tests for the external-bws Bitwarden Secrets Manager provider. */ - -// @ts-expect-error -- bun:test is a runtime built-in with no types installed -import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; -import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -const { bitwardenProvider } = await import('../src/lib/server/secretproviders/bitwarden.ts'); -const { getProvider } = await import('../src/lib/server/secretproviders/index.ts'); -const { UnsupportedOperationError } = await import('../src/lib/server/secretproviders/shared.ts'); - -const PROJECT_ID = '123e4567-e89b-42d3-a456-426614174000'; -const TOKEN = 'test-machine-account-token'; -const originalBwsPath = process.env.DOCKHAND_BWS_PATH; -let testDir = ''; -let fakeCounter = 0; - -beforeEach(async () => { - testDir = await mkdtemp(join(tmpdir(), 'dockhand-bitwarden-test-')); - delete process.env.DOCKHAND_BWS_PATH; - process.env.UNRELATED_DOCKHAND_SECRET = 'must-not-reach-child'; -}); - -afterEach(async () => { - if (originalBwsPath === undefined) delete process.env.DOCKHAND_BWS_PATH; - else process.env.DOCKHAND_BWS_PATH = originalBwsPath; - delete process.env.UNRELATED_DOCKHAND_SECRET; - await rm(testDir, { recursive: true, force: true }); -}); - -async function installFakeBws(source: string): Promise { - const path = join(testDir, `bws-${fakeCounter++}`); - await writeFile(path, `#!${process.execPath}\n${source}\n`, { mode: 0o700 }); - await chmod(path, 0o700); - process.env.DOCKHAND_BWS_PATH = path; - return path; -} - -async function installSecretResponse(payloadSource: string): Promise { - await installFakeBws(` -const args = process.argv.slice(2); -if (JSON.stringify(args) !== JSON.stringify(['secret', 'list', '${PROJECT_ID}', '--output', 'json', '--color', 'no'])) process.exit(41); -if (process.env.BWS_ACCESS_TOKEN !== '${TOKEN}') process.exit(42); -if (args.includes('${TOKEN}') || process.env.UNRELATED_DOCKHAND_SECRET) process.exit(43); -process.stdout.write(${payloadSource}); -`); -} - -describe('bitwardenProvider capabilities', () => { - test('is registered as a bulk-only provider', () => { - expect(getProvider('bitwarden')).toBe(bitwardenProvider); - expect(bitwardenProvider.type).toBe('bitwarden'); - expect(bitwardenProvider.label).toBe('Bitwarden Secrets Manager'); - expect(bitwardenProvider.supportsBulk).toBe(true); - expect(bitwardenProvider.supportsReferences).toBe(false); - expect(bitwardenProvider.isReference('bw://anything')).toBe(false); - }); - - test('rejects inline reference resolution', async () => { - await expect(bitwardenProvider.resolveSecretReferences({ token: TOKEN }, [])).rejects.toThrow( - UnsupportedOperationError - ); - }); -}); - -describe('testConnection', () => { - test('checks the compatible version, then performs an authenticated project query', async () => { - const logPath = join(testDir, 'calls.jsonl'); - await installFakeBws(` -const fs = require('node:fs'); -const args = process.argv.slice(2); -fs.appendFileSync(${JSON.stringify(logPath)}, JSON.stringify({ - args, - hasToken: Boolean(process.env.BWS_ACCESS_TOKEN), - home: process.env.HOME, - sameHome: process.env.HOME === process.env.XDG_CONFIG_HOME, - leaked: Boolean(process.env.UNRELATED_DOCKHAND_SECRET) -}) + '\\n'); -if (args.length === 1 && args[0] === '--version') { - if (process.env.BWS_ACCESS_TOKEN) process.exit(51); - console.log('bws 2.1.0'); -} else if (JSON.stringify(args) === JSON.stringify(['project', 'list', '--output', 'json', '--color', 'no'])) { - if (process.env.BWS_ACCESS_TOKEN !== '${TOKEN}' || args.includes('${TOKEN}')) process.exit(52); - console.log('[]'); -} else { - process.exit(53); -} -`); - - expect(await bitwardenProvider.testConnection({ token: TOKEN })).toEqual({ ok: true }); - const calls = (await readFile(logPath, 'utf8')) - .trim() - .split('\n') - .map((line) => JSON.parse(line)); - expect(calls.map((call) => call.args)).toEqual([ - ['--version'], - ['project', 'list', '--output', 'json', '--color', 'no'] - ]); - expect(calls[0].hasToken).toBe(false); - expect(calls[1].hasToken).toBe(true); - expect(calls.every((call) => call.sameHome && !call.leaked)).toBe(true); - expect(calls.every((call) => !existsSync(call.home))).toBe(true); - }); - - test('configures a safe self-hosted server in the isolated profile before authentication', async () => { - const logPath = join(testDir, 'self-hosted-calls.jsonl'); - await installFakeBws(` -const fs = require('node:fs'); -const args = process.argv.slice(2); -fs.appendFileSync(${JSON.stringify(logPath)}, JSON.stringify({ - args, - hasToken: Boolean(process.env.BWS_ACCESS_TOKEN), - home: process.env.HOME -}) + '\\n'); -if (args.length === 1 && args[0] === '--version') { - console.log('bws 2.1.0'); -} else if (JSON.stringify(args) === JSON.stringify(['config', 'server-base', 'https://bitwarden.example.com'])) { - if (process.env.BWS_ACCESS_TOKEN) process.exit(54); -} else if (JSON.stringify(args) === JSON.stringify(['project', 'list', '--output', 'json', '--color', 'no'])) { - if (process.env.BWS_ACCESS_TOKEN !== '${TOKEN}') process.exit(55); - console.log('[]'); -} else { - process.exit(56); -} -`); - - expect( - await bitwardenProvider.testConnection({ - token: TOKEN, - serverUrl: ' https://bitwarden.example.com/ ' - }) - ).toEqual({ ok: true }); - const calls = (await readFile(logPath, 'utf8')) - .trim() - .split('\n') - .map((line) => JSON.parse(line)); - expect(calls.map((call) => call.args)).toEqual([ - ['--version'], - ['config', 'server-base', 'https://bitwarden.example.com'], - ['project', 'list', '--output', 'json', '--color', 'no'] - ]); - expect(calls.map((call) => call.hasToken)).toEqual([false, false, true]); - expect(calls[1].home).toBe(calls[2].home); - expect(calls.every((call) => !existsSync(call.home))).toBe(true); - }); - - test('rejects an unsafe self-hosted server before executing bws', async () => { - process.env.DOCKHAND_BWS_PATH = join(testDir, 'missing-bws'); - expect( - await bitwardenProvider.testConnection({ token: TOKEN, serverUrl: 'http://127.0.0.1' }) - ).toEqual({ - ok: false, - error: - 'Bitwarden Secrets Manager: host not allowed (loopback address (127.0.0.1) blocked)' - }); - }); - - test('rejects missing, relative-path, and incompatible clients with sanitized errors', async () => { - process.env.DOCKHAND_BWS_PATH = join(testDir, 'missing-bws'); - expect(await bitwardenProvider.testConnection({ token: TOKEN })).toEqual({ - ok: false, - error: 'Bitwarden bws executable was not found' - }); - - process.env.DOCKHAND_BWS_PATH = 'relative/bws'; - expect(await bitwardenProvider.testConnection({ token: TOKEN })).toEqual({ - ok: false, - error: 'Bitwarden bws executable path must be absolute' - }); - - for (const version of ['2.0.9', '2.1.0-beta.1', '3.0.0']) { - await installFakeBws(`console.log('bws ${version}');`); - expect(await bitwardenProvider.testConnection({ token: TOKEN })).toEqual({ - ok: false, - error: 'Bitwarden bws version must be >=2.1.0 and <3.0.0' - }); - } - }); - - test('rejects an empty token before executing bws', async () => { - process.env.DOCKHAND_BWS_PATH = join(testDir, 'missing-bws'); - expect(await bitwardenProvider.testConnection({ token: ' ' })).toEqual({ - ok: false, - error: 'Bitwarden Machine Account access token is empty' - }); - }); -}); - -describe('resolveBulk', () => { - test('uses the exact command and returns a null-prototype string record', async () => { - const statePath = join(testDir, 'state-home'); - await installFakeBws(` -const fs = require('node:fs'); -const args = process.argv.slice(2); -if (JSON.stringify(args) !== JSON.stringify(['secret', 'list', '${PROJECT_ID}', '--output', 'json', '--color', 'no'])) process.exit(61); -if (process.env.BWS_ACCESS_TOKEN !== '${TOKEN}' || args.includes('${TOKEN}')) process.exit(62); -if (process.env.UNRELATED_DOCKHAND_SECRET || process.env.HOME !== process.env.XDG_CONFIG_HOME) process.exit(63); -fs.writeFileSync(${JSON.stringify(statePath)}, process.env.HOME); -console.log(JSON.stringify([{ key: 'DB_PASSWORD', value: 'hunter2' }, { key: '_PORT', value: '5432' }])); -`); - - const result = await bitwardenProvider.resolveBulk({ token: TOKEN }, PROJECT_ID); - expect(Object.getPrototypeOf(result)).toBeNull(); - expect(Object.entries(result)).toEqual([ - ['DB_PASSWORD', 'hunter2'], - ['_PORT', '5432'] - ]); - const stateHome = await readFile(statePath, 'utf8'); - expect(existsSync(stateHome)).toBe(false); - }); - - test('validates the Project UUID before execution', async () => { - process.env.DOCKHAND_BWS_PATH = join(testDir, 'missing-bws'); - await expect(bitwardenProvider.resolveBulk({ token: TOKEN }, 'not-a-uuid')).rejects.toThrow( - 'Bitwarden Project selector must be a valid UUID' - ); - }); - - test('rejects duplicate, invalid, dangerous, and malformed secret fields', async () => { - const cases: Array<[unknown, string]> = [ - [ - [ - { key: 'DUPLICATE', value: 'one' }, - { key: 'DUPLICATE', value: 'two' } - ], - 'duplicate secret keys' - ], - [[{ key: 'BAD-NAME', value: 'value' }], 'not a valid environment variable name'], - [[{ key: 'constructor', value: 'value' }], 'secret key is not allowed'], - [[{ key: 'PORT', value: 5432 }], 'invalid secret list'], - [[{ key: 'VALUE', value: 'contains\0nul' }], 'invalid secret list'], - [['not-an-object'], 'invalid secret list'] - ]; - - for (const [payload, expected] of cases) { - await installSecretResponse(JSON.stringify(JSON.stringify(payload))); - await expect(bitwardenProvider.resolveBulk({ token: TOKEN }, PROJECT_ID)).rejects.toThrow( - expected - ); - } - }); - - test('rejects malformed JSON and a non-array root', async () => { - await installSecretResponse(JSON.stringify('{broken')); - await expect(bitwardenProvider.resolveBulk({ token: TOKEN }, PROJECT_ID)).rejects.toThrow( - 'Bitwarden bws returned invalid JSON' - ); - - await installSecretResponse(JSON.stringify('{"key":"value"}')); - await expect(bitwardenProvider.resolveBulk({ token: TOKEN }, PROJECT_ID)).rejects.toThrow( - 'Bitwarden bws returned an invalid JSON response' - ); - }); - - test('does not expose stderr, tokens, or secret output on process failure', async () => { - await installFakeBws(` -process.stdout.write('secret-output'); -process.stderr.write('raw stderr ${TOKEN} another-secret'); -process.exit(7); -`); - try { - await bitwardenProvider.resolveBulk({ token: TOKEN }, PROJECT_ID); - throw new Error('expected resolveBulk to fail'); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - expect(message).toBe('Bitwarden bws command failed'); - expect(message).not.toContain(TOKEN); - expect(message).not.toContain('secret-output'); - expect(message).not.toContain('raw stderr'); - } - }); - - test('bounds stdout and terminates an oversized command', async () => { - await installFakeBws(` -process.on('SIGTERM', () => process.exit(0)); -process.stdout.write('x'.repeat(11 * 1024 * 1024)); -setTimeout(() => {}, 60_000); -`); - await expect(bitwardenProvider.resolveBulk({ token: TOKEN }, PROJECT_ID)).rejects.toThrow( - 'Bitwarden bws command exceeded the stdout limit' - ); - }); -}); diff --git a/tests/secret-provider-config-redact.test.ts b/tests/secret-provider-config-redact.test.ts new file mode 100644 index 0000000..246bc19 --- /dev/null +++ b/tests/secret-provider-config-redact.test.ts @@ -0,0 +1,55 @@ +/** + * The provider edit form pre-fills NON-secret config fields (host, projectId, mount, + * ...) but the token must never leave the server. redactProviderConfig strips secrets + * so GET /api/secret-providers/[id] can return the coordinates without the token. + */ +import { describe, test, expect } from 'bun:test'; +import { + redactProviderConfig, + SECRET_CONFIG_KEYS +} from '../src/lib/server/secretproviders/shared'; + +describe('SECRET_CONFIG_KEYS', () => { + test('token is the secret key (matches the password fields in the modal)', () => { + expect(SECRET_CONFIG_KEYS.has('token')).toBe(true); + }); + test('non-secret coordinates are NOT marked secret', () => { + for (const k of ['host', 'address', 'projectId', 'environment', 'path', 'namespace', 'mount', 'project', 'config']) { + expect(SECRET_CONFIG_KEYS.has(k)).toBe(false); + } + }); +}); + +describe('redactProviderConfig', () => { + test('strips the token, keeps the non-secret coordinates', () => { + const out = redactProviderConfig({ + address: 'https://vault.example.com', + token: 'hvs.SECRET', + mount: 'kv', + namespace: 'admin' + } as any); + expect(out).toEqual({ address: 'https://vault.example.com', mount: 'kv', namespace: 'admin' }); + expect('token' in out).toBe(false); + }); + + test('Infisical: keeps host/projectId/environment/path, drops token', () => { + const out = redactProviderConfig({ + host: 'https://app.infisical.com', + token: 'st.SECRET', + projectId: 'proj-1', + environment: 'prod', + path: '/db' + } as any); + expect(out).toEqual({ host: 'https://app.infisical.com', projectId: 'proj-1', environment: 'prod', path: '/db' }); + }); + + test('service-account (token only) redacts to an empty object', () => { + expect(redactProviderConfig({ token: 'ops_SECRET' } as any)).toEqual({}); + }); + + test('does not mutate the input', () => { + const input = { address: 'x', token: 'secret' } as any; + redactProviderConfig(input); + expect(input.token).toBe('secret'); + }); +}); diff --git a/tests/secret-provider-connect.test.ts b/tests/secret-provider-connect.test.ts deleted file mode 100644 index d522428..0000000 --- a/tests/secret-provider-connect.test.ts +++ /dev/null @@ -1,318 +0,0 @@ -/** - * Unit tests for the 1Password Connect secret provider. - * - * The Connect backend is a self-hosted REST server we have no Business account - * for, so these tests mock undici's `request` and exercise the provider's HTTP - * choreography directly: health check, op:// reference resolution (including the - * optional section segment and the per-call vault/item cache), the not-found - * skip+warn path, auth-failure propagation, and the unsupported bulk pull. - */ - -import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; - -/** - * A queued response for the mocked undici `request`. `json`/`text` drive the - * body helpers the provider calls; `status` is the HTTP status code. - */ -interface MockResponse { - status: number; - json?: unknown; - text?: string; -} - -/** Records one outgoing request so tests can assert URL/headers/count. */ -interface RecordedCall { - url: string; - method?: string; - headers?: Record; -} - -let responses: MockResponse[]; -let calls: RecordedCall[]; - -const requestMock = mock(async (url: unknown, opts?: { method?: string; headers?: Record }) => { - calls.push({ url: String(url), method: opts?.method, headers: opts?.headers }); - const next = responses.shift(); - if (!next) { - throw new Error(`Unexpected request to ${String(url)}: no mock response queued`); - } - return { - statusCode: next.status, - body: { - async json() { - return next.json; - }, - async text() { - return next.text ?? (next.json !== undefined ? JSON.stringify(next.json) : ''); - } - } - }; -}); - -mock.module('undici', () => ({ request: requestMock })); - -// Imported after the mock is registered so connect.ts binds the mocked request. -const { connectProvider } = await import('../src/lib/server/secretproviders/connect'); -const { UnsupportedOperationError } = await import('../src/lib/server/secretproviders/shared'); - -const CONFIG = { host: 'https://connect.example.com/', token: 'test-token' }; - -/** Queue the responses a single-field resolution walks through. */ -function queueSuccessfulResolution() { - responses = [ - { status: 200, json: [{ id: 'vault-1', name: 'Prod' }] }, - { status: 200, json: [{ id: 'item-1', title: 'Database' }] }, - { - status: 200, - json: { - id: 'item-1', - title: 'Database', - fields: [{ id: 'f1', label: 'password', value: 's3cret' }] - } - } - ]; -} - -beforeEach(() => { - responses = []; - calls = []; - requestMock.mockClear(); -}); - -afterEach(() => { - // Fail loudly if a test queued responses it never consumed. - expect(responses.length).toBe(0); -}); - -describe('connectProvider capabilities', () => { - test('advertises the expected type, label, and capability flags', () => { - expect(connectProvider.type).toBe('op-connect'); - expect(connectProvider.label).toBe('1Password Connect'); - expect(connectProvider.supportsReferences).toBe(true); - expect(connectProvider.supportsBulk).toBe(false); - }); - - test('isReference matches op:// strings only', () => { - expect(connectProvider.isReference('op://Prod/Database/password')).toBe(true); - expect(connectProvider.isReference(' op://Prod/Database/password')).toBe(true); - expect(connectProvider.isReference('plain-value')).toBe(false); - expect(connectProvider.isReference(42)).toBe(false); - expect(connectProvider.isReference(undefined)).toBe(false); - }); -}); - -describe('testConnection', () => { - test('rejects empty host or token without hitting the network', async () => { - expect(await connectProvider.testConnection({ host: '', token: 't' })).toEqual({ - ok: false, - error: 'Host is empty' - }); - expect(await connectProvider.testConnection({ host: 'https://x', token: ' ' })).toEqual({ - ok: false, - error: 'Token is empty' - }); - expect(requestMock).not.toHaveBeenCalled(); - }); - - test('ok on a 2xx /v1/vaults response and sends bearer auth', async () => { - responses = [{ status: 200, json: [{ id: 'v1', name: 'Vault' }] }]; - const result = await connectProvider.testConnection(CONFIG); - expect(result).toEqual({ ok: true }); - expect(calls[0].url).toBe('https://connect.example.com/v1/vaults'); - expect(calls[0].headers?.Authorization).toBe('Bearer test-token'); - expect(calls[0].headers?.Accept).toBe('application/json'); - }); - - test('ok:false with a clear message on auth failure, without throwing', async () => { - responses = [{ status: 401, text: 'invalid token' }]; - const result = await connectProvider.testConnection(CONFIG); - expect(result.ok).toBe(false); - expect(result.error).toBe('Invalid 1Password Connect token'); - }); - - test('ok:false when the request throws (transport error)', async () => { - responses = []; - requestMock.mockImplementationOnce(async () => { - throw new Error('ECONNREFUSED'); - }); - const result = await connectProvider.testConnection(CONFIG); - expect(result.ok).toBe(false); - expect(result.error).toContain('ECONNREFUSED'); - }); -}); - -describe('resolveSecretReferences', () => { - test('returns an empty map for no refs without any request', async () => { - const result = await connectProvider.resolveSecretReferences(CONFIG, []); - expect(result.size).toBe(0); - expect(requestMock).not.toHaveBeenCalled(); - }); - - test('resolves a op://vault/item/field reference', async () => { - queueSuccessfulResolution(); - const ref = 'op://Prod/Database/password'; - const result = await connectProvider.resolveSecretReferences(CONFIG, [ref]); - expect(result.get(ref)).toBe('s3cret'); - expect(result.size).toBe(1); - // vaults, items, item detail — three GETs against the v1 base. - expect(calls.map((c) => c.url)).toEqual([ - 'https://connect.example.com/v1/vaults', - 'https://connect.example.com/v1/vaults/vault-1/items', - 'https://connect.example.com/v1/vaults/vault-1/items/item-1' - ]); - }); - - test('resolves a reference with a section segment', async () => { - responses = [ - { status: 200, json: [{ id: 'vault-1', name: 'Prod' }] }, - { status: 200, json: [{ id: 'item-1', title: 'Database' }] }, - { - status: 200, - json: { - id: 'item-1', - title: 'Database', - fields: [ - { id: 'f1', label: 'password', value: 'wrong', section: { id: 's-other' } }, - { - id: 'f2', - label: 'password', - value: 'right', - section: { id: 's-1', label: 'replica' } - } - ] - } - } - ]; - const ref = 'op://Prod/Database/replica/password'; - const result = await connectProvider.resolveSecretReferences(CONFIG, [ref]); - expect(result.get(ref)).toBe('right'); - }); - - test('matches vault by id and item by id', async () => { - responses = [ - { status: 200, json: [{ id: 'vault-1', name: 'Prod' }] }, - { status: 200, json: [{ id: 'item-1', title: 'Database' }] }, - { - status: 200, - json: { id: 'item-1', title: 'Database', fields: [{ id: 'notes', value: 'byid' }] } - } - ]; - const ref = 'op://vault-1/item-1/notes'; - const result = await connectProvider.resolveSecretReferences(CONFIG, [ref]); - expect(result.get(ref)).toBe('byid'); - }); - - test('caches vault and item lists across refs in one call', async () => { - responses = [ - { status: 200, json: [{ id: 'vault-1', name: 'Prod' }] }, - { status: 200, json: [{ id: 'item-1', title: 'Database' }] }, - { - status: 200, - json: { id: 'item-1', title: 'Database', fields: [{ label: 'password', value: 'p1' }] } - }, - // Second ref hits the same vault+item: only the item-detail GET repeats. - { - status: 200, - json: { id: 'item-1', title: 'Database', fields: [{ label: 'username', value: 'u1' }] } - } - ]; - const refs = ['op://Prod/Database/password', 'op://Prod/Database/username']; - const result = await connectProvider.resolveSecretReferences(CONFIG, refs); - expect(result.get(refs[0])).toBe('p1'); - expect(result.get(refs[1])).toBe('u1'); - // 1 vaults + 1 items + 2 item-details = 4 requests (lists were cached). - expect(calls.length).toBe(4); - expect(calls.filter((c) => c.url.endsWith('/v1/vaults')).length).toBe(1); - expect(calls.filter((c) => c.url.endsWith('/items')).length).toBe(1); - }); - - test('skips and warns a ref whose field is missing, keeping the others', async () => { - const warn = mock(() => {}); - const original = console.warn; - console.warn = warn; - try { - responses = [ - { status: 200, json: [{ id: 'vault-1', name: 'Prod' }] }, - { status: 200, json: [{ id: 'item-1', title: 'Database' }] }, - // missing field -> skip - { - status: 200, - json: { id: 'item-1', title: 'Database', fields: [{ label: 'password', value: 'p1' }] } - }, - // second ref resolves fine - { - status: 200, - json: { id: 'item-1', title: 'Database', fields: [{ label: 'password', value: 'p1' }] } - } - ]; - const missing = 'op://Prod/Database/nope'; - const present = 'op://Prod/Database/password'; - const result = await connectProvider.resolveSecretReferences(CONFIG, [missing, present]); - expect(result.has(missing)).toBe(false); - expect(result.get(present)).toBe('p1'); - expect(warn).toHaveBeenCalled(); - } finally { - console.warn = original; - } - }); - - test('skips a ref whose vault 404s on the specific lookup', async () => { - const warn = mock(() => {}); - const original = console.warn; - console.warn = warn; - try { - // Empty vault list -> vault not found -> skip+warn, no throw. - responses = [{ status: 200, json: [] }]; - const ref = 'op://Ghost/Database/password'; - const result = await connectProvider.resolveSecretReferences(CONFIG, [ref]); - expect(result.size).toBe(0); - expect(warn).toHaveBeenCalled(); - } finally { - console.warn = original; - } - }); - - test('skips a malformed op:// reference', async () => { - const warn = mock(() => {}); - const original = console.warn; - console.warn = warn; - try { - responses = []; - const bad = 'op://Prod/Database'; // too few segments - const result = await connectProvider.resolveSecretReferences(CONFIG, [bad]); - expect(result.size).toBe(0); - expect(requestMock).not.toHaveBeenCalled(); - expect(warn).toHaveBeenCalled(); - } finally { - console.warn = original; - } - }); - - test('throws on an auth failure (401) instead of skipping', async () => { - responses = [{ status: 401, text: 'unauthorized' }]; - await expect( - connectProvider.resolveSecretReferences(CONFIG, ['op://Prod/Database/password']) - ).rejects.toThrow(/401/); - }); - - test('throws on a server error (500)', async () => { - responses = [{ status: 500, text: 'boom' }]; - await expect( - connectProvider.resolveSecretReferences(CONFIG, ['op://Prod/Database/password']) - ).rejects.toThrow(/500/); - }); -}); - -describe('resolveBulk', () => { - test('throws UnsupportedOperationError', async () => { - await expect(connectProvider.resolveBulk(CONFIG, 'any-selector')).rejects.toBeInstanceOf( - UnsupportedOperationError - ); - }); - - test('the error message points at using a service account', async () => { - await expect(connectProvider.resolveBulk(CONFIG, 'any-selector')).rejects.toThrow( - /service account/ - ); - }); -}); diff --git a/tests/secret-provider-infisical.test.ts b/tests/secret-provider-infisical.test.ts deleted file mode 100644 index 2368977..0000000 --- a/tests/secret-provider-infisical.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * Unit tests for the Infisical secret provider. - * - * Infisical is open-source and self-hostable, so it is integration-testable - * against a real instance later. Here we exercise the provider in isolation by - * mocking undici's `request`, covering: - * - a successful bulk pull mapping { secretKey, secretValue } → Record - * - the selector / config.path / '/' precedence for the queried path - * - an auth failure (HTTP 401) surfacing as a thrown error - * - testConnection returning ok on 2xx and a helpful error otherwise - * - resolveSecretReferences throwing UnsupportedOperationError (bulk-only) - */ - -import { describe, it, expect, mock, beforeEach } from 'bun:test'; - -/** - * A single mock for undici's `request`. Registered before the provider is - * imported so the provider's `import { request } from 'undici'` binds to it. - * Each test sets its behaviour via `requestMock.mockImplementation(...)`. - */ -const requestMock = mock(); -mock.module('undici', () => ({ request: requestMock })); - -const { infisicalProvider } = await import('../src/lib/server/secretproviders/infisical.ts'); -const { UnsupportedOperationError } = await import('../src/lib/server/secretproviders/shared.ts'); - -/** Builds an undici-style response with json()/text() bodies. */ -function response(statusCode: number, json: unknown, text = '') { - return { - statusCode, - body: { - json: async () => json, - text: async () => text - } - }; -} - -const baseConfig = { - host: 'https://app.infisical.com', - token: 'st.token.value', - projectId: 'proj-123', - environment: 'prod', - path: '/' -}; - -beforeEach(() => { - requestMock.mockReset(); -}); - -describe('infisicalProvider capabilities', () => { - it('advertises bulk-only, no references', () => { - expect(infisicalProvider.type).toBe('infisical'); - expect(infisicalProvider.label).toBe('Infisical'); - expect(infisicalProvider.supportsBulk).toBe(true); - expect(infisicalProvider.supportsReferences).toBe(false); - }); - - it('isReference is always false', () => { - expect(infisicalProvider.isReference('op://vault/item/field')).toBe(false); - expect(infisicalProvider.isReference('anything')).toBe(false); - expect(infisicalProvider.isReference(42)).toBe(false); - }); -}); - -describe('infisicalProvider.resolveBulk', () => { - it('maps secretKey/secretValue pairs into a flat record', async () => { - requestMock.mockImplementation(async () => - response(200, { - secrets: [ - { secretKey: 'DB_PASSWORD', secretValue: 's3cr3t' }, - { secretKey: 'API_KEY', secretValue: 'abc123' } - ] - }) - ); - - const result = await infisicalProvider.resolveBulk(baseConfig, ''); - - expect(result).toEqual({ DB_PASSWORD: 's3cr3t', API_KEY: 'abc123' }); - - // Verify the request shape: raw secrets endpoint, bearer auth, and the - // workspace/environment/path query. - const [url, opts] = requestMock.mock.calls[0]; - expect(url).toContain('https://app.infisical.com/api/v3/secrets/raw'); - expect(url).toContain('workspaceId=proj-123'); - expect(url).toContain('environment=prod'); - expect(url).toContain('secretPath=%2F'); - expect(opts.method).toBe('GET'); - expect(opts.headers.authorization).toBe('Bearer st.token.value'); - }); - - it('tolerates an absent secrets array', async () => { - requestMock.mockImplementation(async () => response(200, {})); - const result = await infisicalProvider.resolveBulk(baseConfig, ''); - expect(result).toEqual({}); - }); - - it('prefers the selector over config.path for secretPath', async () => { - requestMock.mockImplementation(async () => response(200, { secrets: [] })); - await infisicalProvider.resolveBulk({ ...baseConfig, path: '/config' }, '/services/web'); - const [url] = requestMock.mock.calls[0]; - expect(url).toContain('secretPath=%2Fservices%2Fweb'); - }); - - it('falls back to config.path when the selector is empty', async () => { - requestMock.mockImplementation(async () => response(200, { secrets: [] })); - await infisicalProvider.resolveBulk({ ...baseConfig, path: '/config' }, ''); - const [url] = requestMock.mock.calls[0]; - expect(url).toContain('secretPath=%2Fconfig'); - }); - - it('throws on an auth failure (HTTP 401)', async () => { - requestMock.mockImplementation(async () => - response(401, {}, 'Unauthorized: invalid token') - ); - await expect(infisicalProvider.resolveBulk(baseConfig, '')).rejects.toThrow(/401/); - }); - - it('throws a clear error when the environment is missing', async () => { - await expect( - infisicalProvider.resolveBulk({ ...baseConfig, environment: undefined }, '') - ).rejects.toThrow(/Environment is required/); - // No HTTP call should have been attempted. - expect(requestMock).not.toHaveBeenCalled(); - }); -}); - -describe('infisicalProvider.resolveSecretReferences', () => { - it('throws UnsupportedOperationError (bulk-only backend)', async () => { - await expect( - infisicalProvider.resolveSecretReferences(baseConfig, ['ref']) - ).rejects.toBeInstanceOf(UnsupportedOperationError); - await expect( - infisicalProvider.resolveSecretReferences(baseConfig, ['ref']) - ).rejects.toThrow(/does not support inline references/); - expect(requestMock).not.toHaveBeenCalled(); - }); -}); - -describe('infisicalProvider.testConnection', () => { - it('returns ok on a 2xx response', async () => { - requestMock.mockImplementation(async () => response(200, { secrets: [] })); - const result = await infisicalProvider.testConnection(baseConfig); - expect(result.ok).toBe(true); - const [url] = requestMock.mock.calls[0]; - expect(url).toContain('secretPath=%2F'); - }); - - it('returns ok:false with the status on an auth failure', async () => { - requestMock.mockImplementation(async () => response(401, {}, 'Unauthorized')); - const result = await infisicalProvider.testConnection(baseConfig); - expect(result.ok).toBe(false); - expect(result.error).toContain('401'); - }); - - it('does not throw when the transport fails, returns the message', async () => { - requestMock.mockImplementation(async () => { - throw new Error('ECONNREFUSED'); - }); - const result = await infisicalProvider.testConnection(baseConfig); - expect(result.ok).toBe(false); - expect(result.error).toContain('ECONNREFUSED'); - }); - - it('returns a helpful error when projectId is empty (no HTTP call)', async () => { - const result = await infisicalProvider.testConnection({ ...baseConfig, projectId: '' }); - expect(result.ok).toBe(false); - expect(result.error).toContain('Project ID'); - expect(requestMock).not.toHaveBeenCalled(); - }); - - it('returns a helpful error when environment is empty (no HTTP call)', async () => { - const result = await infisicalProvider.testConnection({ - ...baseConfig, - environment: undefined - }); - expect(result.ok).toBe(false); - expect(result.error).toContain('Environment'); - expect(requestMock).not.toHaveBeenCalled(); - }); -}); diff --git a/tests/secret-provider-onepassword.test.ts b/tests/secret-provider-onepassword.test.ts new file mode 100644 index 0000000..a5ecd67 --- /dev/null +++ b/tests/secret-provider-onepassword.test.ts @@ -0,0 +1,95 @@ +// Unit tests for the 1Password service-account provider's log/client hygiene (#1436). +// Mocks @1password/sdk (process-global via bun mock.module) and drives the REAL +// serviceAccountProvider. No other unit test imports service-account.ts, so this +// mock does not leak into another suite. +import { describe, test, expect, mock, beforeEach } from 'bun:test'; + +let createClientCalls = 0; +let resolveAllCalls = 0; + +mock.module('@1password/sdk', () => ({ + createClient: async () => { + createClientCalls++; + return { + secrets: { + resolveAll: async (refs: string[]) => { + resolveAllCalls++; + const individualResponses: Record = {}; + for (const r of refs) { + // op://vault/item/field is valid; anything with < 3 segments is malformed. + const segs = r.replace('op://', '').split('/'); + individualResponses[r] = + segs.length < 3 + ? { error: { message: 'invalid format', type: 'BadRef' } } + : { content: { secret: `secret-for-${r}` } }; + } + return { individualResponses }; + } + }, + environments: { + getVariables: async () => ({ variables: [{ name: 'BULK_A', value: 'a' }] }) + } + }; + } +})); + +const { serviceAccountProvider } = await import('../src/lib/server/secretproviders/service-account.ts'); +const config = { token: 'ops_faketoken_for_test' } as any; + +describe('1Password service-account provider (#1436 hygiene)', () => { + beforeEach(() => { + createClientCalls = 0; + resolveAllCalls = 0; + }); + + test('collapses malformed refs into a single warning, not one per ref', async () => { + const warnings: string[] = []; + const origWarn = console.warn; + console.warn = (...args: unknown[]) => warnings.push(args.join(' ')); + try { + const refs = [ + 'op://Docker_Production/LEAFWIKI_PASSWORD', // malformed (no field) + 'op://Docker_Production/LEAFWIKI_JWT_TOKEN', // malformed + 'op://Docker_Production/EMILSE_MAIN_USER', // malformed + 'op://Docker_Production/item/password' // valid + ]; + const resolved = await serviceAccountProvider.resolveSecretReferences(config, refs, '[1Password]'); + // Exactly one summary warning for the 3 bad refs, not three. + expect(warnings.length).toBe(1); + expect(warnings[0]).toContain('Skipped 3 of 4'); + expect(warnings[0]).toContain('op:////'); + // The valid ref still resolves. + expect(resolved.get('op://Docker_Production/item/password')).toBe( + 'secret-for-op://Docker_Production/item/password' + ); + expect(resolved.size).toBe(1); + } finally { + console.warn = origWarn; + } + }); + + test('does not warn when every ref resolves', async () => { + const warnings: string[] = []; + const origWarn = console.warn; + console.warn = (...args: unknown[]) => warnings.push(args.join(' ')); + try { + const refs = ['op://v/item/password', 'op://v/item/token']; + const resolved = await serviceAccountProvider.resolveSecretReferences(config, refs, '[1Password]'); + expect(warnings.length).toBe(0); + expect(resolved.size).toBe(2); + } finally { + console.warn = origWarn; + } + }); + + test('reuses one SDK client across bulk + inline in a deploy (single handshake)', async () => { + // A token unique to this test so the module-level client cache starts cold here. + const freshConfig = { token: 'ops_fresh_token_for_shared_client_test' } as any; + // Simulate what resolveProviderEnvVars does per deploy: one bulk, then one inline. + await serviceAccountProvider.resolveBulk(freshConfig, 'Docker_Production'); + await serviceAccountProvider.resolveSecretReferences(freshConfig, ['op://v/item/password'], '[1Password]'); + // Both operations run, but createClient is cached per token -> one handshake. + expect(createClientCalls).toBe(1); + expect(resolveAllCalls).toBe(1); + }); +}); diff --git a/tests/secret-provider-precedence.test.ts b/tests/secret-provider-precedence.test.ts new file mode 100644 index 0000000..17b51d0 --- /dev/null +++ b/tests/secret-provider-precedence.test.ts @@ -0,0 +1,46 @@ +/** + * Bulk-pull merge precedence. + * + * resolveProviderEnvVars merges a provider's bulk pull UNDER the stack's explicit + * DB secrets with `Object.assign(bulkVars, secretVars)` - so an explicit DB secret + * with the same key wins over the value pulled from the provider. The full resolver + * imports the DB + provider registry (not unit-loadable), so this asserts the merge + * contract in isolation. The e2e (secret-provider-stack-integration) can no longer + * show which value won because inspect masks both, so this is where precedence lives. + */ +import { describe, test, expect } from 'bun:test'; + +// Mirrors the merge in resolveProviderEnvVars: bulk values first, explicit DB +// secrets assigned over them. +function mergeBulkUnderSecrets( + bulkVars: Record, + dbSecretVars: Record +): Record { + return Object.assign({ ...bulkVars }, dbSecretVars); +} + +describe('bulk pull merge precedence', () => { + test('explicit DB secret wins over the same key from bulk', () => { + const merged = mergeBulkUnderSecrets( + { DB_PASSWORD: 's3cr3t-vault', API_KEY: 'ak-vault-123' }, + { DB_PASSWORD: 'explicit-override' } + ); + expect(merged.DB_PASSWORD).toBe('explicit-override'); // DB secret won + expect(merged.API_KEY).toBe('ak-vault-123'); // only from bulk, untouched + }); + + test('keys present only in bulk pass through unchanged', () => { + const merged = mergeBulkUnderSecrets( + { REDIS_URL: 'redis://from-vault', API_KEY: 'ak-vault-123' }, + { DB_PASSWORD: 'db-only' } + ); + expect(merged.REDIS_URL).toBe('redis://from-vault'); + expect(merged.API_KEY).toBe('ak-vault-123'); + expect(merged.DB_PASSWORD).toBe('db-only'); + }); + + test('empty bulk leaves DB secrets intact', () => { + const merged = mergeBulkUnderSecrets({}, { DB_PASSWORD: 'db-only' }); + expect(merged).toEqual({ DB_PASSWORD: 'db-only' }); + }); +}); diff --git a/tests/secret-provider-ssrf-guard.test.ts b/tests/secret-provider-ssrf-guard.test.ts new file mode 100644 index 0000000..4cac7ff --- /dev/null +++ b/tests/secret-provider-ssrf-guard.test.ts @@ -0,0 +1,125 @@ +/** + * SSRF guard for secret provider hosts (C1). + * + * Every REST provider (Vault, Infisical, Doppler, 1Password Connect) makes + * server-side HTTP requests to a user-supplied host. Without a guard an + * authenticated user could point a provider at cloud metadata (169.254.169.254) + * or a loopback service and read the response back through testConnection / bulk + * pull. assertSafeProviderHost blocks loopback + cloud metadata while allowing + * ordinary LAN (self-hosted Vault/Infisical/Connect on a private network is a + * legitimate deployment). + */ +import { describe, test, expect } from 'bun:test'; +import { assertSafeProviderHost, sanitizeSelectorPath, parseProviderError } from '../src/lib/server/secretproviders/shared'; + +describe('assertSafeProviderHost', () => { + test('blocks cloud-metadata address', () => { + expect(() => assertSafeProviderHost('http://169.254.169.254', 'Vault')).toThrow(); + }); + + test('blocks loopback (localhost + 127.0.0.1)', () => { + expect(() => assertSafeProviderHost('http://localhost:8200', 'Vault')).toThrow(); + expect(() => assertSafeProviderHost('http://127.0.0.1:9000', 'Doppler')).toThrow(); + }); + + test('allows ordinary LAN host (self-hosted Vault/Connect)', () => { + expect(() => assertSafeProviderHost('http://192.168.1.50:8200', 'Vault')).not.toThrow(); + expect(() => assertSafeProviderHost('http://10.0.0.5:8080', 'Infisical')).not.toThrow(); + }); + + test('allows public SaaS host (api.doppler.com)', () => { + expect(() => assertSafeProviderHost('https://api.doppler.com', 'Doppler')).not.toThrow(); + }); + + // IPv4-mapped IPv6 encoding bypass: new URL() normalizes the dotted form to hex + // (::ffff:169.254.169.254 -> ::ffff:a9fe:a9fe), so a regex on the dotted form is + // dead. Every mapped form must resolve to the embedded v4 and be blocked. + test('blocks IPv4-mapped IPv6 forms reaching loopback/metadata', () => { + for (const host of [ + 'http://[::ffff:169.254.169.254]/x', // metadata, dotted (URL -> hex) + 'http://[::ffff:a9fe:a9fe]/x', // metadata, hex + 'http://[::ffff:7f00:1]:8200/x', // loopback, hex + 'http://[::ffff:127.0.0.1]/x', // loopback, dotted + 'http://[0:0:0:0:0:ffff:127.0.0.1]/x', // loopback, expanded + 'http://[::1]/x', // ipv6 loopback + 'http://[::]/x' // unspecified / all-interfaces + ]) { + expect(() => assertSafeProviderHost(host, 'Vault')).toThrow(); + } + }); + + test('still allows public IPv6 (not an embedded private v4)', () => { + expect(() => assertSafeProviderHost('http://[2606:4700:4700::1111]/x', 'Vault')).not.toThrow(); + }); + + test('error message names the provider label and the reason', () => { + try { + assertSafeProviderHost('http://169.254.169.254', 'HashiCorp Vault'); + throw new Error('should have thrown'); + } catch (e) { + const msg = (e as Error).message; + expect(msg).toContain('HashiCorp Vault'); + expect(msg.toLowerCase()).toContain('metadata'); + } + }); +}); + +// The Vault KV path is concatenated raw into the API URL; the selector comes from a +// stack env var an operator controls. sanitizeSelectorPath must reject traversal and +// encode segments so it can't forge a request to another Vault endpoint. +describe('sanitizeSelectorPath (selector path injection)', () => { + test('rejects .. traversal', () => { + expect(() => sanitizeSelectorPath('../../sys/mounts', 'Vault')).toThrow(); + expect(() => sanitizeSelectorPath('secret/../..', 'Vault')).toThrow(); + }); + test('strips leading slash and keeps a normal path', () => { + expect(sanitizeSelectorPath('/secret/prod', 'Vault')).toBe('secret/prod'); + }); + test('percent-encodes query/fragment/space characters', () => { + expect(sanitizeSelectorPath('secret/foo?x=1', 'Vault')).toBe('secret/foo%3Fx%3D1'); + expect(sanitizeSelectorPath('secret/a b', 'Vault')).toBe('secret/a%20b'); + }); +}); + +// testConnection shows a parsed provider error (good UX) but never reflects an +// arbitrary upstream body: a non-provider host probed via SSRF returns HTML/text that +// doesn't match a provider error shape, so parseProviderError returns null and only +// the status is shown. +describe('parseProviderError (error reflection / SSRF probing)', () => { + test('extracts a message from known provider error shapes', () => { + expect(parseProviderError('{"errors":["permission denied"]}')).toBe('permission denied'); + expect(parseProviderError('{"messages":["Invalid Auth token"],"success":false}')).toBe('Invalid Auth token'); + expect(parseProviderError('{"message":"project not found"}')).toBe('project not found'); + }); + test('returns null for anything that is not a provider error shape', () => { + expect(parseProviderError('502 Bad Gateway')).toBeNull(); // SSRF probe of a LAN service + expect(parseProviderError('plain text')).toBeNull(); + expect(parseProviderError('{"unrelated":"x"}')).toBeNull(); + expect(parseProviderError('')).toBeNull(); + expect(parseProviderError(null)).toBeNull(); + }); + test('caps message length', () => { + const long = 'x'.repeat(1000); + const out = parseProviderError(`{"message":"${long}"}`); + expect(out!.length).toBeLessThanOrEqual(200); + }); +}); + +// Guard against a provider silently dropping the wiring: each REST provider's +// base-URL helper must call assertSafeProviderHost. A source-level check catches a +// future refactor that forgets one provider (the exact shape of the C1 bug). +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); +const providerDir = join(here, '..', 'src', 'lib', 'server', 'secretproviders'); + +describe('every REST provider wires the SSRF guard', () => { + for (const file of ['vault.ts', 'infisical.ts', 'doppler.ts', 'connect.ts']) { + test(`${file} calls assertSafeProviderHost`, () => { + const src = readFileSync(join(providerDir, file), 'utf8'); + expect(src).toContain('assertSafeProviderHost('); + }); + } +}); diff --git a/tests/secret-provider-vault.test.ts b/tests/secret-provider-vault.test.ts deleted file mode 100644 index a118a7c..0000000 --- a/tests/secret-provider-vault.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -/** - * Unit tests for the HashiCorp Vault (KV v2) secret provider. - * - * undici's `request` is mocked so these run without a real Vault server. They - * cover: a successful KV v2 read (mapping `data.data`, coercing non-strings), - * the `X-Vault-Namespace` header being sent only when configured, the mount / - * URL shape, auth failure (403) on both bulk read and testConnection, a missing - * path (404) surfacing as an error, and the unsupported inline-reference mode. - * - * Vault OSS is separately integration-testable against `vault server -dev`. - */ - -// `svelte-check` runs over `tests/**` (per .svelte-kit/tsconfig.json) but this -// repo ships no bun type declarations (`@types/bun`), so it cannot resolve the -// `bun:test` built-in. Harmless at runtime — bun provides the module. Drop this -// suppression once `@types/bun` is a dev dependency (see coordinator note). -// @ts-expect-error -- bun:test is a runtime built-in with no types installed -import { test, expect, describe, mock, beforeEach } from 'bun:test'; - -/** Per-test undici stub; each test overrides this before exercising the provider. */ -let requestImpl: (url: string, opts: { method: string; headers: Record }) => unknown; - -mock.module('undici', () => ({ - request: (url: string, opts: { method: string; headers: Record }) => - requestImpl(url, opts) -})); - -const { vaultProvider } = await import('../src/lib/server/secretproviders/vault.ts'); -const { UnsupportedOperationError } = await import('../src/lib/server/secretproviders/shared.ts'); - -/** Fake undici response with a body exposing json()/text()/dump(). */ -function makeResponse( - statusCode: number, - opts: { json?: unknown; text?: string } = {} -): { statusCode: number; headers: Record; body: unknown } { - return { - statusCode, - headers: {}, - body: { - async json() { - if ('json' in opts) return opts.json; - return JSON.parse(opts.text ?? 'null'); - }, - async text() { - if (opts.text !== undefined) return opts.text; - return opts.json !== undefined ? JSON.stringify(opts.json) : ''; - }, - async dump() { - /* drain — no-op in the mock */ - } - } - }; -} - -const baseConfig = { address: 'https://vault.example.com', token: 's.rootToken' }; - -beforeEach(() => { - requestImpl = () => { - throw new Error('unexpected undici.request call'); - }; -}); - -describe('vaultProvider capabilities', () => { - test('advertises bulk-only, no references', () => { - expect(vaultProvider.type).toBe('vault'); - expect(vaultProvider.label).toBe('HashiCorp Vault'); - expect(vaultProvider.supportsBulk).toBe(true); - expect(vaultProvider.supportsReferences).toBe(false); - }); - - test('isReference returns false for anything', () => { - expect(vaultProvider.isReference('vault://secret/foo#bar')).toBe(false); - expect(vaultProvider.isReference('op://a/b/c')).toBe(false); - expect(vaultProvider.isReference(null)).toBe(false); - }); -}); - -describe('resolveBulk', () => { - test('reads KV v2 data.data and coerces non-string values', async () => { - let capturedUrl = ''; - let capturedHeaders: Record = {}; - requestImpl = (url, opts) => { - capturedUrl = url; - capturedHeaders = opts.headers; - return makeResponse(200, { - json: { - data: { - data: { DB_PASSWORD: 'hunter2', PORT: 5432, ENABLED: true }, - metadata: { version: 3 } - } - } - }); - }; - - const out = await vaultProvider.resolveBulk(baseConfig, 'myapp/config'); - - expect(out).toEqual({ DB_PASSWORD: 'hunter2', PORT: '5432', ENABLED: 'true' }); - // mount defaults to 'secret'; address keeps no trailing slash. - expect(capturedUrl).toBe('https://vault.example.com/v1/secret/data/myapp/config'); - expect(capturedHeaders['X-Vault-Token']).toBe('s.rootToken'); - expect(capturedHeaders['X-Vault-Namespace']).toBeUndefined(); - }); - - test('honours a custom mount and strips trailing slash / leading path slash', async () => { - let capturedUrl = ''; - requestImpl = (url) => { - capturedUrl = url; - return makeResponse(200, { json: { data: { data: { K: 'v' } } } }); - }; - - await vaultProvider.resolveBulk( - { address: 'https://vault.example.com/', token: 't', mount: 'kv' }, - '/team/app' - ); - - expect(capturedUrl).toBe('https://vault.example.com/v1/kv/data/team/app'); - }); - - test('sends X-Vault-Namespace when configured', async () => { - let capturedHeaders: Record = {}; - requestImpl = (_url, opts) => { - capturedHeaders = opts.headers; - return makeResponse(200, { json: { data: { data: { K: 'v' } } } }); - }; - - await vaultProvider.resolveBulk({ ...baseConfig, namespace: 'admin/team' }, 'app'); - - expect(capturedHeaders['X-Vault-Namespace']).toBe('admin/team'); - expect(capturedHeaders['X-Vault-Token']).toBe('s.rootToken'); - }); - - test('returns empty map when data.data is absent', async () => { - requestImpl = () => makeResponse(200, { json: { data: { metadata: {} } } }); - const out = await vaultProvider.resolveBulk(baseConfig, 'empty'); - expect(out).toEqual({}); - }); - - test('throws a clear "path not found" on 404', async () => { - requestImpl = () => makeResponse(404, { json: { errors: [] } }); - await expect(vaultProvider.resolveBulk(baseConfig, 'missing/path')).rejects.toThrow( - /path not found: secret\/missing\/path/ - ); - }); - - test('throws on auth failure (403), including Vault error detail', async () => { - requestImpl = () => makeResponse(403, { json: { errors: ['permission denied'] } }); - await expect(vaultProvider.resolveBulk(baseConfig, 'app')).rejects.toThrow( - /status 403.*permission denied/ - ); - }); -}); - -describe('testConnection', () => { - test('ok:true on 2xx from lookup-self', async () => { - let capturedUrl = ''; - requestImpl = (url) => { - capturedUrl = url; - return makeResponse(200, { json: { data: { display_name: 'token' } } }); - }; - - const res = await vaultProvider.testConnection(baseConfig); - - expect(res.ok).toBe(true); - expect(capturedUrl).toBe('https://vault.example.com/v1/auth/token/lookup-self'); - }); - - test('ok:false (no throw) on auth failure (403)', async () => { - requestImpl = () => makeResponse(403, { json: { errors: ['permission denied'] } }); - const res = await vaultProvider.testConnection(baseConfig); - expect(res.ok).toBe(false); - expect(res.error).toContain('403'); - expect(res.error).toContain('permission denied'); - }); - - test('ok:false on transport error without throwing', async () => { - requestImpl = () => { - throw new Error('ECONNREFUSED'); - }; - const res = await vaultProvider.testConnection(baseConfig); - expect(res.ok).toBe(false); - expect(res.error).toContain('ECONNREFUSED'); - }); - - test('rejects empty address / token before any request', async () => { - expect((await vaultProvider.testConnection({ address: '', token: 't' })).ok).toBe(false); - expect((await vaultProvider.testConnection({ address: 'x', token: '' })).ok).toBe(false); - }); -}); - -describe('resolveSecretReferences', () => { - test('throws UnsupportedOperationError (bulk-only backend)', async () => { - await expect(vaultProvider.resolveSecretReferences(baseConfig, ['anything'])).rejects.toThrow( - UnsupportedOperationError - ); - await expect(vaultProvider.resolveSecretReferences(baseConfig, [])).rejects.toThrow( - /does not support inline references/ - ); - }); -}); diff --git a/tests/select-volumes-to-prune.test.ts b/tests/select-volumes-to-prune.test.ts new file mode 100644 index 0000000..ad95503 --- /dev/null +++ b/tests/select-volumes-to-prune.test.ts @@ -0,0 +1,58 @@ +/** + * Unit tests for the pure volume-prune selector. No Docker, no db — drives the + * selection logic directly so the "unused & not protected" rule and, critically, + * the scanner-cache protection can never silently regress. + */ +import { describe, it, expect } from 'bun:test'; +import { + selectVolumesToPrune, + PROTECTED_VOLUME_NAMES, +} from '../src/lib/server/volume-prune-core'; + +const unused = (name: string) => ({ name, usedBy: [] as unknown[] }); +const inUse = (name: string) => ({ name, usedBy: [{ containerId: 'c1', containerName: 'x' }] }); + +describe('selectVolumesToPrune', () => { + it('selects an unused, unprotected volume', () => { + expect(selectVolumesToPrune([unused('data-1')])).toEqual(['data-1']); + }); + + it('does NOT select a volume in use (usedBy non-empty)', () => { + expect(selectVolumesToPrune([inUse('data-1')])).toEqual([]); + }); + + it('does NOT select the grype scanner cache even when unused', () => { + expect(selectVolumesToPrune([unused('dockhand-grype-db')])).toEqual([]); + }); + + it('does NOT select the trivy scanner cache even when unused', () => { + expect(selectVolumesToPrune([unused('dockhand-trivy-db')])).toEqual([]); + }); + + it('returns exactly the unused-and-unprotected names from a mixed list', () => { + const result = selectVolumesToPrune([ + unused('dangling-a'), + inUse('live-b'), + unused('dockhand-grype-db'), + unused('dockhand-trivy-db'), + unused('dangling-c'), + inUse('live-d'), + ]); + expect(result.sort()).toEqual(['dangling-a', 'dangling-c']); + }); + + it('returns [] for empty / all-protected / all-in-use inputs', () => { + expect(selectVolumesToPrune([])).toEqual([]); + expect(selectVolumesToPrune([unused('dockhand-grype-db'), unused('dockhand-trivy-db')])).toEqual([]); + expect(selectVolumesToPrune([inUse('a'), inUse('b')])).toEqual([]); + }); + + it('protects exactly the two scanner cache volumes and nothing else', () => { + // Guard against the protected set drifting to include/exclude the wrong names. + expect([...PROTECTED_VOLUME_NAMES].sort()).toEqual(['dockhand-grype-db', 'dockhand-trivy-db']); + }); + + it('honors a custom protected list argument', () => { + expect(selectVolumesToPrune([unused('keep-me'), unused('drop-me')], ['keep-me'])).toEqual(['drop-me']); + }); +}); diff --git a/tests/self-inspect-candidate.test.ts b/tests/self-inspect-candidate.test.ts new file mode 100644 index 0000000..47f32e6 --- /dev/null +++ b/tests/self-inspect-candidate.test.ts @@ -0,0 +1,52 @@ +/** + * Unit tests for isSelfInspectCandidate — which environments qualify for the + * socket-proxy fallback (inspecting Dockhand's OWN container over tcp://) in + * host-path.ts (#1203/#1204). + * + * Run with: bun test tests/unit/self-inspect-candidate.test.ts + */ + +import { describe, test, expect } from 'bun:test'; +import { isSelfInspectCandidate } from '../src/lib/server/host-path-core'; + +describe('isSelfInspectCandidate', () => { + test('plain-http direct env with host+port qualifies', () => { + expect(isSelfInspectCandidate({ connectionType: 'direct', protocol: 'http', host: 'socket-proxy', port: 2375 })).toBe(true); + }); + + test('direct env with null protocol defaults to http and qualifies', () => { + expect(isSelfInspectCandidate({ connectionType: 'direct', protocol: null, host: 'socket-proxy', port: 2375 })).toBe(true); + }); + + test('direct env with undefined protocol qualifies', () => { + expect(isSelfInspectCandidate({ connectionType: 'direct', host: 'socket-proxy', port: 2375 })).toBe(true); + }); + + test('https direct env is rejected (needs TLS certs a bare tcp:// would drop)', () => { + expect(isSelfInspectCandidate({ connectionType: 'direct', protocol: 'https', host: 'docker', port: 2376 })).toBe(false); + }); + + test('socket env is rejected (no host:port, and a socketless container cannot reach a socket)', () => { + expect(isSelfInspectCandidate({ connectionType: 'socket', protocol: 'http', host: null, port: null })).toBe(false); + }); + + test('hawser-standard env is rejected (remote agent, not the local daemon)', () => { + expect(isSelfInspectCandidate({ connectionType: 'hawser-standard', protocol: 'http', host: 'agent', port: 2376 })).toBe(false); + }); + + test('hawser-edge env is rejected', () => { + expect(isSelfInspectCandidate({ connectionType: 'hawser-edge', protocol: 'http', host: null, port: null })).toBe(false); + }); + + test('direct env missing host is rejected', () => { + expect(isSelfInspectCandidate({ connectionType: 'direct', protocol: 'http', host: null, port: 2375 })).toBe(false); + }); + + test('direct env missing port is rejected', () => { + expect(isSelfInspectCandidate({ connectionType: 'direct', protocol: 'http', host: 'socket-proxy', port: null })).toBe(false); + }); + + test('null connectionType is rejected', () => { + expect(isSelfInspectCandidate({ connectionType: null, protocol: 'http', host: 'socket-proxy', port: 2375 })).toBe(false); + }); +}); diff --git a/tests/semver-find-newer.test.ts b/tests/semver-find-newer.test.ts new file mode 100644 index 0000000..c5bfe01 --- /dev/null +++ b/tests/semver-find-newer.test.ts @@ -0,0 +1,164 @@ +/** + * findNewerVersionTag: the advisory "a newer version is out" decision, against + * messy real-world tag lists. + */ +import { describe, it, expect } from 'bun:test'; +import { findNewerVersionTag, findNewerImageTag, classifyBump } from '../src/lib/server/semver/find-newer'; +import { parseTag } from '../src/lib/server/semver/tag-parser'; + +describe('findNewerVersionTag — happy paths', () => { + it('finds the highest newer version and lists what was skipped', () => { + const result = findNewerVersionTag('16.2', ['16.2', '16.3', '16.4', '15.9']); + expect(result).toEqual({ tag: '16.4', bump: 'minor', skipped: ['16.3', '16.4'] }); + }); + + it('respects flavor: 1.0-alpine only considers -alpine tags', () => { + const result = findNewerVersionTag('1.0-alpine', ['1.1-alpine', '2.0', '1.2', '1.5-alpine']); + expect(result?.tag).toBe('1.5-alpine'); + expect(result?.skipped).toEqual(['1.1-alpine', '1.5-alpine']); + }); + + it('treats v-prefix and bare as the same series', () => { + expect(findNewerVersionTag('v3.0', ['v3.1', '3.2', 'v2.9'])?.tag).toBe('3.2'); + }); + + it('collapses the same version published both with and without v-prefix', () => { + // A registry (e.g. traefik) offers v3.6 AND 3.6 for the same release. + const result = findNewerVersionTag('v3.0', ['v3.6', '3.6', 'v3.7', '3.7']); + // One entry per version, prefix matching the running tag (v3.0 -> v-prefixed). + expect(result?.skipped).toEqual(['v3.6', 'v3.7']); + expect(result?.tag).toBe('v3.7'); + }); + + it('collapses the same version with different segment arity (1.2 and 1.2.0)', () => { + // 1.2 and 1.2.0 are equal under compareParts - must not both appear in skipped. + const result = findNewerVersionTag('1.1', ['1.2', '1.2.0']); + expect(result?.skipped).toHaveLength(1); + expect(result?.bump).toBe('minor'); + }); + + it('handles CalVer (Home Assistant)', () => { + const result = findNewerVersionTag('2024.1.3', ['2024.1.4', '2024.2.0', '2023.12.1']); + expect(result?.tag).toBe('2024.2.0'); + }); + + it('tolerates version gaps (1.0.1 -> 1.0.5 with nothing between)', () => { + expect(findNewerVersionTag('1.0.1', ['1.0.5'])?.tag).toBe('1.0.5'); + }); +}); + +describe('findNewerVersionTag — nothing to offer', () => { + it('returns null for a floating current tag', () => { + for (const tag of ['latest', 'stable', 'main', 'sha256deadbeef']) { + expect(findNewerVersionTag(tag, ['1.0', '2.0', '3.0'])).toBeNull(); + } + }); + + it('returns null when nothing is newer', () => { + expect(findNewerVersionTag('2.0', ['1.0', '1.9', '2.0'])).toBeNull(); + }); + + it('returns null when only other-flavor tags are newer', () => { + expect(findNewerVersionTag('1.0-alpine', ['2.0', '2.0-slim'])).toBeNull(); + }); +}); + +describe('findNewerVersionTag — prereleases', () => { + // Prereleases are a FILTER (show/hide), not a comparable progression: two tags + // that differ only in the prerelease part (rc1 vs rc2) share the same numeric + // version, so neither is "newer" than the other. Scope: we surface a newer + // STABLE version, and (opt-in) a newer version that happens to be a prerelease. + it('hides prereleases by default', () => { + expect(findNewerVersionTag('1.0.0', ['1.1.0-rc1', '1.0.5'])?.tag).toBe('1.0.5'); + }); + + it('offers a higher-version prerelease when the current tag is itself a prerelease', () => { + // current 1.0.0-rc1 -> 1.2.0-rc1 is a real numeric bump, same rc flavor. + expect(findNewerVersionTag('1.0.0-rc1', ['1.2.0-rc1', '0.9.0-rc1'])?.tag).toBe('1.2.0-rc1'); + }); + + it('offers a higher-version prerelease when explicitly opted in (same base flavor)', () => { + expect( + findNewerVersionTag('1.0.0-alpine', ['1.1.0-alpine-rc1'], { includePrerelease: true })?.tag + ).toBe('1.1.0-alpine-rc1'); + }); + + it('does not treat rc1 -> rc2 (same numeric version) as newer', () => { + expect(findNewerVersionTag('1.0.0-rc1', ['1.0.0-rc2', '1.0.0-rc3'])).toBeNull(); + }); +}); + +describe('findNewerVersionTag — maxBump cap', () => { + const tags = ['1.0.2', '1.1.0', '2.0.0']; + + it('patch-only stays on the patch line', () => { + expect(findNewerVersionTag('1.0.1', tags, { maxBump: 'patch' })?.tag).toBe('1.0.2'); + }); + it('minor allows minor but not major', () => { + expect(findNewerVersionTag('1.0.1', tags, { maxBump: 'minor' })?.tag).toBe('1.1.0'); + }); + it('major (default) allows everything', () => { + expect(findNewerVersionTag('1.0.1', tags)?.tag).toBe('2.0.0'); + }); +}); + +describe('classifyBump', () => { + const bump = (a: string, b: string) => classifyBump(parseTag(a)!, parseTag(b)!); + it('classifies major/minor/patch by the first differing segment', () => { + expect(bump('1.2.3', '2.0.0')).toBe('major'); + expect(bump('1.2.3', '1.3.0')).toBe('minor'); + expect(bump('1.2.3', '1.2.4')).toBe('patch'); + }); +}); + +describe('findNewerImageTag — skip non-image (Helm chart) tags', () => { + // The monorepo case: a repo ships image tags AND a higher chart tag. The chart + // must not be offered; the highest real IMAGE version is. + it('falls back to the highest image version when the top tag is a chart', async () => { + const charts = new Set(['2.1.29']); // chart tag, higher than any image tag + const probe = async (t: string) => ({ ok: !charts.has(t) }); + const r = await findNewerImageTag('1.5.0', ['1.5.0', '1.6.0', '1.6.11', '2.1.29'], probe); + expect(r?.tag).toBe('1.6.11'); + }); + + it('skips several stacked chart tags to reach a real image', async () => { + const charts = new Set(['3.0.0', '2.9.0', '2.8.0']); + const probe = async (t: string) => ({ ok: !charts.has(t) }); + const r = await findNewerImageTag('1.0.0', ['1.0.0', '2.0.0', '2.8.0', '2.9.0', '3.0.0'], probe); + expect(r?.tag).toBe('2.0.0'); + }); + + it('returns null when every newer tag is a chart/artifact', async () => { + const probe = async () => ({ ok: false }); // nothing is an image + const r = await findNewerImageTag('1.0.0', ['1.0.0', '1.1.0', '1.2.0'], probe); + expect(r).toBeNull(); + }); + + it('offers the top tag unchanged when it is a real image (one probe, happy path)', async () => { + let probes = 0; + const probe = async () => { probes++; return { ok: true }; }; + const r = await findNewerImageTag('1.0.0', ['1.0.0', '1.1.0', '1.2.0'], probe); + expect(r?.tag).toBe('1.2.0'); + expect(probes).toBe(1); // only the chosen candidate is probed + }); + + it('attaches the target digest when the probe returns one', async () => { + const probe = async () => ({ ok: true, digest: 'sha256:deadbeef' }); + const r = await findNewerImageTag('1.0.0', ['1.0.0', '1.2.0'], probe); + expect(r?.tag).toBe('1.2.0'); + expect(r?.digest).toBe('sha256:deadbeef'); + }); + + it('omits digest when the probe has none', async () => { + const probe = async () => ({ ok: true, digest: null }); + const r = await findNewerImageTag('1.0.0', ['1.0.0', '1.2.0'], probe); + expect(r?.digest).toBeUndefined(); + }); + + it('is bounded by maxSkips (a repo full of charts cannot fan out unbounded)', async () => { + let probes = 0; + const probe = async () => { probes++; return { ok: false }; }; + await findNewerImageTag('1.0.0', ['1.0.0', '1.1.0', '1.2.0', '1.3.0', '1.4.0', '1.5.0', '1.6.0', '1.7.0'], probe, {}, 2); + expect(probes).toBeLessThanOrEqual(3); // maxSkips=2 -> at most 3 attempts + }); +}); diff --git a/tests/semver-generic-v2.test.ts b/tests/semver-generic-v2.test.ts new file mode 100644 index 0000000..c36ffd8 --- /dev/null +++ b/tests/semver-generic-v2.test.ts @@ -0,0 +1,65 @@ +/** + * Generic Registry v2 tag listing: the Link-header pagination that keeps newer + * version tags from being lost behind a first page of git-hash tags. + */ +import { describe, it, expect, mock, beforeEach } from 'bun:test'; + +// The strategy resolves auth through docker.ts's getRegistryAuthHeader; stub it so +// the test never touches the network or the native docker module. +mock.module('../src/lib/server/docker', () => ({ + getRegistryAuthHeader: async () => 'Bearer test-token' +})); + +const { fetchGenericV2Tags } = await import('../src/lib/server/semver/strategies/generic-v2'); + +function tagsResponse(tags: string[], nextPath?: string) { + return { + ok: true, + json: async () => ({ tags }), + headers: { get: (h: string) => (h === 'link' && nextPath ? `<${nextPath}>; rel="next"` : null) } + }; +} + +describe('fetchGenericV2Tags', () => { + beforeEach(() => mock.restore()); + + it('returns a single page when there is no next link', async () => { + globalThis.fetch = mock(async () => tagsResponse(['1.0', '1.1'])) as unknown as typeof fetch; + expect(await fetchGenericV2Tags('codeberg.org', 'forgejo/forgejo')).toEqual(['1.0', '1.1']); + }); + + it('follows the Link header across pages and concatenates', async () => { + const calls: string[] = []; + globalThis.fetch = mock(async (url: string) => { + calls.push(String(url)); + return String(url).includes('last=') + ? tagsResponse(['15.0.2', '15.1.0']) + : tagsResponse(['14.0.0'], '/v2/forgejo/forgejo/tags/list?last=14.0.0&n=1000'); + }) as unknown as typeof fetch; + + const tags = await fetchGenericV2Tags('codeberg.org', 'forgejo/forgejo'); + expect(tags).toEqual(['14.0.0', '15.0.2', '15.1.0']); + expect(calls.length).toBe(2); + }); + + it('stops and returns what it has on a non-ok response', async () => { + globalThis.fetch = mock(async () => ({ ok: false, status: 404 })) as unknown as typeof fetch; + expect(await fetchGenericV2Tags('codeberg.org', 'missing/repo')).toEqual([]); + }); + + // SSRF guard: `registry` is image-derived (user-controlled). A blocked host must + // short-circuit to [] WITHOUT ever calling fetch. + it('refuses a cloud-metadata / loopback registry host without fetching', async () => { + for (const host of ['169.254.169.254', '127.0.0.1', 'localhost', '169.254.169.254:80']) { + const f = mock(async () => tagsResponse(['1.0'])); + globalThis.fetch = f as unknown as typeof fetch; + expect(await fetchGenericV2Tags(host, 'x/y')).toEqual([]); + expect(f).not.toHaveBeenCalled(); + } + }); + + it('still allows a LAN registry (self-hosted Harbor)', async () => { + globalThis.fetch = mock(async () => tagsResponse(['2.0'])) as unknown as typeof fetch; + expect(await fetchGenericV2Tags('192.168.1.50:5000', 'team/app')).toEqual(['2.0']); + }); +}); diff --git a/tests/semver-image-ref.test.ts b/tests/semver-image-ref.test.ts new file mode 100644 index 0000000..2e21e26 --- /dev/null +++ b/tests/semver-image-ref.test.ts @@ -0,0 +1,65 @@ +/** + * Image reference parsing across the registries semver has to handle. + */ +import { describe, it, expect } from 'bun:test'; +import { parseImageReference, isDockerHub, isGhcr } from '../src/lib/server/registry/image-ref'; + +describe('parseImageReference', () => { + it('defaults a bare official image to Docker Hub library/', () => { + expect(parseImageReference('nginx:1.25')).toEqual({ + registry: 'index.docker.io', + repo: 'library/nginx', + tag: '1.25' + }); + }); + + it('keeps a Hub user/repo', () => { + expect(parseImageReference('grafana/grafana:11.0.0')).toEqual({ + registry: 'index.docker.io', + repo: 'grafana/grafana', + tag: '11.0.0' + }); + }); + + it('parses ghcr', () => { + expect(parseImageReference('ghcr.io/user/app:v1.2')).toEqual({ + registry: 'ghcr.io', + repo: 'user/app', + tag: 'v1.2' + }); + }); + + it('parses codeberg (the forgejo case)', () => { + expect(parseImageReference('codeberg.org/forgejo/forgejo:15.0.2')).toEqual({ + registry: 'codeberg.org', + repo: 'forgejo/forgejo', + tag: '15.0.2' + }); + }); + + it('parses a registry with a port', () => { + expect(parseImageReference('registry.example.com:5000/repo:tag')).toEqual({ + registry: 'registry.example.com:5000', + repo: 'repo', + tag: 'tag' + }); + }); + + it('defaults the tag to latest and strips a digest', () => { + expect(parseImageReference('nginx')).toMatchObject({ tag: 'latest' }); + expect(parseImageReference('nginx@sha256:abc')).toMatchObject({ repo: 'library/nginx', tag: 'latest' }); + }); +}); + +describe('registry host predicates', () => { + it('recognises every Docker Hub alias', () => { + for (const h of ['index.docker.io', 'docker.io', 'registry-1.docker.io', 'hub.docker.com']) { + expect(isDockerHub(h)).toBe(true); + } + expect(isDockerHub('ghcr.io')).toBe(false); + }); + it('recognises ghcr', () => { + expect(isGhcr('ghcr.io')).toBe(true); + expect(isGhcr('codeberg.org')).toBe(false); + }); +}); diff --git a/tests/semver-manifest-artifact.test.ts b/tests/semver-manifest-artifact.test.ts new file mode 100644 index 0000000..2f770f0 --- /dev/null +++ b/tests/semver-manifest-artifact.test.ts @@ -0,0 +1,48 @@ +/** + * classifyManifest / isRunnableImage: tell a real container image apart from a + * Helm chart or other OCI artifact published into the same repo, using only the + * manifest media types (the values seen on a real monorepo like maximhq/bifrost). + */ +import { describe, it, expect } from 'bun:test'; +import { classifyManifest, isRunnableImage } from '../src/lib/server/semver/manifest-artifact'; + +describe('classifyManifest', () => { + it('multi-arch OCI image index -> image', () => { + expect(classifyManifest({ mediaType: 'application/vnd.oci.image.index.v1+json', manifests: [{}, {}] })).toBe('image'); + }); + + it('docker manifest list -> image', () => { + expect(classifyManifest({ manifests: [{}] }, 'application/vnd.docker.distribution.manifest.list.v2+json')).toBe('image'); + }); + + it('single OCI image manifest -> image', () => { + expect(classifyManifest({ config: { mediaType: 'application/vnd.oci.image.config.v1+json' } })).toBe('image'); + }); + + it('docker v2 image config -> image', () => { + expect(classifyManifest({ config: { mediaType: 'application/vnd.docker.container.image.v1+json' } })).toBe('image'); + }); + + it('Helm chart config -> chart (the bifrost 2.1.29 case)', () => { + expect(classifyManifest({ config: { mediaType: 'application/vnd.cncf.helm.config.v1+json' } })).toBe('chart'); + }); + + it('unknown non-image config on a single manifest -> other', () => { + expect(classifyManifest({ config: { mediaType: 'application/vnd.example.sbom.v1+json' } })).toBe('other'); + }); + + it('recognized image manifest type with no config -> image', () => { + expect(classifyManifest({}, 'application/vnd.docker.distribution.manifest.v2+json')).toBe('image'); + }); + + // Fail-open: never hide a real update because a registry omitted a field. + it('null / empty manifest -> image (fail-open)', () => { + expect(classifyManifest(null)).toBe('image'); + expect(classifyManifest({})).toBe('image'); + }); + + it('isRunnableImage mirrors the image verdict', () => { + expect(isRunnableImage({ config: { mediaType: 'application/vnd.oci.image.config.v1+json' } })).toBe(true); + expect(isRunnableImage({ config: { mediaType: 'application/vnd.cncf.helm.config.v1+json' } })).toBe(false); + }); +}); diff --git a/tests/semver-release-notes.test.ts b/tests/semver-release-notes.test.ts new file mode 100644 index 0000000..ee47ead --- /dev/null +++ b/tests/semver-release-notes.test.ts @@ -0,0 +1,121 @@ +/** + * matchReleasesToVersions: pair each wanted image tag with the forge release + * whose tag is the SAME numeric version, ignoring v-prefix / flavor noise. + */ +import { describe, it, expect, mock } from 'bun:test'; +import { matchReleasesToVersions, fetchReleaseNotes } from '../src/lib/server/semver/release-notes'; +import type { ReleaseSource } from '../src/lib/server/semver/release-source'; + +const rel = (tag: string, body = 'notes') => ({ + tag_name: tag, + name: tag, + body, + published_at: '2024-01-01T00:00:00Z', + html_url: `https://example/releases/tag/${tag}` +}); + +describe('matchReleasesToVersions', () => { + it('matches v-prefixed forge tags to bare wanted versions', () => { + const notes = matchReleasesToVersions( + [rel('v3.7.10'), rel('v3.7.9'), rel('v3.6.0')], + ['3.7.9', '3.7.10'] + ); + expect(notes.map((n) => n.version)).toEqual(['3.7.9', '3.7.10']); + expect(notes.map((n) => n.githubTag)).toEqual(['v3.7.9', 'v3.7.10']); + }); + + it('matches across flavor suffixes (16.4-alpine <-> v16.4)', () => { + const notes = matchReleasesToVersions([rel('v16.4')], ['16.4-alpine']); + expect(notes).toHaveLength(1); + expect(notes[0].version).toBe('16.4-alpine'); + expect(notes[0].githubTag).toBe('v16.4'); + }); + + it('drops wanted versions with no matching release', () => { + const notes = matchReleasesToVersions([rel('v1.0.0')], ['1.0.0', '2.0.0']); + expect(notes.map((n) => n.version)).toEqual(['1.0.0']); + }); + + it('ignores non-version wanted entries', () => { + expect(matchReleasesToVersions([rel('v1.0.0')], ['latest'])).toEqual([]); + }); + + it('carries the release body, url and date through', () => { + const notes = matchReleasesToVersions([rel('v1.2.3', '## Fixed things')], ['1.2.3']); + expect(notes[0]).toMatchObject({ + version: '1.2.3', + body: '## Fixed things', + url: 'https://example/releases/tag/v1.2.3', + publishedAt: '2024-01-01T00:00:00Z' + }); + }); +}); + +const giteaSource = (host: string): ReleaseSource => ({ + kind: 'gitea', + slug: 'x/y', + apiBase: `https://${host}/api/v1/repos/x/y/releases`, + releasesUrl: `https://${host}/x/y/releases` +}); + +describe('fetchReleaseNotes SSRF guard', () => { + // The gitea apiBase host comes from a container's image.source LABEL. A crafted + // label must not turn this into a fetch of loopback/metadata. + it('refuses a metadata / loopback forge host without fetching', async () => { + for (const host of ['169.254.169.254', '127.0.0.1', 'localhost']) { + const f = mock(async () => ({ ok: true, json: async () => [] })); + const { notes } = await fetchReleaseNotes(giteaSource(host), ['1.0.0'], f as unknown as typeof fetch); + expect(notes).toEqual([]); + expect(f).not.toHaveBeenCalled(); + } + }); + + it('allows a LAN forge host', async () => { + const f = mock(async () => ({ ok: true, json: async () => [] })); + await fetchReleaseNotes(giteaSource('192.168.1.20'), ['1.0.0'], f as unknown as typeof fetch); + expect(f).toHaveBeenCalled(); + }); + + it('refuses to follow a redirect (possible bounce to a private host)', async () => { + const f = mock(async () => ({ status: 302, ok: false, headers: { get: () => 'http://169.254.169.254/' }, json: async () => [] })); + const { notes } = await fetchReleaseNotes(giteaSource('codeberg.org'), ['1.0.0'], f as unknown as typeof fetch); + expect(notes).toEqual([]); + expect(f).toHaveBeenCalledTimes(1); // one hop, then stop - no follow + }); +}); + +const githubSource = (): ReleaseSource => ({ + kind: 'github', + slug: 'o/r', + apiBase: 'https://api.github.com/repos/o/r/releases', + releasesUrl: 'https://github.com/o/r/releases' +}); + +describe('fetchReleaseNotes rate-limit detection', () => { + const hdr = (m: Record) => ({ get: (k: string) => m[k.toLowerCase()] ?? null }); + + it('flags rateLimited on a 403 with X-RateLimit-Remaining: 0', async () => { + const f = mock(async () => ({ ok: false, status: 403, headers: hdr({ 'x-ratelimit-remaining': '0' }), json: async () => ({}) })); + const r = await fetchReleaseNotes(githubSource(), ['1.0.0'], f as unknown as typeof fetch); + expect(r.rateLimited).toBe(true); + expect(r.notes).toEqual([]); + }); + + it('flags rateLimited on a 429 even without the header', async () => { + const f = mock(async () => ({ ok: false, status: 429, headers: hdr({}), json: async () => ({}) })); + const r = await fetchReleaseNotes(githubSource(), ['1.0.0'], f as unknown as typeof fetch); + expect(r.rateLimited).toBe(true); + }); + + it('does NOT flag a 403 that is not a rate limit (remaining > 0)', async () => { + const f = mock(async () => ({ ok: false, status: 403, headers: hdr({ 'x-ratelimit-remaining': '42' }), json: async () => ({}) })); + const r = await fetchReleaseNotes(githubSource(), ['1.0.0'], f as unknown as typeof fetch); + expect(r.rateLimited).toBe(false); + }); + + it('does NOT flag rateLimited for a non-github (gitea) forge', async () => { + const f = mock(async () => ({ ok: false, status: 403, headers: hdr({ 'x-ratelimit-remaining': '0' }), json: async () => ({}) })); + const r = await fetchReleaseNotes(giteaSource('codeberg.org'), ['1.0.0'], f as unknown as typeof fetch); + expect(r.rateLimited).toBe(false); + }); +}); diff --git a/tests/semver-release-source.test.ts b/tests/semver-release-source.test.ts new file mode 100644 index 0000000..2197e5c --- /dev/null +++ b/tests/semver-release-source.test.ts @@ -0,0 +1,62 @@ +/** + * resolveReleaseSource: where to fetch release notes for an image, across the + * forge families semver supports (GitHub, Gitea/Forgejo, GHCR). + */ +import { describe, it, expect } from 'bun:test'; +import { resolveReleaseSource } from '../src/lib/server/semver/release-source'; + +describe('resolveReleaseSource', () => { + it('resolves a GitHub source label to the GitHub releases API', () => { + const src = resolveReleaseSource('traefik:v3.0', { + 'org.opencontainers.image.source': 'https://github.com/traefik/traefik' + }); + expect(src).toEqual({ + kind: 'github', + slug: 'traefik/traefik', + apiBase: 'https://api.github.com/repos/traefik/traefik/releases', + releasesUrl: 'https://github.com/traefik/traefik/releases' + }); + }); + + it('resolves a Codeberg (Forgejo) source label to that host\'s Gitea API', () => { + const src = resolveReleaseSource('codeberg.org/forgejo/forgejo:9.0.0', { + 'org.opencontainers.image.source': 'https://codeberg.org/forgejo/forgejo' + }); + expect(src).toEqual({ + kind: 'gitea', + slug: 'forgejo/forgejo', + apiBase: 'https://codeberg.org/api/v1/repos/forgejo/forgejo/releases', + releasesUrl: 'https://codeberg.org/forgejo/forgejo/releases' + }); + }); + + it('treats any self-hosted Gitea host the same way', () => { + const src = resolveReleaseSource('git.example.com/team/app:1.0', { + 'org.opencontainers.image.source': 'https://git.example.com/team/app' + }); + expect(src?.kind).toBe('gitea'); + expect(src?.apiBase).toBe('https://git.example.com/api/v1/repos/team/app/releases'); + }); + + it('resolves a ghcr.io image name to GitHub without any label', () => { + const src = resolveReleaseSource('ghcr.io/immich-app/immich-server:v1.100.0'); + expect(src?.kind).toBe('github'); + expect(src?.slug).toBe('immich-app/immich-server'); + }); + + it('tolerates a trailing .git and slash in the source URL', () => { + const src = resolveReleaseSource('x:1', { + 'org.opencontainers.image.source': 'https://github.com/owner/repo.git/' + }); + expect(src?.slug).toBe('owner/repo'); + }); + + it('returns null for a plain Docker Hub image with no source', () => { + expect(resolveReleaseSource('nginx:1.25')).toBeNull(); + expect(resolveReleaseSource('postgres:16.2-alpine', {})).toBeNull(); + }); + + it('returns null for a single-segment ghcr path (malformed)', () => { + expect(resolveReleaseSource('ghcr.io/something:1')).toBeNull(); + }); +}); diff --git a/tests/semver-tag-parser.test.ts b/tests/semver-tag-parser.test.ts new file mode 100644 index 0000000..c18e56d --- /dev/null +++ b/tests/semver-tag-parser.test.ts @@ -0,0 +1,173 @@ +/** + * Tag parser: extract a comparable version out of real-world Docker tags. + */ +import { describe, it, expect } from 'bun:test'; +import { + parseTag, + prefixMatches, + flavorMatches, + compareParts, + isPrerelease, + compileVersionPattern +} from '../src/lib/server/semver/tag-parser'; +import { findNewerVersionTag } from '../src/lib/server/semver/find-newer'; + +describe('parseTag', () => { + it('splits a plain semver tag', () => { + expect(parseTag('1.2.3')).toEqual({ version: '1.2.3', prefix: '', suffix: '', parts: [1, 2, 3] }); + }); + + it('captures a v-prefix and an -alpine suffix', () => { + expect(parseTag('v1.25-alpine')).toEqual({ + version: '1.25', + prefix: 'v', + suffix: '-alpine', + parts: [1, 25] + }); + }); + + it('handles CalVer (Home Assistant) as a plain numeric tuple', () => { + expect(parseTag('2024.1.3')).toEqual({ version: '2024.1.3', prefix: '', suffix: '', parts: [2024, 1, 3] }); + }); + + it('keeps a linuxserver -ls suffix as the flavor', () => { + const p = parseTag('1.20.0-ls123')!; + expect(p.version).toBe('1.20.0'); + expect(p.suffix).toBe('-ls123'); + }); + + it('parses a 4-segment version', () => { + expect(parseTag('1.2.3.4')!.parts).toEqual([1, 2, 3, 4]); + }); + + it('returns null for floating / non-version tags', () => { + for (const tag of ['latest', 'stable', 'main', 'edge', 'dev', 'sha256abc']) { + expect(parseTag(tag)).toBeNull(); + } + }); +}); + +describe('prefixMatches', () => { + it('treats "" and "v" as equal', () => { + expect(prefixMatches('', 'v')).toBe(true); + expect(prefixMatches('v', '')).toBe(true); + }); + it('distinguishes a real prefix', () => { + expect(prefixMatches('release-', 'v')).toBe(false); + }); +}); + +describe('flavorMatches', () => { + it('is an exact suffix match', () => { + expect(flavorMatches(parseTag('1.0-alpine')!, parseTag('2.0-alpine')!)).toBe(true); + expect(flavorMatches(parseTag('1.0-alpine')!, parseTag('2.0')!)).toBe(false); + expect(flavorMatches(parseTag('1.0-alpine')!, parseTag('2.0-alpine3.19')!)).toBe(false); + }); + + it('collapses a trailing commit hash so hashed releases share a flavor (searxng)', () => { + // searxng publishes 2026.8.16-; every release has a different hash, + // so without stripping it nothing would ever match. + expect(flavorMatches(parseTag('2026.8.15-a1b2c3d')!, parseTag('2026.8.16-b2da6b9')!)).toBe(true); + }); + + it('keeps a real build variant that only looks hex-ish, and a numeric build', () => { + // -ubuntu22.04 / -ls123 are variants, not hashes; a pure-numeric build isn't a hash. + expect(flavorMatches(parseTag('1.0-ubuntu22.04')!, parseTag('2.0-ubuntu22.04')!)).toBe(true); + expect(flavorMatches(parseTag('1.0-ubuntu22.04')!, parseTag('2.0')!)).toBe(false); + expect(flavorMatches(parseTag('1.0-12345678')!, parseTag('2.0')!)).toBe(false); + }); +}); + +describe('compareParts', () => { + it('orders versions with missing segments as 0', () => { + expect(compareParts(parseTag('1.2')!, parseTag('1.2.0')!)).toBe(0); + expect(compareParts(parseTag('1.2.1')!, parseTag('1.2')!)).toBeGreaterThan(0); + expect(compareParts(parseTag('1.10.0')!, parseTag('1.9.0')!)).toBeGreaterThan(0); + }); + it('orders CalVer correctly', () => { + expect(compareParts(parseTag('2024.2.0')!, parseTag('2024.1.9')!)).toBeGreaterThan(0); + expect(compareParts(parseTag('2025.1.0')!, parseTag('2024.12.9')!)).toBeGreaterThan(0); + }); +}); + +describe('isPrerelease', () => { + it('flags rc/beta/alpha/nightly/dev/pre channels in the suffix', () => { + for (const tag of ['1.0.0-rc1', '2.0-beta', '1.5-alpha.2', '3.0-nightly', '1.0-dev', '1.0-pre']) { + expect(isPrerelease(parseTag(tag)!)).toBe(true); + } + }); + it('does not flag a stable release or a plain flavor', () => { + for (const tag of ['1.0.0', '1.0.0-alpine', '1.0.0-ls123']) { + expect(isPrerelease(parseTag(tag)!)).toBe(false); + } + }); +}); + +describe('compileVersionPattern', () => { + it('returns null for absent / non-regex-scheme / bad values', () => { + expect(compileVersionPattern(undefined)).toBeNull(); + expect(compileVersionPattern('')).toBeNull(); + expect(compileVersionPattern('^(?\\d+)$')).toBeNull(); // missing regex: scheme + expect(compileVersionPattern('regex:(?\\d+)')).toBeNull(); // no major group + expect(compileVersionPattern('regex:(')).toBeNull(); // invalid regex + expect(compileVersionPattern('regex:' + 'a'.repeat(400))).toBeNull(); // too long + }); + + it('rejects catastrophic-backtracking shapes (ReDoS guard)', () => { + expect(compileVersionPattern('regex:(?(a+)+)$')).toBeNull(); + expect(compileVersionPattern('regex:(?\\d+)(a*)*$')).toBeNull(); + }); + + it('compiles a valid pattern with a major group', () => { + const re = compileVersionPattern('regex:^(?\\d{4})\\.(?\\d+)\\.(?\\d+)-[0-9a-f]+$'); + expect(re).toBeInstanceOf(RegExp); + }); +}); + +describe('parseTag with a version-pattern override', () => { + // CalVer + commit hash - the generic parser would read parts wrong / stop early. + const calver = compileVersionPattern( + 'regex:^(?\\d{4})\\.(?\\d+)\\.(?\\d+)-[0-9a-f]+$' + )!; + + it('parses a CalVer+hash tag into numeric parts', () => { + const p = parseTag('2024.12.5-a1b2c3d', calver)!; + expect(p.parts).toEqual([2024, 12, 5]); + }); + + it('treats a tag the override does not match as a non-version (null)', () => { + expect(parseTag('latest', calver)).toBeNull(); + expect(parseTag('2024.12.5', calver)).toBeNull(); // no hash -> no match + }); + + it('compares two override-parsed tags correctly', () => { + const a = parseTag('2024.12.5-aaaaaaa', calver)!; + const b = parseTag('2024.12.6-bbbbbbb', calver)!; + expect(compareParts(b, a)).toBeGreaterThan(0); + }); + + it('requires only major; missing later groups fill in as present', () => { + const majorOnly = compileVersionPattern('regex:^build-(?\\d+)$')!; + expect(parseTag('build-42', majorOnly)!.parts).toEqual([42]); + }); +}); + +describe('findNewerVersionTag with a version-pattern override', () => { + const calver = compileVersionPattern( + 'regex:^(?\\d{4})\\.(?\\d+)\\.(?\\d+)-[0-9a-f]+$' + )!; + + it('finds a newer CalVer+hash tag that the default parser would miss', () => { + const result = findNewerVersionTag( + '2024.12.5-a1b2c3d', + ['2024.12.5-a1b2c3d', '2024.12.6-b2c3d4e', '2025.1.0-c3d4e5f', 'latest'], + { versionPattern: calver } + ); + expect(result?.tag).toBe('2025.1.0-c3d4e5f'); + }); + + it('returns null when the current tag does not match the override', () => { + const result = findNewerVersionTag('latest', ['2024.12.6-b2c3d4e'], { versionPattern: calver }); + expect(result).toBeNull(); + }); +}); diff --git a/tests/settings-formatters.test.ts b/tests/settings-formatters.test.ts new file mode 100644 index 0000000..82ca365 --- /dev/null +++ b/tests/settings-formatters.test.ts @@ -0,0 +1,116 @@ +/** + * UI date/time formatter timezone handling (#1183). + * + * Tests the pure helpers extracted to $lib/utils/date-format. The store wrapper + * in $lib/stores/settings.ts just plugs the user's cached preferences into + * these helpers, so covering the helpers covers the bug. + * + * Run with: bun test tests/unit/settings-formatters.test.ts + */ + +import { describe, test, expect } from 'bun:test'; +import { + buildFormatters, + formatDatePartWith, + formatTimePartWith, + type DateFormat, + type TimeFormat +} from '../src/lib/utils/date-format'; + +// 14:30:45 UTC on the summer solstice — chosen so: +// - Asia/Dhaka shifts forward (no DST there) to 20:30:45 +// - America/New_York is on DST (UTC−4) → 10:30:45 +// - Date stays on the same calendar day in all the test zones +const SAMPLE = new Date('2026-06-21T14:30:45Z'); + +function format(d: Date, tz: string, dateFormat: DateFormat, timeFormat: TimeFormat, includeSeconds = true): string { + const f = buildFormatters(tz); + return `${formatDatePartWith(d, f.date, dateFormat)} ${formatTimePartWith(d, f.time, timeFormat, includeSeconds)}`; +} + +describe('timezone handling', () => { + test('UTC renders at the unshifted wall-clock', () => { + expect(format(SAMPLE, 'UTC', 'DD.MM.YYYY', '24h')).toBe('21.06.2026 14:30:45'); + }); + + test('Asia/Dhaka shifts by +06:00 (the #1183 case)', () => { + expect(format(SAMPLE, 'Asia/Dhaka', 'DD.MM.YYYY', '24h')).toBe('21.06.2026 20:30:45'); + }); + + test('America/New_York shifts by −04:00 (DST)', () => { + expect(format(SAMPLE, 'America/New_York', 'DD.MM.YYYY', '24h')).toBe('21.06.2026 10:30:45'); + }); + + test('Asia/Tokyo shifts by +09:00', () => { + expect(format(SAMPLE, 'Asia/Tokyo', 'DD.MM.YYYY', '24h')).toBe('21.06.2026 23:30:45'); + }); + + test('seconds-off variant drops seconds but still respects tz', () => { + const f = buildFormatters('Asia/Dhaka'); + const date = formatDatePartWith(SAMPLE, f.date, 'DD.MM.YYYY'); + const time = formatTimePartWith(SAMPLE, f.time, '24h', false); + expect(`${date} ${time}`).toBe('21.06.2026 20:30'); + }); + + test('empty timezone string falls back to runtime local (no crash)', () => { + const f = buildFormatters(''); + // Don't assert the exact value — depends on test runner's locale — just + // confirm it produces a well-shaped date+time string in DD.MM.YYYY HH:MM:SS. + const out = `${formatDatePartWith(SAMPLE, f.date, 'DD.MM.YYYY')} ${formatTimePartWith(SAMPLE, f.time, '24h', true)}`; + expect(out).toMatch(/^\d{2}\.\d{2}\.\d{4} \d{2}:\d{2}:\d{2}$/); + }); +}); + +describe('date format preservation', () => { + test('DD.MM.YYYY', () => { + expect(format(SAMPLE, 'UTC', 'DD.MM.YYYY', '24h')).toBe('21.06.2026 14:30:45'); + }); + + test('DD/MM/YYYY', () => { + expect(format(SAMPLE, 'UTC', 'DD/MM/YYYY', '24h')).toBe('21/06/2026 14:30:45'); + }); + + test('MM/DD/YYYY', () => { + expect(format(SAMPLE, 'UTC', 'MM/DD/YYYY', '24h')).toBe('06/21/2026 14:30:45'); + }); + + test('YYYY-MM-DD', () => { + expect(format(SAMPLE, 'UTC', 'YYYY-MM-DD', '24h')).toBe('2026-06-21 14:30:45'); + }); +}); + +describe('12h/24h handling under tz shift', () => { + test('12h format converts noon-shifted values correctly', () => { + // 14:30 UTC → 10:30 EDT + expect(format(SAMPLE, 'America/New_York', 'MM/DD/YYYY', '12h')).toBe('06/21/2026 10:30:45 AM'); + }); + + test('12h format flips to PM after a westbound shift past noon', () => { + // 14:30 UTC → 20:30 BDT + expect(format(SAMPLE, 'Asia/Dhaka', 'MM/DD/YYYY', '12h')).toBe('06/21/2026 8:30:45 PM'); + }); + + test('midnight in 12h format renders as 12:XX AM', () => { + const midnight = new Date('2026-06-21T00:15:30Z'); + expect(format(midnight, 'UTC', 'MM/DD/YYYY', '12h')).toBe('06/21/2026 12:15:30 AM'); + }); + + test('noon in 12h format renders as 12:XX PM', () => { + const noon = new Date('2026-06-21T12:00:00Z'); + expect(format(noon, 'UTC', 'MM/DD/YYYY', '12h')).toBe('06/21/2026 12:00:00 PM'); + }); +}); + +describe('day-roll across international date line', () => { + test('UTC+14 rolls a late-day UTC timestamp to the next calendar day', () => { + const f = buildFormatters('Pacific/Kiritimati'); // UTC+14 + const lateUtc = new Date('2026-06-21T23:30:00Z'); // → 2026-06-22 13:30 local + expect(formatDatePartWith(lateUtc, f.date, 'DD.MM.YYYY')).toBe('22.06.2026'); + }); + + test('UTC-10 rolls an early-day UTC timestamp to the previous calendar day', () => { + const f = buildFormatters('Pacific/Honolulu'); // UTC-10 + const earlyUtc = new Date('2026-06-21T05:00:00Z'); // → 2026-06-20 19:00 local + expect(formatDatePartWith(earlyUtc, f.date, 'DD.MM.YYYY')).toBe('20.06.2026'); + }); +}); diff --git a/tests/sse-parser.test.ts b/tests/sse-parser.test.ts new file mode 100644 index 0000000..4c8d7dd --- /dev/null +++ b/tests/sse-parser.test.ts @@ -0,0 +1,76 @@ +/** + * Pure unit tests for SSE server-side parser (sseToJSON). + * No server required — builds Response objects from raw strings. + * + * Run with: bun test tests/unit/sse-parser.test.ts + */ + +import { describe, test, expect } from 'bun:test'; +import { sseToJSON } from '../src/lib/server/sse-parser'; + +// --------------------------------------------------------------------------- +// Helper: build a fake SSE Response from one or more raw string chunks +// --------------------------------------------------------------------------- +function makeSSEResponse(chunks: string[]): Response { + let i = 0; + const stream = new ReadableStream({ + pull(controller) { + if (i < chunks.length) { + controller.enqueue(new TextEncoder().encode(chunks[i++])); + } else { + controller.close(); + } + } + }); + return new Response(stream, { + headers: { 'Content-Type': 'text/event-stream; charset=utf-8' } + }); +} + +// --------------------------------------------------------------------------- +// sseToJSON tests +// --------------------------------------------------------------------------- +describe('sseToJSON', () => { + test('captures result event data', async () => { + const sse = makeSSEResponse(['event: result\ndata: {"success":true}\n\n']); + const json = await sseToJSON(sse); + expect(json.headers.get('content-type')).toBe('application/json'); + expect(await json.json()).toEqual({ success: true }); + }); + + test('progress events before result — returns result', async () => { + const input = + 'event: progress\ndata: {"step":1}\n\n' + + 'event: result\ndata: {"ok":1}\n\n'; + const sse = makeSSEResponse([input]); + expect(await (await sseToJSON(sse)).json()).toEqual({ ok: 1 }); + }); + + test('no result event — returns fallback', async () => { + const sse = makeSSEResponse(['event: progress\ndata: {"step":1}\n\n']); + const body = await (await sseToJSON(sse)).json(); + expect(body.success).toBe(false); + expect(body.error).toBe('No result'); + }); + + test('multi-line valid JSON in result event is joined and parsed', async () => { + // {"a":1,\n"b":2} is valid JSON + const sse = makeSSEResponse(['event: result\ndata: {"a":1,\ndata: "b":2}\n\n']); + expect(await (await sseToJSON(sse)).json()).toEqual({ a: 1, b: 2 }); + }); + + test('CRLF line endings', async () => { + const sse = makeSSEResponse(['event: result\r\ndata: {"ok":true}\r\n\r\n']); + expect(await (await sseToJSON(sse)).json()).toEqual({ ok: true }); + }); + + test('no trailing blank line (final flush)', async () => { + const sse = makeSSEResponse(['event: result\ndata: {"success":true}']); + expect(await (await sseToJSON(sse)).json()).toEqual({ success: true }); + }); + + test('data: without space after colon', async () => { + const sse = makeSSEResponse(['event: result\ndata:{"ok":1}\n\n']); + expect(await (await sseToJSON(sse)).json()).toEqual({ ok: 1 }); + }); +}); diff --git a/tests/stack-delete-guard.test.ts b/tests/stack-delete-guard.test.ts new file mode 100644 index 0000000..88124ea --- /dev/null +++ b/tests/stack-delete-guard.test.ts @@ -0,0 +1,46 @@ +/** + * Unit tests for the #675 stack-delete path guard. The critical case: a stack ADOPTED + * AT THE STACKS ROOT (auto-named "stacks") must NOT be deletable, because its dir IS + * the stacks root and a non-strict guard would have rmSync'd every other stack. + */ +import { describe, it, expect } from 'bun:test'; +import { isDeletableStackDir } from '../src/lib/server/stack-delete-guard'; + +const STACKS = '/data/stacks'; + +describe('isDeletableStackDir', () => { + it('#675: a stack adopted at the stacks ROOT is NOT deletable', () => { + // compose at /data/stacks/docker-compose.yml -> dir is the root, name is "stacks" + expect(isDeletableStackDir('/data/stacks', STACKS, 'stacks')).toBe(false); + }); + + it('#675: trailing-slash root form is also NOT deletable', () => { + expect(isDeletableStackDir('/data/stacks/', STACKS, 'stacks')).toBe(false); + }); + + it('a normal nested stack dir IS deletable', () => { + expect(isDeletableStackDir('/data/stacks/myapp', STACKS, 'myapp')).toBe(true); + }); + + it('an env-scoped nested stack dir IS deletable', () => { + expect(isDeletableStackDir('/data/stacks/prod/myapp', STACKS, 'myapp')).toBe(true); + }); + + it('basename must match the stack name', () => { + // dir exists under stacks but its folder name differs from the stack name + expect(isDeletableStackDir('/data/stacks/other', STACKS, 'myapp')).toBe(false); + }); + + it('a path OUTSIDE the stacks dir is NOT deletable (adopted-in-place)', () => { + expect(isDeletableStackDir('/home/user/compose/myapp', STACKS, 'myapp')).toBe(false); + }); + + it('a sibling dir that merely shares the stacks prefix is NOT deletable', () => { + // /data/stacks-backup startsWith "/data/stacks" but not "/data/stacks/" + expect(isDeletableStackDir('/data/stacks-backup/myapp', STACKS, 'myapp')).toBe(false); + }); + + it('a "../" escape resolves out and is NOT deletable', () => { + expect(isDeletableStackDir('/data/stacks/../evil', STACKS, 'evil')).toBe(false); + }); +}); diff --git a/tests/stack-filename.test.ts b/tests/stack-filename.test.ts new file mode 100644 index 0000000..6bb72f6 --- /dev/null +++ b/tests/stack-filename.test.ts @@ -0,0 +1,65 @@ +/** + * isAllowedStackFilename unit tests (#1196). + * + * v1.0.34 locked stack writes to a hardcoded set of names (docker-compose.yml, + * compose.yml, .env) which broke users who name their compose files after + * the service (e.g. headscale.yml). v1.0.35 relaxes to any .yml / .yaml file + * plus the .env family. + * + * The regex is the only thing standing between the API and writing files + * with arbitrary extensions, so it gets a focused unit test rather than + * relying on integration coverage. + */ +import { describe, expect, test } from 'bun:test'; +import { isAllowedStackFilename } from '../src/lib/server/stack-filename'; + +describe('isAllowedStackFilename', () => { + test.each([ + // Conventional compose names + ['docker-compose.yml'], + ['docker-compose.yaml'], + ['compose.yml'], + ['compose.yaml'], + // Custom compose names per service (the #1196 case) + ['headscale.yml'], + ['nginx.yaml'], + ['my-custom-thing.yml'], + // Env file family + ['.env'], + ['.env.local'], + ['.env.production'], + ['prod.env'], + ['staging.env'] + ])('accepts %s', (filename: string) => { + expect(isAllowedStackFilename(filename)).toBe(true); + }); + + test.each([ + // Shell / executable + ['payload.sh'], + ['evil.bash'], + // SSH key target + ['authorized_keys'], + // Cron / systemd files + ['crontab'], + ['my.service'], + // Web / config files + ['nginx.conf'], + ['index.html'], + // Plain extensions not in our allowlist + ['data.json'], + ['readme.md'], + // No extension + ['Caddyfile'], + // Empty / whitespace + [''], + [' '], + // Tricks: .yml in the middle, not at the end + ['compose.yml.bak'], + // .env in the middle + ['environment'], + ['notenv'] + ])('rejects %s', (filename: string) => { + expect(isAllowedStackFilename(filename)).toBe(false); + }); +}); diff --git a/tests/stack-injected-secret-keys.test.ts b/tests/stack-injected-secret-keys.test.ts new file mode 100644 index 0000000..49bfad6 --- /dev/null +++ b/tests/stack-injected-secret-keys.test.ts @@ -0,0 +1,60 @@ +/** + * injected_secret_keys parse/serialize semantics (H1 masking source of truth). + * + * The masking set for provider-injected secrets is whatever names were persisted on + * the last deploy. These pure helpers back that column; the round-trip must be exact + * (no over-masking) and clearing must store null (no stale set after unbind), and a + * malformed/legacy value must degrade to "nothing extra masked", never throw. + */ +import { describe, test, expect } from 'bun:test'; +import { + parseInjectedSecretKeys, + serializeInjectedSecretKeys +} from '../src/lib/server/stack-secret-keys'; + +describe('serializeInjectedSecretKeys', () => { + test('non-empty -> JSON array', () => { + expect(serializeInjectedSecretKeys(['DB_PASSWORD', 'API_KEY'])).toBe('["DB_PASSWORD","API_KEY"]'); + }); + test('empty -> null (clears the column, no stale [] left behind)', () => { + expect(serializeInjectedSecretKeys([])).toBeNull(); + }); +}); + +describe('parseInjectedSecretKeys', () => { + test('parses a JSON array of names', () => { + expect([...parseInjectedSecretKeys('["A","B"]')].sort()).toEqual(['A', 'B']); + }); + test('null / empty -> empty set', () => { + expect(parseInjectedSecretKeys(null).size).toBe(0); + expect(parseInjectedSecretKeys(undefined).size).toBe(0); + expect(parseInjectedSecretKeys('').size).toBe(0); + }); + test('malformed / legacy value -> empty set, never throws', () => { + expect(parseInjectedSecretKeys('not json').size).toBe(0); + expect(parseInjectedSecretKeys('{"x":1}').size).toBe(0); // object, not array + }); + test('drops non-string members', () => { + expect([...parseInjectedSecretKeys('["A",1,null,"B"]')].sort()).toEqual(['A', 'B']); + }); +}); + +describe('round-trip (no over/under-masking)', () => { + test('serialize -> parse yields exactly the input names', () => { + const keys = ['DB_PASSWORD', 'API_KEY', 'REDIS_URL']; + const stored = serializeInjectedSecretKeys(keys); + expect([...parseInjectedSecretKeys(stored)].sort()).toEqual([...keys].sort()); + }); + test('a redeploy that injects a DIFFERENT set replaces (not merges) the old one', () => { + // Deploy 1 stored [A,B]; deploy 2 injects [A,C]. The setter overwrites the + // whole column, so the mask set tracks the container env exactly - B is gone. + const afterDeploy2 = serializeInjectedSecretKeys(['A', 'C']); + expect([...parseInjectedSecretKeys(afterDeploy2)].sort()).toEqual(['A', 'C']); + }); + test('unbinding a provider (redeploy with no injected keys) clears the set', () => { + // resolveProviderEnvVars persists [] when nothing was injected -> null. + const afterUnbind = serializeInjectedSecretKeys([]); + expect(afterUnbind).toBeNull(); + expect(parseInjectedSecretKeys(afterUnbind).size).toBe(0); + }); +}); diff --git a/tests/stack-name-normalize.test.ts b/tests/stack-name-normalize.test.ts new file mode 100644 index 0000000..5814139 --- /dev/null +++ b/tests/stack-name-normalize.test.ts @@ -0,0 +1,36 @@ +import { describe, test, expect } from 'bun:test'; +import { normalizeStackName } from '../src/lib/utils/stack-name'; + +describe('normalizeStackName', () => { + test('lowercases uppercase names', () => { + expect(normalizeStackName('Test')).toBe('test'); + expect(normalizeStackName('MyStack')).toBe('mystack'); + expect(normalizeStackName('ALLCAPS')).toBe('allcaps'); + }); + + test('replaces invalid characters with hyphens', () => { + expect(normalizeStackName('my stack')).toBe('my-stack'); + expect(normalizeStackName('my.stack.name')).toBe('my-stack-name'); + expect(normalizeStackName('stack@home!')).toBe('stack-home'); + }); + + test('collapses multiple hyphens', () => { + expect(normalizeStackName('my--stack')).toBe('my-stack'); + expect(normalizeStackName('a - b - c')).toBe('a-b-c'); + }); + + test('trims leading and trailing hyphens', () => { + expect(normalizeStackName('-stack-')).toBe('stack'); + expect(normalizeStackName('--stack--')).toBe('stack'); + }); + + test('preserves underscores and digits', () => { + expect(normalizeStackName('my_stack_2')).toBe('my_stack_2'); + expect(normalizeStackName('app123')).toBe('app123'); + }); + + test('handles mixed cases from directory names', () => { + expect(normalizeStackName('My Docker Stack')).toBe('my-docker-stack'); + expect(normalizeStackName('Production_App.v2')).toBe('production_app-v2'); + }); +}); diff --git a/tests/stack-order.test.ts b/tests/stack-order.test.ts new file mode 100644 index 0000000..855a5af --- /dev/null +++ b/tests/stack-order.test.ts @@ -0,0 +1,88 @@ +/** + * Unit tests for container sort order within stacks via dockhand.order label. + * + * Run with: bun test tests/unit/stack-order.test.ts + */ + +import { describe, test, expect } from 'bun:test'; +import { getOrderValue } from '../src/lib/server/container-labels'; + +interface MockContainer { + service: string; + labels: Record; +} + +function sortContainers(containers: MockContainer[]): MockContainer[] { + return [...containers].sort((a, b) => { + const orderA = getOrderValue(a.labels); + const orderB = getOrderValue(b.labels); + if (orderA !== orderB) return orderA - orderB; + return a.service.localeCompare(b.service); + }); +} + +describe('stack container sort order', () => { + test('containers without labels sort alphabetically', () => { + const containers: MockContainer[] = [ + { service: 'redis', labels: {} }, + { service: 'app', labels: {} }, + { service: 'nginx', labels: {} }, + ]; + const sorted = sortContainers(containers); + expect(sorted.map(c => c.service)).toEqual(['app', 'nginx', 'redis']); + }); + + test('lower order values appear first', () => { + const containers: MockContainer[] = [ + { service: 'redis', labels: { 'dockhand.order': '3' } }, + { service: 'app', labels: { 'dockhand.order': '1' } }, + { service: 'nginx', labels: { 'dockhand.order': '2' } }, + ]; + const sorted = sortContainers(containers); + expect(sorted.map(c => c.service)).toEqual(['app', 'nginx', 'redis']); + }); + + test('same order value falls back to alphabetical', () => { + const containers: MockContainer[] = [ + { service: 'redis', labels: { 'dockhand.order': '1' } }, + { service: 'app', labels: { 'dockhand.order': '1' } }, + { service: 'nginx', labels: { 'dockhand.order': '1' } }, + ]; + const sorted = sortContainers(containers); + expect(sorted.map(c => c.service)).toEqual(['app', 'nginx', 'redis']); + }); + + test('negative values sort before default (0)', () => { + const containers: MockContainer[] = [ + { service: 'redis', labels: {} }, + { service: 'app', labels: { 'dockhand.order': '-1' } }, + { service: 'nginx', labels: {} }, + ]; + const sorted = sortContainers(containers); + expect(sorted.map(c => c.service)).toEqual(['app', 'nginx', 'redis']); + }); + + test('mixed labeled and unlabeled containers', () => { + const containers: MockContainer[] = [ + { service: 'redis', labels: {} }, + { service: 'app', labels: { 'dockhand.order': '1' } }, + { service: 'nginx', labels: { 'dockhand.order': '-1' } }, + { service: 'postgres', labels: {} }, + { service: 'worker', labels: { 'dockhand.order': '2' } }, + ]; + const sorted = sortContainers(containers); + // nginx(-1) → postgres(0) → redis(0) → app(1) → worker(2) + expect(sorted.map(c => c.service)).toEqual(['nginx', 'postgres', 'redis', 'app', 'worker']); + }); + + test('invalid order values treated as 0', () => { + const containers: MockContainer[] = [ + { service: 'redis', labels: { 'dockhand.order': 'abc' } }, + { service: 'app', labels: { 'dockhand.order': '1' } }, + { service: 'nginx', labels: {} }, + ]; + const sorted = sortContainers(containers); + // nginx(0) → redis(0, invalid=0) → app(1) + expect(sorted.map(c => c.service)).toEqual(['nginx', 'redis', 'app']); + }); +}); diff --git a/tests/stack-paths.test.ts b/tests/stack-paths.test.ts new file mode 100644 index 0000000..8f594e1 --- /dev/null +++ b/tests/stack-paths.test.ts @@ -0,0 +1,45 @@ +/** + * stackDirIn is the ONE formula deploy staging WRITES to and backup READS from. If they ever + * diverge, a deploy stages files to one path while a backup looks at another (#1383 / #1240). + * These tests pin the formula + normalization so both sides stay byte-identical. + */ +import { describe, test, expect } from 'bun:test'; +import { normalizeBaseDir, stackDirIn } from '../src/lib/server/stack-paths'; +import { planRemoteStaging } from '../src/lib/server/remote-staging-plan'; + +describe('normalizeBaseDir', () => { + test('trims and strips trailing slashes', () => { + expect(normalizeBaseDir('/data/stacks/')).toBe('/data/stacks'); + expect(normalizeBaseDir('/data/stacks///')).toBe('/data/stacks'); + expect(normalizeBaseDir(' /opt/x ')).toBe('/opt/x'); + expect(normalizeBaseDir('/data/stacks')).toBe('/data/stacks'); + }); +}); + +describe('stackDirIn', () => { + test('joins normalized base + verbatim stack name', () => { + expect(stackDirIn('/data/stacks', 'gitlab')).toBe('/data/stacks/gitlab'); + expect(stackDirIn('/data/stacks/', 'gitlab')).toBe('/data/stacks/gitlab'); + }); + test('does NOT slugify or lowercase the stack name (both sides use it raw)', () => { + expect(stackDirIn('/base', 'My_Stack-1')).toBe('/base/My_Stack-1'); + }); +}); + +describe('deploy staging and the shared formula agree', () => { + // planRemoteStaging (the WRITE side) must produce exactly stackDirIn(rsd, stack) - the same + // value the backup resolver READS as its remoteStacksDirHostPath candidate. + for (const [rsd, stack] of [ + ['/data/stacks', 'gitlab'], + ['/opt/hawser-stacks/', 'adguard'], + ['/srv/x///', 'app_1'], + ] as const) { + test(`hostDir == stackDirIn for rsd="${rsd}" stack="${stack}"`, () => { + const plan = planRemoteStaging({ + operation: 'up', remoteStacksDir: rsd, stackName: stack, composeContent: '', hasStackFiles: true, + }); + expect(plan.stage).toBe(true); + expect(plan.hostDir).toBe(stackDirIn(rsd, stack)); + }); + } +}); diff --git a/tests/stack-status.test.ts b/tests/stack-status.test.ts new file mode 100644 index 0000000..23b2d25 --- /dev/null +++ b/tests/stack-status.test.ts @@ -0,0 +1,45 @@ +/** + * deriveStackStatus: a container in a restart loop is 'restarting', never 'stopped', + * so the stack view offers Stop instead of Start (#1438). + */ +import { describe, it, expect } from 'bun:test'; +import { deriveStackStatus } from '../src/lib/server/stack-status'; + +describe('deriveStackStatus', () => { + it('#1438: a single container in a restart loop is "restarting", not "stopped"', () => { + expect(deriveStackStatus({ total: 1, running: 0, restarting: 1, completed: 0 })).toBe('restarting'); + }); + + it('all containers running -> running', () => { + expect(deriveStackStatus({ total: 3, running: 3, restarting: 0, completed: 0 })).toBe('running'); + }); + + it('no active containers -> stopped', () => { + expect(deriveStackStatus({ total: 2, running: 0, restarting: 0, completed: 0 })).toBe('stopped'); + }); + + it('some running, some not -> partial', () => { + expect(deriveStackStatus({ total: 3, running: 1, restarting: 0, completed: 0 })).toBe('partial'); + }); + + it('some running AND some restarting -> partial (mixed, but live)', () => { + expect(deriveStackStatus({ total: 3, running: 1, restarting: 1, completed: 0 })).toBe('partial'); + }); + + it('all live containers restarting, none up -> restarting', () => { + expect(deriveStackStatus({ total: 2, running: 0, restarting: 2, completed: 0 })).toBe('restarting'); + }); + + it('completed (exit 0) init containers do not count against health', () => { + // 1 completed init + 1 running app -> running (activeTotal = 1, running = 1) + expect(deriveStackStatus({ total: 2, running: 1, restarting: 0, completed: 1 })).toBe('running'); + }); + + it('a stack that is only a completed init container -> stopped', () => { + expect(deriveStackStatus({ total: 1, running: 0, restarting: 0, completed: 1 })).toBe('stopped'); + }); + + it('completed init + a restarting app -> restarting (Stop must be offered)', () => { + expect(deriveStackStatus({ total: 2, running: 0, restarting: 1, completed: 1 })).toBe('restarting'); + }); +}); diff --git a/tests/stream-pump.test.ts b/tests/stream-pump.test.ts new file mode 100644 index 0000000..c9ab85d --- /dev/null +++ b/tests/stream-pump.test.ts @@ -0,0 +1,68 @@ +import { describe, test, expect } from 'bun:test'; +import { pumpWebStreamToWritable, type BackpressureWritable, type ChunkReader } from '../src/lib/server/stream-pump'; + +// A reader over a fixed list of chunks. +function readerOf(chunks: Uint8Array[]): ChunkReader { + let i = 0; + return { read: async () => (i < chunks.length ? { done: false, value: chunks[i++] } : { done: true }) }; +} + +// A writable whose write() returns false after `bufferLimit` bytes are "in flight" and only +// drains when we manually fire it — this models a slow socket applying backpressure. +function makeSlowWritable(bufferLimit: number) { + const rec = { written: [] as number[], ended: false, destroyed: null as Error | null, drainWaiters: [] as (() => void)[], inFlight: 0 }; + const w: BackpressureWritable = { + write(chunk) { + rec.written.push(chunk.length); + rec.inFlight += chunk.length; + // Return false (backpressure) once the buffer is over the limit. + return rec.inFlight < bufferLimit; + }, + end() { rec.ended = true; }, + destroy(err) { rec.destroyed = err ?? new Error('destroyed'); }, + once(_event, cb) { rec.drainWaiters.push(cb); }, + }; + // Test helper: drain the buffer and wake one waiter. + const drain = () => { rec.inFlight = 0; const cb = rec.drainWaiters.shift(); if (cb) cb(); }; + return { w, rec, drain }; +} + +describe('pumpWebStreamToWritable - backpressure', () => { + test('writes all chunks and calls end() when the sink never backpressures', async () => { + const { w, rec } = makeSlowWritable(Number.MAX_SAFE_INTEGER); // write() always returns true + await pumpWebStreamToWritable(readerOf([new Uint8Array(10), new Uint8Array(20)]), w); + expect(rec.written).toEqual([10, 20]); + expect(rec.ended).toBe(true); + expect(rec.destroyed).toBeNull(); + }); + + test('STOPS reading and awaits drain when write() signals backpressure', async () => { + const { w, rec, drain } = makeSlowWritable(15); // buffer fills after ~15 bytes + const chunks = [new Uint8Array(10), new Uint8Array(10), new Uint8Array(10)]; + const done = pumpWebStreamToWritable(readerOf(chunks), w); + + // After the first 10-byte write, inFlight=10 < 15 -> no backpressure, second write runs; + // after the second write inFlight=20 >= 15 -> write() returned false -> pump must PAUSE + // waiting for drain, NOT write the third chunk yet. + await new Promise((r) => setTimeout(r, 10)); + expect(rec.written).toEqual([10, 10]); // third chunk NOT written yet + expect(rec.drainWaiters.length).toBe(1); // pump is parked on drain + expect(rec.ended).toBe(false); + + drain(); // socket flushed -> resume + await done; + expect(rec.written).toEqual([10, 10, 10]); // now all three written + expect(rec.ended).toBe(true); + }); + + test('destroys the writable and rethrows if the reader errors mid-stream', async () => { + const { w, rec } = makeSlowWritable(Number.MAX_SAFE_INTEGER); + let n = 0; + const badReader: ChunkReader = { + read: async () => { if (n++ === 0) return { done: false, value: new Uint8Array(5) }; throw new Error('read blew up'); }, + }; + await expect(pumpWebStreamToWritable(badReader, w)).rejects.toThrow('read blew up'); + expect(rec.destroyed).not.toBeNull(); + expect(rec.ended).toBe(false); + }); +}); diff --git a/tests/system-container-filter.test.ts b/tests/system-container-filter.test.ts new file mode 100644 index 0000000..1860b15 --- /dev/null +++ b/tests/system-container-filter.test.ts @@ -0,0 +1,155 @@ +/** + * Unit tests for system container detection and filtering. + * + * Verifies that Dockhand and Hawser containers are correctly identified + * and excluded from batch update operations (#485). + * + * Run with: bun test tests/system-container-filter.test.ts + */ + +import { describe, test, expect } from 'bun:test'; +import { + isDockhandContainer, + isHawserContainer, + isSystemContainer, + isPodmanInfraContainer +} from '../src/lib/server/scheduler/tasks/update-utils'; + +describe('isDockhandContainer', () => { + test('matches fnsys/dockhand', () => { + expect(isDockhandContainer('fnsys/dockhand:latest')).toBe(true); + expect(isDockhandContainer('fnsys/dockhand:v1.0.0')).toBe(true); + expect(isDockhandContainer('fnsys/dockhand')).toBe(true); + }); + + test('matches registry-prefixed dockhand', () => { + expect(isDockhandContainer('registry.example.com/dockhand:abc123')).toBe(true); + expect(isDockhandContainer('ghcr.io/finsys/dockhand:latest')).toBe(true); + }); + + test('matches plain dockhand', () => { + expect(isDockhandContainer('dockhand:latest')).toBe(true); + expect(isDockhandContainer('dockhand')).toBe(true); + }); + + test('is case insensitive', () => { + expect(isDockhandContainer('Fnsys/Dockhand:Latest')).toBe(true); + expect(isDockhandContainer('FNSYS/DOCKHAND')).toBe(true); + }); + + test('does not match unrelated images', () => { + expect(isDockhandContainer('nginx:latest')).toBe(false); + expect(isDockhandContainer('my-dockhand-fork:v1')).toBe(false); + }); +}); + +describe('isHawserContainer', () => { + test('matches finsys/hawser', () => { + expect(isHawserContainer('finsys/hawser:latest')).toBe(true); + expect(isHawserContainer('finsys/hawser:v0.5.0')).toBe(true); + expect(isHawserContainer('finsys/hawser')).toBe(true); + }); + + test('matches ghcr.io/finsys/hawser', () => { + expect(isHawserContainer('ghcr.io/finsys/hawser:latest')).toBe(true); + expect(isHawserContainer('ghcr.io/finsys/hawser')).toBe(true); + }); + + test('is case insensitive', () => { + expect(isHawserContainer('Finsys/Hawser:Latest')).toBe(true); + expect(isHawserContainer('GHCR.IO/FINSYS/HAWSER')).toBe(true); + }); + + test('does not match unrelated images', () => { + expect(isHawserContainer('nginx:latest')).toBe(false); + expect(isHawserContainer('hawser-custom:v1')).toBe(false); + }); +}); + +describe('isSystemContainer', () => { + test('returns "dockhand" for Dockhand images', () => { + expect(isSystemContainer('fnsys/dockhand:latest')).toBe('dockhand'); + }); + + test('returns "hawser" for Hawser images', () => { + expect(isSystemContainer('finsys/hawser:latest')).toBe('hawser'); + expect(isSystemContainer('ghcr.io/finsys/hawser:v1')).toBe('hawser'); + }); + + test('returns null for regular images', () => { + expect(isSystemContainer('nginx:latest')).toBeNull(); + expect(isSystemContainer('redis:7')).toBeNull(); + expect(isSystemContainer('postgres:16')).toBeNull(); + }); +}); + +describe('batch update filtering (#485)', () => { + // Simulates the check-updates response filtering + interface CheckResult { + containerId: string; + containerName: string; + imageName: string; + hasUpdate: boolean; + systemContainer: string | null; + } + + const mockResults: CheckResult[] = [ + { containerId: 'c1', containerName: 'nginx', imageName: 'nginx:latest', hasUpdate: true, systemContainer: null }, + { containerId: 'c2', containerName: 'redis', imageName: 'redis:7', hasUpdate: true, systemContainer: null }, + { containerId: 'c3', containerName: 'dockhand', imageName: 'fnsys/dockhand:latest', hasUpdate: true, systemContainer: 'dockhand' }, + { containerId: 'c4', containerName: 'hawser-agent', imageName: 'ghcr.io/finsys/hawser:v0.5', hasUpdate: true, systemContainer: 'hawser' }, + { containerId: 'c5', containerName: 'postgres', imageName: 'postgres:16', hasUpdate: false, systemContainer: null }, + ]; + + test('updatesFound count excludes system containers', () => { + const updatesFound = mockResults.filter(r => r.hasUpdate && !r.systemContainer).length; + expect(updatesFound).toBe(2); // nginx + redis only + }); + + test('containersWithUpdates excludes system containers', () => { + const containersWithUpdates = mockResults.filter(r => r.hasUpdate && !r.systemContainer); + expect(containersWithUpdates.map(r => r.containerName)).toEqual(['nginx', 'redis']); + }); + + test('system containers with updates are not included', () => { + const containersWithUpdates = mockResults.filter(r => r.hasUpdate && !r.systemContainer); + const ids = containersWithUpdates.map(r => r.containerId); + expect(ids).not.toContain('c3'); // dockhand + expect(ids).not.toContain('c4'); // hawser + }); +}); + +describe('isPodmanInfraContainer (#1221)', () => { + test('accepts pod-id-prefixed infra (real podman 5.4.2 shape)', () => { + expect(isPodmanInfraContainer('fb575a549efc-infra')).toBe(true); + }); + + test('accepts pod-name-prefixed infra (the #1221 reporter shape)', () => { + expect(isPodmanInfraContainer('someapp-pod-infra')).toBe(true); + expect(isPodmanInfraContainer('testpod-infra')).toBe(true); + }); + + test('accepts underscore separator', () => { + expect(isPodmanInfraContainer('foo_infra')).toBe(true); + }); + + test('is case-insensitive', () => { + expect(isPodmanInfraContainer('TESTPOD-INFRA')).toBe(true); + }); + + test('rejects user containers that merely contain "infra"', () => { + expect(isPodmanInfraContainer('my-infrastructure')).toBe(false); + expect(isPodmanInfraContainer('core-infra-svc')).toBe(false); + expect(isPodmanInfraContainer('infra-monitoring')).toBe(false); + expect(isPodmanInfraContainer('infra')).toBe(false); // no separator + }); + + test('rejects empty / undefined', () => { + expect(isPodmanInfraContainer(undefined)).toBe(false); + expect(isPodmanInfraContainer('')).toBe(false); + }); + + test('handles leading-slash names (Docker API returns /name)', () => { + expect(isPodmanInfraContainer('/fb575a549efc-infra')).toBe(true); + }); +}); diff --git a/tests/tar-extract.test.ts b/tests/tar-extract.test.ts new file mode 100644 index 0000000..4915c58 --- /dev/null +++ b/tests/tar-extract.test.ts @@ -0,0 +1,78 @@ +import { describe, test, expect } from 'bun:test'; +import { extractFirstFileFromTar } from '../src/lib/server/tar-extract'; + +// Hand-roll a minimal USTAR tar containing a single regular file. +// Mirrors the writer in docker.ts:writeContainerFile (which Dockhand already +// uses against Docker's archive API), so the parser is exercised against the +// exact format the production code emits. +function makeTar(filename: string, content: Uint8Array): Uint8Array { + const fileSize = content.length; + const paddedContentSize = Math.ceil(fileSize / 512) * 512; + const totalSize = 512 + paddedContentSize + 1024; + const tar = new Uint8Array(totalSize); + const enc = new TextEncoder(); + + tar.set(enc.encode(filename).slice(0, 100), 0); + tar.set(enc.encode('0000644\0'), 100); + tar.set(enc.encode('0000000\0'), 108); + tar.set(enc.encode('0000000\0'), 116); + tar.set(enc.encode(fileSize.toString(8).padStart(11, '0') + '\0'), 124); + tar.set(enc.encode('0'.repeat(11) + '\0'), 136); + tar.set(enc.encode(' '), 148); // checksum placeholder (spaces) + tar[156] = 0x30; // typeflag '0' = regular file + tar.set(enc.encode('ustar\0'), 257); + tar.set(enc.encode('00'), 263); + + // Checksum: sum of first 512 bytes with checksum field treated as spaces + let cs = 0; + for (let i = 0; i < 512; i++) cs += tar[i]; + tar.set(enc.encode(cs.toString(8).padStart(6, '0') + '\0 '), 148); + + tar.set(content, 512); + return tar; +} + +describe('extractFirstFileFromTar', () => { + test('returns the bytes of a single text file', () => { + const content = new TextEncoder().encode('hello world\n'); + const tar = makeTar('greeting.txt', content); + const out = extractFirstFileFromTar(tar); + expect(new TextDecoder().decode(out)).toBe('hello world\n'); + }); + + test('round-trips arbitrary binary bytes including NULs', () => { + const content = new Uint8Array([0x00, 0xff, 0x42, 0x00, 0x01, 0x7f, 0x80]); + const tar = makeTar('blob.bin', content); + const out = extractFirstFileFromTar(tar); + expect(Array.from(out)).toEqual(Array.from(content)); + }); + + test('handles a file whose size is not a multiple of 512', () => { + // 600 bytes — pads to 1024 in the tar but the extracted slice must be exactly 600 + const content = new Uint8Array(600).fill(0xab); + const tar = makeTar('odd.bin', content); + const out = extractFirstFileFromTar(tar); + expect(out.length).toBe(600); + expect(out.every((b) => b === 0xab)).toBe(true); + }); + + test('handles an empty file', () => { + const tar = makeTar('empty.txt', new Uint8Array(0)); + const out = extractFirstFileFromTar(tar); + expect(out.length).toBe(0); + }); + + test('throws when the tar contains no regular file', () => { + // All-zero block looks like end-of-archive — no entries at all + const tar = new Uint8Array(1024); + expect(() => extractFirstFileFromTar(tar)).toThrow('No regular file'); + }); + + test('throws on a truncated tar', () => { + const content = new TextEncoder().encode('this content is missing'); + const tar = makeTar('truncated.txt', content); + // Lop off the content — leave just the header + const truncated = tar.slice(0, 512); + expect(() => extractFirstFileFromTar(truncated)).toThrow(); + }); +}); diff --git a/tests/template-compose-ssrf.test.ts b/tests/template-compose-ssrf.test.ts new file mode 100644 index 0000000..b35513f --- /dev/null +++ b/tests/template-compose-ssrf.test.ts @@ -0,0 +1,42 @@ +/** + * SSRF guard for the template compose-fetch endpoint (POST /api/templates/compose). + * + * For a stack template the server fetches `repository.url + stackfile` and returns + * the body. That URL is fully user-controlled, so without a guard an authenticated + * user could point it at loopback / private / cloud-metadata hosts and read the + * response (a response-disclosing SSRF). The handler gates the fetch with + * isSafeWebhookUrl (strict: block all private ranges - a compose template always + * lives on a public host) and refuses to follow redirects. This test pins the + * guard behaviour the endpoint relies on. + */ +import { describe, test, expect } from 'bun:test'; +import { isSafeWebhookUrl } from '../src/lib/server/url-safety'; + +describe('template compose-fetch SSRF guard', () => { + test('blocks cloud metadata, loopback and private ranges', () => { + const blocked = [ + 'http://169.254.169.254/latest/meta-data/iam/security-credentials/', // AWS metadata + 'http://localhost:8080/internal', + 'http://127.0.0.1/admin', + 'http://[::1]/admin', + 'http://0.0.0.0/x', + 'http://10.0.0.5/service', + 'http://192.168.1.1/router', + 'http://172.16.0.1/x', + ]; + for (const url of blocked) { + expect(isSafeWebhookUrl(url).ok).toBe(false); + } + }); + + test('blocks non-http(s) schemes', () => { + for (const url of ['file:///etc/passwd', 'gopher://x/', 'ftp://host/f']) { + expect(isSafeWebhookUrl(url).ok).toBe(false); + } + }); + + test('allows a normal public compose URL', () => { + expect(isSafeWebhookUrl('https://raw.githubusercontent.com/owner/repo/main/compose.yml').ok).toBe(true); + expect(isSafeWebhookUrl('https://gitea.example.com/o/r/raw/branch/main/compose.yml').ok).toBe(true); + }); +}); diff --git a/tests/traefik-urls.test.ts b/tests/traefik-urls.test.ts new file mode 100644 index 0000000..3509e08 --- /dev/null +++ b/tests/traefik-urls.test.ts @@ -0,0 +1,107 @@ +import { describe, test, expect } from 'bun:test'; +import { extractTraefikUrls } from '../src/lib/utils/traefik-urls'; + +describe('extractTraefikUrls', () => { + test('returns empty for no labels / null / undefined', () => { + expect(extractTraefikUrls(null)).toEqual([]); + expect(extractTraefikUrls(undefined)).toEqual([]); + expect(extractTraefikUrls({})).toEqual([]); + }); + + test('ignores containers with no Traefik labels', () => { + expect(extractTraefikUrls({ 'com.docker.compose.project': 'foo' })).toEqual([]); + }); + + test('plain Host() defaults to https', () => { + const out = extractTraefikUrls({ + 'traefik.http.routers.app.rule': 'Host(`app.example.com`)' + }); + expect(out).toEqual([{ url: 'https://app.example.com', router: 'app' }]); + }); + + test('entrypoints=websecure → https', () => { + const out = extractTraefikUrls({ + 'traefik.http.routers.app.rule': 'Host(`app.example.com`)', + 'traefik.http.routers.app.entrypoints': 'websecure' + }); + expect(out[0].url).toBe('https://app.example.com'); + }); + + test('entrypoints=web → http', () => { + const out = extractTraefikUrls({ + 'traefik.http.routers.app.rule': 'Host(`app.example.com`)', + 'traefik.http.routers.app.entrypoints': 'web' + }); + expect(out[0].url).toBe('http://app.example.com'); + }); + + test('explicit tls=true overrides entrypoints', () => { + const out = extractTraefikUrls({ + 'traefik.http.routers.app.rule': 'Host(`app.example.com`)', + 'traefik.http.routers.app.entrypoints': 'web', + 'traefik.http.routers.app.tls': 'true' + }); + expect(out[0].url).toBe('https://app.example.com'); + }); + + test('appends PathPrefix', () => { + const out = extractTraefikUrls({ + 'traefik.http.routers.api.rule': 'Host(`api.example.com`) && PathPrefix(`/v1`)', + 'traefik.http.routers.api.entrypoints': 'websecure' + }); + expect(out[0].url).toBe('https://api.example.com/v1'); + }); + + test('emits one URL per Host() in a multi-host rule', () => { + const out = extractTraefikUrls({ + 'traefik.http.routers.web.rule': 'Host(`a.example.com`) || Host(`b.example.com`)', + 'traefik.http.routers.web.entrypoints': 'websecure' + }); + expect(out.map((u) => u.url)).toEqual([ + 'https://a.example.com', + 'https://b.example.com' + ]); + }); + + test('dedupes identical hosts across routers', () => { + const out = extractTraefikUrls({ + 'traefik.http.routers.a.rule': 'Host(`same.example.com`)', + 'traefik.http.routers.b.rule': 'Host(`same.example.com`)' + }); + expect(out).toHaveLength(1); + expect(out[0].url).toBe('https://same.example.com'); + }); + + test('skips routers with no Host() (PathPrefix-only / HostRegexp)', () => { + expect( + extractTraefikUrls({ + 'traefik.http.routers.api.rule': 'PathPrefix(`/api`)', + 'traefik.http.routers.regex.rule': 'HostRegexp(`{subdomain:[a-z]+}.example.com`)' + }) + ).toEqual([]); + }); + + test('accepts double-quoted and single-quoted Host()', () => { + const out = extractTraefikUrls({ + 'traefik.http.routers.dq.rule': 'Host("dq.example.com")', + 'traefik.http.routers.sq.rule': "Host('sq.example.com')" + }); + expect(out.map((u) => u.url).sort()).toEqual([ + 'https://dq.example.com', + 'https://sq.example.com' + ]); + }); + + test('multiple routers on one container each produce a URL', () => { + const out = extractTraefikUrls({ + 'traefik.http.routers.api.rule': 'Host(`api.example.com`)', + 'traefik.http.routers.api.entrypoints': 'websecure', + 'traefik.http.routers.ui.rule': 'Host(`ui.example.com`)', + 'traefik.http.routers.ui.entrypoints': 'web' + }); + expect(out.map((u) => u.url).sort()).toEqual([ + 'http://ui.example.com', + 'https://api.example.com' + ]); + }); +}); diff --git a/tests/unbackupable-mounts.test.ts b/tests/unbackupable-mounts.test.ts new file mode 100644 index 0000000..3222481 --- /dev/null +++ b/tests/unbackupable-mounts.test.ts @@ -0,0 +1,52 @@ +/** + * Unit tests for isUnbackupableBindSource — the shared rule that keeps sockets + * and host system paths out of backups (both the engine's "backup all" and the + * UI picker). A regression here would either re-admit /var/run/docker.sock into + * snapshots or wrongly exclude real data binds like /app/data. + */ +import { describe, test, expect } from 'bun:test'; +import { isUnbackupableBindSource } from '../src/lib/utils/unbackupable-mounts'; + +describe('isUnbackupableBindSource', () => { + test('docker + containerd sockets are unbackupable', () => { + expect(isUnbackupableBindSource('/var/run/docker.sock')).toBe(true); + expect(isUnbackupableBindSource('/run/docker.sock')).toBe(true); + expect(isUnbackupableBindSource('/run/containerd/containerd.sock')).toBe(true); + // rootless docker socket path (under /var/run prefix) + expect(isUnbackupableBindSource('/var/run/user/1000/docker.sock')).toBe(true); + // a socket bound from an unusual path still caught by basename + expect(isUnbackupableBindSource('/opt/custom/docker.sock')).toBe(true); + }); + + test('host system paths are unbackupable', () => { + expect(isUnbackupableBindSource('/proc')).toBe(true); + expect(isUnbackupableBindSource('/proc/sys')).toBe(true); + expect(isUnbackupableBindSource('/sys/fs/cgroup')).toBe(true); + expect(isUnbackupableBindSource('/dev')).toBe(true); + expect(isUnbackupableBindSource('/dev/net/tun')).toBe(true); + expect(isUnbackupableBindSource('/var/run')).toBe(true); + expect(isUnbackupableBindSource('/run')).toBe(true); + // trailing slash tolerated + expect(isUnbackupableBindSource('/var/run/')).toBe(true); + }); + + test('real data binds stay backupable', () => { + expect(isUnbackupableBindSource('/app/data')).toBe(false); + expect(isUnbackupableBindSource('/external-stacks')).toBe(false); + expect(isUnbackupableBindSource('/backups')).toBe(false); + expect(isUnbackupableBindSource('/home/user/stacks/myapp')).toBe(false); + // a data dir that merely CONTAINS 'run' or 'dev' as a segment is fine + expect(isUnbackupableBindSource('/srv/dev-data')).toBe(false); + expect(isUnbackupableBindSource('/data/runner')).toBe(false); + // prefix must be a full segment: /developer is NOT /dev + expect(isUnbackupableBindSource('/developer')).toBe(false); + expect(isUnbackupableBindSource('/systemd-data')).toBe(false); + }); + + test('named volumes (non-absolute) and empty are backupable', () => { + expect(isUnbackupableBindSource('my_named_volume')).toBe(false); + expect(isUnbackupableBindSource('')).toBe(false); + expect(isUnbackupableBindSource(undefined)).toBe(false); + expect(isUnbackupableBindSource(null)).toBe(false); + }); +}); diff --git a/tests/url-format.test.ts b/tests/url-format.test.ts new file mode 100644 index 0000000..003776d --- /dev/null +++ b/tests/url-format.test.ts @@ -0,0 +1,24 @@ +import { describe, test, expect } from 'bun:test'; +import { formatHostPortUrl } from '../src/lib/utils/url'; + +describe('formatHostPortUrl', () => { + test('formats IPv4 address', () => { + expect(formatHostPortUrl('192.168.1.1', 8080)).toBe('http://192.168.1.1:8080'); + }); + + test('formats localhost', () => { + expect(formatHostPortUrl('localhost', 3000)).toBe('http://localhost:3000'); + }); + + test('wraps IPv6 address in brackets', () => { + expect(formatHostPortUrl('2001:db8::1', 8080)).toBe('http://[2001:db8::1]:8080'); + }); + + test('does not double-bracket already bracketed IPv6', () => { + expect(formatHostPortUrl('[2001:db8::1]', 8080)).toBe('http://[2001:db8::1]:8080'); + }); + + test('formats hostname', () => { + expect(formatHostPortUrl('example.com', 443)).toBe('http://example.com:443'); + }); +}); diff --git a/tests/version.test.ts b/tests/version.test.ts new file mode 100644 index 0000000..5819c0a --- /dev/null +++ b/tests/version.test.ts @@ -0,0 +1,63 @@ +import { describe, test, expect } from 'bun:test'; +import { compareVersions, shouldShowWhatsNew } from '../src/lib/utils/version'; + +describe('compareVersions', () => { + test('returns 0 for equal versions', () => { + expect(compareVersions('1.0.0', '1.0.0')).toBe(0); + }); + + test('handles v prefix', () => { + expect(compareVersions('v1.0.0', '1.0.0')).toBe(0); + expect(compareVersions('1.0.0', 'v1.0.0')).toBe(0); + expect(compareVersions('v1.0.0', 'v1.0.0')).toBe(0); + }); + + test('returns 1 when v1 > v2', () => { + expect(compareVersions('2.0.0', '1.0.0')).toBe(1); + expect(compareVersions('1.1.0', '1.0.0')).toBe(1); + expect(compareVersions('1.0.1', '1.0.0')).toBe(1); + }); + + test('returns -1 when v1 < v2', () => { + expect(compareVersions('1.0.0', '2.0.0')).toBe(-1); + expect(compareVersions('1.0.0', '1.1.0')).toBe(-1); + expect(compareVersions('1.0.0', '1.0.1')).toBe(-1); + }); + + test('handles different segment lengths', () => { + expect(compareVersions('1.0', '1.0.0')).toBe(0); + expect(compareVersions('1.0.0', '1.0')).toBe(0); + expect(compareVersions('1.0', '1.0.1')).toBe(-1); + }); + + test('handles large version numbers', () => { + expect(compareVersions('10.20.30', '10.20.29')).toBe(1); + expect(compareVersions('1.0.100', '1.0.99')).toBe(1); + }); +}); + +describe('shouldShowWhatsNew', () => { + test('returns false when currentVersion is null', () => { + expect(shouldShowWhatsNew(null, null)).toBe(false); + }); + + test('returns false when currentVersion is "unknown"', () => { + expect(shouldShowWhatsNew('unknown', null)).toBe(false); + }); + + test('returns true when lastSeenVersion is null (first time)', () => { + expect(shouldShowWhatsNew('v1.0.0', null)).toBe(true); + }); + + test('returns true when current > lastSeen', () => { + expect(shouldShowWhatsNew('v1.1.0', 'v1.0.0')).toBe(true); + }); + + test('returns false when current == lastSeen', () => { + expect(shouldShowWhatsNew('v1.0.0', 'v1.0.0')).toBe(false); + }); + + test('returns false when current < lastSeen', () => { + expect(shouldShowWhatsNew('v1.0.0', 'v1.1.0')).toBe(false); + }); +}); diff --git a/tests/volume-identity.test.ts b/tests/volume-identity.test.ts new file mode 100644 index 0000000..eb5effd --- /dev/null +++ b/tests/volume-identity.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'bun:test'; +import { volumeDedupKey, volumeStorageKey, safeKey } from '../src/lib/utils/volume-identity'; +import { discoverVolumesFromMounts } from '../src/lib/server/backups/discovery-core'; +import { normalizeStackMounts } from '../src/lib/utils/mounts'; + +describe('volumeDedupKey', () => { + it('a named volume is identified by its name', () => { + expect(volumeDedupKey({ type: 'volume', source: 'db', destination: '/data', name: 'db' })).toBe('vol:db'); + }); + it('a bind is identified by (source, destination) - two /data binds from different hosts differ', () => { + const a = volumeDedupKey({ type: 'bind', source: '/mnt/gitea/data', destination: '/data' }); + const b = volumeDedupKey({ type: 'bind', source: '/mnt/gitea-runner/data', destination: '/data' }); + expect(a).not.toBe(b); + }); + it('the same bind (source AND destination) has the same identity', () => { + const a = volumeDedupKey({ type: 'bind', source: '/host/x', destination: '/data' }); + const b = volumeDedupKey({ type: 'bind', source: '/host/x', destination: '/data' }); + expect(a).toBe(b); + }); +}); + +describe('volumeStorageKey', () => { + it('encodes the source tail so two binds sharing a destination get distinct readable keys (#1373)', () => { + const taken = new Set(); + const a = volumeStorageKey({ type: 'bind', source: '/mnt/cache/appdata/gitea/data', destination: '/data' }, taken); + const b = volumeStorageKey({ type: 'bind', source: '/mnt/cache/appdata/gitea-runner/data', destination: '/data' }, taken); + expect(a).toBe('data__gitea_data'); + expect(b).toBe('data__gitea-runner_data'); + expect(a).not.toBe(b); + }); + it('a named volume keys on its name', () => { + expect(volumeStorageKey({ type: 'volume', source: 'db', destination: '/data', name: 'db' })).toBe('db'); + }); + it('collision-suffixes when two mounts would slug to the same key', () => { + const taken = new Set(); + const a = volumeStorageKey({ type: 'bind', source: '/a/data', destination: '/data' }, taken); + const b = volumeStorageKey({ type: 'bind', source: '/a/data', destination: '/DATA' }, taken); // same tail+dest slug collision path + expect(a).not.toBe(b); + }); + it('produces a shell-safe key', () => { + const k = volumeStorageKey({ type: 'bind', source: '/srv/$(id)', destination: '/data/$(x)' }); + expect(k).toMatch(/^[A-Za-z0-9._-]+$/); + }); +}); + +describe('safeKey', () => { + it('slugs unsafe chars and suffixes collisions', () => { + const taken = new Set(); + expect(safeKey('/etc/app', taken)).toBe('etc_app'); + expect(safeKey('/etc/app', taken)).toBe('etc_app_2'); + }); +}); + +// The whole point of the extraction: server discovery and the UI picker compute IDENTICAL keys +// for the same mounts, so the picker can't drift from what a backup actually captures (#1373). +describe('server discovery and UI picker agree on keys', () => { + it('two containers, two /data binds from different sources -> same keys on both sides', () => { + const containers = [ + { name: 'gitea', mounts: [{ Type: 'bind', Source: '/mnt/cache/appdata/gitea/data', Destination: '/data' }] }, + { name: 'gitea-runner', mounts: [{ Type: 'bind', Source: '/mnt/cache/appdata/gitea-runner/data', Destination: '/data' }] }, + ]; + const serverKeys = discoverVolumesFromMounts(containers).volumes.map((v) => v.key).sort(); + const uiKeys = normalizeStackMounts( + containers.map((c) => ({ Mounts: c.mounts })) + ).map((v) => v.key).sort(); + expect(uiKeys).toEqual(serverKeys); + expect(serverKeys).toEqual(['data__gitea-runner_data', 'data__gitea_data']); + }); + + it('a mixed named-volume + bind stack agrees on keys across both sides', () => { + const containers = [ + { name: 'a', mounts: [{ Type: 'volume', Name: 'shared', Destination: '/data' }, { Type: 'bind', Source: '/host/conf', Destination: '/conf' }] }, + { name: 'b', mounts: [{ Type: 'volume', Name: 'shared', Destination: '/data' }] }, + ]; + const serverKeys = discoverVolumesFromMounts(containers).volumes.map((v) => v.key).sort(); + const uiKeys = normalizeStackMounts(containers.map((c) => ({ Mounts: c.mounts }))).map((v) => v.key).sort(); + expect(uiKeys).toEqual(serverKeys); + }); +}); diff --git a/tests/vuln-criteria.test.ts b/tests/vuln-criteria.test.ts new file mode 100644 index 0000000..5f62f6e --- /dev/null +++ b/tests/vuln-criteria.test.ts @@ -0,0 +1,113 @@ +import { describe, test, expect } from 'bun:test'; +import { shouldBlockUpdate, combineScanSummaries } from '../src/lib/server/scheduler/tasks/update-utils'; + +const clean = { critical: 0, high: 0, medium: 0, low: 0, negligible: 0, unknown: 0 }; + +describe('shouldBlockUpdate', () => { + test('never blocks with "never" criteria', () => { + const vulny = { ...clean, critical: 5, high: 10 }; + expect(shouldBlockUpdate('never', vulny).blocked).toBe(false); + }); + + test('"any" blocks when any vulnerabilities exist', () => { + expect(shouldBlockUpdate('any', { ...clean, low: 1 }).blocked).toBe(true); + expect(shouldBlockUpdate('any', clean).blocked).toBe(false); + }); + + test('"critical_high" blocks on critical or high only', () => { + expect(shouldBlockUpdate('critical_high', { ...clean, critical: 1 }).blocked).toBe(true); + expect(shouldBlockUpdate('critical_high', { ...clean, high: 1 }).blocked).toBe(true); + expect(shouldBlockUpdate('critical_high', { ...clean, medium: 5 }).blocked).toBe(false); + expect(shouldBlockUpdate('critical_high', { ...clean, low: 10 }).blocked).toBe(false); + }); + + test('"critical" blocks on critical only', () => { + expect(shouldBlockUpdate('critical', { ...clean, critical: 1 }).blocked).toBe(true); + expect(shouldBlockUpdate('critical', { ...clean, high: 10 }).blocked).toBe(false); + }); + + test('"more_than_current" blocks when new > current', () => { + const current = { ...clean, low: 5 }; + const newMore = { ...clean, low: 6 }; + const newLess = { ...clean, low: 4 }; + const newEqual = { ...clean, low: 5 }; + + expect(shouldBlockUpdate('more_than_current', newMore, current).blocked).toBe(true); + expect(shouldBlockUpdate('more_than_current', newLess, current).blocked).toBe(false); + expect(shouldBlockUpdate('more_than_current', newEqual, current).blocked).toBe(false); + }); + + test('"more_than_current" does not block without current scan', () => { + expect(shouldBlockUpdate('more_than_current', { ...clean, critical: 100 }).blocked).toBe(false); + }); + + test('returns reason string when blocked', () => { + const result = shouldBlockUpdate('critical', { ...clean, critical: 3 }); + expect(result.blocked).toBe(true); + expect(result.reason).toContain('3 critical'); + }); + + // --- M7 regression --- + // The 'more_than_current' comparison is gated on a current scan summary; the + // env-wide update path used to pass undefined, so it silently never blocked. + // These pin the contract that env-update-check.ts now relies on. + test('M7: passing undefined current scan never blocks, even for a very vulnerable image', () => { + const veryVulnerable = { ...clean, critical: 50, high: 50, medium: 50, low: 50 }; + expect(shouldBlockUpdate('more_than_current', veryVulnerable, undefined).blocked).toBe(false); + }); + + test('M7: blocks when current scan is supplied and new has more vulns', () => { + const current = { ...clean, critical: 1, high: 1 }; // 2 total + const newMore = { ...clean, critical: 2, high: 1 }; // 3 total + const { blocked, reason } = shouldBlockUpdate('more_than_current', newMore, current); + expect(blocked).toBe(true); + expect(reason).toContain('3'); + expect(reason).toContain('2'); + }); + + test('returns empty reason when not blocked', () => { + expect(shouldBlockUpdate('never', { ...clean, critical: 5 }).reason).toBe(''); + }); + + test('"any" reason includes all severity counts', () => { + const scan = { ...clean, critical: 1, high: 2, medium: 3, low: 4 }; + const result = shouldBlockUpdate('any', scan); + expect(result.reason).toContain('1 critical'); + expect(result.reason).toContain('2 high'); + expect(result.reason).toContain('3 medium'); + expect(result.reason).toContain('4 low'); + }); +}); + +describe('combineScanSummaries', () => { + test('returns zeros for empty input', () => { + expect(combineScanSummaries([])).toEqual(clean); + }); + + test('returns single result as-is', () => { + const summary = { ...clean, critical: 3, high: 5 }; + expect(combineScanSummaries([{ summary }])).toEqual(summary); + }); + + test('takes maximum of each severity across scanners', () => { + const grype = { summary: { critical: 2, high: 5, medium: 0, low: 1, negligible: 0, unknown: 0 } }; + const trivy = { summary: { critical: 3, high: 1, medium: 4, low: 0, negligible: 2, unknown: 1 } }; + + expect(combineScanSummaries([grype, trivy])).toEqual({ + critical: 3, + high: 5, + medium: 4, + low: 1, + negligible: 2, + unknown: 1 + }); + }); + + test('handles three scanners', () => { + const a = { summary: { ...clean, critical: 1 } }; + const b = { summary: { ...clean, critical: 5 } }; + const c = { summary: { ...clean, critical: 3 } }; + + expect(combineScanSummaries([a, b, c]).critical).toBe(5); + }); +}); diff --git a/tests/vuln-export-helpers.test.ts b/tests/vuln-export-helpers.test.ts new file mode 100644 index 0000000..5905db7 --- /dev/null +++ b/tests/vuln-export-helpers.test.ts @@ -0,0 +1,252 @@ +import { describe, test, expect } from 'bun:test'; +import { filterFindings, flattenScansToFindings, sortFindings } from '../src/lib/utils/vulnerability'; +import type { Finding } from '../src/lib/utils/vulnerability'; +import { escapeCSV, rowsToCSV } from '../src/lib/server/csv'; + +const base = { + cve: 'CVE-1', + package: 'openssl', + severity: 'high', + imageName: 'nginx:latest', + containers: [{ name: 'web-1' }], + stacks: ['frontend'] +}; + +describe('filterFindings', () => { + const findings = [ + { ...base, cve: 'CVE-1', severity: 'critical', imageName: 'nginx', containers: [{ name: 'web-1' }], stacks: ['frontend'] }, + { ...base, cve: 'CVE-2', severity: 'low', imageName: 'redis', containers: [{ name: 'cache-1' }], stacks: ['backend'] }, + { ...base, cve: 'CVE-3', severity: 'high', imageName: 'redis', containers: [], stacks: [] } + ]; + + test('no filter returns all', () => { + expect(filterFindings(findings, {})).toHaveLength(3); + }); + test('severity filter', () => { + expect(filterFindings(findings, { severities: ['critical'] }).map((f) => f.cve)).toEqual(['CVE-1']); + }); + test('image filter', () => { + expect(filterFindings(findings, { images: ['redis'] }).map((f) => f.cve)).toEqual(['CVE-2', 'CVE-3']); + }); + test('container filter', () => { + expect(filterFindings(findings, { containers: ['cache-1'] }).map((f) => f.cve)).toEqual(['CVE-2']); + }); + test('stack filter', () => { + expect(filterFindings(findings, { stacks: ['frontend'] }).map((f) => f.cve)).toEqual(['CVE-1']); + }); + test('search matches cve/package/image/container/stack', () => { + expect(filterFindings(findings, { q: 'cache-1' }).map((f) => f.cve)).toEqual(['CVE-2']); + expect(filterFindings(findings, { q: 'frontend' }).map((f) => f.cve)).toEqual(['CVE-1']); + expect(filterFindings(findings, { q: 'redis' }).map((f) => f.cve)).toEqual(['CVE-2', 'CVE-3']); + }); + test('filters combine (AND)', () => { + expect(filterFindings(findings, { images: ['redis'], severities: ['high'] }).map((f) => f.cve)).toEqual(['CVE-3']); + }); + test('empty arrays are treated as no filter', () => { + expect(filterFindings(findings, { severities: [], images: [] })).toHaveLength(3); + }); +}); + +describe('flattenScansToFindings', () => { + test('flattens and dedupes by image|cve|package|version', () => { + const scans = [ + { + imageId: 'sha256:a', imageName: 'nginx', scannedAt: 't1', + vulnerabilities: [ + { id: 'CVE-1', severity: 'high', package: 'p', version: '1', fixedVersion: '2', link: 'l' }, + { id: 'CVE-1', severity: 'high', package: 'p', version: '1' } // duplicate + ] + } + ]; + const findings = flattenScansToFindings(scans); + expect(findings).toHaveLength(1); + expect(findings[0].cve).toBe('CVE-1'); + expect(findings[0].fixedVersion).toBe('2'); + expect(findings[0].imageId).toBe('sha256:a'); + }); + + test('parses legacy double-encoded vulnerabilities string', () => { + const scans = [ + { imageId: 'sha256:b', imageName: 'redis', scannedAt: 't', vulnerabilities: JSON.stringify([{ id: 'CVE-9', severity: 'low', package: 'q', version: '3' }]) } + ]; + const findings = flattenScansToFindings(scans); + expect(findings).toHaveLength(1); + expect(findings[0].cve).toBe('CVE-9'); + }); + + test('tolerates missing/invalid vulnerabilities', () => { + expect(flattenScansToFindings([{ imageId: 'x', imageName: 'y', scannedAt: 't', vulnerabilities: null }])).toHaveLength(0); + expect(flattenScansToFindings([{ imageId: 'x', imageName: 'y', scannedAt: 't', vulnerabilities: 'not-json' }])).toHaveLength(0); + }); +}); + +describe('sortFindings', () => { + function f(over: Partial): Finding { + return { + key: 'k', cve: 'CVE-0', package: 'pkg', severity: 'low', installedVersion: '1.0', + fixedVersion: '', imageId: 'sha256:x', imageName: 'img', description: '', link: '', + scannedAt: '', containers: [], stacks: [], ...over + } as Finding; + } + + test('severity: rank is critical=0..low=3, so asc lists critical first', () => { + const rows = [f({ severity: 'low' }), f({ severity: 'critical' }), f({ severity: 'medium' }), f({ severity: 'high' })]; + expect(sortFindings(rows, 'severity', 'asc').map((r) => r.severity)).toEqual(['critical', 'high', 'medium', 'low']); + expect(sortFindings(rows, 'severity', 'desc').map((r) => r.severity)).toEqual(['low', 'medium', 'high', 'critical']); + }); + + test('cve: plain string order', () => { + const rows = [f({ cve: 'CVE-3' }), f({ cve: 'CVE-1' }), f({ cve: 'CVE-2' })]; + expect(sortFindings(rows, 'cve', 'asc').map((r) => r.cve)).toEqual(['CVE-1', 'CVE-2', 'CVE-3']); + }); + + test('installed: numeric-aware version compare (1.2.10 > 1.2.9)', () => { + const rows = [f({ installedVersion: '1.2.9' }), f({ installedVersion: '1.2.10' }), f({ installedVersion: '1.2.1' })]; + expect(sortFindings(rows, 'installed', 'asc').map((r) => r.installedVersion)).toEqual(['1.2.1', '1.2.9', '1.2.10']); + }); + + test('container: sorts by the alphabetically-first container name (min of the list)', () => { + const rows = [ + f({ cve: 'A', containers: [{ name: 'zeta' }, { name: 'beta' }] }), // min = beta + f({ cve: 'B', containers: [{ name: 'alpha' }] }), // min = alpha + f({ cve: 'C', containers: [] }) // min = '' (first) + ]; + expect(sortFindings(rows, 'container', 'asc').map((r) => r.cve)).toEqual(['C', 'B', 'A']); + }); + + test('stack: sorts by the alphabetically-first stack; empty sorts first', () => { + const rows = [ + f({ cve: 'A', stacks: ['web', 'api'] }), // min = api + f({ cve: 'B', stacks: ['db'] }), // min = db + f({ cve: 'C', stacks: [] }) // min = '' + ]; + expect(sortFindings(rows, 'stack', 'asc').map((r) => r.cve)).toEqual(['C', 'A', 'B']); + }); + + test('returns a new array and does not mutate the input', () => { + const rows = [f({ cve: 'CVE-2' }), f({ cve: 'CVE-1' })]; + const sorted = sortFindings(rows, 'cve', 'asc'); + expect(sorted).not.toBe(rows); + expect(rows.map((r) => r.cve)).toEqual(['CVE-2', 'CVE-1']); // original order intact + }); +}); + +describe('escapeCSV — plain values', () => { + test('leaves a plain string untouched', () => { + expect(escapeCSV('plain')).toBe('plain'); + expect(escapeCSV('CVE-2024-1234')).toBe('CVE-2024-1234'); + expect(escapeCSV('nginx:1.25.3')).toBe('nginx:1.25.3'); + }); + + test('renders positive numbers as their string form, unquoted', () => { + expect(escapeCSV(42)).toBe('42'); + expect(escapeCSV(0)).toBe('0'); + expect(escapeCSV(3.14)).toBe('3.14'); + }); + + test('a NEGATIVE number stringifies to a leading "-", so it is prefixed', () => { + // String(-5) === "-5" starts with '-', a formula trigger. Prefixing is the + // safe, intended outcome (the cell stays human-readable as '-5). + expect(escapeCSV(-5)).toBe("'-5"); + }); + + test('maps null/undefined to an empty cell', () => { + expect(escapeCSV(null)).toBe(''); + expect(escapeCSV(undefined)).toBe(''); + }); + + test('leaves an empty string empty', () => { + expect(escapeCSV('')).toBe(''); + }); +}); + +describe('escapeCSV — RFC-4180 quoting', () => { + test('quotes values containing a comma', () => { + expect(escapeCSV('a,b')).toBe('"a,b"'); + }); + + test('quotes and doubles embedded quotes', () => { + expect(escapeCSV('say "hi"')).toBe('"say ""hi"""'); + expect(escapeCSV('"')).toBe('""""'); // one quote -> quoted + doubled + }); + + test('quotes values containing a newline (LF)', () => { + expect(escapeCSV('line1\nline2')).toBe('"line1\nline2"'); + }); + + test('quotes values containing a carriage return (CR)', () => { + // CR is also a row-break in many CSV parsers; must be quoted. It is also a + // leading-CR formula-injection trigger, so it additionally gets the ' prefix. + expect(escapeCSV('a\rb')).toBe('"a\rb"'); + }); + + test('does not quote a value with no special characters', () => { + expect(escapeCSV('no-specials_here.123')).toBe('no-specials_here.123'); + }); +}); + +describe('escapeCSV — formula-injection neutralization', () => { + test('prefixes a leading = with a single quote', () => { + expect(escapeCSV('=SUM(A1:A9)')).toBe("'=SUM(A1:A9)"); + expect(escapeCSV('=1+1')).toBe("'=1+1"); + }); + + test('prefixes leading +, -, @ (spreadsheet formula starters)', () => { + expect(escapeCSV('+1')).toBe("'+1"); + expect(escapeCSV('-1+2')).toBe("'-1+2"); + expect(escapeCSV('@SUM')).toBe("'@SUM"); + }); + + test('prefixes a leading tab (payload smuggling via whitespace)', () => { + // Tab is a formula trigger (gets the ' prefix) but NOT an RFC-4180 quote + // trigger, so no surrounding quotes are added. + expect(escapeCSV('\t=cmd')).toBe("'\t=cmd"); + expect(escapeCSV('\thello')).toBe("'\thello"); + }); + + test('a leading CR is prefixed AND quoted (CR is both triggers)', () => { + expect(escapeCSV('\rhello')).toBe('"\'\rhello"'); + }); + + test('only the FIRST character matters — interior symbols are safe', () => { + expect(escapeCSV('a=b')).toBe('a=b'); + expect(escapeCSV('nginx-1.25')).toBe('nginx-1.25'); + expect(escapeCSV('user@host')).toBe('user@host'); + }); + + test('injection + RFC-4180: a leading = with a comma is BOTH prefixed and quoted', () => { + // e.g. an attacker-crafted image name like "=HYPERLINK(x),evil" + expect(escapeCSV('=cmd,arg')).toBe('"\'=cmd,arg"'); + }); + + test('injection char that also needs quoting for an embedded quote', () => { + expect(escapeCSV('=say"hi"')).toBe('"\'=say""hi"""'); + }); +}); + +describe('rowsToCSV', () => { + test('builds a header row plus escaped data rows', () => { + const csv = rowsToCSV(['A', 'B'], [['x', 'y,z'], [1, null]]); + expect(csv).toBe('A,B\nx,"y,z"\n1,'); + }); + + test('emits just the header when there are no rows', () => { + expect(rowsToCSV(['A', 'B'], [])).toBe('A,B'); + }); + + test('escapes injection payloads inside data rows', () => { + const csv = rowsToCSV(['CVE', 'Image'], [['CVE-1', '=IMPORTXML(1)']]); + expect(csv).toBe("CVE,Image\nCVE-1,'=IMPORTXML(1)"); + }); + + test('handles mixed null / number / string cells per row', () => { + const csv = rowsToCSV(['A', 'B', 'C'], [[null, 7, 'hi'], ['', undefined, 'a,b']]); + expect(csv).toBe('A,B,C\n,7,hi\n,,"a,b"'); + }); + + test('does not escape header cells (headers are fixed, trusted labels)', () => { + // Sanity: headers are joined verbatim; callers pass safe constant labels. + const csv = rowsToCSV(['Fixed Version', 'Link'], [['1.2.3', 'http://x']]); + expect(csv.split('\n')[0]).toBe('Fixed Version,Link'); + }); +}); diff --git a/tests/vulnerabilities-cache.test.ts b/tests/vulnerabilities-cache.test.ts new file mode 100644 index 0000000..8e96fca --- /dev/null +++ b/tests/vulnerabilities-cache.test.ts @@ -0,0 +1,118 @@ +/** + * Unit tests for the vulnerability aggregation cache. + * + * This module is deliberately db-free (it imports only client-safe types) so + * it can be exercised under `bun test` without pulling in better-sqlite3 — the + * whole reason the cache was extracted from vulnerabilities.ts. These tests + * pin down the invalidation semantics that saveVulnerabilityScan relies on. + */ +import { test, expect, beforeEach } from 'bun:test'; +import { + aggregateCache, + inflight, + invalidateVulnerabilitiesCache, + CACHE_TTL_MS, + MAX_VIEWS, + type CacheEntry, + type AggregatedVulnerabilities +} from '../src/lib/server/vulnerabilities-cache'; + +function makeEntry(): CacheEntry { + const data: AggregatedVulnerabilities = { + findings: [], + summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0, imagesScanned: 0, totalImages: 0 } + }; + return { at: 0, data, views: new Map() }; +} + +beforeEach(() => { + aggregateCache.clear(); + inflight.clear(); +}); + +test('exports sane constants', () => { + expect(CACHE_TTL_MS).toBe(30_000); + expect(MAX_VIEWS).toBe(8); +}); + +test('invalidate with an env id drops only that environment', () => { + aggregateCache.set(1, makeEntry()); + aggregateCache.set(2, makeEntry()); + + invalidateVulnerabilitiesCache(1); + + expect(aggregateCache.has(1)).toBe(false); + expect(aggregateCache.has(2)).toBe(true); +}); + +test('invalidate with no argument clears every environment', () => { + aggregateCache.set(1, makeEntry()); + aggregateCache.set(2, makeEntry()); + + invalidateVulnerabilitiesCache(); + + expect(aggregateCache.size).toBe(0); +}); + +test('invalidate with null clears every environment (unknown-env broad change)', () => { + aggregateCache.set(1, makeEntry()); + aggregateCache.set(2, makeEntry()); + + invalidateVulnerabilitiesCache(null); + + expect(aggregateCache.size).toBe(0); +}); + +test('invalidating a missing env id is a no-op, not an error', () => { + aggregateCache.set(5, makeEntry()); + + expect(() => invalidateVulnerabilitiesCache(999)).not.toThrow(); + expect(aggregateCache.size).toBe(1); + expect(aggregateCache.has(5)).toBe(true); +}); + +test('null-environment scans key under 0', () => { + // saveVulnerabilityScan passes `data.environmentId ?? undefined`; a null env + // therefore invalidates env 0, which is where the dashboard aggregates it. + aggregateCache.set(0, makeEntry()); + + invalidateVulnerabilitiesCache(0); + + expect(aggregateCache.has(0)).toBe(false); +}); + +test('invalidating an env also drops its in-flight aggregation', () => { + // A fetch racing a scan-save would otherwise resolve to pre-scan data and be + // installed with a fresh TTL, masking the new scan for a full window. + inflight.set(4, Promise.resolve({ + findings: [], + summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0, imagesScanned: 0, totalImages: 0 } + })); + inflight.set(5, Promise.resolve({ + findings: [], + summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0, imagesScanned: 0, totalImages: 0 } + })); + + invalidateVulnerabilitiesCache(4); + expect(inflight.has(4)).toBe(false); + expect(inflight.has(5)).toBe(true); // other env untouched + + invalidateVulnerabilitiesCache(); // clear-all also clears inflight + expect(inflight.size).toBe(0); +}); + +test('invalidation drops memoized views along with the entry', () => { + const entry = makeEntry(); + entry.views.set('some-query', []); + entry.meta = { + total: 0, + summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0, imagesScanned: 0, totalImages: 0 }, + options: { images: [], containers: [], stacks: [] } + }; + aggregateCache.set(3, entry); + + invalidateVulnerabilitiesCache(3); + + // Whole entry gone → its views/meta are gone too; a refetch rebuilds them. + expect(aggregateCache.get(3)).toBeUndefined(); +}); diff --git a/tests/webhook-signature.test.ts b/tests/webhook-signature.test.ts new file mode 100644 index 0000000..f045867 --- /dev/null +++ b/tests/webhook-signature.test.ts @@ -0,0 +1,81 @@ +/** + * Unit tests for git webhook signature verification. + * + * Covers every provider wire format we accept: + * - GitHub / Gitea / Forgejo: `X-Hub-Signature-256: sha256=` (HMAC-SHA256 of body) + * - GitLab: `X-Gitlab-Token: ` (plain token, exact match) + * + * Run with: bun test tests/unit/webhook-signature.test.ts + */ + +import { describe, test, expect } from 'bun:test'; +import crypto from 'node:crypto'; +import { verifyWebhookSignature } from '../src/lib/server/webhook-signature'; + +const SECRET = 'super-secret-value-123'; +const PAYLOAD = JSON.stringify({ ref: 'refs/heads/main', repository: { name: 'demo' } }); + +// The sha256= HMAC scheme, as GitHub/Gitea/Forgejo compute it. +function hmacHeader(secret: string, payload: string): string { + return 'sha256=' + crypto.createHmac('sha256', secret).update(payload).digest('hex'); +} + +describe('verifyWebhookSignature - HMAC providers (GitHub / Gitea / Forgejo)', () => { + test('GitHub: valid sha256 HMAC passes', () => { + expect(verifyWebhookSignature(PAYLOAD, hmacHeader(SECRET, PAYLOAD), SECRET)).toBe(true); + }); + + test('Gitea: valid sha256 HMAC passes (GitHub-compatible scheme)', () => { + expect(verifyWebhookSignature(PAYLOAD, hmacHeader(SECRET, PAYLOAD), SECRET)).toBe(true); + }); + + test('Forgejo: valid sha256 HMAC passes (GitHub-compatible scheme)', () => { + expect(verifyWebhookSignature(PAYLOAD, hmacHeader(SECRET, PAYLOAD), SECRET)).toBe(true); + }); + + test('HMAC computed with the wrong secret fails', () => { + expect(verifyWebhookSignature(PAYLOAD, hmacHeader('wrong-secret', PAYLOAD), SECRET)).toBe(false); + }); + + test('HMAC over a different payload fails (body tamper)', () => { + const sig = hmacHeader(SECRET, PAYLOAD); + expect(verifyWebhookSignature(PAYLOAD + 'x', sig, SECRET)).toBe(false); + }); + + test('truncated sha256 signature fails (length mismatch, no throw)', () => { + expect(verifyWebhookSignature(PAYLOAD, 'sha256=deadbeef', SECRET)).toBe(false); + }); + + test('sha256 prefix with empty digest fails', () => { + expect(verifyWebhookSignature(PAYLOAD, 'sha256=', SECRET)).toBe(false); + }); +}); + +describe('verifyWebhookSignature - token provider (GitLab)', () => { + test('GitLab: exact token match passes', () => { + expect(verifyWebhookSignature(PAYLOAD, SECRET, SECRET)).toBe(true); + }); + + test('GitLab: wrong token fails', () => { + expect(verifyWebhookSignature(PAYLOAD, 'not-the-secret', SECRET)).toBe(false); + }); + + test('GitLab: token differing only in length fails (no throw)', () => { + expect(verifyWebhookSignature(PAYLOAD, SECRET + 'extra', SECRET)).toBe(false); + }); + + test('GitLab: token is compared independently of payload', () => { + // A plain token must not depend on the body, unlike the HMAC scheme. + expect(verifyWebhookSignature('any-other-body', SECRET, SECRET)).toBe(true); + }); +}); + +describe('verifyWebhookSignature - missing signature', () => { + test('null signature fails', () => { + expect(verifyWebhookSignature(PAYLOAD, null, SECRET)).toBe(false); + }); + + test('empty signature fails', () => { + expect(verifyWebhookSignature(PAYLOAD, '', SECRET)).toBe(false); + }); +}); diff --git a/tests/windowed-list.test.ts b/tests/windowed-list.test.ts new file mode 100644 index 0000000..ab8eab1 --- /dev/null +++ b/tests/windowed-list.test.ts @@ -0,0 +1,192 @@ +import { describe, test, expect } from 'bun:test'; +import { windowOffsetFor, WindowedListCore, type WindowState } from '../src/lib/utils/windowed-list.svelte'; + +describe('windowOffsetFor', () => { + test('centers the window on the target', () => { + expect(windowOffsetFor(500, 400)).toBe(300); // 500 - 200 + expect(windowOffsetFor(1000, 100)).toBe(950); // 1000 - 50 + }); + test('clamps to 0 near the top', () => { + expect(windowOffsetFor(0, 400)).toBe(0); + expect(windowOffsetFor(50, 400)).toBe(0); + }); + test('odd window sizes floor the half', () => { + expect(windowOffsetFor(500, 401)).toBe(300); + }); +}); + +function deferred() { + let resolve!: (v: T) => void; + const promise = new Promise((r) => (resolve = r)); + return { promise, resolve }; +} + +describe('WindowedListCore', () => { + test('reset() fetches offset 0 at the window size and emits loaded state', async () => { + const calls: { offset: number; limit: number }[] = []; + let last: WindowState | null = null; + const core = new WindowedListCore( + { windowSize: 400, fetchPage: async (offset, limit) => { calls.push({ offset, limit }); return { items: [1, 2], total: 2 }; } }, + (s) => (last = s) + ); + await core.reset(); + expect(calls).toEqual([{ offset: 0, limit: 400 }]); + expect(last!.items).toEqual([1, 2]); + expect(last!.total).toBe(2); + expect(last!.offset).toBe(0); + expect(last!.loaded).toBe(true); + expect(last!.loading).toBe(false); + }); + + test('shiftTo() centers the requested offset', async () => { + const calls: number[] = []; + const core = new WindowedListCore( + { windowSize: 400, fetchPage: async (offset) => { calls.push(offset); return { items: [], total: 1000 }; } } + ); + await core.shiftTo(600); + expect(calls).toEqual([400]); // 600 - 200 + }); + + test('sets loading=true while a fetch is in flight', async () => { + const d = deferred<{ items: number[]; total: number }>(); + const states: boolean[] = []; + const core = new WindowedListCore( + { windowSize: 100, fetchPage: async () => d.promise }, + (s) => states.push(s.loading) + ); + const p = core.reset(); + expect(states.at(-1)).toBe(true); // emitted loading=true synchronously + d.resolve({ items: [1], total: 1 }); + await p; + expect(states.at(-1)).toBe(false); + }); + + test('a superseded fetch is aborted and its late result is discarded', async () => { + const first = deferred<{ items: number[]; total: number }>(); + const second = deferred<{ items: number[]; total: number }>(); + const signals: AbortSignal[] = []; + let n = 0; + let last: WindowState | null = null; + const core = new WindowedListCore( + { windowSize: 100, fetchPage: async (_o, _l, signal) => { signals.push(signal); return n++ === 0 ? first.promise : second.promise; } }, + (s) => (last = s) + ); + + const p1 = core.shiftTo(0); // fetch #1 + const p2 = core.shiftTo(500); // supersedes #1 + + expect(signals[0].aborted).toBe(true); // #1 aborted by #2 + expect(signals[1].aborted).toBe(false); + + first.resolve({ items: [1, 2, 3], total: 3 }); // stale — must be ignored + second.resolve({ items: [9], total: 1000 }); + await Promise.all([p1, p2]); + + // Only the winning (second) result is applied. + expect(last!.items).toEqual([9]); + expect(last!.total).toBe(1000); + }); + + test('a repeat shiftTo to the same in-flight offset is deduped (fling guard)', async () => { + const d = deferred<{ items: number[]; total: number }>(); + let fetchCount = 0; + const core = new WindowedListCore( + { windowSize: 400, fetchPage: async () => { fetchCount++; return d.promise; } } + ); + const p1 = core.shiftTo(600); // desired offset 400 + const p2 = core.shiftTo(600); // same offset while #1 is loading → skipped + expect(fetchCount).toBe(1); + d.resolve({ items: [1], total: 1000 }); + await Promise.all([p1, p2]); + }); + + test('reset() forces a refetch even while an identical offset-0 fetch is in flight', async () => { + // Regression: a filter/sort change maps to offset 0 like the in-flight load, + // so the fling-dedupe used to drop it, leaving stale data. reset() must force. + const first = deferred<{ items: number[]; total: number }>(); + const second = deferred<{ items: number[]; total: number }>(); + const signals: AbortSignal[] = []; + let n = 0; + let last: WindowState | null = null; + const core = new WindowedListCore( + { windowSize: 400, fetchPage: async (_o, _l, s) => { signals.push(s); return n++ === 0 ? first.promise : second.promise; } }, + (s) => (last = s) + ); + + const p1 = core.reset(); // initial load, offset 0, in flight + const p2 = core.reset(); // filter changed → same offset 0, must NOT be deduped + expect(n).toBe(2); // second fetch actually started + expect(signals[0].aborted).toBe(true); // first was aborted + + first.resolve({ items: [1, 2], total: 2 }); // stale — must be ignored + second.resolve({ items: [9], total: 1 }); // new query result wins + await Promise.all([p1, p2]); + expect(last!.items).toEqual([9]); + expect(last!.total).toBe(1); + }); + + test('a non-forced shiftTo to the same in-flight offset is still deduped (fling)', async () => { + const d = deferred<{ items: number[]; total: number }>(); + let fetchCount = 0; + const core = new WindowedListCore( + { windowSize: 400, fetchPage: async () => { fetchCount++; return d.promise; } } + ); + const p1 = core.shiftTo(600); + const p2 = core.shiftTo(600); // same offset, not forced → deduped + expect(fetchCount).toBe(1); + d.resolve({ items: [1], total: 1000 }); + await Promise.all([p1, p2]); + }); + + test('shiftTo to a different offset still supersedes the in-flight fetch', async () => { + const first = deferred<{ items: number[]; total: number }>(); + const second = deferred<{ items: number[]; total: number }>(); + let n = 0; + const core = new WindowedListCore( + { windowSize: 400, fetchPage: async () => (n++ === 0 ? first.promise : second.promise) } + ); + const p1 = core.shiftTo(600); // offset 400 + const p2 = core.shiftTo(2000); // offset 1800 — different, must NOT be deduped + expect(n).toBe(2); + first.resolve({ items: [], total: 0 }); + second.resolve({ items: [], total: 0 }); + await Promise.all([p1, p2]); + }); + + test('fetchPage error is routed to onError, not thrown', async () => { + let captured: unknown = null; + const core = new WindowedListCore( + { windowSize: 100, onError: (e) => (captured = e), fetchPage: async () => { throw new Error('boom'); } } + ); + await core.reset(); + expect((captured as Error)?.message).toBe('boom'); + }); + + test('AbortError is swallowed (not routed to onError)', async () => { + let captured: unknown = null; + const core = new WindowedListCore( + { windowSize: 100, onError: (e) => (captured = e), fetchPage: async () => { const err = new Error('x'); err.name = 'AbortError'; throw err; } } + ); + await core.reset(); + expect(captured).toBeNull(); + }); + + test('clear() aborts in-flight and resets state', async () => { + const d = deferred<{ items: number[]; total: number }>(); + const signals: AbortSignal[] = []; + let last: WindowState | null = null; + const core = new WindowedListCore( + { windowSize: 100, fetchPage: async (_o, _l, s) => { signals.push(s); return d.promise; } }, + (s) => (last = s) + ); + const p = core.shiftTo(300); + core.clear(); + expect(signals[0].aborted).toBe(true); + expect(last!.items).toEqual([]); + expect(last!.total).toBe(0); + expect(last!.loading).toBe(false); + d.resolve({ items: [1], total: 1 }); // late resolve must not reapply + await p; + expect(last!.items).toEqual([]); + }); +}); diff --git a/tests/workflows-notifications.test.ts b/tests/workflows-notifications.test.ts new file mode 100644 index 0000000..903fdb2 --- /dev/null +++ b/tests/workflows-notifications.test.ts @@ -0,0 +1,179 @@ +/** + * Workflows & Telegram notification parsing tests + * + * Tests for: + * - workflows:// URL parsing and HTTP URL construction + * - Telegram URL parsing with topic_id support + * - Telegram link_preview_options body construction + * + * Run with: bun test tests/unit/workflows-notifications.test.ts + */ + +import { describe, test, expect } from 'bun:test'; +import { parseWorkflowsUrl, buildWorkflowsHttpUrl, parseTelegramUrl } from '../src/lib/utils/notification-parsers'; + +// Telegram request body construction (mirrors notifications.ts) +function buildTelegramBody(chatId: string, text: string, topicId?: number) { + return { + chat_id: chatId, + text, + ...(topicId ? { message_thread_id: topicId } : {}), + parse_mode: 'Markdown', + link_preview_options: { + is_disabled: true + } + }; +} + +describe('Workflows URL Parsing', () => { + test('parses valid workflows:// URL', () => { + const result = parseWorkflowsUrl('workflows://prod.logic.azure.com/abc123/sig456'); + expect(result).not.toBeNull(); + expect(result!.hostname).toBe('prod.logic.azure.com'); + expect(result!.workflow).toBe('abc123'); + expect(result!.signature).toBe('sig456'); + }); + + test('parses URL with complex workflow path', () => { + const result = parseWorkflowsUrl('workflows://myhost.com/long-workflow-id-here/signature-value'); + expect(result).not.toBeNull(); + expect(result!.hostname).toBe('myhost.com'); + expect(result!.workflow).toBe('long-workflow-id-here'); + expect(result!.signature).toBe('signature-value'); + }); + + test('parses workflow:// (singular) variant', () => { + const result = parseWorkflowsUrl('workflow://host.com/wf/sig'); + expect(result).not.toBeNull(); + expect(result!.hostname).toBe('host.com'); + }); + + test('returns null for missing signature', () => { + expect(parseWorkflowsUrl('workflows://host.com/workflow-only')).toBeNull(); + }); + + test('returns null for missing path', () => { + expect(parseWorkflowsUrl('workflows://host.com')).toBeNull(); + }); + + test('returns null for invalid protocol', () => { + expect(parseWorkflowsUrl('http://host.com/wf/sig')).toBeNull(); + }); + + test('returns null for empty string', () => { + expect(parseWorkflowsUrl('')).toBeNull(); + }); +}); + +describe('Workflows HTTP URL Construction', () => { + test('builds correct Power Automate URL', () => { + const url = buildWorkflowsHttpUrl('prod.logic.azure.com', 'abc123', 'sig456'); + expect(url).toBe( + 'https://prod.logic.azure.com/powerautomate/automations/direct/workflows/abc123/triggers/manual/paths/invoke?api-version=1&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=sig456' + ); + }); + + test('URL starts with https', () => { + const url = buildWorkflowsHttpUrl('any.host', 'wf', 'sig'); + expect(url.startsWith('https://')).toBe(true); + }); + + test('signature is placed in sig query param', () => { + const url = buildWorkflowsHttpUrl('host', 'wf', 'my-signature'); + expect(url).toContain('sig=my-signature'); + }); +}); + +describe('Workflows Adaptive Card Payload', () => { + test('constructs valid Adaptive Card body', () => { + const title = 'Container Started'; + const message = 'nginx started on production'; + const environmentName = 'prod-cluster'; + + const titleWithEnv = environmentName ? `${title} [${environmentName}]` : title; + + const body = { + type: 'message', + attachments: [ + { + contentType: 'application/vnd.microsoft.card.adaptive', + content: { + $schema: 'https://adaptivecards.io/schemas/adaptive-card.json', + type: 'AdaptiveCard', + version: '1.2', + body: [ + { type: 'TextBlock', style: 'heading', wrap: true, text: titleWithEnv }, + { type: 'TextBlock', style: 'default', wrap: true, text: message } + ] + } + } + ] + }; + + expect(body.attachments[0].contentType).toBe('application/vnd.microsoft.card.adaptive'); + expect(body.attachments[0].content.body[0].text).toBe('Container Started [prod-cluster]'); + expect(body.attachments[0].content.body[1].text).toBe('nginx started on production'); + }); + + test('title without environment has no brackets', () => { + const title = 'License Expiring'; + const environmentName = undefined; + const titleWithEnv = environmentName ? `${title} [${environmentName}]` : title; + expect(titleWithEnv).toBe('License Expiring'); + }); +}); + +describe('Telegram URL Parsing with Topic ID', () => { + test('parses URL without topic ID', () => { + const result = parseTelegramUrl('tgram://123456:ABC-token/-100999'); + expect(result).not.toBeNull(); + expect(result!.botToken).toBe('123456:ABC-token'); + expect(result!.chatId).toBe('-100999'); + expect(result!.topicId).toBeUndefined(); + }); + + test('parses URL with topic ID', () => { + const result = parseTelegramUrl('tgram://123456:ABC-token/-100999:42'); + expect(result).not.toBeNull(); + expect(result!.botToken).toBe('123456:ABC-token'); + expect(result!.chatId).toBe('-100999'); + expect(result!.topicId).toBe(42); + }); + + test('topic ID is parsed as integer', () => { + const result = parseTelegramUrl('tgram://bot/chat:999'); + expect(result).not.toBeNull(); + expect(typeof result!.topicId).toBe('number'); + expect(result!.topicId).toBe(999); + }); + + test('returns null for URL with no chat ID', () => { + expect(parseTelegramUrl('tgram://bot-token-only')).toBeNull(); + }); + + test('returns null for non-tgram protocol', () => { + expect(parseTelegramUrl('discord://id/token')).toBeNull(); + }); +}); + +describe('Telegram Request Body', () => { + test('includes link_preview_options with is_disabled: true', () => { + const body = buildTelegramBody('-100123', 'test message'); + expect(body.link_preview_options).toEqual({ is_disabled: true }); + }); + + test('includes message_thread_id when topic ID provided', () => { + const body = buildTelegramBody('-100123', 'test', 42); + expect(body.message_thread_id).toBe(42); + }); + + test('omits message_thread_id when no topic ID', () => { + const body = buildTelegramBody('-100123', 'test'); + expect(body).not.toHaveProperty('message_thread_id'); + }); + + test('uses Markdown parse mode', () => { + const body = buildTelegramBody('-100123', 'test'); + expect(body.parse_mode).toBe('Markdown'); + }); +}); diff --git a/tests/ws-auth-cookie.test.ts b/tests/ws-auth-cookie.test.ts new file mode 100644 index 0000000..fb45ab7 --- /dev/null +++ b/tests/ws-auth-cookie.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'bun:test'; +import { parseCookieHeader, safeDecode } from '../src/lib/utils/cookie-parse'; + +describe('parseCookieHeader resilience (#1224)', () => { + it('decodes well-formed percent-encoded values', () => { + const out = parseCookieHeader('foo=hello%20world; bar=a%2Bb'); + expect(out.foo).toBe('hello world'); + expect(out.bar).toBe('a+b'); + }); + + it('does not throw on a malformed value — falls back to raw', () => { + // `%E0%A4%A` is truncated; decodeURIComponent throws URIError on it. + // Real-world reproducer: stray `%` from a third-party tracker cookie + // that hitchhiked into the WS upgrade and crashed terminal exec. + const out = parseCookieHeader('session=valid-token; bad=%E0%A4%A'); + expect(out.session).toBe('valid-token'); + expect(out.bad).toBe('%E0%A4%A'); + }); + + it('does not throw on a bare lonely percent', () => { + const out = parseCookieHeader('session=valid; weird=100%'); + expect(out.session).toBe('valid'); + expect(out.weird).toBe('100%'); + }); + + it('strips surrounding double quotes', () => { + const out = parseCookieHeader('foo="quoted-value"'); + expect(out.foo).toBe('quoted-value'); + }); + + it('returns empty object for undefined or empty header', () => { + expect(parseCookieHeader(undefined)).toEqual({}); + expect(parseCookieHeader('')).toEqual({}); + }); + + it('safeDecode returns input on URIError', () => { + expect(safeDecode('%E0%A4%A')).toBe('%E0%A4%A'); + expect(safeDecode('hello%20world')).toBe('hello world'); + }); +}); diff --git a/tests/ws-exec-permission.test.ts b/tests/ws-exec-permission.test.ts new file mode 100644 index 0000000..bdf0a3d --- /dev/null +++ b/tests/ws-exec-permission.test.ts @@ -0,0 +1,47 @@ +/** + * canExecDecision: the action-level gate the WebSocket terminal path uses so it + * matches the REST exec endpoint. Free edition / auth-disabled / admin all pass; + * enterprise requires the env-scoped containers:exec permission. Deps are + * injected, so no shared modules are mocked (mock.module is process-global). + */ +import { describe, it, expect } from 'bun:test'; +import { canExecDecision } from '../src/lib/server/ws-exec-core'; + +const auth = (over: Partial<{ isAdmin: boolean; authDisabled: boolean }> = {}) => ({ + userId: 1, + username: 'u', + isAdmin: false, + authDisabled: false, + ...over +}); + +const deps = (enterprise: boolean, containers: string[]) => ({ + isEnterprise: async () => enterprise, + getPerms: async () => ({ containers }) +}); + +describe('canExecDecision', () => { + it('auth-disabled (bootstrap) -> allow', async () => { + expect(await canExecDecision(auth({ authDisabled: true }), 1, deps(true, []))).toBe(true); + }); + + it('admin -> allow', async () => { + expect(await canExecDecision(auth({ isAdmin: true }), 1, deps(true, []))).toBe(true); + }); + + it('free edition -> allow for any authed user', async () => { + expect(await canExecDecision(auth(), 1, deps(false, []))).toBe(true); + }); + + it('enterprise without the exec permission -> deny', async () => { + expect(await canExecDecision(auth(), 1, deps(true, ['view', 'logs']))).toBe(false); + }); + + it('enterprise with the exec permission -> allow', async () => { + expect(await canExecDecision(auth(), 1, deps(true, ['view', 'logs', 'exec']))).toBe(true); + }); + + it('enterprise, no envId -> deny (cannot resolve env-scoped perm)', async () => { + expect(await canExecDecision(auth(), undefined, deps(true, ['exec']))).toBe(false); + }); +}); diff --git a/tests/xss-rendering.test.ts b/tests/xss-rendering.test.ts new file mode 100644 index 0000000..e60c451 --- /dev/null +++ b/tests/xss-rendering.test.ts @@ -0,0 +1,85 @@ +import { describe, test, expect } from 'bun:test'; +import { highlightCommand } from '../src/lib/utils/highlight-command'; +import { renderDescription, isSafeLinkUrl } from '../src/lib/utils/template-description'; + +/** + * Both functions feed {@html} with attacker-influenced data: + * - highlightCommand: image layer build commands (crafted image) + * - renderDescription: third-party template catalog descriptions + * They must never let raw HTML or a javascript:/data: URL through. + */ + +// Strip our own known-safe wrappers, then assert no raw < > remains. +function withoutSpans(html: string): string { + return html.replace(/<\/?span[^>]*>/g, ''); +} + +describe('highlightCommand (image layer XSS, H3)', () => { + test('escapes an payload', () => { + const out = highlightCommand(''); + expect(out).toContain('<img'); + expect(withoutSpans(out)).not.toContain(']/); + }); + + test('escapes "'); + expect(out).toContain('<script>'); + expect(withoutSpans(out)).not.toMatch(/[<>]/); + }); + + test('still highlights a normal command (feature preserved)', () => { + const out = highlightCommand('RUN echo "hello" && rm -rf /tmp'); + expect(out).toContain(' { + expect(highlightCommand('')).toBe(''); + }); +}); + +describe('isSafeLinkUrl (template description XSS, M3)', () => { + test('rejects javascript: in all obfuscations', () => { + expect(isSafeLinkUrl('javascript:alert(1)')).toBe(false); + expect(isSafeLinkUrl('JavaScript:alert(1)')).toBe(false); + expect(isSafeLinkUrl(' javascript:alert(1)')).toBe(false); + expect(isSafeLinkUrl('java\tscript:alert(1)')).toBe(false); + expect(isSafeLinkUrl('java\nscript:alert(1)')).toBe(false); + }); + + test('rejects data: and vbscript:', () => { + expect(isSafeLinkUrl('data:text/html,')).toBe(false); + expect(isSafeLinkUrl('vbscript:msgbox(1)')).toBe(false); + }); + + test('allows http(s) and relative/anchor URLs', () => { + expect(isSafeLinkUrl('https://example.com')).toBe(true); + expect(isSafeLinkUrl('http://example.com/x')).toBe(true); + expect(isSafeLinkUrl('/local/path')).toBe(true); + expect(isSafeLinkUrl('#section')).toBe(true); + }); +}); + +describe('renderDescription (template description XSS, M3)', () => { + test('drops a javascript: markdown link, keeps the text', () => { + const out = renderDescription('[click me](javascript:alert(1))'); + expect(out).not.toContain(' { + const out = renderDescription('[Docs](https://example.com/page)'); + expect(out).toContain('Docs'); + }); + + test('strips raw HTML tags', () => { + const out = renderDescription('Plain bold text'); + expect(out).not.toContain(''); + expect(out).not.toContain('