@vellumai/assistant 0.6.3 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +273 -10
- package/Dockerfile +2 -3
- package/bun.lock +5 -13
- package/docs/backup-troubleshooting.md +52 -0
- package/docs/browser-use-architecture-phase2.md +174 -0
- package/docs/stt-provider-onboarding.md +120 -0
- package/knip.json +12 -2
- package/node_modules/@vellumai/ces-contracts/bun.lock +8 -6
- package/node_modules/@vellumai/ces-contracts/package.json +3 -3
- package/openapi.yaml +982 -72
- package/package.json +4 -6
- package/scripts/generate-openapi.ts +0 -1
- package/scripts/test.sh +73 -18
- package/src/__tests__/agent-image-optimize.test.ts +28 -0
- package/src/__tests__/agent-loop.test.ts +123 -0
- package/src/__tests__/anthropic-provider.test.ts +263 -10
- package/src/__tests__/auto-analysis-end-to-end.test.ts +550 -0
- package/src/__tests__/auto-analysis-prompt.test.ts +50 -0
- package/src/__tests__/browser-fill-credential.test.ts +11 -0
- package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +2 -2
- package/src/__tests__/browser-skill-endstate.test.ts +31 -7
- package/src/__tests__/btw-routes.test.ts +7 -0
- package/src/__tests__/call-controller.test.ts +581 -20
- package/src/__tests__/catalog-files.test.ts +138 -0
- package/src/__tests__/channel-invite-transport.test.ts +2 -2
- package/src/__tests__/channel-readiness-routes.test.ts +16 -20
- package/src/__tests__/channel-readiness-service.test.ts +12 -7
- package/src/__tests__/checker.test.ts +157 -10
- package/src/__tests__/clawhub-files.test.ts +347 -0
- package/src/__tests__/commit-message-enrichment-service.test.ts +36 -19
- package/src/__tests__/config-analysis.test.ts +100 -0
- package/src/__tests__/config-schema.test.ts +1013 -66
- package/src/__tests__/config-watcher-cleanup-throttle.test.ts +339 -0
- package/src/__tests__/config-watcher.test.ts +43 -8
- package/src/__tests__/contact-store-user-file.test.ts +512 -0
- package/src/__tests__/contacts-write.test.ts +197 -0
- package/src/__tests__/context-window-manager.test.ts +88 -0
- package/src/__tests__/conversation-abort-tool-results.test.ts +2 -0
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -0
- package/src/__tests__/conversation-agent-loop.test.ts +98 -2
- package/src/__tests__/conversation-confirmation-signals.test.ts +135 -0
- package/src/__tests__/conversation-error.test.ts +70 -0
- package/src/__tests__/conversation-history-web-search.test.ts +11 -4
- package/src/__tests__/conversation-init.benchmark.test.ts +6 -1
- package/src/__tests__/conversation-launcher-skill-regression.test.ts +51 -0
- package/src/__tests__/conversation-list-source.test.ts +145 -0
- package/src/__tests__/conversation-pre-run-repair.test.ts +2 -0
- package/src/__tests__/conversation-provider-retry-repair.test.ts +2 -0
- package/src/__tests__/conversation-queue.test.ts +901 -60
- package/src/__tests__/conversation-routes-disk-view.test.ts +270 -0
- package/src/__tests__/conversation-runtime-assembly.test.ts +55 -0
- package/src/__tests__/conversation-skill-tools.test.ts +7 -4
- package/src/__tests__/conversation-slash-commands.test.ts +33 -0
- package/src/__tests__/conversation-slash-queue.test.ts +89 -18
- package/src/__tests__/conversation-slash-unknown.test.ts +2 -0
- package/src/__tests__/conversation-tool-setup-batch-authorized.test.ts +226 -0
- package/src/__tests__/conversation-workspace-injection.test.ts +2 -0
- package/src/__tests__/conversation-workspace-tool-tracking.test.ts +2 -0
- package/src/__tests__/credential-health-service.test.ts +352 -0
- package/src/__tests__/credential-security-invariants.test.ts +5 -3
- package/src/__tests__/credential-vault-unit.test.ts +379 -3
- package/src/__tests__/credentials-cli.test.ts +40 -16
- package/src/__tests__/cross-provider-web-search.test.ts +146 -35
- package/src/__tests__/deterministic-verification-control-plane.test.ts +10 -1
- package/src/__tests__/device-id.test.ts +112 -0
- package/src/__tests__/docker-signing-key-bootstrap.test.ts +167 -4
- package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +1 -3
- package/src/__tests__/email-html-renderer.test.ts +71 -0
- package/src/__tests__/email-invite-adapter.test.ts +36 -32
- package/src/__tests__/emit-event-signal.test.ts +71 -0
- package/src/__tests__/extension-id-sync-guard.test.ts +75 -8
- package/src/__tests__/fixtures/mock-chrome-extension.ts +11 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +206 -1
- package/src/__tests__/gateway-only-guard.test.ts +0 -1
- package/src/__tests__/gemini-provider.test.ts +64 -0
- package/src/__tests__/get-skill-detail-audit.test.ts +325 -0
- package/src/__tests__/gmail-archive-fallback.test.ts +193 -0
- package/src/__tests__/gmail-archive-gate.test.ts +246 -0
- package/src/__tests__/gmail-preferences.test.ts +117 -0
- package/src/__tests__/headless-browser-interactions.test.ts +43 -0
- package/src/__tests__/headless-browser-mode.test.ts +614 -0
- package/src/__tests__/headless-browser-navigate.test.ts +142 -5
- package/src/__tests__/headless-browser-read-tools.test.ts +11 -0
- package/src/__tests__/headless-browser-snapshot.test.ts +10 -0
- package/src/__tests__/heartbeat-service.test.ts +70 -17
- package/src/__tests__/home-state-routes.test.ts +162 -0
- package/src/__tests__/host-bash-proxy.test.ts +0 -5
- package/src/__tests__/host-browser-e2e-cloud.test.ts +138 -4
- package/src/__tests__/host-browser-e2e-self-hosted.test.ts +4 -4
- package/src/__tests__/host-browser-ws-events-e2e.test.ts +103 -0
- package/src/__tests__/host-cu-proxy.test.ts +0 -5
- package/src/__tests__/identity-intro-cache.test.ts +40 -10
- package/src/__tests__/init-feature-flag-overrides.test.ts +38 -112
- package/src/__tests__/jobs-store-upsert-debounced.test.ts +141 -0
- package/src/__tests__/llm-context-normalization.test.ts +488 -0
- package/src/__tests__/llm-context-route-provider.test.ts +86 -5
- package/src/__tests__/llm-usage-store.test.ts +363 -0
- package/src/__tests__/media-stream-output.test.ts +555 -0
- package/src/__tests__/media-stream-parser.test.ts +374 -0
- package/src/__tests__/media-stream-server-integration.test.ts +1234 -0
- package/src/__tests__/media-stream-stt-session.test.ts +588 -0
- package/src/__tests__/media-turn-detector.test.ts +440 -0
- package/src/__tests__/message-queue.test.ts +125 -0
- package/src/__tests__/migration-export-http.test.ts +6 -6
- package/src/__tests__/migration-import-commit-http.test.ts +8 -6
- package/src/__tests__/migration-import-preflight-http.test.ts +6 -5
- package/src/__tests__/migration-validate-http.test.ts +3 -3
- package/src/__tests__/mock-gateway-ipc.ts +151 -0
- package/src/__tests__/model-intents.test.ts +2 -2
- package/src/__tests__/oauth-apps-routes.test.ts +1 -0
- package/src/__tests__/oauth-cli.test.ts +2 -0
- package/src/__tests__/oauth-connect-orchestrator.test.ts +2 -0
- package/src/__tests__/oauth-provider-serializer.test.ts +1 -0
- package/src/__tests__/oauth-providers-routes.test.ts +2 -0
- package/src/__tests__/oauth-store.test.ts +85 -0
- package/src/__tests__/oauth2-gateway-transport.test.ts +249 -6
- package/src/__tests__/onboarding-template-contract.test.ts +6 -13
- package/src/__tests__/openai-provider.test.ts +176 -0
- package/src/__tests__/openai-responses-cutover-guard.test.ts +184 -0
- package/src/__tests__/openai-responses-provider.test.ts +1105 -0
- package/src/__tests__/openrouter-token-estimation.test.ts +100 -0
- package/src/__tests__/outlook-unsubscribe.test.ts +31 -2
- package/src/__tests__/persona-resolver.test.ts +251 -0
- package/src/__tests__/platform-bash-auto-approve.test.ts +4 -0
- package/src/__tests__/platform.test.ts +92 -1
- package/src/__tests__/post-turn-tool-result-truncation.test.ts +47 -0
- package/src/__tests__/prechat-onboarding-contract.test.ts +267 -0
- package/src/__tests__/pricing.test.ts +174 -0
- package/src/__tests__/qdrant-manager.test.ts +29 -8
- package/src/__tests__/regenerate-fire-and-forget-trace.test.ts +194 -0
- package/src/__tests__/relationship-state-contract.test.ts +175 -0
- package/src/__tests__/relay-server.test.ts +423 -5
- package/src/__tests__/search-skills-unified.test.ts +118 -0
- package/src/__tests__/secret-scanner-executor.test.ts +4 -0
- package/src/__tests__/secure-keys.test.ts +107 -0
- package/src/__tests__/send-endpoint-busy.test.ts +5 -1
- package/src/__tests__/sequence-store.test.ts +1 -1
- package/src/__tests__/server-history-render.test.ts +49 -0
- package/src/__tests__/settings-routes.test.ts +201 -0
- package/src/__tests__/skill-load-feature-flag.test.ts +1 -0
- package/src/__tests__/skills-file-content-endpoint.test.ts +276 -145
- package/src/__tests__/skills-files-catalog-fallback.test.ts +381 -93
- package/src/__tests__/skills.test.ts +5 -2
- package/src/__tests__/skillssh-files.test.ts +446 -0
- package/src/__tests__/slack-block-formatting.test.ts +110 -0
- package/src/__tests__/slack-channel-config.test.ts +564 -1
- package/src/__tests__/stt-catalog-parity.test.ts +282 -0
- package/src/__tests__/stt-stream-session.test.ts +535 -0
- package/src/__tests__/system-prompt.test.ts +112 -26
- package/src/__tests__/telephony-stt-routing.test.ts +329 -0
- package/src/__tests__/terminal-tools.test.ts +18 -7
- package/src/__tests__/test-preload.ts +18 -0
- package/src/__tests__/test-support/browser-skill-harness.ts +4 -1
- package/src/__tests__/tool-executor-lifecycle-events.test.ts +9 -5
- package/src/__tests__/tool-executor-shell-integration.test.ts +4 -0
- package/src/__tests__/tool-executor.test.ts +33 -24
- package/src/__tests__/tool-result-truncation.test.ts +36 -0
- package/src/__tests__/trust-store.test.ts +7 -1
- package/src/__tests__/trusted-contact-approval-notifier.test.ts +1 -1
- package/src/__tests__/tts-catalog-parity.test.ts +345 -0
- package/src/__tests__/twilio-routes-twiml.test.ts +512 -114
- package/src/__tests__/twilio-routes.test.ts +376 -0
- package/src/__tests__/unicode.test.ts +293 -0
- package/src/__tests__/update-bulletin-format.test.ts +59 -0
- package/src/__tests__/update-bulletin.test.ts +206 -5
- package/src/__tests__/usage-routes.test.ts +25 -4
- package/src/__tests__/user-reference.test.ts +46 -61
- package/src/__tests__/verification-control-plane-policy.test.ts +4 -0
- package/src/__tests__/voice-config-update.test.ts +403 -0
- package/src/__tests__/voice-quality.test.ts +434 -19
- package/src/__tests__/workspace-heartbeat-service.test.ts +7 -0
- package/src/__tests__/workspace-migration-033-stt-service-explicit-config.test.ts +547 -0
- package/src/__tests__/workspace-migration-034-remove-calls-voice-transcription-provider.test.ts +596 -0
- package/src/__tests__/workspace-migration-drop-user-md.test.ts +368 -0
- package/src/__tests__/workspace-migration-meets.test.ts +244 -0
- package/src/__tests__/workspace-migration-seed-device-id.test.ts +14 -20
- package/src/__tests__/workspace-policy.test.ts +2 -0
- package/src/agent/image-optimize.ts +24 -12
- package/src/agent/loop.ts +43 -3
- package/src/backup/__tests__/backup-key.test.ts +152 -0
- package/src/backup/__tests__/backup-worker.test.ts +767 -0
- package/src/backup/__tests__/list-snapshots.test.ts +87 -0
- package/src/backup/__tests__/local-writer.test.ts +218 -0
- package/src/backup/__tests__/offsite-writer.test.ts +641 -0
- package/src/backup/__tests__/paths.test.ts +300 -0
- package/src/backup/__tests__/restore.test.ts +498 -0
- package/src/backup/__tests__/snapshot-lock.test.ts +352 -0
- package/src/backup/__tests__/stream-crypt.test.ts +228 -0
- package/src/backup/backup-key.ts +137 -0
- package/src/backup/backup-worker.ts +459 -0
- package/src/backup/list-snapshots.ts +147 -0
- package/src/backup/local-writer.ts +133 -0
- package/src/backup/offsite-writer.ts +222 -0
- package/src/backup/paths.ts +226 -0
- package/src/backup/restore.ts +322 -0
- package/src/backup/snapshot-lock.ts +431 -0
- package/src/backup/stream-crypt.ts +263 -0
- package/src/bundler/package-resolver.ts +4 -0
- package/src/calls/audio-store.ts +11 -5
- package/src/calls/call-controller.ts +226 -71
- package/src/calls/call-domain.ts +9 -0
- package/src/calls/call-speech-output.ts +190 -0
- package/src/calls/call-transport.ts +77 -0
- package/src/calls/media-stream-audio-transcode.ts +173 -0
- package/src/calls/media-stream-output.ts +660 -0
- package/src/calls/media-stream-parser.ts +300 -0
- package/src/calls/media-stream-protocol.ts +166 -0
- package/src/calls/media-stream-server.ts +592 -0
- package/src/calls/media-stream-stt-session.ts +460 -0
- package/src/calls/media-turn-detector.ts +230 -0
- package/src/calls/relay-server.ts +90 -75
- package/src/calls/resolve-call-tts-provider.ts +136 -0
- package/src/calls/telephony-stt-routing.ts +145 -0
- package/src/calls/tts-call-strategy.ts +161 -0
- package/src/calls/tts-text-sanitizer.ts +32 -16
- package/src/calls/twilio-routes.ts +281 -17
- package/src/calls/voice-quality.ts +78 -35
- package/src/calls/voice-session-bridge.ts +8 -1
- package/src/channels/types.ts +16 -0
- package/src/cli/__tests__/run-assistant-command.ts +11 -1
- package/src/cli/commands/__tests__/backup.test.ts +1165 -0
- package/src/cli/commands/__tests__/domain-register.test.ts +234 -0
- package/src/cli/commands/__tests__/domain-status.test.ts +132 -0
- package/src/cli/commands/__tests__/email-attachment.test.ts +422 -0
- package/src/cli/commands/__tests__/email-download.test.ts +16 -1
- package/src/cli/commands/__tests__/email-list.test.ts +22 -4
- package/src/cli/commands/__tests__/email-register.test.ts +4 -4
- package/src/cli/commands/__tests__/email-send.test.ts +37 -4
- package/src/cli/commands/__tests__/email-status.test.ts +5 -1
- package/src/cli/commands/__tests__/email-unregister.test.ts +34 -5
- package/src/cli/commands/backup.ts +993 -0
- package/src/cli/commands/conversations.ts +77 -0
- package/src/cli/commands/credentials.ts +0 -1
- package/src/cli/commands/domain.ts +210 -0
- package/src/cli/commands/email.ts +255 -3
- package/src/cli/commands/oauth/__tests__/connect.test.ts +12 -0
- package/src/cli/commands/oauth/__tests__/providers-delete.test.ts +1 -0
- package/src/cli/commands/oauth/__tests__/providers-register.test.ts +1 -0
- package/src/cli/commands/oauth/__tests__/providers-update.test.ts +1 -0
- package/src/cli/commands/oauth/mode.ts +12 -3
- package/src/cli/commands/oauth/providers.ts +15 -0
- package/src/cli/commands/oauth/shared.ts +2 -1
- package/src/cli/commands/platform/__tests__/callback-routes-list.test.ts +4 -9
- package/src/cli/commands/platform/__tests__/connect.test.ts +6 -0
- package/src/cli/commands/platform/__tests__/disconnect.test.ts +7 -1
- package/src/cli/commands/platform/__tests__/status.test.ts +6 -0
- package/src/cli/program.ts +30 -4
- package/src/config/__tests__/backup-schema.test.ts +134 -0
- package/src/config/assistant-feature-flags.ts +61 -62
- package/src/config/bundled-skills/app-builder/references/CUSTOM_ROUTES.md +37 -1
- package/src/config/bundled-skills/browser/SKILL.md +30 -5
- package/src/config/bundled-skills/browser/TOOLS.json +123 -0
- package/src/config/bundled-skills/browser/tools/browser-attach.ts +12 -0
- package/src/config/bundled-skills/browser/tools/browser-detach.ts +12 -0
- package/src/config/bundled-skills/browser/tools/browser-status.ts +12 -0
- package/src/config/bundled-skills/browser/tools/browser-wait-for-download.ts +17 -0
- package/src/config/bundled-skills/contacts/SKILL.md +2 -2
- package/src/config/bundled-skills/gmail/SKILL.md +53 -7
- package/src/config/bundled-skills/gmail/TOOLS.json +33 -3
- package/src/config/bundled-skills/gmail/tools/gmail-archive.ts +116 -9
- package/src/config/bundled-skills/gmail/tools/gmail-outreach-scan.ts +138 -11
- package/src/config/bundled-skills/gmail/tools/gmail-preferences-tool.ts +59 -0
- package/src/config/bundled-skills/gmail/tools/gmail-preferences.ts +82 -0
- package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +113 -17
- package/src/config/bundled-skills/gmail/tools/gmail-unsubscribe.ts +2 -2
- package/src/config/bundled-skills/media-processing/SKILL.md +3 -9
- package/src/config/bundled-skills/media-processing/TOOLS.json +1 -6
- package/src/config/bundled-skills/media-processing/__tests__/audio-transcribe.test.ts +125 -0
- package/src/config/bundled-skills/media-processing/__tests__/extract-keyframes.test.ts +181 -0
- package/src/config/bundled-skills/media-processing/__tests__/preprocess-audio.test.ts +141 -0
- package/src/config/bundled-skills/media-processing/services/audio-transcribe.ts +32 -87
- package/src/config/bundled-skills/media-processing/services/preprocess.ts +8 -4
- package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +0 -10
- package/src/config/bundled-skills/messaging/SKILL.md +3 -3
- package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +2 -2
- package/src/config/bundled-skills/outlook/SKILL.md +2 -2
- package/src/config/bundled-skills/outlook/tools/outlook-unsubscribe.ts +2 -2
- package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
- package/src/config/bundled-skills/phone-calls/references/CONFIG.md +27 -18
- package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +3 -3
- package/src/config/bundled-skills/settings/TOOLS.json +3 -3
- package/src/config/bundled-skills/settings/tools/voice-config-update.ts +26 -22
- package/src/config/bundled-skills/slack/SKILL.md +1 -0
- package/src/config/bundled-skills/transcribe/SKILL.md +9 -14
- package/src/config/bundled-skills/transcribe/TOOLS.json +2 -7
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.test.ts +256 -0
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +38 -188
- package/src/config/bundled-tool-registry.ts +8 -0
- package/src/config/env-registry.ts +24 -0
- package/src/config/env.ts +34 -10
- package/src/config/feature-flag-registry.json +46 -14
- package/src/config/loader.ts +26 -12
- package/src/config/schema.ts +35 -10
- package/src/config/schemas/__tests__/stt.test.ts +43 -0
- package/src/config/schemas/analysis.ts +51 -0
- package/src/config/schemas/backup.ts +72 -0
- package/src/config/schemas/calls.ts +1 -26
- package/src/config/schemas/elevenlabs.ts +0 -59
- package/src/config/schemas/filing.ts +47 -7
- package/src/config/schemas/heartbeat.ts +27 -5
- package/src/config/schemas/host-browser.ts +47 -1
- package/src/config/schemas/inference.ts +1 -1
- package/src/config/schemas/memory-lifecycle.ts +14 -2
- package/src/config/schemas/services.ts +44 -0
- package/src/config/schemas/stt.ts +59 -0
- package/src/config/schemas/tts.ts +230 -0
- package/src/config/schemas/updates.ts +14 -0
- package/src/config/skills.ts +4 -0
- package/src/config/types.ts +4 -0
- package/src/contacts/contact-store.ts +56 -11
- package/src/contacts/contacts-write.ts +38 -1
- package/src/context/post-turn-tool-result-truncation.ts +3 -2
- package/src/context/tool-result-truncation.ts +2 -1
- package/src/context/window-manager.ts +45 -12
- package/src/credential-execution/executable-discovery.ts +12 -2
- package/src/credential-execution/process-manager.ts +33 -2
- package/src/credential-health/credential-health-service.ts +366 -0
- package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +324 -0
- package/src/daemon/__tests__/conversation-surfaces-launch.test.ts +497 -0
- package/src/daemon/__tests__/conversation-tool-setup.test.ts +17 -8
- package/src/daemon/__tests__/lifecycle-startup-ordering.test.ts +127 -0
- package/src/daemon/config-watcher.ts +99 -5
- package/src/daemon/conversation-agent-loop-handlers.ts +6 -0
- package/src/daemon/conversation-agent-loop.ts +101 -24
- package/src/daemon/conversation-error.ts +11 -0
- package/src/daemon/conversation-history.ts +40 -6
- package/src/daemon/conversation-launch.ts +220 -0
- package/src/daemon/conversation-lifecycle.ts +59 -9
- package/src/daemon/conversation-messaging.ts +37 -3
- package/src/daemon/conversation-notifiers.ts +5 -0
- package/src/daemon/conversation-process.ts +581 -19
- package/src/daemon/conversation-queue-manager.ts +24 -0
- package/src/daemon/conversation-runtime-assembly.ts +11 -1
- package/src/daemon/conversation-slash.ts +36 -0
- package/src/daemon/conversation-surfaces.ts +94 -4
- package/src/daemon/conversation-tool-setup.ts +25 -0
- package/src/daemon/conversation-usage.ts +7 -4
- package/src/daemon/conversation.ts +86 -28
- package/src/daemon/handlers/config-slack-channel.ts +269 -94
- package/src/daemon/handlers/conversations.ts +4 -1
- package/src/daemon/handlers/shared.ts +22 -0
- package/src/daemon/handlers/skills.ts +321 -77
- package/src/daemon/host-browser-proxy.ts +2 -1
- package/src/daemon/lifecycle.ts +122 -25
- package/src/daemon/message-protocol.ts +6 -0
- package/src/daemon/message-types/conversations.ts +34 -1
- package/src/daemon/message-types/home.ts +40 -0
- package/src/daemon/message-types/meet.ts +143 -0
- package/src/daemon/message-types/messages.ts +14 -0
- package/src/daemon/message-types/schedules.ts +34 -2
- package/src/daemon/message-types/skills.ts +16 -0
- package/src/daemon/message-types/surfaces.ts +2 -0
- package/src/daemon/server.ts +347 -2
- package/src/daemon/shutdown-handlers.ts +32 -4
- package/src/daemon/shutdown-registry.ts +40 -0
- package/src/daemon/tool-side-effects.ts +9 -0
- package/src/email/html-renderer.ts +76 -0
- package/src/heartbeat/heartbeat-service.ts +93 -7
- package/src/home/__tests__/assistant-feed-authoring.test.ts +156 -0
- package/src/home/__tests__/emit-feed-event.test.ts +169 -0
- package/src/home/__tests__/feed-scheduler.test.ts +194 -0
- package/src/home/__tests__/feed-types.test.ts +275 -0
- package/src/home/__tests__/feed-writer.test.ts +688 -0
- package/src/home/__tests__/phase5-exit-criteria.test.ts +212 -0
- package/src/home/__tests__/platform-gmail-digest.test.ts +222 -0
- package/src/home/__tests__/progress-formula.test.ts +213 -0
- package/src/home/__tests__/relationship-state-writer.test.ts +740 -0
- package/src/home/__tests__/rollup-producer.test.ts +398 -0
- package/src/home/assistant-feed-authoring.ts +124 -0
- package/src/home/emit-feed-event.ts +158 -0
- package/src/home/feed-scheduler.ts +247 -0
- package/src/home/feed-types.ts +181 -0
- package/src/home/feed-writer.ts +469 -0
- package/src/home/platform-gmail-digest.ts +163 -0
- package/src/home/progress-formula.ts +86 -0
- package/src/home/relationship-state-writer.ts +824 -0
- package/src/home/relationship-state.ts +143 -0
- package/src/home/rollup-producer.ts +384 -0
- package/src/hooks/runner.ts +7 -0
- package/src/inbound/platform-callback-registration.ts +12 -3
- package/src/inbound/public-ingress-urls.ts +12 -0
- package/src/instrument.ts +1 -1
- package/src/ipc/__tests__/cli-ipc.test.ts +200 -0
- package/src/ipc/cli-client.ts +151 -0
- package/src/ipc/cli-server.ts +234 -0
- package/src/ipc/gateway-client.ts +180 -0
- package/src/ipc/routes/index.ts +5 -0
- package/src/ipc/routes/wake-conversation.ts +19 -0
- package/src/memory/__tests__/auto-analysis-enqueue.test.ts +356 -0
- package/src/memory/__tests__/auto-analysis-guard.test.ts +57 -0
- package/src/memory/__tests__/conversation-analyze-job.test.ts +232 -0
- package/src/memory/__tests__/find-analysis-conversation.test.ts +196 -0
- package/src/memory/app-store.ts +1 -1
- package/src/memory/attachments-store.ts +70 -0
- package/src/memory/auto-analysis-enqueue.ts +127 -0
- package/src/memory/auto-analysis-guard.ts +27 -0
- package/src/memory/cleanup-schedule-state.ts +37 -0
- package/src/memory/conversation-analyze-job.ts +73 -0
- package/src/memory/conversation-crud.ts +99 -0
- package/src/memory/conversation-disk-view.ts +7 -0
- package/src/memory/conversation-group-migration.ts +34 -2
- package/src/memory/conversation-queries.ts +6 -5
- package/src/memory/db-init.ts +6 -0
- package/src/memory/db-maintenance.ts +108 -0
- package/src/memory/db.ts +1 -0
- package/src/memory/graph/conversation-graph-memory.ts +15 -0
- package/src/memory/graph/extraction.test.ts +23 -0
- package/src/memory/graph/extraction.ts +8 -0
- package/src/memory/graph/retriever.ts +27 -18
- package/src/memory/graph/scoring.test.ts +186 -0
- package/src/memory/graph/scoring.ts +31 -1
- package/src/memory/graph/tools.ts +1 -1
- package/src/memory/group-crud.ts +6 -1
- package/src/memory/indexer.ts +95 -16
- package/src/memory/job-handlers/cleanup.ts +11 -8
- package/src/memory/job-handlers/conversation-starters.ts +16 -10
- package/src/memory/jobs-store.ts +64 -4
- package/src/memory/jobs-worker.ts +22 -9
- package/src/memory/llm-usage-store.ts +92 -56
- package/src/memory/migrations/219-oauth-providers-token-exchange-body-format.ts +15 -0
- package/src/memory/migrations/220-normalize-user-file-by-principal.ts +190 -0
- package/src/memory/migrations/221-conversations-archived-at.ts +16 -0
- package/src/memory/migrations/index.ts +6 -0
- package/src/memory/migrations/registry.ts +8 -0
- package/src/memory/qdrant-manager.ts +43 -16
- package/src/memory/schema/conversations.ts +2 -0
- package/src/memory/schema/oauth.ts +3 -0
- package/src/memory/usage-buckets.ts +396 -0
- package/src/messaging/providers/gmail/client.ts +57 -6
- package/src/messaging/providers/slack/__tests__/adapter-token-routing.test.ts +282 -0
- package/src/messaging/providers/slack/adapter.ts +143 -38
- package/src/messaging/providers/slack/client.ts +16 -0
- package/src/messaging/providers/slack/types.ts +4 -0
- package/src/notifications/decision-engine.ts +3 -3
- package/src/notifications/signal.ts +5 -0
- package/src/oauth/__tests__/identity-verifier.test.ts +1 -0
- package/src/oauth/byo-connection.test.ts +18 -1
- package/src/oauth/byo-connection.ts +3 -1
- package/src/oauth/connect-orchestrator.ts +2 -0
- package/src/oauth/connection-resolver.ts +6 -2
- package/src/oauth/connection.ts +2 -0
- package/src/oauth/oauth-store.ts +9 -0
- package/src/oauth/platform-connection.test.ts +98 -0
- package/src/oauth/platform-connection.ts +52 -31
- package/src/oauth/seed-providers.ts +7 -0
- package/src/permissions/checker.ts +16 -6
- package/src/permissions/defaults.ts +49 -1
- package/src/permissions/trust-store.ts +3 -3
- package/src/permissions/workspace-policy.ts +3 -0
- package/src/platform/client.test.ts +10 -0
- package/src/platform/sync-identity.ts +129 -0
- package/src/prompts/persona-resolver.ts +126 -2
- package/src/prompts/system-prompt.ts +59 -18
- package/src/prompts/templates/BOOTSTRAP.md +5 -5
- package/src/prompts/templates/SOUL.md +3 -1
- package/src/prompts/templates/UPDATES.md +12 -0
- package/src/prompts/templates/channels/slack.md +20 -0
- package/src/prompts/update-bulletin-format.ts +26 -9
- package/src/prompts/update-bulletin.ts +34 -23
- package/src/prompts/user-reference.ts +20 -17
- package/src/providers/__tests__/provider-secret-catalog.test.ts +42 -0
- package/src/providers/anthropic/client.ts +157 -61
- package/src/providers/fireworks/client.ts +2 -2
- package/src/providers/gemini/client.ts +9 -1
- package/src/providers/model-catalog.ts +6 -0
- package/src/providers/model-intents.ts +4 -4
- package/src/providers/ollama/client.ts +2 -2
- package/src/providers/openai/chat-completions-provider.ts +474 -0
- package/src/providers/openai/client.ts +25 -440
- package/src/providers/openai/responses-provider.ts +502 -0
- package/src/providers/openrouter/client.ts +101 -4
- package/src/providers/provider-secret-catalog.ts +139 -0
- package/src/providers/registry.ts +2 -2
- package/src/providers/retry.ts +14 -3
- package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +251 -0
- package/src/providers/speech-to-text/__tests__/resolve.test.ts +828 -0
- package/src/providers/speech-to-text/deepgram-realtime.test.ts +980 -0
- package/src/providers/speech-to-text/deepgram-realtime.ts +767 -0
- package/src/providers/speech-to-text/deepgram.test.ts +332 -0
- package/src/providers/speech-to-text/deepgram.ts +115 -0
- package/src/providers/speech-to-text/google-gemini-live-stream.test.ts +743 -0
- package/src/providers/speech-to-text/google-gemini-live-stream.ts +625 -0
- package/src/providers/speech-to-text/google-gemini.test.ts +226 -0
- package/src/providers/speech-to-text/google-gemini.ts +101 -0
- package/src/providers/speech-to-text/openai-whisper-stream.test.ts +564 -0
- package/src/providers/speech-to-text/openai-whisper-stream.ts +381 -0
- package/src/providers/speech-to-text/openai-whisper.test.ts +1 -37
- package/src/providers/speech-to-text/openai-whisper.ts +63 -33
- package/src/providers/speech-to-text/provider-catalog.ts +306 -0
- package/src/providers/speech-to-text/resolve.ts +386 -6
- package/src/providers/types.ts +9 -0
- package/src/runtime/AGENTS.md +43 -1
- package/src/runtime/__tests__/agent-wake.test.ts +831 -0
- package/src/runtime/__tests__/runtime-mode.test.ts +62 -0
- package/src/runtime/__tests__/slack-block-formatting.test.ts +481 -0
- package/src/runtime/agent-wake.ts +512 -0
- package/src/runtime/auth/__tests__/route-policy.test.ts +40 -0
- package/src/runtime/auth/route-policy.ts +30 -5
- package/src/runtime/auth/token-service.ts +56 -1
- package/src/runtime/btw-sidechain.ts +2 -0
- package/src/runtime/capability-tokens.ts +10 -10
- package/src/runtime/channel-invite-transport.ts +1 -1
- package/src/runtime/channel-invite-transports/email.ts +14 -6
- package/src/runtime/channel-readiness-service.ts +12 -22
- package/src/runtime/chrome-extension-registry.ts +38 -2
- package/src/runtime/http-server.ts +395 -10
- package/src/runtime/http-types.ts +6 -2
- package/src/runtime/migrations/__tests__/vbundle-import-credentials.test.ts +36 -0
- package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +360 -0
- package/src/runtime/migrations/migration-transport.ts +1 -0
- package/src/runtime/migrations/migration-wizard.ts +1 -0
- package/src/runtime/migrations/vbundle-import-analyzer.ts +77 -1
- package/src/runtime/migrations/vbundle-importer.ts +34 -0
- package/src/runtime/pending-interactions.ts +0 -11
- package/src/runtime/routes/__tests__/backup-routes.test.ts +967 -0
- package/src/runtime/routes/__tests__/home-feed-routes.test.ts +507 -0
- package/src/runtime/routes/__tests__/migration-import-credential-filter.test.ts +208 -0
- package/src/runtime/routes/__tests__/stt-routes.test.ts +406 -0
- package/src/runtime/routes/__tests__/tts-routes.test.ts +474 -0
- package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +148 -17
- package/src/runtime/routes/app-management-routes.ts +12 -18
- package/src/runtime/routes/attachment-routes.test.ts +9 -3
- package/src/runtime/routes/attachment-routes.ts +216 -17
- package/src/runtime/routes/backup-routes.ts +519 -0
- package/src/runtime/routes/browser-extension-pair-routes.ts +82 -23
- package/src/runtime/routes/btw-routes.ts +8 -6
- package/src/runtime/routes/contact-routes.test.ts +298 -0
- package/src/runtime/routes/contact-routes.ts +132 -5
- package/src/runtime/routes/conversation-analysis-routes.ts +22 -142
- package/src/runtime/routes/conversation-management-routes.ts +115 -0
- package/src/runtime/routes/conversation-routes.ts +367 -146
- package/src/runtime/routes/filing-routes.ts +93 -0
- package/src/runtime/routes/home-feed-routes.ts +334 -0
- package/src/runtime/routes/home-state-routes.ts +138 -0
- package/src/runtime/routes/host-browser-routes.ts +3 -14
- package/src/runtime/routes/identity-intro-cache.ts +7 -3
- package/src/runtime/routes/identity-routes.ts +3 -17
- package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +46 -39
- package/src/runtime/routes/inbound-stages/transcribe-audio.ts +15 -15
- package/src/runtime/routes/integrations/slack/__tests__/channel.test.ts +137 -0
- package/src/runtime/routes/integrations/slack/__tests__/share.test.ts +179 -0
- package/src/runtime/routes/integrations/slack/channel.ts +11 -3
- package/src/runtime/routes/integrations/slack/share.ts +45 -7
- package/src/runtime/routes/llm-context-normalization.ts +303 -0
- package/src/runtime/routes/memory-item-routes.test.ts +3 -2
- package/src/runtime/routes/migration-routes.ts +40 -5
- package/src/runtime/routes/settings-routes.ts +22 -5
- package/src/runtime/routes/skills-routes.ts +76 -7
- package/src/runtime/routes/stt-routes.ts +233 -0
- package/src/runtime/routes/surface-action-routes.ts +41 -2
- package/src/runtime/routes/tts-routes.ts +108 -24
- package/src/runtime/routes/usage-routes.ts +30 -2
- package/src/runtime/routes/user-route-dispatcher.ts +50 -5
- package/src/runtime/routes/user-routes.ts +13 -1
- package/src/runtime/routes/work-items-routes.ts +8 -1
- package/src/runtime/runtime-mode.ts +33 -0
- package/src/runtime/services/__tests__/analyze-conversation.test.ts +444 -0
- package/src/runtime/services/__tests__/analyze-deps-singleton.test.ts +67 -0
- package/src/runtime/services/__tests__/auto-analysis-prompt.test.ts +53 -0
- package/src/runtime/services/__tests__/manual-analysis-prompt.test.ts +41 -0
- package/src/runtime/services/analyze-conversation.ts +344 -0
- package/src/runtime/services/analyze-deps-singleton.ts +32 -0
- package/src/runtime/services/auto-analysis-prompt.ts +55 -0
- package/src/runtime/skill-route-registry.ts +49 -0
- package/src/runtime/slack-block-formatting.ts +437 -10
- package/src/schedule/scheduler.ts +50 -0
- package/src/security/oauth2.ts +26 -4
- package/src/security/secure-keys.ts +25 -2
- package/src/security/token-manager.ts +8 -0
- package/src/sequence/engine.ts +23 -0
- package/src/sequence/types.ts +1 -1
- package/src/skills/catalog-files.ts +64 -2
- package/src/skills/category-inference.ts +122 -0
- package/src/skills/clawhub-files.ts +213 -0
- package/src/skills/clawhub.ts +84 -23
- package/src/skills/skill-file-provider.ts +40 -0
- package/src/skills/skillssh-files.ts +395 -0
- package/src/skills/skillssh-registry.ts +4 -4
- package/src/stt/__tests__/daemon-batch-transcriber.test.ts +392 -0
- package/src/stt/__tests__/types.test.ts +89 -0
- package/src/stt/daemon-batch-transcriber.ts +195 -0
- package/src/stt/stt-stream-session.ts +499 -0
- package/src/stt/types.ts +330 -0
- package/src/stt/wav-encoder.test.ts +373 -0
- package/src/stt/wav-encoder.ts +175 -0
- package/src/subagent/manager.ts +38 -14
- package/src/tools/browser/__tests__/browser-mode.test.ts +119 -0
- package/src/tools/browser/__tests__/browser-status.test.ts +123 -0
- package/src/tools/browser/browser-execution.ts +1163 -23
- package/src/tools/browser/browser-manager.ts +45 -0
- package/src/tools/browser/browser-mode-constants.ts +12 -0
- package/src/tools/browser/browser-mode.ts +92 -0
- package/src/tools/browser/browser-status-constants.ts +33 -0
- package/src/tools/browser/cdp-client/__tests__/cdp-inspect-client.test.ts +393 -0
- package/src/tools/browser/cdp-client/__tests__/extension-cdp-client.test.ts +29 -0
- package/src/tools/browser/cdp-client/__tests__/factory.test.ts +1648 -32
- package/src/tools/browser/cdp-client/cdp-inspect/__tests__/discovery.test.ts +264 -0
- package/src/tools/browser/cdp-client/cdp-inspect/discovery.ts +183 -17
- package/src/tools/browser/cdp-client/cdp-inspect-client.ts +254 -21
- package/src/tools/browser/cdp-client/errors.ts +15 -0
- package/src/tools/browser/cdp-client/extension-cdp-client.ts +39 -16
- package/src/tools/browser/cdp-client/factory.ts +797 -87
- package/src/tools/browser/cdp-client/index.ts +16 -2
- package/src/tools/browser/cdp-client/types.ts +68 -0
- package/src/tools/credentials/vault.ts +35 -6
- package/src/tools/network/web-fetch.ts +5 -2
- package/src/tools/network/web-search.ts +5 -2
- package/src/tools/shared/shell-output.ts +3 -1
- package/src/tools/side-effects.ts +2 -0
- package/src/tools/skills/sandbox-runner.ts +3 -2
- package/src/tools/terminal/safe-env.ts +10 -2
- package/src/tools/terminal/shell.ts +15 -4
- package/src/tools/tool-manifest.ts +21 -0
- package/src/tools/types.ts +17 -0
- package/src/tools/ui-surface/definitions.ts +6 -1
- package/src/tts/__tests__/provider-adapters.test.ts +834 -0
- package/src/tts/__tests__/provider-catalog-consistency.test.ts +196 -0
- package/src/tts/__tests__/provider-catalog.test.ts +183 -0
- package/src/tts/__tests__/provider-registry.test.ts +90 -0
- package/src/tts/provider-catalog.ts +201 -0
- package/src/tts/provider-registry.ts +73 -0
- package/src/tts/providers/deepgram-provider.ts +219 -0
- package/src/tts/providers/elevenlabs-provider.ts +211 -0
- package/src/tts/providers/fish-audio-provider.ts +183 -0
- package/src/tts/providers/index.ts +42 -0
- package/src/tts/providers/register-builtins.ts +130 -0
- package/src/tts/synthesize-text.ts +110 -0
- package/src/tts/tts-config-resolver.ts +78 -0
- package/src/tts/types.ts +153 -0
- package/src/types/onboarding-context.ts +7 -0
- package/src/util/abort-reasons.ts +58 -0
- package/src/util/device-id.ts +32 -16
- package/src/util/errors.ts +9 -1
- package/src/util/platform.ts +54 -10
- package/src/util/pricing.ts +66 -3
- package/src/util/spawn.ts +1 -1
- package/src/util/truncate.ts +4 -2
- package/src/util/unicode.ts +201 -0
- package/src/version.ts +19 -24
- package/src/watcher/engine.ts +23 -0
- package/src/watcher/watcher-store.ts +31 -0
- package/src/workspace/migrations/003-seed-device-id.ts +9 -3
- package/src/workspace/migrations/017-seed-persona-dirs.ts +68 -4
- package/src/workspace/migrations/029-seed-pkb.ts +1 -1
- package/src/workspace/migrations/031-drop-user-md.ts +317 -0
- package/src/workspace/migrations/031-llm-log-retention-zero-to-null.ts +73 -0
- package/src/workspace/migrations/032-tts-provider-unification.ts +227 -0
- package/src/workspace/migrations/033-stt-service-explicit-config.ts +122 -0
- package/src/workspace/migrations/034-remove-calls-voice-transcription-provider.ts +215 -0
- package/src/workspace/migrations/035-seed-slack-channel-persona.ts +50 -0
- package/src/workspace/migrations/036-update-pkb-index-bar.ts +37 -0
- package/src/workspace/migrations/037-create-meets-dir.ts +61 -0
- package/src/workspace/migrations/registry.ts +16 -0
- package/src/workspace/top-level-renderer.ts +13 -1
- package/src/workspace/turn-commit.ts +31 -0
- package/src/__tests__/email-cli.test.ts +0 -297
- package/src/__tests__/email-service-config-fallback.test.ts +0 -102
- package/src/cli/commands/browser-relay.ts +0 -466
- package/src/email/guardrails.ts +0 -221
- package/src/email/provider.ts +0 -117
- package/src/email/providers/agentmail.ts +0 -361
- package/src/email/providers/index.ts +0 -65
- package/src/email/service.ts +0 -384
- package/src/email/types.ts +0 -126
- package/src/prompts/templates/USER.md +0 -13
- package/src/providers/speech-to-text/types.ts +0 -17
- package/src/runtime/routes/browser-cdp-routes.ts +0 -229
|
@@ -0,0 +1,967 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the /v1/backups HTTP route handlers.
|
|
3
|
+
*
|
|
4
|
+
* These tests drive the handler functions directly (bypassing the router)
|
|
5
|
+
* so they exercise the handler logic — input validation, path containment,
|
|
6
|
+
* key-loading, and error mapping — without needing a live HTTP server.
|
|
7
|
+
*
|
|
8
|
+
* Module-level mocks replace the real `config/loader`, `memory/checkpoints`,
|
|
9
|
+
* `backup/backup-worker`, `backup/restore`, and `backup/backup-key` modules
|
|
10
|
+
* with test doubles. Each test shapes the doubles through the `setMockXxx`
|
|
11
|
+
* helpers in the setup/teardown block.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
mkdirSync,
|
|
16
|
+
mkdtempSync,
|
|
17
|
+
rmSync,
|
|
18
|
+
symlinkSync,
|
|
19
|
+
writeFileSync,
|
|
20
|
+
} from "node:fs";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import {
|
|
24
|
+
afterEach,
|
|
25
|
+
beforeEach,
|
|
26
|
+
describe,
|
|
27
|
+
expect,
|
|
28
|
+
mock,
|
|
29
|
+
test,
|
|
30
|
+
} from "bun:test";
|
|
31
|
+
|
|
32
|
+
import type { BackupRunResult } from "../../../backup/backup-worker.js";
|
|
33
|
+
import type { SnapshotEntry } from "../../../backup/list-snapshots.js";
|
|
34
|
+
import type { RestoreResult, VerifyResult } from "../../../backup/restore.js";
|
|
35
|
+
import type { BackupConfig } from "../../../config/schema.js";
|
|
36
|
+
import { BackupConfigSchema } from "../../../config/schema.js";
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Module mocks — must appear before any imports of the module under test
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
mock.module("../../../util/logger.js", () => ({
|
|
43
|
+
getLogger: () =>
|
|
44
|
+
new Proxy({} as Record<string, unknown>, {
|
|
45
|
+
get: () => () => {},
|
|
46
|
+
}),
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
// -- listSnapshotsInDir spy ------------------------------------------------
|
|
50
|
+
// Wraps the real implementation so tests can assert on which directories
|
|
51
|
+
// were enumerated. Needed to verify handleBackupList skips offsite
|
|
52
|
+
// enumeration when backup.offsite.enabled is false.
|
|
53
|
+
|
|
54
|
+
const listSnapshotsCallLog: string[] = [];
|
|
55
|
+
const { listSnapshotsInDir: realListSnapshotsInDir } = await import(
|
|
56
|
+
"../../../backup/list-snapshots.js"
|
|
57
|
+
);
|
|
58
|
+
mock.module("../../../backup/list-snapshots.js", () => ({
|
|
59
|
+
listSnapshotsInDir: async (dir: string) => {
|
|
60
|
+
listSnapshotsCallLog.push(dir);
|
|
61
|
+
return realListSnapshotsInDir(dir);
|
|
62
|
+
},
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
// -- Config mock -----------------------------------------------------------
|
|
66
|
+
// Built in `beforeEach` from BackupConfigSchema defaults, with overrides
|
|
67
|
+
// applied per test via `setMockBackupConfig`.
|
|
68
|
+
|
|
69
|
+
let mockBackupConfig: BackupConfig = BackupConfigSchema.parse({});
|
|
70
|
+
let mockWorkspaceDir = "/tmp/mock-workspace-unused";
|
|
71
|
+
|
|
72
|
+
let mockInvalidateConfigCacheCalls = 0;
|
|
73
|
+
|
|
74
|
+
mock.module("../../../config/loader.js", () => ({
|
|
75
|
+
getConfig: () => ({
|
|
76
|
+
backup: mockBackupConfig,
|
|
77
|
+
// The handlers only touch `.backup`, but getConfig() is typed as returning
|
|
78
|
+
// the full AssistantConfig. Cast through `unknown` so the partial shape is
|
|
79
|
+
// accepted without pulling in the full config schema.
|
|
80
|
+
}),
|
|
81
|
+
invalidateConfigCache: () => {
|
|
82
|
+
mockInvalidateConfigCacheCalls += 1;
|
|
83
|
+
recoveryCallOrder.push("invalidateConfigCache");
|
|
84
|
+
},
|
|
85
|
+
}));
|
|
86
|
+
|
|
87
|
+
// -- Trust-cache mock ------------------------------------------------------
|
|
88
|
+
// After a successful `restoreFromSnapshot`, handleBackupRestore must call
|
|
89
|
+
// `invalidateConfigCache()` and `clearTrustCache()` (matching the migration
|
|
90
|
+
// importer). The SQLite handle reset is owned by `restoreFromSnapshot` and
|
|
91
|
+
// covered in restore.test.ts. Tests here record the call sequence via
|
|
92
|
+
// `recoveryCallOrder` and assert on the relative ordering.
|
|
93
|
+
|
|
94
|
+
let mockClearTrustCacheCalls = 0;
|
|
95
|
+
const recoveryCallOrder: string[] = [];
|
|
96
|
+
|
|
97
|
+
mock.module("../../../permissions/trust-store.js", () => ({
|
|
98
|
+
clearCache: () => {
|
|
99
|
+
mockClearTrustCacheCalls += 1;
|
|
100
|
+
recoveryCallOrder.push("clearTrustCache");
|
|
101
|
+
},
|
|
102
|
+
}));
|
|
103
|
+
|
|
104
|
+
// -- Platform paths mock ---------------------------------------------------
|
|
105
|
+
// `getWorkspaceDir` / `getWorkspaceHooksDir` are used inside the restore
|
|
106
|
+
// handler to build a DefaultPathResolver. Return test-friendly paths so
|
|
107
|
+
// restore tests don't pollute the real workspace.
|
|
108
|
+
|
|
109
|
+
mock.module("../../../util/platform.js", () => ({
|
|
110
|
+
getWorkspaceDir: () => mockWorkspaceDir,
|
|
111
|
+
getWorkspaceHooksDir: () => join(mockWorkspaceDir, "hooks"),
|
|
112
|
+
// Passed through when tests need the protected dir (e.g. via paths.ts).
|
|
113
|
+
getProtectedDir: () => join(mockWorkspaceDir, "protected"),
|
|
114
|
+
getDbPath: () => join(mockWorkspaceDir, "data", "db", "assistant.db"),
|
|
115
|
+
}));
|
|
116
|
+
|
|
117
|
+
// -- Memory checkpoint mock ------------------------------------------------
|
|
118
|
+
|
|
119
|
+
const mockCheckpointStore: Record<string, string | null> = {};
|
|
120
|
+
|
|
121
|
+
mock.module("../../../memory/checkpoints.js", () => ({
|
|
122
|
+
getMemoryCheckpoint: (key: string) => mockCheckpointStore[key] ?? null,
|
|
123
|
+
setMemoryCheckpoint: (key: string, value: string) => {
|
|
124
|
+
mockCheckpointStore[key] = value;
|
|
125
|
+
},
|
|
126
|
+
}));
|
|
127
|
+
|
|
128
|
+
// -- Backup key mock -------------------------------------------------------
|
|
129
|
+
// Tests override this via `setMockBackupKey` / `setMockBackupKeyMissing`.
|
|
130
|
+
// The mock also records how many times the key was read so tests can assert
|
|
131
|
+
// "key file was never touched" for plaintext-only code paths.
|
|
132
|
+
|
|
133
|
+
let mockBackupKey: Buffer | null = Buffer.alloc(32, 0xaa);
|
|
134
|
+
let mockReadBackupKeyCalls = 0;
|
|
135
|
+
|
|
136
|
+
mock.module("../../../backup/backup-key.js", () => ({
|
|
137
|
+
readBackupKey: async (_path: string) => {
|
|
138
|
+
mockReadBackupKeyCalls += 1;
|
|
139
|
+
return mockBackupKey;
|
|
140
|
+
},
|
|
141
|
+
ensureBackupKey: async (_path: string) => mockBackupKey ?? Buffer.alloc(32),
|
|
142
|
+
}));
|
|
143
|
+
|
|
144
|
+
// -- Backup worker mock ----------------------------------------------------
|
|
145
|
+
// `createSnapshotNow` is replaced so tests can control success / 409
|
|
146
|
+
// behavior without touching the real export pipeline.
|
|
147
|
+
|
|
148
|
+
let mockCreateSnapshotResult: BackupRunResult | null = null;
|
|
149
|
+
let mockCreateSnapshotError: Error | null = null;
|
|
150
|
+
let mockCreateSnapshotCalls = 0;
|
|
151
|
+
|
|
152
|
+
mock.module("../../../backup/backup-worker.js", () => ({
|
|
153
|
+
createSnapshotNow: async (_config: BackupConfig, _now: Date) => {
|
|
154
|
+
mockCreateSnapshotCalls += 1;
|
|
155
|
+
if (mockCreateSnapshotError) throw mockCreateSnapshotError;
|
|
156
|
+
if (mockCreateSnapshotResult == null) {
|
|
157
|
+
throw new Error("Test forgot to set mockCreateSnapshotResult");
|
|
158
|
+
}
|
|
159
|
+
return mockCreateSnapshotResult;
|
|
160
|
+
},
|
|
161
|
+
}));
|
|
162
|
+
|
|
163
|
+
// -- Restore module mock ---------------------------------------------------
|
|
164
|
+
// Both `restoreFromSnapshot` and `verifySnapshot` are replaced. Tests
|
|
165
|
+
// inspect `lastRestoreArgs` / `lastVerifyArgs` to assert the handler
|
|
166
|
+
// forwarded the correct key and options.
|
|
167
|
+
|
|
168
|
+
interface RestoreCall {
|
|
169
|
+
path: string;
|
|
170
|
+
hasKey: boolean;
|
|
171
|
+
workspaceDir: string | undefined;
|
|
172
|
+
}
|
|
173
|
+
interface VerifyCall {
|
|
174
|
+
path: string;
|
|
175
|
+
hasKey: boolean;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let lastRestoreArgs: RestoreCall | null = null;
|
|
179
|
+
let lastVerifyArgs: VerifyCall | null = null;
|
|
180
|
+
let mockRestoreResult: RestoreResult = {
|
|
181
|
+
manifest: {
|
|
182
|
+
schema_version: "1.0",
|
|
183
|
+
created_at: "2026-04-11T10:00:00.000Z",
|
|
184
|
+
files: [],
|
|
185
|
+
manifest_sha256: "0".repeat(64),
|
|
186
|
+
} as unknown as RestoreResult["manifest"],
|
|
187
|
+
restoredFiles: 0,
|
|
188
|
+
};
|
|
189
|
+
let mockRestoreError: Error | null = null;
|
|
190
|
+
let mockVerifyResult: VerifyResult = { valid: true };
|
|
191
|
+
|
|
192
|
+
mock.module("../../../backup/restore.js", () => ({
|
|
193
|
+
restoreFromSnapshot: async (
|
|
194
|
+
path: string,
|
|
195
|
+
opts: {
|
|
196
|
+
key?: Buffer;
|
|
197
|
+
workspaceDir?: string;
|
|
198
|
+
},
|
|
199
|
+
) => {
|
|
200
|
+
recoveryCallOrder.push("restoreFromSnapshot");
|
|
201
|
+
lastRestoreArgs = {
|
|
202
|
+
path,
|
|
203
|
+
hasKey: opts.key != null,
|
|
204
|
+
workspaceDir: opts.workspaceDir,
|
|
205
|
+
};
|
|
206
|
+
if (mockRestoreError) throw mockRestoreError;
|
|
207
|
+
return mockRestoreResult;
|
|
208
|
+
},
|
|
209
|
+
verifySnapshot: async (path: string, opts: { key?: Buffer }) => {
|
|
210
|
+
lastVerifyArgs = { path, hasKey: opts.key != null };
|
|
211
|
+
return mockVerifyResult;
|
|
212
|
+
},
|
|
213
|
+
}));
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// Import under test — after mocks
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
import {
|
|
220
|
+
backupRouteDefinitions,
|
|
221
|
+
handleBackupCreate,
|
|
222
|
+
handleBackupList,
|
|
223
|
+
handleBackupRestore,
|
|
224
|
+
handleBackupVerify,
|
|
225
|
+
} from "../backup-routes.js";
|
|
226
|
+
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
// Helpers
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
let ROOT: string;
|
|
232
|
+
let LOCAL_DIR: string;
|
|
233
|
+
|
|
234
|
+
/** Build a valid BackupConfig with overrides applied via spread. */
|
|
235
|
+
function makeConfig(overrides: Partial<BackupConfig> = {}): BackupConfig {
|
|
236
|
+
const base = BackupConfigSchema.parse({});
|
|
237
|
+
return { ...base, ...overrides };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Write a backup-shaped file to disk so `listSnapshotsInDir` picks it up. */
|
|
241
|
+
function writeBackupFile(
|
|
242
|
+
dir: string,
|
|
243
|
+
filename: string,
|
|
244
|
+
payload: string = "fake-bundle",
|
|
245
|
+
): string {
|
|
246
|
+
mkdirSync(dir, { recursive: true });
|
|
247
|
+
const fullPath = join(dir, filename);
|
|
248
|
+
writeFileSync(fullPath, payload);
|
|
249
|
+
return fullPath;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function jsonRequest(method: string, body: unknown): Request {
|
|
253
|
+
return new Request("http://localhost/v1/backups", {
|
|
254
|
+
method,
|
|
255
|
+
headers: { "Content-Type": "application/json" },
|
|
256
|
+
body: JSON.stringify(body),
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// Setup / teardown
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
beforeEach(() => {
|
|
265
|
+
ROOT = mkdtempSync(join(tmpdir(), "vellum-backup-routes-"));
|
|
266
|
+
LOCAL_DIR = join(ROOT, "local");
|
|
267
|
+
// Reset mocks to defaults
|
|
268
|
+
mockBackupConfig = makeConfig({ localDirectory: LOCAL_DIR });
|
|
269
|
+
mockWorkspaceDir = join(ROOT, "workspace");
|
|
270
|
+
for (const key of Object.keys(mockCheckpointStore)) {
|
|
271
|
+
delete mockCheckpointStore[key];
|
|
272
|
+
}
|
|
273
|
+
mockBackupKey = Buffer.alloc(32, 0xaa);
|
|
274
|
+
mockReadBackupKeyCalls = 0;
|
|
275
|
+
mockCreateSnapshotResult = null;
|
|
276
|
+
mockCreateSnapshotError = null;
|
|
277
|
+
mockCreateSnapshotCalls = 0;
|
|
278
|
+
lastRestoreArgs = null;
|
|
279
|
+
lastVerifyArgs = null;
|
|
280
|
+
mockRestoreError = null;
|
|
281
|
+
mockRestoreResult = {
|
|
282
|
+
manifest: {
|
|
283
|
+
schema_version: "1.0",
|
|
284
|
+
created_at: "2026-04-11T10:00:00.000Z",
|
|
285
|
+
files: [],
|
|
286
|
+
manifest_sha256: "0".repeat(64),
|
|
287
|
+
} as unknown as RestoreResult["manifest"],
|
|
288
|
+
restoredFiles: 0,
|
|
289
|
+
};
|
|
290
|
+
mockVerifyResult = { valid: true };
|
|
291
|
+
mockInvalidateConfigCacheCalls = 0;
|
|
292
|
+
mockClearTrustCacheCalls = 0;
|
|
293
|
+
recoveryCallOrder.length = 0;
|
|
294
|
+
listSnapshotsCallLog.length = 0;
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
afterEach(() => {
|
|
298
|
+
try {
|
|
299
|
+
rmSync(ROOT, { recursive: true, force: true });
|
|
300
|
+
} catch {
|
|
301
|
+
// best-effort
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
// handleBackupList
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
describe("handleBackupList", () => {
|
|
310
|
+
test("empty workspace: returns empty local array and one unreachable iCloud default", async () => {
|
|
311
|
+
// Use default offsite destinations (null) so the iCloud default kicks in.
|
|
312
|
+
// `getICloudDriveRoot` reads `process.env.HOME` at call time. Overriding
|
|
313
|
+
// HOME to our ROOT tempdir both (a) keeps the test hermetic — it does not
|
|
314
|
+
// depend on whether the dev machine has iCloud Drive enabled or contains
|
|
315
|
+
// any real backup bundles at the real iCloud path, and (b) ensures the
|
|
316
|
+
// derived default iCloud dir is unreachable (no Library/Mobile Documents
|
|
317
|
+
// under ROOT) so the probe returns `reachable: false` and the snapshots
|
|
318
|
+
// array is empty.
|
|
319
|
+
const origHome = process.env.HOME;
|
|
320
|
+
process.env.HOME = ROOT;
|
|
321
|
+
try {
|
|
322
|
+
mockBackupConfig = makeConfig({
|
|
323
|
+
localDirectory: LOCAL_DIR,
|
|
324
|
+
offsite: {
|
|
325
|
+
enabled: true,
|
|
326
|
+
destinations: null,
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
const res = await handleBackupList(
|
|
331
|
+
new Request("http://localhost/v1/backups"),
|
|
332
|
+
);
|
|
333
|
+
expect(res.status).toBe(200);
|
|
334
|
+
const body = (await res.json()) as {
|
|
335
|
+
local: SnapshotEntry[];
|
|
336
|
+
offsite: Array<{
|
|
337
|
+
destination: { path: string; encrypt: boolean };
|
|
338
|
+
snapshots: SnapshotEntry[];
|
|
339
|
+
reachable: boolean;
|
|
340
|
+
}>;
|
|
341
|
+
nextRunAt: string | null;
|
|
342
|
+
};
|
|
343
|
+
expect(body.local).toEqual([]);
|
|
344
|
+
// iCloud default is present as a single destination pointing at the
|
|
345
|
+
// derived-from-ROOT iCloud path, which does not exist on disk.
|
|
346
|
+
expect(body.offsite).toHaveLength(1);
|
|
347
|
+
expect(body.offsite[0].destination.encrypt).toBe(true);
|
|
348
|
+
expect(body.offsite[0].snapshots).toEqual([]);
|
|
349
|
+
expect(body.offsite[0].reachable).toBe(false);
|
|
350
|
+
expect(body.nextRunAt).toBeNull();
|
|
351
|
+
} finally {
|
|
352
|
+
if (origHome === undefined) {
|
|
353
|
+
delete process.env.HOME;
|
|
354
|
+
} else {
|
|
355
|
+
process.env.HOME = origHome;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("two local files: returned newest-first", async () => {
|
|
361
|
+
writeBackupFile(LOCAL_DIR, "backup-20260411-100000.vbundle");
|
|
362
|
+
writeBackupFile(LOCAL_DIR, "backup-20260411-120000.vbundle");
|
|
363
|
+
mockBackupConfig = makeConfig({
|
|
364
|
+
localDirectory: LOCAL_DIR,
|
|
365
|
+
offsite: { enabled: true, destinations: [] },
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
const res = await handleBackupList(new Request("http://localhost/v1/backups"));
|
|
369
|
+
expect(res.status).toBe(200);
|
|
370
|
+
const body = (await res.json()) as {
|
|
371
|
+
local: SnapshotEntry[];
|
|
372
|
+
offsite: Array<unknown>;
|
|
373
|
+
};
|
|
374
|
+
expect(body.local).toHaveLength(2);
|
|
375
|
+
expect(body.local[0].filename).toBe("backup-20260411-120000.vbundle");
|
|
376
|
+
expect(body.local[1].filename).toBe("backup-20260411-100000.vbundle");
|
|
377
|
+
expect(body.offsite).toEqual([]);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("two offsite destinations: reachable + unreachable reflected per-entry", async () => {
|
|
381
|
+
const reachableDir = join(ROOT, "offsite-reachable");
|
|
382
|
+
const unreachableDir = join(ROOT, "nope", "deeper", "backups");
|
|
383
|
+
mkdirSync(reachableDir, { recursive: true });
|
|
384
|
+
// Put a snapshot in the reachable one so the `snapshots` array is populated.
|
|
385
|
+
writeBackupFile(reachableDir, "backup-20260411-100000.vbundle");
|
|
386
|
+
|
|
387
|
+
mockBackupConfig = makeConfig({
|
|
388
|
+
localDirectory: LOCAL_DIR,
|
|
389
|
+
offsite: {
|
|
390
|
+
enabled: true,
|
|
391
|
+
destinations: [
|
|
392
|
+
{ path: reachableDir, encrypt: false },
|
|
393
|
+
{ path: unreachableDir, encrypt: true },
|
|
394
|
+
],
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const res = await handleBackupList(new Request("http://localhost/v1/backups"));
|
|
399
|
+
expect(res.status).toBe(200);
|
|
400
|
+
const body = (await res.json()) as {
|
|
401
|
+
offsite: Array<{
|
|
402
|
+
destination: { path: string; encrypt: boolean };
|
|
403
|
+
snapshots: SnapshotEntry[];
|
|
404
|
+
reachable: boolean;
|
|
405
|
+
}>;
|
|
406
|
+
};
|
|
407
|
+
expect(body.offsite).toHaveLength(2);
|
|
408
|
+
expect(body.offsite[0].destination.path).toBe(reachableDir);
|
|
409
|
+
expect(body.offsite[0].reachable).toBe(true);
|
|
410
|
+
expect(body.offsite[0].snapshots).toHaveLength(1);
|
|
411
|
+
expect(body.offsite[0].snapshots[0].filename).toBe(
|
|
412
|
+
"backup-20260411-100000.vbundle",
|
|
413
|
+
);
|
|
414
|
+
expect(body.offsite[1].destination.path).toBe(unreachableDir);
|
|
415
|
+
expect(body.offsite[1].reachable).toBe(false);
|
|
416
|
+
expect(body.offsite[1].snapshots).toEqual([]);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test("encrypted files in a reachable offsite dir return with encrypted: true", async () => {
|
|
420
|
+
const encryptedDir = join(ROOT, "offsite-enc");
|
|
421
|
+
mkdirSync(encryptedDir, { recursive: true });
|
|
422
|
+
writeBackupFile(encryptedDir, "backup-20260411-100000.vbundle.enc");
|
|
423
|
+
writeBackupFile(encryptedDir, "backup-20260411-120000.vbundle.enc");
|
|
424
|
+
|
|
425
|
+
mockBackupConfig = makeConfig({
|
|
426
|
+
localDirectory: LOCAL_DIR,
|
|
427
|
+
offsite: {
|
|
428
|
+
enabled: true,
|
|
429
|
+
destinations: [{ path: encryptedDir, encrypt: true }],
|
|
430
|
+
},
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
const res = await handleBackupList(new Request("http://localhost/v1/backups"));
|
|
434
|
+
expect(res.status).toBe(200);
|
|
435
|
+
const body = (await res.json()) as {
|
|
436
|
+
offsite: Array<{
|
|
437
|
+
snapshots: SnapshotEntry[];
|
|
438
|
+
reachable: boolean;
|
|
439
|
+
}>;
|
|
440
|
+
};
|
|
441
|
+
expect(body.offsite).toHaveLength(1);
|
|
442
|
+
expect(body.offsite[0].reachable).toBe(true);
|
|
443
|
+
expect(body.offsite[0].snapshots).toHaveLength(2);
|
|
444
|
+
// Newest-first
|
|
445
|
+
expect(body.offsite[0].snapshots[0].filename).toBe(
|
|
446
|
+
"backup-20260411-120000.vbundle.enc",
|
|
447
|
+
);
|
|
448
|
+
expect(body.offsite[0].snapshots[0].encrypted).toBe(true);
|
|
449
|
+
expect(body.offsite[0].snapshots[1].encrypted).toBe(true);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test("nextRunAt is computed from checkpoint + intervalHours when enabled", async () => {
|
|
453
|
+
const lastRunMs = Date.parse("2026-04-11T10:00:00Z");
|
|
454
|
+
mockCheckpointStore["backup:last_run_at"] = String(lastRunMs);
|
|
455
|
+
mockBackupConfig = makeConfig({
|
|
456
|
+
enabled: true,
|
|
457
|
+
intervalHours: 6,
|
|
458
|
+
localDirectory: LOCAL_DIR,
|
|
459
|
+
offsite: { enabled: true, destinations: [] },
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
const res = await handleBackupList(new Request("http://localhost/v1/backups"));
|
|
463
|
+
expect(res.status).toBe(200);
|
|
464
|
+
const body = (await res.json()) as { nextRunAt: string | null };
|
|
465
|
+
// 6 hours after 10:00 UTC is 16:00 UTC
|
|
466
|
+
expect(body.nextRunAt).toBe("2026-04-11T16:00:00.000Z");
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test("nextRunAt is null when backup is disabled", async () => {
|
|
470
|
+
mockCheckpointStore["backup:last_run_at"] = String(
|
|
471
|
+
Date.parse("2026-04-11T10:00:00Z"),
|
|
472
|
+
);
|
|
473
|
+
mockBackupConfig = makeConfig({
|
|
474
|
+
enabled: false,
|
|
475
|
+
localDirectory: LOCAL_DIR,
|
|
476
|
+
offsite: { enabled: true, destinations: [] },
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
const res = await handleBackupList(new Request("http://localhost/v1/backups"));
|
|
480
|
+
const body = (await res.json()) as { nextRunAt: string | null };
|
|
481
|
+
expect(body.nextRunAt).toBeNull();
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test("offsite.enabled=false returns offsite:[] and offsiteEnabled:false without probing destinations", async () => {
|
|
485
|
+
// Regression test: when the user disables offsite backups, the HTTP
|
|
486
|
+
// handler must mirror the worker's behavior and return an empty offsite
|
|
487
|
+
// list without enumerating any destinations. Previously the handler
|
|
488
|
+
// would still probe each configured destination, causing the macOS UI
|
|
489
|
+
// to render offsite cards even after offsite was turned off.
|
|
490
|
+
//
|
|
491
|
+
// Even with destinations present in config, `offsite.enabled=false`
|
|
492
|
+
// should short-circuit the enumeration loop.
|
|
493
|
+
const configuredDestDir = join(ROOT, "offsite-still-configured");
|
|
494
|
+
mkdirSync(configuredDestDir, { recursive: true });
|
|
495
|
+
mockBackupConfig = makeConfig({
|
|
496
|
+
localDirectory: LOCAL_DIR,
|
|
497
|
+
offsite: {
|
|
498
|
+
enabled: false,
|
|
499
|
+
destinations: [
|
|
500
|
+
{ path: configuredDestDir, encrypt: true },
|
|
501
|
+
],
|
|
502
|
+
},
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
const res = await handleBackupList(
|
|
506
|
+
new Request("http://localhost/v1/backups"),
|
|
507
|
+
);
|
|
508
|
+
expect(res.status).toBe(200);
|
|
509
|
+
const body = (await res.json()) as {
|
|
510
|
+
local: SnapshotEntry[];
|
|
511
|
+
offsite: unknown[];
|
|
512
|
+
offsiteEnabled: boolean;
|
|
513
|
+
};
|
|
514
|
+
expect(body.offsite).toEqual([]);
|
|
515
|
+
expect(body.offsiteEnabled).toBe(false);
|
|
516
|
+
// listSnapshotsInDir should only have been called for the local dir —
|
|
517
|
+
// never for any offsite destination.
|
|
518
|
+
expect(listSnapshotsCallLog).toEqual([LOCAL_DIR]);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test("offsite.enabled=true returns offsiteEnabled:true", async () => {
|
|
522
|
+
mockBackupConfig = makeConfig({
|
|
523
|
+
localDirectory: LOCAL_DIR,
|
|
524
|
+
offsite: { enabled: true, destinations: [] },
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
const res = await handleBackupList(
|
|
528
|
+
new Request("http://localhost/v1/backups"),
|
|
529
|
+
);
|
|
530
|
+
expect(res.status).toBe(200);
|
|
531
|
+
const body = (await res.json()) as { offsiteEnabled: boolean };
|
|
532
|
+
expect(body.offsiteEnabled).toBe(true);
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
// ---------------------------------------------------------------------------
|
|
537
|
+
// handleBackupCreate
|
|
538
|
+
// ---------------------------------------------------------------------------
|
|
539
|
+
|
|
540
|
+
describe("handleBackupCreate", () => {
|
|
541
|
+
const fakeRunResult: BackupRunResult = {
|
|
542
|
+
local: {
|
|
543
|
+
path: "/tmp/fake/backup-20260411-100000.vbundle",
|
|
544
|
+
filename: "backup-20260411-100000.vbundle",
|
|
545
|
+
createdAt: new Date("2026-04-11T10:00:00Z"),
|
|
546
|
+
sizeBytes: 100,
|
|
547
|
+
encrypted: false,
|
|
548
|
+
},
|
|
549
|
+
offsite: [],
|
|
550
|
+
durationMs: 42,
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
test("manual create bypasses enabled flag and succeeds with disabled config", async () => {
|
|
554
|
+
mockBackupConfig = makeConfig({
|
|
555
|
+
enabled: false,
|
|
556
|
+
localDirectory: LOCAL_DIR,
|
|
557
|
+
offsite: { enabled: false, destinations: null },
|
|
558
|
+
});
|
|
559
|
+
mockCreateSnapshotResult = fakeRunResult;
|
|
560
|
+
|
|
561
|
+
const res = await handleBackupCreate(
|
|
562
|
+
new Request("http://localhost/v1/backups/create", { method: "POST" }),
|
|
563
|
+
);
|
|
564
|
+
expect(res.status).toBe(200);
|
|
565
|
+
const body = (await res.json()) as BackupRunResult;
|
|
566
|
+
expect(body.durationMs).toBe(42);
|
|
567
|
+
expect(body.offsite).toEqual([]);
|
|
568
|
+
expect(mockCreateSnapshotCalls).toBe(1);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
test("plaintext-only destinations do not create backup.key file", async () => {
|
|
572
|
+
const plaintextDir = join(ROOT, "offsite-plain");
|
|
573
|
+
mkdirSync(plaintextDir, { recursive: true });
|
|
574
|
+
mockBackupConfig = makeConfig({
|
|
575
|
+
enabled: true,
|
|
576
|
+
localDirectory: LOCAL_DIR,
|
|
577
|
+
offsite: {
|
|
578
|
+
enabled: true,
|
|
579
|
+
destinations: [{ path: plaintextDir, encrypt: false }],
|
|
580
|
+
},
|
|
581
|
+
});
|
|
582
|
+
mockCreateSnapshotResult = fakeRunResult;
|
|
583
|
+
|
|
584
|
+
// The mocked createSnapshotNow never touches the key file. We assert:
|
|
585
|
+
// (a) the HTTP layer itself did not try to load readBackupKey, and
|
|
586
|
+
// (b) no backup.key file exists under the protected dir (which is under
|
|
587
|
+
// our ROOT per the platform mock).
|
|
588
|
+
mockReadBackupKeyCalls = 0;
|
|
589
|
+
const res = await handleBackupCreate(
|
|
590
|
+
new Request("http://localhost/v1/backups/create", { method: "POST" }),
|
|
591
|
+
);
|
|
592
|
+
expect(res.status).toBe(200);
|
|
593
|
+
expect(mockReadBackupKeyCalls).toBe(0);
|
|
594
|
+
// ROOT is a fresh temp dir — no protected/backup.key was ever written.
|
|
595
|
+
const keyFileExists = await import("node:fs").then((m) =>
|
|
596
|
+
m.existsSync(join(ROOT, "workspace", "protected", "backup.key")),
|
|
597
|
+
);
|
|
598
|
+
expect(keyFileExists).toBe(false);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
test("concurrent call returns 409 when mock raises 'snapshot in progress'", async () => {
|
|
602
|
+
mockBackupConfig = makeConfig({ localDirectory: LOCAL_DIR });
|
|
603
|
+
mockCreateSnapshotError = new Error("snapshot in progress");
|
|
604
|
+
|
|
605
|
+
const res = await handleBackupCreate(
|
|
606
|
+
new Request("http://localhost/v1/backups/create", { method: "POST" }),
|
|
607
|
+
);
|
|
608
|
+
expect(res.status).toBe(409);
|
|
609
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
610
|
+
expect(body.error.code).toBe("CONFLICT");
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
test("cross-process conflict ('locked by pid N') is still mapped to 409", async () => {
|
|
614
|
+
// Regression test for the startsWith matcher in handleBackupCreate: the
|
|
615
|
+
// cross-process file lock in snapshot-lock.ts throws
|
|
616
|
+
// "snapshot in progress (locked by pid N)" rather than the bare
|
|
617
|
+
// "snapshot in progress" message the in-process flag emits. Both must
|
|
618
|
+
// map to 409 / CONFLICT — pin the matcher against future drift.
|
|
619
|
+
mockBackupConfig = makeConfig({ localDirectory: LOCAL_DIR });
|
|
620
|
+
mockCreateSnapshotError = new Error(
|
|
621
|
+
"snapshot in progress (locked by pid 12345)",
|
|
622
|
+
);
|
|
623
|
+
|
|
624
|
+
const res = await handleBackupCreate(
|
|
625
|
+
new Request("http://localhost/v1/backups/create", { method: "POST" }),
|
|
626
|
+
);
|
|
627
|
+
expect(res.status).toBe(409);
|
|
628
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
629
|
+
expect(body.error.code).toBe("CONFLICT");
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
test("other errors are surfaced as 500", async () => {
|
|
633
|
+
mockCreateSnapshotError = new Error("disk full");
|
|
634
|
+
const res = await handleBackupCreate(
|
|
635
|
+
new Request("http://localhost/v1/backups/create", { method: "POST" }),
|
|
636
|
+
);
|
|
637
|
+
expect(res.status).toBe(500);
|
|
638
|
+
const body = (await res.json()) as { error: { code: string; message: string } };
|
|
639
|
+
expect(body.error.code).toBe("INTERNAL_ERROR");
|
|
640
|
+
expect(body.error.message).toBe("disk full");
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
// ---------------------------------------------------------------------------
|
|
645
|
+
// handleBackupRestore
|
|
646
|
+
// ---------------------------------------------------------------------------
|
|
647
|
+
|
|
648
|
+
describe("handleBackupRestore", () => {
|
|
649
|
+
test("rejects path outside the allowed directories with 400", async () => {
|
|
650
|
+
const outsidePath = join(ROOT, "elsewhere", "backup-20260411-100000.vbundle");
|
|
651
|
+
mkdirSync(join(ROOT, "elsewhere"), { recursive: true });
|
|
652
|
+
writeFileSync(outsidePath, "payload");
|
|
653
|
+
mockBackupConfig = makeConfig({
|
|
654
|
+
localDirectory: LOCAL_DIR,
|
|
655
|
+
offsite: { enabled: true, destinations: [] },
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
const res = await handleBackupRestore(
|
|
659
|
+
jsonRequest("POST", { path: outsidePath }),
|
|
660
|
+
);
|
|
661
|
+
expect(res.status).toBe(400);
|
|
662
|
+
const body = (await res.json()) as { error: { code: string; message: string } };
|
|
663
|
+
expect(body.error.code).toBe("BAD_REQUEST");
|
|
664
|
+
expect(body.error.message).toMatch(/outside/i);
|
|
665
|
+
expect(lastRestoreArgs).toBeNull();
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
test("rejects symlink that escapes the allowed directories", async () => {
|
|
669
|
+
// Create a valid-looking symlink inside LOCAL_DIR that points to a file
|
|
670
|
+
// outside. realpath() follows the symlink, so containment check fails.
|
|
671
|
+
const outsideTarget = join(ROOT, "evil-target.vbundle");
|
|
672
|
+
writeFileSync(outsideTarget, "payload");
|
|
673
|
+
mkdirSync(LOCAL_DIR, { recursive: true });
|
|
674
|
+
const symlinkPath = join(LOCAL_DIR, "backup-20260411-100000.vbundle");
|
|
675
|
+
symlinkSync(outsideTarget, symlinkPath);
|
|
676
|
+
mockBackupConfig = makeConfig({
|
|
677
|
+
localDirectory: LOCAL_DIR,
|
|
678
|
+
offsite: { enabled: true, destinations: [] },
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
const res = await handleBackupRestore(
|
|
682
|
+
jsonRequest("POST", { path: symlinkPath }),
|
|
683
|
+
);
|
|
684
|
+
expect(res.status).toBe(400);
|
|
685
|
+
expect(lastRestoreArgs).toBeNull();
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
test("plaintext .vbundle inside local dir is restored without loading key", async () => {
|
|
689
|
+
const snapshotPath = writeBackupFile(
|
|
690
|
+
LOCAL_DIR,
|
|
691
|
+
"backup-20260411-100000.vbundle",
|
|
692
|
+
);
|
|
693
|
+
mockBackupConfig = makeConfig({
|
|
694
|
+
localDirectory: LOCAL_DIR,
|
|
695
|
+
offsite: { enabled: true, destinations: [] },
|
|
696
|
+
});
|
|
697
|
+
mockReadBackupKeyCalls = 0;
|
|
698
|
+
|
|
699
|
+
const res = await handleBackupRestore(
|
|
700
|
+
jsonRequest("POST", { path: snapshotPath }),
|
|
701
|
+
);
|
|
702
|
+
expect(res.status).toBe(200);
|
|
703
|
+
expect(mockReadBackupKeyCalls).toBe(0);
|
|
704
|
+
expect(lastRestoreArgs).not.toBeNull();
|
|
705
|
+
expect(lastRestoreArgs!.hasKey).toBe(false);
|
|
706
|
+
// restoreFromSnapshot should be called with the realpath'd snapshot path.
|
|
707
|
+
// On macOS, `/var/...` resolves to `/private/var/...`, so compare against
|
|
708
|
+
// the realpath of the input rather than the raw string.
|
|
709
|
+
const expectedRealpath = await (
|
|
710
|
+
await import("node:fs/promises")
|
|
711
|
+
).realpath(snapshotPath);
|
|
712
|
+
expect(lastRestoreArgs!.path).toBe(expectedRealpath);
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
test("encrypted .vbundle.enc inside local dir loads key and restores", async () => {
|
|
716
|
+
const snapshotPath = writeBackupFile(
|
|
717
|
+
LOCAL_DIR,
|
|
718
|
+
"backup-20260411-100000.vbundle.enc",
|
|
719
|
+
);
|
|
720
|
+
mockBackupConfig = makeConfig({
|
|
721
|
+
localDirectory: LOCAL_DIR,
|
|
722
|
+
offsite: { enabled: true, destinations: [] },
|
|
723
|
+
});
|
|
724
|
+
mockBackupKey = Buffer.alloc(32, 0xbb);
|
|
725
|
+
mockReadBackupKeyCalls = 0;
|
|
726
|
+
|
|
727
|
+
const res = await handleBackupRestore(
|
|
728
|
+
jsonRequest("POST", { path: snapshotPath }),
|
|
729
|
+
);
|
|
730
|
+
expect(res.status).toBe(200);
|
|
731
|
+
expect(mockReadBackupKeyCalls).toBe(1);
|
|
732
|
+
expect(lastRestoreArgs).not.toBeNull();
|
|
733
|
+
expect(lastRestoreArgs!.hasKey).toBe(true);
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
test("encrypted bundle with missing backup.key returns a clear 400", async () => {
|
|
737
|
+
const snapshotPath = writeBackupFile(
|
|
738
|
+
LOCAL_DIR,
|
|
739
|
+
"backup-20260411-100000.vbundle.enc",
|
|
740
|
+
);
|
|
741
|
+
mockBackupConfig = makeConfig({
|
|
742
|
+
localDirectory: LOCAL_DIR,
|
|
743
|
+
offsite: { enabled: true, destinations: [] },
|
|
744
|
+
});
|
|
745
|
+
mockBackupKey = null; // readBackupKey returns null when the file is missing
|
|
746
|
+
|
|
747
|
+
const res = await handleBackupRestore(
|
|
748
|
+
jsonRequest("POST", { path: snapshotPath }),
|
|
749
|
+
);
|
|
750
|
+
expect(res.status).toBe(400);
|
|
751
|
+
const body = (await res.json()) as { error: { code: string; message: string } };
|
|
752
|
+
expect(body.error.code).toBe("BAD_REQUEST");
|
|
753
|
+
expect(body.error.message).toMatch(/backup.key is missing/);
|
|
754
|
+
// restoreFromSnapshot must NOT have been called — we bail before handing
|
|
755
|
+
// the path to the restore helper.
|
|
756
|
+
expect(lastRestoreArgs).toBeNull();
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
test("successful restore runs the full recovery sequence in order", async () => {
|
|
760
|
+
// Regression test for the restore-corrupts-daemon-state gap:
|
|
761
|
+
// handleBackupRestore must invoke `restoreFromSnapshot` FIRST (which
|
|
762
|
+
// internally calls `resetDb()` before overwriting `assistant.db`), then
|
|
763
|
+
// invalidateConfigCache() + clearTrustCache() AFTER (so the daemon
|
|
764
|
+
// re-reads the restored config/trust rules). The migration importer
|
|
765
|
+
// already does this — the backup handler must match.
|
|
766
|
+
// Note: `resetDb` ordering vs the commit step is verified in
|
|
767
|
+
// src/backup/__tests__/restore.test.ts; here we only verify the
|
|
768
|
+
// handler-level ordering of restoreFromSnapshot → cache invalidations.
|
|
769
|
+
const snapshotPath = writeBackupFile(
|
|
770
|
+
LOCAL_DIR,
|
|
771
|
+
"backup-20260411-100000.vbundle",
|
|
772
|
+
);
|
|
773
|
+
mockBackupConfig = makeConfig({
|
|
774
|
+
localDirectory: LOCAL_DIR,
|
|
775
|
+
offsite: { enabled: true, destinations: [] },
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
const res = await handleBackupRestore(
|
|
779
|
+
jsonRequest("POST", { path: snapshotPath }),
|
|
780
|
+
);
|
|
781
|
+
expect(res.status).toBe(200);
|
|
782
|
+
expect(mockInvalidateConfigCacheCalls).toBe(1);
|
|
783
|
+
expect(mockClearTrustCacheCalls).toBe(1);
|
|
784
|
+
expect(recoveryCallOrder).toEqual([
|
|
785
|
+
"restoreFromSnapshot",
|
|
786
|
+
"invalidateConfigCache",
|
|
787
|
+
"clearTrustCache",
|
|
788
|
+
]);
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
test("restore failure leaves caches untouched", async () => {
|
|
792
|
+
// When `restoreFromSnapshot` throws, the handler must NOT invalidate
|
|
793
|
+
// in-process caches — nothing new was written to disk, so the cached
|
|
794
|
+
// config/trust state still reflects reality. The SQLite handle reset is
|
|
795
|
+
// owned by `restoreFromSnapshot` and covered in
|
|
796
|
+
// src/backup/__tests__/restore.test.ts.
|
|
797
|
+
const snapshotPath = writeBackupFile(
|
|
798
|
+
LOCAL_DIR,
|
|
799
|
+
"backup-20260411-100000.vbundle",
|
|
800
|
+
);
|
|
801
|
+
mockBackupConfig = makeConfig({
|
|
802
|
+
localDirectory: LOCAL_DIR,
|
|
803
|
+
offsite: { enabled: true, destinations: [] },
|
|
804
|
+
});
|
|
805
|
+
mockRestoreError = new Error("simulated restore failure");
|
|
806
|
+
|
|
807
|
+
const res = await handleBackupRestore(
|
|
808
|
+
jsonRequest("POST", { path: snapshotPath }),
|
|
809
|
+
);
|
|
810
|
+
expect(res.status).toBe(500);
|
|
811
|
+
// Caches should NOT be invalidated on failure — the in-process caches
|
|
812
|
+
// still reflect the pre-restore state on disk (the bundle write failed
|
|
813
|
+
// so there's nothing new to re-read).
|
|
814
|
+
expect(mockInvalidateConfigCacheCalls).toBe(0);
|
|
815
|
+
expect(mockClearTrustCacheCalls).toBe(0);
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
test("response no longer exposes credentialsIncluded", async () => {
|
|
819
|
+
// The dead credentials plumbing has been removed from the backup surface.
|
|
820
|
+
// Credentials intentionally live in the OS keychain / CES and are not
|
|
821
|
+
// part of the backup round trip.
|
|
822
|
+
const snapshotPath = writeBackupFile(
|
|
823
|
+
LOCAL_DIR,
|
|
824
|
+
"backup-20260411-100000.vbundle",
|
|
825
|
+
);
|
|
826
|
+
mockBackupConfig = makeConfig({
|
|
827
|
+
localDirectory: LOCAL_DIR,
|
|
828
|
+
offsite: { enabled: true, destinations: [] },
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
const res = await handleBackupRestore(
|
|
832
|
+
jsonRequest("POST", { path: snapshotPath }),
|
|
833
|
+
);
|
|
834
|
+
expect(res.status).toBe(200);
|
|
835
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
836
|
+
expect("credentialsIncluded" in body).toBe(false);
|
|
837
|
+
expect(body.manifest).toBeDefined();
|
|
838
|
+
expect(body.restoredFiles).toBeDefined();
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
test("missing path field returns 400", async () => {
|
|
842
|
+
const res = await handleBackupRestore(jsonRequest("POST", {}));
|
|
843
|
+
expect(res.status).toBe(400);
|
|
844
|
+
const body = (await res.json()) as { error: { code: string; message: string } };
|
|
845
|
+
expect(body.error.code).toBe("BAD_REQUEST");
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
test("malformed JSON body returns 400", async () => {
|
|
849
|
+
const req = new Request("http://localhost/v1/backups/restore", {
|
|
850
|
+
method: "POST",
|
|
851
|
+
headers: { "Content-Type": "application/json" },
|
|
852
|
+
body: "{not-json",
|
|
853
|
+
});
|
|
854
|
+
const res = await handleBackupRestore(req);
|
|
855
|
+
expect(res.status).toBe(400);
|
|
856
|
+
});
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
// ---------------------------------------------------------------------------
|
|
860
|
+
// handleBackupVerify
|
|
861
|
+
// ---------------------------------------------------------------------------
|
|
862
|
+
|
|
863
|
+
describe("handleBackupVerify", () => {
|
|
864
|
+
test("corrupted bundle returns { valid: false }", async () => {
|
|
865
|
+
const snapshotPath = writeBackupFile(
|
|
866
|
+
LOCAL_DIR,
|
|
867
|
+
"backup-20260411-100000.vbundle",
|
|
868
|
+
"not-a-real-bundle",
|
|
869
|
+
);
|
|
870
|
+
mockBackupConfig = makeConfig({
|
|
871
|
+
localDirectory: LOCAL_DIR,
|
|
872
|
+
offsite: { enabled: true, destinations: [] },
|
|
873
|
+
});
|
|
874
|
+
mockVerifyResult = { valid: false, error: "bad checksum" };
|
|
875
|
+
|
|
876
|
+
const res = await handleBackupVerify(
|
|
877
|
+
jsonRequest("POST", { path: snapshotPath }),
|
|
878
|
+
);
|
|
879
|
+
expect(res.status).toBe(200);
|
|
880
|
+
const body = (await res.json()) as VerifyResult;
|
|
881
|
+
expect(body.valid).toBe(false);
|
|
882
|
+
expect(body.error).toBe("bad checksum");
|
|
883
|
+
expect(lastVerifyArgs!.hasKey).toBe(false);
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
test("valid plaintext bundle returns { valid: true } without loading key", async () => {
|
|
887
|
+
const snapshotPath = writeBackupFile(
|
|
888
|
+
LOCAL_DIR,
|
|
889
|
+
"backup-20260411-100000.vbundle",
|
|
890
|
+
);
|
|
891
|
+
mockBackupConfig = makeConfig({
|
|
892
|
+
localDirectory: LOCAL_DIR,
|
|
893
|
+
offsite: { enabled: true, destinations: [] },
|
|
894
|
+
});
|
|
895
|
+
mockReadBackupKeyCalls = 0;
|
|
896
|
+
mockVerifyResult = {
|
|
897
|
+
valid: true,
|
|
898
|
+
manifest: {
|
|
899
|
+
schema_version: "1.0",
|
|
900
|
+
created_at: "2026-04-11T10:00:00.000Z",
|
|
901
|
+
files: [],
|
|
902
|
+
manifest_sha256: "0".repeat(64),
|
|
903
|
+
} as unknown as VerifyResult["manifest"],
|
|
904
|
+
};
|
|
905
|
+
|
|
906
|
+
const res = await handleBackupVerify(
|
|
907
|
+
jsonRequest("POST", { path: snapshotPath }),
|
|
908
|
+
);
|
|
909
|
+
expect(res.status).toBe(200);
|
|
910
|
+
expect(mockReadBackupKeyCalls).toBe(0);
|
|
911
|
+
const body = (await res.json()) as VerifyResult;
|
|
912
|
+
expect(body.valid).toBe(true);
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
test("encrypted bundle with key loads key and forwards to verifySnapshot", async () => {
|
|
916
|
+
const snapshotPath = writeBackupFile(
|
|
917
|
+
LOCAL_DIR,
|
|
918
|
+
"backup-20260411-100000.vbundle.enc",
|
|
919
|
+
);
|
|
920
|
+
mockBackupConfig = makeConfig({
|
|
921
|
+
localDirectory: LOCAL_DIR,
|
|
922
|
+
offsite: { enabled: true, destinations: [] },
|
|
923
|
+
});
|
|
924
|
+
mockBackupKey = Buffer.alloc(32, 0xcc);
|
|
925
|
+
mockReadBackupKeyCalls = 0;
|
|
926
|
+
|
|
927
|
+
const res = await handleBackupVerify(
|
|
928
|
+
jsonRequest("POST", { path: snapshotPath }),
|
|
929
|
+
);
|
|
930
|
+
expect(res.status).toBe(200);
|
|
931
|
+
expect(mockReadBackupKeyCalls).toBe(1);
|
|
932
|
+
expect(lastVerifyArgs!.hasKey).toBe(true);
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
test("path outside allowed directories returns 400", async () => {
|
|
936
|
+
const outsidePath = join(ROOT, "elsewhere", "backup-20260411-100000.vbundle");
|
|
937
|
+
mkdirSync(join(ROOT, "elsewhere"), { recursive: true });
|
|
938
|
+
writeFileSync(outsidePath, "payload");
|
|
939
|
+
mockBackupConfig = makeConfig({
|
|
940
|
+
localDirectory: LOCAL_DIR,
|
|
941
|
+
offsite: { enabled: true, destinations: [] },
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
const res = await handleBackupVerify(
|
|
945
|
+
jsonRequest("POST", { path: outsidePath }),
|
|
946
|
+
);
|
|
947
|
+
expect(res.status).toBe(400);
|
|
948
|
+
expect(lastVerifyArgs).toBeNull();
|
|
949
|
+
});
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
// ---------------------------------------------------------------------------
|
|
953
|
+
// backupRouteDefinitions
|
|
954
|
+
// ---------------------------------------------------------------------------
|
|
955
|
+
|
|
956
|
+
describe("backupRouteDefinitions", () => {
|
|
957
|
+
test("registers four routes with the expected endpoint+method pairs", () => {
|
|
958
|
+
const defs = backupRouteDefinitions();
|
|
959
|
+
const pairs = defs.map((d) => `${d.method} ${d.endpoint}`).sort();
|
|
960
|
+
expect(pairs).toEqual([
|
|
961
|
+
"GET backups",
|
|
962
|
+
"POST backups/create",
|
|
963
|
+
"POST backups/restore",
|
|
964
|
+
"POST backups/verify",
|
|
965
|
+
]);
|
|
966
|
+
});
|
|
967
|
+
});
|