@logictan/dsh-config-manager 0.1.60
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/LICENSE +21 -0
- package/README.md +105 -0
- package/cordis.patch.yml +21 -0
- package/lib/adapters/agent-instructions.d.ts +13 -0
- package/lib/adapters/agent-instructions.js +46 -0
- package/lib/adapters/agent-presets.d.ts +13 -0
- package/lib/adapters/agent-presets.js +14 -0
- package/lib/adapters/credentials.d.ts +37 -0
- package/lib/adapters/credentials.js +122 -0
- package/lib/adapters/file-collection.d.ts +15 -0
- package/lib/adapters/file-collection.js +127 -0
- package/lib/adapters/index.d.ts +57 -0
- package/lib/adapters/index.js +55 -0
- package/lib/adapters/link-report.d.ts +16 -0
- package/lib/adapters/link-report.js +47 -0
- package/lib/adapters/mcp.d.ts +28 -0
- package/lib/adapters/mcp.js +178 -0
- package/lib/adapters/plugin-files.d.ts +18 -0
- package/lib/adapters/plugin-files.js +142 -0
- package/lib/adapters/plugins.d.ts +41 -0
- package/lib/adapters/plugins.js +603 -0
- package/lib/adapters/pnpm-workspace.d.ts +26 -0
- package/lib/adapters/pnpm-workspace.js +147 -0
- package/lib/adapters/prompts.d.ts +30 -0
- package/lib/adapters/prompts.js +231 -0
- package/lib/adapters/providers.d.ts +34 -0
- package/lib/adapters/providers.js +155 -0
- package/lib/adapters/self.d.ts +17 -0
- package/lib/adapters/self.js +71 -0
- package/lib/adapters/sessions.d.ts +14 -0
- package/lib/adapters/sessions.js +15 -0
- package/lib/adapters/settings.d.ts +28 -0
- package/lib/adapters/settings.js +165 -0
- package/lib/adapters/skills.d.ts +13 -0
- package/lib/adapters/skills.js +14 -0
- package/lib/adapters/test-helpers.d.ts +125 -0
- package/lib/adapters/test-helpers.js +236 -0
- package/lib/adapters/ui.d.ts +27 -0
- package/lib/adapters/ui.js +89 -0
- package/lib/adapters/workspaces.d.ts +13 -0
- package/lib/adapters/workspaces.js +106 -0
- package/lib/client.d.ts +1026 -0
- package/lib/client.js +10047 -0
- package/lib/core/analyzer.d.ts +99 -0
- package/lib/core/analyzer.js +934 -0
- package/lib/core/backup.d.ts +138 -0
- package/lib/core/backup.js +513 -0
- package/lib/core/boot-rescue.d.ts +146 -0
- package/lib/core/boot-rescue.js +618 -0
- package/lib/core/cache-cleaner.d.ts +31 -0
- package/lib/core/cache-cleaner.js +95 -0
- package/lib/core/config-lifecycle.d.ts +180 -0
- package/lib/core/config-lifecycle.js +481 -0
- package/lib/core/config-snapshot.d.ts +135 -0
- package/lib/core/config-snapshot.js +424 -0
- package/lib/core/config-state.d.ts +70 -0
- package/lib/core/config-state.js +177 -0
- package/lib/core/consult-source.d.ts +58 -0
- package/lib/core/consult-source.js +260 -0
- package/lib/core/crash-report.d.ts +84 -0
- package/lib/core/crash-report.js +317 -0
- package/lib/core/exporter.d.ts +68 -0
- package/lib/core/exporter.js +375 -0
- package/lib/core/ghost-sweep.d.ts +19 -0
- package/lib/core/ghost-sweep.js +52 -0
- package/lib/core/importer.d.ts +60 -0
- package/lib/core/importer.js +29 -0
- package/lib/core/index.d.ts +14 -0
- package/lib/core/index.js +14 -0
- package/lib/core/journal.d.ts +197 -0
- package/lib/core/journal.js +418 -0
- package/lib/core/local-plugin-host.d.ts +35 -0
- package/lib/core/local-plugin-host.js +120 -0
- package/lib/core/local-plugin-pack.d.ts +135 -0
- package/lib/core/local-plugin-pack.js +278 -0
- package/lib/core/messages.d.ts +310 -0
- package/lib/core/messages.js +621 -0
- package/lib/core/migration-consult.d.ts +117 -0
- package/lib/core/migration-consult.js +363 -0
- package/lib/core/migration-history.d.ts +153 -0
- package/lib/core/migration-history.js +411 -0
- package/lib/core/model-tools.d.ts +50 -0
- package/lib/core/model-tools.js +148 -0
- package/lib/core/msg-types.d.ts +8 -0
- package/lib/core/msg-types.js +6 -0
- package/lib/core/phase3-child-crash.d.ts +1 -0
- package/lib/core/phase3-child-crash.js +87 -0
- package/lib/core/phase3-host.d.ts +138 -0
- package/lib/core/phase3-host.js +359 -0
- package/lib/core/phase3-prod-child.d.ts +1 -0
- package/lib/core/phase3-prod-child.js +35 -0
- package/lib/core/phase4-crash-child.d.ts +1 -0
- package/lib/core/phase4-crash-child.js +103 -0
- package/lib/core/plugin-cli.d.ts +150 -0
- package/lib/core/plugin-cli.js +497 -0
- package/lib/core/reconcile.d.ts +120 -0
- package/lib/core/reconcile.js +389 -0
- package/lib/core/recovery-orchestrator.d.ts +94 -0
- package/lib/core/recovery-orchestrator.js +327 -0
- package/lib/core/restore.d.ts +162 -0
- package/lib/core/restore.js +703 -0
- package/lib/core/rollback.d.ts +12 -0
- package/lib/core/rollback.js +145 -0
- package/lib/core/run-registry.d.ts +85 -0
- package/lib/core/run-registry.js +157 -0
- package/lib/core/startup-barrier.d.ts +53 -0
- package/lib/core/startup-barrier.js +77 -0
- package/lib/core/types.d.ts +515 -0
- package/lib/core/types.js +27 -0
- package/lib/core/undo.d.ts +73 -0
- package/lib/core/undo.js +87 -0
- package/lib/core/validator.d.ts +18 -0
- package/lib/core/validator.js +77 -0
- package/lib/core/verify-recovery.d.ts +21 -0
- package/lib/core/verify-recovery.js +256 -0
- package/lib/core/watcher.d.ts +101 -0
- package/lib/core/watcher.js +219 -0
- package/lib/index.d.ts +238 -0
- package/lib/index.js +2764 -0
- package/lib/migrations/index.d.ts +29 -0
- package/lib/migrations/index.js +56 -0
- package/lib/migrations/v1-to-v2.d.ts +9 -0
- package/lib/migrations/v1-to-v2.js +10 -0
- package/lib/profiles/index.d.ts +4 -0
- package/lib/profiles/index.js +5 -0
- package/lib/profiles/profile-manager.d.ts +106 -0
- package/lib/profiles/profile-manager.js +543 -0
- package/lib/schema/config.d.ts +28 -0
- package/lib/schema/config.js +165 -0
- package/lib/schema/index.d.ts +24 -0
- package/lib/schema/index.js +26 -0
- package/lib/schema/manifest.d.ts +28 -0
- package/lib/schema/manifest.js +109 -0
- package/lib/schema/tombstones.d.ts +46 -0
- package/lib/schema/tombstones.js +64 -0
- package/lib/schema/types.d.ts +232 -0
- package/lib/schema/types.js +8 -0
- package/lib/schema/versions.d.ts +27 -0
- package/lib/schema/versions.js +50 -0
- package/lib/security/encryption.d.ts +85 -0
- package/lib/security/encryption.js +279 -0
- package/lib/security/index.d.ts +16 -0
- package/lib/security/index.js +17 -0
- package/lib/security/integrity.d.ts +30 -0
- package/lib/security/integrity.js +78 -0
- package/lib/security/redaction.d.ts +26 -0
- package/lib/security/redaction.js +95 -0
- package/lib/security/secret-scanner.d.ts +86 -0
- package/lib/security/secret-scanner.js +404 -0
- package/lib/security/vault.d.ts +59 -0
- package/lib/security/vault.js +151 -0
- package/lib/security/zip-security.d.ts +27 -0
- package/lib/security/zip-security.js +154 -0
- package/lib/sync/autosync-config.d.ts +39 -0
- package/lib/sync/autosync-config.js +151 -0
- package/lib/sync/autosync-scheduler.d.ts +139 -0
- package/lib/sync/autosync-scheduler.js +564 -0
- package/lib/sync/fs.d.ts +18 -0
- package/lib/sync/fs.js +49 -0
- package/lib/sync/git/git-transport.d.ts +100 -0
- package/lib/sync/git/git-transport.js +437 -0
- package/lib/sync/github-auth.d.ts +131 -0
- package/lib/sync/github-auth.js +264 -0
- package/lib/sync/layout.d.ts +43 -0
- package/lib/sync/layout.js +203 -0
- package/lib/sync/snapshot-json.d.ts +9 -0
- package/lib/sync/snapshot-json.js +107 -0
- package/lib/sync/sync-config.d.ts +81 -0
- package/lib/sync/sync-config.js +281 -0
- package/lib/sync/sync-engine.d.ts +269 -0
- package/lib/sync/sync-engine.js +591 -0
- package/lib/sync/sync-history.d.ts +42 -0
- package/lib/sync/sync-history.js +78 -0
- package/lib/sync/sync-selection.d.ts +29 -0
- package/lib/sync/sync-selection.js +127 -0
- package/lib/sync/sync-session.d.ts +40 -0
- package/lib/sync/sync-session.js +54 -0
- package/lib/sync/sync-state.d.ts +37 -0
- package/lib/sync/sync-state.js +108 -0
- package/lib/sync/transport.d.ts +75 -0
- package/lib/sync/transport.js +55 -0
- package/lib/sync/ui-prefs.d.ts +34 -0
- package/lib/sync/ui-prefs.js +109 -0
- package/lib/sync/webdav/webdav-transport.d.ts +77 -0
- package/lib/sync/webdav/webdav-transport.js +451 -0
- package/lib/ui/errors.d.ts +19 -0
- package/lib/ui/errors.js +81 -0
- package/lib/ui/export-flow.d.ts +82 -0
- package/lib/ui/export-flow.js +98 -0
- package/lib/ui/i18n.d.ts +289 -0
- package/lib/ui/i18n.js +581 -0
- package/lib/ui/migration-consult-view.d.ts +91 -0
- package/lib/ui/migration-consult-view.js +115 -0
- package/lib/ui/progress.d.ts +38 -0
- package/lib/ui/progress.js +87 -0
- package/lib/ui/report.d.ts +23 -0
- package/lib/ui/report.js +153 -0
- package/lib/ui/test-helpers.d.ts +69 -0
- package/lib/ui/test-helpers.js +154 -0
- package/lib/ui/types.d.ts +257 -0
- package/lib/ui/types.js +21 -0
- package/lib/utils/atomic-write.d.ts +88 -0
- package/lib/utils/atomic-write.js +399 -0
- package/lib/utils/env-lock.d.ts +393 -0
- package/lib/utils/env-lock.js +930 -0
- package/lib/utils/hashing.d.ts +17 -0
- package/lib/utils/hashing.js +41 -0
- package/lib/utils/json.d.ts +24 -0
- package/lib/utils/json.js +76 -0
- package/lib/utils/logger.d.ts +39 -0
- package/lib/utils/logger.js +82 -0
- package/lib/utils/paths.d.ts +42 -0
- package/lib/utils/paths.js +189 -0
- package/lib/utils/proxy.d.ts +69 -0
- package/lib/utils/proxy.js +455 -0
- package/lib/utils/recursive-walk.d.ts +24 -0
- package/lib/utils/recursive-walk.js +134 -0
- package/lib/utils/zip.d.ts +54 -0
- package/lib/utils/zip.js +285 -0
- package/package.json +188 -0
- package/src/adapters/agent-instructions.ts +48 -0
- package/src/adapters/agent-presets.ts +14 -0
- package/src/adapters/credentials.test.ts +90 -0
- package/src/adapters/credentials.ts +143 -0
- package/src/adapters/file-collection.ts +137 -0
- package/src/adapters/files.test.ts +353 -0
- package/src/adapters/index.ts +94 -0
- package/src/adapters/link-report.test.ts +65 -0
- package/src/adapters/link-report.ts +61 -0
- package/src/adapters/mcp.test.ts +96 -0
- package/src/adapters/mcp.ts +185 -0
- package/src/adapters/plugin-files.ts +146 -0
- package/src/adapters/plugins.test.ts +326 -0
- package/src/adapters/plugins.ts +615 -0
- package/src/adapters/pnpm-workspace.test.ts +87 -0
- package/src/adapters/pnpm-workspace.ts +150 -0
- package/src/adapters/prompts.test.ts +110 -0
- package/src/adapters/prompts.ts +230 -0
- package/src/adapters/providers.test.ts +126 -0
- package/src/adapters/providers.ts +173 -0
- package/src/adapters/roundtrip.test.ts +190 -0
- package/src/adapters/self.test.ts +92 -0
- package/src/adapters/self.ts +75 -0
- package/src/adapters/sessions.ts +15 -0
- package/src/adapters/settings.test.ts +100 -0
- package/src/adapters/settings.ts +187 -0
- package/src/adapters/skills.ts +14 -0
- package/src/adapters/test-helpers.ts +248 -0
- package/src/adapters/ui.test.ts +57 -0
- package/src/adapters/ui.ts +108 -0
- package/src/adapters/workspaces.test.ts +106 -0
- package/src/adapters/workspaces.ts +109 -0
- package/src/client/ConfigManagerSection.tsx +90 -0
- package/src/client/api.ts +66 -0
- package/src/client/client-types.ts +18 -0
- package/src/client/common/ErrorBanner.tsx +73 -0
- package/src/client/common/Icon.tsx +129 -0
- package/src/client/common/Modal.tsx +173 -0
- package/src/client/common/ToastViewport.tsx +73 -0
- package/src/client/common/toast-store.test.ts +192 -0
- package/src/client/common/toast-store.ts +181 -0
- package/src/client/common/toast-types.ts +7 -0
- package/src/client/common/ui.tsx +342 -0
- package/src/client/config-manager.module.css +2971 -0
- package/src/client/css-modules.d.ts +8 -0
- package/src/client/index.ts +65 -0
- package/src/client/locales.ts +56 -0
- package/src/client/lucide-icons.d.ts +38 -0
- package/src/client/run-store.test.ts +223 -0
- package/src/client/run-store.ts +372 -0
- package/src/client/sync/SyncConfirmView.tsx +379 -0
- package/src/client/sync/SyncHistoryView.test.ts +40 -0
- package/src/client/sync/SyncHistoryView.tsx +220 -0
- package/src/client/sync/SyncSettingsView.tsx +1535 -0
- package/src/client/sync/history-model.test.ts +194 -0
- package/src/client/sync/history-model.ts +195 -0
- package/src/client/sync/sync-api.test.ts +585 -0
- package/src/client/sync/sync-api.ts +543 -0
- package/src/client/sync/sync-locales.ts +416 -0
- package/src/client/sync/sync-push-preview.test.ts +52 -0
- package/src/client/sync/sync-view-v2.test.ts +213 -0
- package/src/client/sync/sync-view.test.ts +487 -0
- package/src/client/sync/sync-view.ts +759 -0
- package/src/core/analyzer.ts +1068 -0
- package/src/core/backup.ts +605 -0
- package/src/core/boot-rescue.test.ts +847 -0
- package/src/core/boot-rescue.ts +737 -0
- package/src/core/cache-cleaner.test.ts +169 -0
- package/src/core/cache-cleaner.ts +121 -0
- package/src/core/config-lifecycle.test.ts +727 -0
- package/src/core/config-lifecycle.ts +573 -0
- package/src/core/config-snapshot.test.ts +369 -0
- package/src/core/config-snapshot.ts +522 -0
- package/src/core/config-state.test.ts +381 -0
- package/src/core/config-state.ts +207 -0
- package/src/core/consult-source.test.ts +199 -0
- package/src/core/consult-source.ts +293 -0
- package/src/core/crash-report.test.ts +380 -0
- package/src/core/crash-report.ts +369 -0
- package/src/core/exporter.ts +417 -0
- package/src/core/ghost-sweep.test.ts +94 -0
- package/src/core/ghost-sweep.ts +67 -0
- package/src/core/importer.ts +83 -0
- package/src/core/index.ts +56 -0
- package/src/core/journal.test.ts +257 -0
- package/src/core/journal.ts +518 -0
- package/src/core/local-plugin-host.ts +152 -0
- package/src/core/local-plugin-pack.test.ts +359 -0
- package/src/core/local-plugin-pack.ts +363 -0
- package/src/core/messages.test.ts +54 -0
- package/src/core/messages.ts +664 -0
- package/src/core/migration-consult.test.ts +315 -0
- package/src/core/migration-consult.ts +494 -0
- package/src/core/migration-history.test.ts +319 -0
- package/src/core/migration-history.ts +484 -0
- package/src/core/model-tools.test.ts +190 -0
- package/src/core/model-tools.ts +208 -0
- package/src/core/msg-types.ts +10 -0
- package/src/core/phase3-child-crash.ts +95 -0
- package/src/core/phase3-host.ts +411 -0
- package/src/core/phase3-p1.test.ts +212 -0
- package/src/core/phase3-p2b-fingerprint.test.ts +189 -0
- package/src/core/phase3-prod-child.ts +36 -0
- package/src/core/phase3-production-integration.test.ts +286 -0
- package/src/core/phase4-crash-child.ts +101 -0
- package/src/core/phase4-crash-injection.test.ts +166 -0
- package/src/core/plugin-cli.fs.test.ts +184 -0
- package/src/core/plugin-cli.test.ts +137 -0
- package/src/core/plugin-cli.ts +563 -0
- package/src/core/reconcile.test.ts +392 -0
- package/src/core/reconcile.ts +498 -0
- package/src/core/recovery-orchestrator.test.ts +145 -0
- package/src/core/recovery-orchestrator.ts +366 -0
- package/src/core/restore-manage.test.ts +94 -0
- package/src/core/restore.ts +849 -0
- package/src/core/rollback.ts +151 -0
- package/src/core/run-progress.test.ts +237 -0
- package/src/core/run-registry.test.ts +232 -0
- package/src/core/run-registry.ts +209 -0
- package/src/core/smoke.test.ts +842 -0
- package/src/core/startup-barrier.ts +99 -0
- package/src/core/types.ts +542 -0
- package/src/core/undo.ts +116 -0
- package/src/core/validator.ts +84 -0
- package/src/core/verify-recovery.ts +265 -0
- package/src/core/watcher.test.ts +326 -0
- package/src/core/watcher.ts +263 -0
- package/src/index.facade.test.ts +265 -0
- package/src/index.sync.test.ts +217 -0
- package/src/index.ts +2975 -0
- package/src/migrations/index.ts +71 -0
- package/src/migrations/v1-to-v2.ts +17 -0
- package/src/profiles/index.ts +8 -0
- package/src/profiles/profile-manager.test.ts +282 -0
- package/src/profiles/profile-manager.ts +630 -0
- package/src/schema/config.ts +165 -0
- package/src/schema/index.ts +71 -0
- package/src/schema/manifest-schema.test.ts +519 -0
- package/src/schema/manifest.ts +124 -0
- package/src/schema/tombstones.test.ts +131 -0
- package/src/schema/tombstones.ts +89 -0
- package/src/schema/types.ts +245 -0
- package/src/schema/versions.ts +55 -0
- package/src/security/encryption.ts +335 -0
- package/src/security/index.ts +16 -0
- package/src/security/integrity.ts +102 -0
- package/src/security/redaction.ts +101 -0
- package/src/security/secret-scanner.ts +445 -0
- package/src/security/security.test.ts +1215 -0
- package/src/security/vault.test.ts +164 -0
- package/src/security/vault.ts +198 -0
- package/src/security/zip-security.ts +180 -0
- package/src/sync/autosync-config.test.ts +147 -0
- package/src/sync/autosync-config.ts +179 -0
- package/src/sync/autosync-scheduler.test.ts +490 -0
- package/src/sync/autosync-scheduler.ts +646 -0
- package/src/sync/fs.ts +48 -0
- package/src/sync/git/git-transport.test.ts +673 -0
- package/src/sync/git/git-transport.ts +491 -0
- package/src/sync/github-auth.test.ts +280 -0
- package/src/sync/github-auth.ts +356 -0
- package/src/sync/layout.test.ts +270 -0
- package/src/sync/layout.ts +222 -0
- package/src/sync/snapshot-json.test.ts +119 -0
- package/src/sync/snapshot-json.ts +128 -0
- package/src/sync/sync-config.test.ts +310 -0
- package/src/sync/sync-config.ts +299 -0
- package/src/sync/sync-engine.test.ts +1074 -0
- package/src/sync/sync-engine.ts +754 -0
- package/src/sync/sync-history.test.ts +116 -0
- package/src/sync/sync-history.ts +120 -0
- package/src/sync/sync-selection.test.ts +186 -0
- package/src/sync/sync-selection.ts +143 -0
- package/src/sync/sync-session.test.ts +143 -0
- package/src/sync/sync-session.ts +77 -0
- package/src/sync/sync-state.test.ts +164 -0
- package/src/sync/sync-state.ts +131 -0
- package/src/sync/transport.test.ts +111 -0
- package/src/sync/transport.ts +118 -0
- package/src/sync/ui-prefs.test.ts +152 -0
- package/src/sync/ui-prefs.ts +135 -0
- package/src/sync/webdav/webdav-transport.test.ts +871 -0
- package/src/sync/webdav/webdav-transport.ts +535 -0
- package/src/ui/errors.test.ts +47 -0
- package/src/ui/errors.ts +100 -0
- package/src/ui/export-flow.test.ts +87 -0
- package/src/ui/export-flow.ts +156 -0
- package/src/ui/i18n.ts +590 -0
- package/src/ui/migration-consult-view.test.ts +151 -0
- package/src/ui/migration-consult-view.ts +178 -0
- package/src/ui/progress.test.ts +47 -0
- package/src/ui/progress.ts +102 -0
- package/src/ui/report.test.ts +101 -0
- package/src/ui/report.ts +146 -0
- package/src/ui/test-helpers.ts +184 -0
- package/src/ui/types.ts +325 -0
- package/src/utils/atomic-write.test.ts +312 -0
- package/src/utils/atomic-write.ts +422 -0
- package/src/utils/env-lock.test.ts +999 -0
- package/src/utils/env-lock.ts +1086 -0
- package/src/utils/hashing.ts +53 -0
- package/src/utils/json.ts +77 -0
- package/src/utils/logger.ts +114 -0
- package/src/utils/paths.ts +176 -0
- package/src/utils/proxy.test.ts +465 -0
- package/src/utils/proxy.ts +493 -0
- package/src/utils/recursive-walk.test.ts +148 -0
- package/src/utils/recursive-walk.ts +145 -0
- package/src/utils/zip.ts +332 -0
|
@@ -0,0 +1,871 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* m-webdav-channel:WebDAV 同步通道测试(TDD:先红后绿)。
|
|
3
|
+
*
|
|
4
|
+
* 验证点(验收判据 m1-red-green + m1-interface):
|
|
5
|
+
* - 远端布局 <base>/dsh-config-manager/<id>.json + <base>/dsh-config-manager/index.json
|
|
6
|
+
* - list:GET index.json,缺失视为空,按 createdAt 升序
|
|
7
|
+
* - upload:幂等 MKCOL → 快照级跳过(同 id 且 sections hash 全等免上传)→ PUT <id>.json →
|
|
8
|
+
* 合并写回 index.json(meta 最后落盘);返回 computeSnapshotMeta
|
|
9
|
+
* - download:GET <id>.json 解析成 SyncSnapshot;不存在抛错(契约)
|
|
10
|
+
* - delete:DELETE <id>.json + 从 index.json 摘除;不存在视为成功
|
|
11
|
+
* - 认证:HTTP Basic(username + credentials.getPassword()),可注入 request
|
|
12
|
+
* - 安全:URL 拒绝 userinfo;错误脱敏(password 永不泄漏/不进日志)
|
|
13
|
+
* - 超时:注入 request 抛超时 → 归一为带 timeout 的消息
|
|
14
|
+
*/
|
|
15
|
+
import test from 'node:test';
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import http from 'node:http';
|
|
18
|
+
import type { Server } from 'node:http';
|
|
19
|
+
|
|
20
|
+
import { WebDavTransport, WebDavTransportError } from './webdav-transport.ts';
|
|
21
|
+
import type { WebDavRequestFn, WebDavResponse, WebDavTransportOptions } from './webdav-transport.ts';
|
|
22
|
+
import { computeSnapshotMeta, sectionsEqual } from '../transport.ts';
|
|
23
|
+
import type { SyncSnapshot, SyncSnapshotMeta } from '../transport.ts';
|
|
24
|
+
import { encryptSectionsPayload } from '../../../tests/fixtures/legacy-snapshot-crypto.ts';
|
|
25
|
+
import type { SectionData, SectionId } from '../../schema/types.ts';
|
|
26
|
+
|
|
27
|
+
const TEST_PASSWORD = 'super-secret-password-9876';
|
|
28
|
+
|
|
29
|
+
function sampleSnapshot(overrides: Partial<SyncSnapshot> = {}): SyncSnapshot {
|
|
30
|
+
return {
|
|
31
|
+
id: 'snap-001',
|
|
32
|
+
createdAt: '2026-08-16T12:00:00.000Z',
|
|
33
|
+
manifest: {
|
|
34
|
+
schemaVersion: 1,
|
|
35
|
+
dshVersion: '1.2.3',
|
|
36
|
+
platform: 'win32',
|
|
37
|
+
sectionIds: ['settings', 'providers'],
|
|
38
|
+
containsSecrets: false,
|
|
39
|
+
},
|
|
40
|
+
sections: {
|
|
41
|
+
settings: { version: 1, namespaces: { general: { value: { theme: 'dark' }, revision: 1, secrets: [] } } },
|
|
42
|
+
providers: { version: 1, providers: { deepseek: { route: '/v1' } } },
|
|
43
|
+
},
|
|
44
|
+
...overrides,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface MockRequest { method: string; url: string; headers?: Record<string, string>; body?: string; }
|
|
49
|
+
|
|
50
|
+
/** 记录请求并交给 handler 返回响应 */
|
|
51
|
+
function mockReq(
|
|
52
|
+
calls: MockRequest[],
|
|
53
|
+
handler: (m: MockRequest) => WebDavResponse | Promise<WebDavResponse>,
|
|
54
|
+
): WebDavRequestFn {
|
|
55
|
+
return async (method, url, opts = {}) => {
|
|
56
|
+
const rec: MockRequest = { method, url, headers: opts.headers, body: opts.body };
|
|
57
|
+
calls.push(rec);
|
|
58
|
+
return handler(rec);
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function makeCalls(): MockRequest[] {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function makeOptions(overrides: Partial<WebDavTransportOptions> = {}): WebDavTransportOptions {
|
|
67
|
+
return {
|
|
68
|
+
baseUrl: 'https://dav.example.com/dav/config',
|
|
69
|
+
username: 'alice',
|
|
70
|
+
credentials: { getPassword: async () => TEST_PASSWORD },
|
|
71
|
+
request: mockReq(makeCalls(), () => res(404, '')), // 默认 handler 需被覆盖
|
|
72
|
+
...overrides,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function res(status: number, bodyText: string): WebDavResponse {
|
|
77
|
+
return { status, ok: status >= 200 && status < 300, async text() { return bodyText; } };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* ---------------- 构造校验 ---------------- */
|
|
81
|
+
|
|
82
|
+
test('构造:baseUrl/username/credentials 必填;非法 URL 拒绝', () => {
|
|
83
|
+
assert.throws(() => new WebDavTransport({ baseUrl: '', username: 'a', credentials: { getPassword: async () => '' } } as never), /baseUrl/);
|
|
84
|
+
assert.throws(() => new WebDavTransport({ baseUrl: 'not-a-url', username: 'a', credentials: { getPassword: async () => '' } } as never), /无法解析/);
|
|
85
|
+
assert.throws(
|
|
86
|
+
() => new WebDavTransport({ baseUrl: 'https://dav.example.com/dav', username: '', credentials: { getPassword: async () => '' } } as never),
|
|
87
|
+
/username/,
|
|
88
|
+
);
|
|
89
|
+
assert.throws(
|
|
90
|
+
() => new WebDavTransport({ baseUrl: 'https://dav.example.com/dav', username: 'a', credentials: null } as never),
|
|
91
|
+
/credentials/,
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('构造:baseUrl 带 userinfo(username:password@)→ 拒绝(安全性)', () => {
|
|
96
|
+
assert.throws(
|
|
97
|
+
() => new WebDavTransport({
|
|
98
|
+
baseUrl: 'https://alice:sekrit@dav.example.com/dav',
|
|
99
|
+
username: 'alice',
|
|
100
|
+
credentials: { getPassword: async () => '' },
|
|
101
|
+
} as never),
|
|
102
|
+
(err: unknown) => {
|
|
103
|
+
assert.ok(err instanceof WebDavTransportError);
|
|
104
|
+
assert.ok(!String((err as Error).message).includes('sekrit'), '错误消息不得泄漏 URL 内嵌密码');
|
|
105
|
+
assert.match(String((err as Error).message), /userinfo|用户名|密码/);
|
|
106
|
+
return true;
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/* ---------------- list ---------------- */
|
|
112
|
+
|
|
113
|
+
test('list:index.json 缺失(404)→ 空列表', async () => {
|
|
114
|
+
const calls = makeCalls();
|
|
115
|
+
const handler = (m: MockRequest): WebDavResponse => {
|
|
116
|
+
assert.equal(m.method, 'GET');
|
|
117
|
+
assert.equal(m.url, 'https://dav.example.com/dav/config/dsh-config-manager/index.json');
|
|
118
|
+
return res(404, '');
|
|
119
|
+
};
|
|
120
|
+
const t = new WebDavTransport(makeOptions({ request: mockReq(calls, handler) }));
|
|
121
|
+
assert.deepEqual(await t.list(), []);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('list:带已存在 index → 返回按 createdAt 升序的 meta 列表', async () => {
|
|
125
|
+
const calls = makeCalls();
|
|
126
|
+
const handler = (m: MockRequest): WebDavResponse => {
|
|
127
|
+
const metaB = computeSnapshotMeta(sampleSnapshot({ id: 'snap-b', createdAt: '2026-08-16T11:00:00.000Z' }));
|
|
128
|
+
const metaA = computeSnapshotMeta(sampleSnapshot({ id: 'snap-a', createdAt: '2026-08-16T09:00:00.000Z' }));
|
|
129
|
+
const metaC = computeSnapshotMeta(sampleSnapshot({ id: 'snap-c', createdAt: '2026-08-16T12:00:00.000Z' }));
|
|
130
|
+
return res(200, JSON.stringify([metaB, metaA, metaC]));
|
|
131
|
+
};
|
|
132
|
+
const t = new WebDavTransport(makeOptions({ request: mockReq(calls, handler) }));
|
|
133
|
+
const listed = await t.list();
|
|
134
|
+
assert.deepEqual(listed.map((x) => x.id), ['snap-a', 'snap-b', 'snap-c'], '应按 createdAt 升序');
|
|
135
|
+
assert.equal(listed[0]!.createdAt, '2026-08-16T09:00:00.000Z');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('list:index.json 结构非法 → 抛错(清晰消息)', async () => {
|
|
139
|
+
const handler = (): WebDavResponse => res(200, '{"not":"an array"}');
|
|
140
|
+
const t = new WebDavTransport(makeOptions({ request: mockReq(makeCalls(), handler) }));
|
|
141
|
+
await assert.rejects(t.list(), /index/i);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
/* ---------------- upload ---------------- */
|
|
145
|
+
|
|
146
|
+
test('upload:幂等 MKCOL → PUT <id>.json → 合并写回 index.json,返回 computeSnapshotMeta', async () => {
|
|
147
|
+
const calls = makeCalls();
|
|
148
|
+
const handler = (m: MockRequest): WebDavResponse => {
|
|
149
|
+
if (m.method === 'MKCOL') return res(201, '');
|
|
150
|
+
if (m.method === 'GET') return res(404, ''); // index 不存在
|
|
151
|
+
if (m.method === 'PUT' && m.url.endsWith('/snap-001.json')) {
|
|
152
|
+
assert.ok(m.body, '快照体应存在');
|
|
153
|
+
assert.deepEqual(JSON.parse(m.body!), sampleSnapshot(), 'PUT 体应为完整快照');
|
|
154
|
+
return res(201, '');
|
|
155
|
+
}
|
|
156
|
+
if (m.method === 'PUT' && m.url.endsWith('/index.json')) {
|
|
157
|
+
const idx = JSON.parse(m.body!);
|
|
158
|
+
assert.ok(Array.isArray(idx), 'index 应为数组');
|
|
159
|
+
assert.equal(idx.length, 1);
|
|
160
|
+
assert.equal(idx[0]!.id, 'snap-001');
|
|
161
|
+
return res(201, '');
|
|
162
|
+
}
|
|
163
|
+
return res(405, '');
|
|
164
|
+
};
|
|
165
|
+
const t = new WebDavTransport(makeOptions({ request: mockReq(calls, handler) }));
|
|
166
|
+
const meta = await t.upload(sampleSnapshot());
|
|
167
|
+
assert.deepEqual(meta, computeSnapshotMeta(sampleSnapshot()));
|
|
168
|
+
|
|
169
|
+
// 请求顺序:MKCOL snapshots → GET index(跳过判定)→ PUT <id>.json → PUT index(meta 最后落盘)
|
|
170
|
+
assert.equal(calls[0]!.method, 'MKCOL', '应先创建 snapshots 集合');
|
|
171
|
+
assert.equal(calls[0]!.url, 'https://dav.example.com/dav/config/dsh-config-manager/');
|
|
172
|
+
assert.equal(calls[1]!.method, 'GET', '应先读 index 做快照级跳过判定');
|
|
173
|
+
assert.match(calls[1]!.url, /index\.json$/);
|
|
174
|
+
assert.equal(calls[2]!.method, 'PUT', '再写快照文件');
|
|
175
|
+
assert.match(calls[2]!.url, /snap-001\.json$/);
|
|
176
|
+
assert.equal(calls[3]!.method, 'PUT', '最后写 index(meta 最后落盘)');
|
|
177
|
+
assert.match(calls[3]!.url, /index\.json$/);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('upload:MKCOL 405(已存在集合)→ 幂等成功', async () => {
|
|
181
|
+
const calls = makeCalls();
|
|
182
|
+
const handler = (m: MockRequest): WebDavResponse => {
|
|
183
|
+
if (m.method === 'MKCOL') return res(405, '');
|
|
184
|
+
if (m.method === 'GET') return res(404, '');
|
|
185
|
+
return res(201, '');
|
|
186
|
+
};
|
|
187
|
+
const t = new WebDavTransport(makeOptions({ request: mockReq(calls, handler) }));
|
|
188
|
+
const meta = await t.upload(sampleSnapshot());
|
|
189
|
+
assert.equal(meta.id, 'snap-001');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('upload:同 id 再上传 → index 合并(保留其他 id,替换同 id 条目)', async () => {
|
|
193
|
+
const calls = makeCalls();
|
|
194
|
+
const existing = [computeSnapshotMeta(sampleSnapshot({ id: 'snap-old', createdAt: '2026-08-16T01:00:00.000Z' }))];
|
|
195
|
+
const handler = (m: MockRequest): WebDavResponse => {
|
|
196
|
+
if (m.method === 'MKCOL') return res(405, '');
|
|
197
|
+
if (m.method === 'GET') return res(200, JSON.stringify(existing));
|
|
198
|
+
if (m.method === 'PUT' && m.url.endsWith('/index.json')) {
|
|
199
|
+
const idx = JSON.parse(m.body!) as Array<{ id: string }>;
|
|
200
|
+
const ids = idx.map((x) => x.id);
|
|
201
|
+
assert.ok(ids.includes('snap-old'), '应保留旧条目');
|
|
202
|
+
assert.ok(ids.includes('snap-001'), '应加入/覆盖新条目');
|
|
203
|
+
assert.equal(idx.filter((x) => x.id === 'snap-001').length, 1, '同 id 不得重复');
|
|
204
|
+
return res(201, '');
|
|
205
|
+
}
|
|
206
|
+
return res(201, '');
|
|
207
|
+
};
|
|
208
|
+
const t = new WebDavTransport(makeOptions({ request: mockReq(calls, handler) }));
|
|
209
|
+
await t.upload(sampleSnapshot());
|
|
210
|
+
await t.upload(sampleSnapshot({ createdAt: '2026-08-16T13:00:00.000Z' }));
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('upload 快照级跳过:同 id 且 sections hash 全等 → 跳过 PUT 快照文件与 index 写回,直接返回远端 meta', async () => {
|
|
214
|
+
const calls = makeCalls();
|
|
215
|
+
const remoteMeta = computeSnapshotMeta(sampleSnapshot());
|
|
216
|
+
const handler = (m: MockRequest): WebDavResponse => {
|
|
217
|
+
if (m.method === 'MKCOL') return res(405, '');
|
|
218
|
+
if (m.method === 'GET') return res(200, JSON.stringify([remoteMeta]));
|
|
219
|
+
return res(201, '');
|
|
220
|
+
};
|
|
221
|
+
const t = new WebDavTransport(makeOptions({ request: mockReq(calls, handler) }));
|
|
222
|
+
const meta = await t.upload(sampleSnapshot());
|
|
223
|
+
assert.deepEqual(meta, remoteMeta, '内容全等 → 应直接返回远端 meta(含其 createdAt)');
|
|
224
|
+
assert.deepEqual(calls.map((c) => c.method), ['MKCOL', 'GET'], '只应发生 MKCOL + GET index');
|
|
225
|
+
assert.ok(!calls.some((c) => c.method === 'PUT'), '内容全等不得发生任何 PUT');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('upload 快照级跳过:加密快照(sections 为空对象,无法明文比较)→ 必须照常上传,绝不跳过', async () => {
|
|
229
|
+
const base = sampleSnapshot();
|
|
230
|
+
const plain: Partial<Record<SectionId, SectionData>> = {};
|
|
231
|
+
for (const [id, data] of Object.entries(base.sections)) {
|
|
232
|
+
plain[id as SectionId] = data as SectionData;
|
|
233
|
+
}
|
|
234
|
+
const enc = await encryptSectionsPayload(plain, 'pw-12345678');
|
|
235
|
+
const encSnap = sampleSnapshot({ sections: enc });
|
|
236
|
+
const remoteMeta = computeSnapshotMeta(encSnap);
|
|
237
|
+
assert.deepEqual(remoteMeta.sections, {}, '加密快照 meta.sections 应为空对象(密文无法明文比较)');
|
|
238
|
+
const calls = makeCalls();
|
|
239
|
+
const handler = (m: MockRequest): WebDavResponse => {
|
|
240
|
+
if (m.method === 'MKCOL') return res(405, '');
|
|
241
|
+
if (m.method === 'GET') return res(200, JSON.stringify([remoteMeta]));
|
|
242
|
+
if (m.method === 'PUT' && m.url.endsWith('/snap-001.json')) return res(201, '');
|
|
243
|
+
if (m.method === 'PUT' && m.url.endsWith('/index.json')) return res(201, '');
|
|
244
|
+
return res(405, '');
|
|
245
|
+
};
|
|
246
|
+
const t = new WebDavTransport(makeOptions({ request: mockReq(calls, handler) }));
|
|
247
|
+
const meta = await t.upload(encSnap);
|
|
248
|
+
assert.equal(meta.id, 'snap-001');
|
|
249
|
+
assert.ok(
|
|
250
|
+
calls.some((c) => c.method === 'PUT' && /snap-001\.json$/.test(c.url)),
|
|
251
|
+
'加密快照无法比较 → 应照常 PUT 快照文件',
|
|
252
|
+
);
|
|
253
|
+
assert.ok(calls.some((c) => c.method === 'PUT' && /index\.json$/.test(c.url)), '应照常写回 index');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('upload 快照级跳过:同 id 但 sections hash 不同 → 照常上传并覆盖 index 条目(唯一)', async () => {
|
|
257
|
+
const calls = makeCalls();
|
|
258
|
+
// 远端同 id 条目内容不同(theme: light vs 本地 dark)
|
|
259
|
+
const remoteMeta = computeSnapshotMeta(sampleSnapshot({
|
|
260
|
+
sections: {
|
|
261
|
+
settings: { version: 1, namespaces: { general: { value: { theme: 'light' }, revision: 1, secrets: [] } } },
|
|
262
|
+
providers: { version: 1, providers: { deepseek: { route: '/v1' } } },
|
|
263
|
+
},
|
|
264
|
+
}));
|
|
265
|
+
const handler = (m: MockRequest): WebDavResponse => {
|
|
266
|
+
if (m.method === 'MKCOL') return res(405, '');
|
|
267
|
+
if (m.method === 'GET') return res(200, JSON.stringify([remoteMeta]));
|
|
268
|
+
if (m.method === 'PUT' && m.url.endsWith('/snap-001.json')) return res(201, '');
|
|
269
|
+
if (m.method === 'PUT' && m.url.endsWith('/index.json')) {
|
|
270
|
+
const idx = JSON.parse(m.body!) as Array<{ id: string }>;
|
|
271
|
+
assert.equal(idx.filter((x) => x.id === 'snap-001').length, 1, '同 id 条目应唯一(覆盖)');
|
|
272
|
+
return res(201, '');
|
|
273
|
+
}
|
|
274
|
+
return res(405, '');
|
|
275
|
+
};
|
|
276
|
+
const t = new WebDavTransport(makeOptions({ request: mockReq(calls, handler) }));
|
|
277
|
+
const meta = await t.upload(sampleSnapshot());
|
|
278
|
+
assert.equal(meta.id, 'snap-001');
|
|
279
|
+
assert.ok(
|
|
280
|
+
calls.some((c) => c.method === 'PUT' && /snap-001\.json$/.test(c.url)),
|
|
281
|
+
'内容不同 → 应 PUT 快照文件',
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test('upload meta 最后落盘:快照文件 PUT 失败 → 绝不写 index(meta 不得先于快照落盘)', async () => {
|
|
286
|
+
const calls = makeCalls();
|
|
287
|
+
const handler = (m: MockRequest): WebDavResponse => {
|
|
288
|
+
if (m.method === 'MKCOL') return res(405, '');
|
|
289
|
+
if (m.method === 'GET') return res(404, '');
|
|
290
|
+
if (m.method === 'PUT' && m.url.endsWith('/snap-001.json')) return res(500, 'disk full');
|
|
291
|
+
return res(201, '');
|
|
292
|
+
};
|
|
293
|
+
const t = new WebDavTransport(makeOptions({ request: mockReq(calls, handler) }));
|
|
294
|
+
await assert.rejects(t.upload(sampleSnapshot()), /失败|500/);
|
|
295
|
+
assert.ok(
|
|
296
|
+
!calls.some((c) => c.method === 'PUT' && /index\.json$/.test(c.url)),
|
|
297
|
+
'快照文件未成功 → 不得写 index(meta 最后落盘不变量)',
|
|
298
|
+
);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
/* ---------------- sectionsEqual 纯函数 ---------------- */
|
|
302
|
+
|
|
303
|
+
test('sectionsEqual:全等 → true;键/值任一不同 → false;本地为空(加密)→ false', () => {
|
|
304
|
+
const a = computeSnapshotMeta(sampleSnapshot());
|
|
305
|
+
const same = computeSnapshotMeta(sampleSnapshot());
|
|
306
|
+
const diff = computeSnapshotMeta(sampleSnapshot({
|
|
307
|
+
sections: {
|
|
308
|
+
settings: { version: 1, namespaces: { general: { value: { theme: 'light' }, revision: 1, secrets: [] } } },
|
|
309
|
+
providers: { version: 1, providers: { deepseek: { route: '/v1' } } },
|
|
310
|
+
},
|
|
311
|
+
}));
|
|
312
|
+
const fewer = computeSnapshotMeta(sampleSnapshot({
|
|
313
|
+
sections: {
|
|
314
|
+
settings: { version: 1, namespaces: { general: { value: { theme: 'dark' }, revision: 1, secrets: [] } } },
|
|
315
|
+
},
|
|
316
|
+
}));
|
|
317
|
+
assert.equal(sectionsEqual(a, same), true, '内容全等 → true');
|
|
318
|
+
assert.equal(sectionsEqual(a, diff), false, '值不同 → false');
|
|
319
|
+
assert.equal(sectionsEqual(a, fewer), false, '键集合不同 → false');
|
|
320
|
+
// 本地为空对象(加密快照 meta)→ 无法比较 → false
|
|
321
|
+
const empty: SyncSnapshotMeta = { ...a, sections: {} };
|
|
322
|
+
assert.equal(sectionsEqual(a, empty), false, '本地为空 → false(加密快照必须上传)');
|
|
323
|
+
assert.equal(sectionsEqual(empty, empty), false, '双方为空 → 仍 false(无法比较)');
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
/* ---------------- download ---------------- */
|
|
327
|
+
|
|
328
|
+
test('download:GET <id>.json 解析成 SyncSnapshot(roundtrip 一致)', async () => {
|
|
329
|
+
const calls = makeCalls();
|
|
330
|
+
const snap = sampleSnapshot();
|
|
331
|
+
const handler = (m: MockRequest): WebDavResponse => {
|
|
332
|
+
assert.equal(m.method, 'GET');
|
|
333
|
+
assert.equal(m.url, 'https://dav.example.com/dav/config/dsh-config-manager/snap-001.json');
|
|
334
|
+
return res(200, JSON.stringify(snap));
|
|
335
|
+
};
|
|
336
|
+
const t = new WebDavTransport(makeOptions({ request: mockReq(calls, handler) }));
|
|
337
|
+
assert.deepEqual(await t.download('snap-001'), snap);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('download:不存在的 id(404)→ 抛错(契约),消息含 id', async () => {
|
|
341
|
+
const handler = (): WebDavResponse => res(404, '');
|
|
342
|
+
const t = new WebDavTransport(makeOptions({ request: mockReq(makeCalls(), handler) }));
|
|
343
|
+
await assert.rejects(
|
|
344
|
+
t.download('missing-001'),
|
|
345
|
+
(err: unknown) => {
|
|
346
|
+
assert.ok(err instanceof WebDavTransportError);
|
|
347
|
+
assert.match(err.message, /missing-001/);
|
|
348
|
+
assert.match(err.message, /不存在/);
|
|
349
|
+
return true;
|
|
350
|
+
},
|
|
351
|
+
);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test('回归:含文件分区(Uint8Array)的快照经 JSON 往返字节无损——PUT 存 base64、GET 还原 Uint8Array', async () => {
|
|
355
|
+
const snap: SyncSnapshot = {
|
|
356
|
+
...sampleSnapshot(),
|
|
357
|
+
sections: {
|
|
358
|
+
...sampleSnapshot().sections,
|
|
359
|
+
skills: {
|
|
360
|
+
version: 1,
|
|
361
|
+
files: [
|
|
362
|
+
{ relativePath: 'coding.md', data: new Uint8Array(Buffer.from('# Coding\n', 'utf8')), contentHash: 'h1' },
|
|
363
|
+
{ relativePath: 'sub/notes.txt', data: new Uint8Array(Buffer.from('hello world', 'utf8')), contentHash: 'h2' },
|
|
364
|
+
],
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
let uploadedBody = '';
|
|
369
|
+
const calls = makeCalls();
|
|
370
|
+
const handler = (m: MockRequest): WebDavResponse => {
|
|
371
|
+
if (m.method === 'MKCOL') return res(201, '');
|
|
372
|
+
if (m.method === 'PUT' && m.url.endsWith('/snap-001.json')) {
|
|
373
|
+
uploadedBody = m.body ?? '';
|
|
374
|
+
return res(201, '');
|
|
375
|
+
}
|
|
376
|
+
if (m.method === 'PUT' && m.url.endsWith('/index.json')) return res(201, '');
|
|
377
|
+
if (m.method === 'GET') return res(404, '');
|
|
378
|
+
return res(405, '');
|
|
379
|
+
};
|
|
380
|
+
const t = new WebDavTransport(makeOptions({ request: mockReq(calls, handler) }));
|
|
381
|
+
await t.upload(snap);
|
|
382
|
+
// PUT 体必须是二进制安全形态:data 为 base64 标记对象而非数字索引对象
|
|
383
|
+
const parsed = JSON.parse(uploadedBody) as { sections: { skills: { files: Array<{ data: unknown }> } } };
|
|
384
|
+
const f0 = parsed.sections.skills.files[0]!.data as { $bin?: string };
|
|
385
|
+
assert.equal(typeof f0.$bin, 'string', '文件 data 应为 { $bin: base64 }');
|
|
386
|
+
assert.ok(!/\"0\"\s*:/.test(uploadedBody), '不得出现数字索引对象(Uint8Array 直序列化)');
|
|
387
|
+
|
|
388
|
+
// download:远端返回该 body → 还原为 Uint8Array,字节无损
|
|
389
|
+
const calls2 = makeCalls();
|
|
390
|
+
const handler2 = (m: MockRequest): WebDavResponse => {
|
|
391
|
+
if (m.method === 'GET') return res(200, uploadedBody);
|
|
392
|
+
return res(405, '');
|
|
393
|
+
};
|
|
394
|
+
const t2 = new WebDavTransport(makeOptions({ request: mockReq(calls2, handler2) }));
|
|
395
|
+
const roundtrip = await t2.download('snap-001');
|
|
396
|
+
const files = (roundtrip.sections as { skills: { files: Array<{ data: Uint8Array; relativePath: string; contentHash?: string }> } }).skills.files;
|
|
397
|
+
assert.ok(files[0]!.data instanceof Uint8Array, '还原后 data 必须是 Uint8Array(此前是普通对象 → Buffer.from 报错)');
|
|
398
|
+
assert.equal(Buffer.from(files[0]!.data).toString('utf8'), '# Coding\n');
|
|
399
|
+
assert.equal(Buffer.from(files[1]!.data).toString('utf8'), 'hello world');
|
|
400
|
+
assert.equal(files[1]!.relativePath, 'sub/notes.txt');
|
|
401
|
+
assert.equal(roundtrip.id, 'snap-001');
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
/* ---------------- delete ---------------- */
|
|
405
|
+
|
|
406
|
+
test('delete:DELETE <id>.json + 摘除 index 条目并写回', async () => {
|
|
407
|
+
const calls = makeCalls();
|
|
408
|
+
const existing = [
|
|
409
|
+
computeSnapshotMeta(sampleSnapshot({ id: 'snap-keep', createdAt: '2026-08-16T01:00:00.000Z' })),
|
|
410
|
+
computeSnapshotMeta(sampleSnapshot({ id: 'snap-del', createdAt: '2026-08-16T02:00:00.000Z' })),
|
|
411
|
+
];
|
|
412
|
+
const handler = (m: MockRequest): WebDavResponse => {
|
|
413
|
+
if (m.method === 'DELETE') {
|
|
414
|
+
assert.equal(m.url, 'https://dav.example.com/dav/config/dsh-config-manager/snap-del.json');
|
|
415
|
+
return res(204, '');
|
|
416
|
+
}
|
|
417
|
+
if (m.method === 'GET') return res(200, JSON.stringify(existing));
|
|
418
|
+
if (m.method === 'PUT' && m.url.endsWith('/index.json')) {
|
|
419
|
+
const idx = JSON.parse(m.body!) as Array<{ id: string }>;
|
|
420
|
+
assert.deepEqual(idx.map((x) => x.id), ['snap-keep'], '应从 index 摘除被删条目');
|
|
421
|
+
return res(201, '');
|
|
422
|
+
}
|
|
423
|
+
return res(204, '');
|
|
424
|
+
};
|
|
425
|
+
const t = new WebDavTransport(makeOptions({ request: mockReq(calls, handler) }));
|
|
426
|
+
await t.delete('snap-del');
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test('delete:文件不存在(404)→ 视为成功,仍写回聚合 index', async () => {
|
|
430
|
+
const calls = makeCalls();
|
|
431
|
+
const existing = [computeSnapshotMeta(sampleSnapshot({ id: 'snap-del', createdAt: '2026-08-16T02:00:00.000Z' }))];
|
|
432
|
+
const handler = (m: MockRequest): WebDavResponse => {
|
|
433
|
+
if (m.method === 'DELETE') return res(404, '');
|
|
434
|
+
if (m.method === 'GET') return res(200, JSON.stringify(existing));
|
|
435
|
+
if (m.method === 'PUT') return res(201, '');
|
|
436
|
+
return res(404, '');
|
|
437
|
+
};
|
|
438
|
+
const t = new WebDavTransport(makeOptions({ request: mockReq(calls, handler) }));
|
|
439
|
+
await t.delete('snap-del'); // 不抛错
|
|
440
|
+
assert.ok(calls.some((c) => c.method === 'PUT' && /index\.json$/.test(c.url)), '应写回 index');
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test('delete:快照与 index 都不存在 → 静默成功(无 PUT 写回)', async () => {
|
|
444
|
+
const calls = makeCalls();
|
|
445
|
+
const handler = (m: MockRequest): WebDavResponse => {
|
|
446
|
+
if (m.method === 'GET') return res(404, '');
|
|
447
|
+
if (m.method === 'DELETE') return res(404, '');
|
|
448
|
+
return res(404, '');
|
|
449
|
+
};
|
|
450
|
+
const t = new WebDavTransport(makeOptions({ request: mockReq(calls, handler) }));
|
|
451
|
+
await t.delete('never-existed');
|
|
452
|
+
assert.ok(!calls.some((c) => c.method === 'PUT'), '无 index 变更时不应写回');
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
/* ---------------- 认证 ---------------- */
|
|
456
|
+
|
|
457
|
+
test('认证:请求带 Authorization: Basic base64(username:password)', async () => {
|
|
458
|
+
const calls = makeCalls();
|
|
459
|
+
const handler = (m: MockRequest): WebDavResponse => {
|
|
460
|
+
if (m.method === 'GET') return res(404, '');
|
|
461
|
+
return res(201, '');
|
|
462
|
+
};
|
|
463
|
+
const t = new WebDavTransport(makeOptions({ request: mockReq(calls, handler) }));
|
|
464
|
+
await t.list();
|
|
465
|
+
const req = calls[0]!;
|
|
466
|
+
const expected = 'Basic ' + Buffer.from(`alice:${TEST_PASSWORD}`).toString('base64');
|
|
467
|
+
assert.equal(req.headers?.['Authorization'], expected);
|
|
468
|
+
// Authorization 值本身不含明文 password
|
|
469
|
+
assert.ok(!String(req.headers?.['Authorization']).includes(TEST_PASSWORD));
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
/* ---------------- 安全性:错误脱敏 / 密码不泄漏 ---------------- */
|
|
473
|
+
|
|
474
|
+
test('安全:错误消息脱敏——服务器错误文本含 password → 抛出消息替换为 [REDACTED]', async () => {
|
|
475
|
+
const calls = makeCalls();
|
|
476
|
+
const handler = (): WebDavResponse => res(401, `auth failed with ${TEST_PASSWORD} in body`);
|
|
477
|
+
const t = new WebDavTransport(makeOptions({ request: mockReq(calls, handler) }));
|
|
478
|
+
await assert.rejects(
|
|
479
|
+
t.list(),
|
|
480
|
+
(err: unknown) => {
|
|
481
|
+
const msg = String((err as Error).message);
|
|
482
|
+
assert.ok(!msg.includes(TEST_PASSWORD), `错误消息泄漏 password: ${msg}`);
|
|
483
|
+
assert.match(msg, /\[REDACTED\]/);
|
|
484
|
+
return true;
|
|
485
|
+
},
|
|
486
|
+
);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test('安全:password 永不进入 URL/错误——要求头为 base64 Basic,明文 password 不出现于任何请求 URL 或抛错消息', async () => {
|
|
490
|
+
const calls = makeCalls();
|
|
491
|
+
const handler = (m: MockRequest): WebDavResponse => {
|
|
492
|
+
if (m.method === 'GET') return res(404, '');
|
|
493
|
+
return res(201, '');
|
|
494
|
+
};
|
|
495
|
+
const t = new WebDavTransport(makeOptions({ request: mockReq(calls, handler) }));
|
|
496
|
+
await t.list();
|
|
497
|
+
const header = String(calls[0]!.headers?.['Authorization']);
|
|
498
|
+
// 要求头为标准 Basic base64(user:password),且 header 值本身不含明文 password
|
|
499
|
+
assert.match(header, /^Basic [A-Za-z0-9+/=]+$/);
|
|
500
|
+
assert.ok(!header.includes(TEST_PASSWORD), 'header 不应含明文 password');
|
|
501
|
+
// 任何请求 URL 都不含明文 password
|
|
502
|
+
for (const c of calls) assert.ok(!c.url.includes(TEST_PASSWORD), `URL 泄漏 password: ${c.url}`);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
/* ---------------- 超时 ---------------- */
|
|
506
|
+
|
|
507
|
+
test('超时:注入 request 抛超时 → 归一为带 method/url/timeout 的消息', async () => {
|
|
508
|
+
const t = new WebDavTransport(makeOptions({
|
|
509
|
+
baseUrl: 'https://dav.example.com/dav/config',
|
|
510
|
+
username: 'alice',
|
|
511
|
+
credentials: { getPassword: async () => TEST_PASSWORD },
|
|
512
|
+
request: async (method, url) => {
|
|
513
|
+
const e = new Error(`fetch timed out after 5000ms`);
|
|
514
|
+
(e as Error & { name: string }).name = 'TimeoutError';
|
|
515
|
+
throw e;
|
|
516
|
+
},
|
|
517
|
+
}));
|
|
518
|
+
await assert.rejects(
|
|
519
|
+
t.list(),
|
|
520
|
+
(err: unknown) => {
|
|
521
|
+
assert.ok(err instanceof WebDavTransportError);
|
|
522
|
+
assert.match(err.message, /超时|timeout/i);
|
|
523
|
+
assert.ok(err.message.includes('GET'));
|
|
524
|
+
return true;
|
|
525
|
+
},
|
|
526
|
+
);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
/* ---------------- 快照 id 安全 ---------------- */
|
|
530
|
+
|
|
531
|
+
test('快照 id 安全:非法 id(路径穿越/特殊字符)→ upload/download/delete 均拒绝', async () => {
|
|
532
|
+
const t = new WebDavTransport(makeOptions({ request: mockReq(makeCalls(), () => res(404, '')) }));
|
|
533
|
+
for (const bad of ['../evil', 'a/b', 'a\\b', '.', '..', 'snap\ninject', 'index']) {
|
|
534
|
+
await assert.rejects(t.upload(sampleSnapshot({ id: bad })), /非法快照 id/);
|
|
535
|
+
await assert.rejects(t.download(bad), /非法快照 id/);
|
|
536
|
+
await assert.rejects(t.delete(bad), /非法快照 id/);
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
/* ---------------- 重定向跟随(端到端:真实 HTTP 服务器 + 默认 request) ---------------- */
|
|
541
|
+
/**
|
|
542
|
+
* 背景(issue #25):123pan 等网盘 WebDAV 把文件下载 GET 一律 302 到带时效签名的 CDN 直链
|
|
543
|
+
* (跨源);旧实现不跟随 3xx,所有同步(list/push/pull 都先读 index.json)直接失败。
|
|
544
|
+
* 下面用本地回环服务器模拟,走默认 request(不注入 mock),验证:
|
|
545
|
+
* - 302 GET → 200:list 正常解析索引(123pan 场景)
|
|
546
|
+
* - 301/302/307/308 保持方法;303 → GET 并丢弃请求体
|
|
547
|
+
* - 相对 Location 解析;同源保留 Authorization;跨源剥离 Authorization
|
|
548
|
+
* - 跳数上限:302 循环 → 抛 WebDavTransportError(归一消息)
|
|
549
|
+
* - 非跟随 3xx(如 300)按失败处理
|
|
550
|
+
*/
|
|
551
|
+
|
|
552
|
+
interface ServerRec { method: string; url: string; headers: http.IncomingHttpHeaders; body: string; }
|
|
553
|
+
|
|
554
|
+
/** 起一个 127.0.0.1:<随机端口> 的本地 HTTP 服务器,handler(req,res,rec) 记录请求 */
|
|
555
|
+
function startTestServer(
|
|
556
|
+
handler: (rec: ServerRec, res: http.ServerResponse) => void,
|
|
557
|
+
): Promise<{ server: Server; port: number }> {
|
|
558
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
559
|
+
const recs: ServerRec[] = [];
|
|
560
|
+
const server = http.createServer((req, res) => {
|
|
561
|
+
const chunks: Buffer[] = [];
|
|
562
|
+
req.on('data', (c) => chunks.push(Buffer.from(c)));
|
|
563
|
+
req.on('end', () => {
|
|
564
|
+
const rec: ServerRec = {
|
|
565
|
+
method: req.method ?? '',
|
|
566
|
+
url: req.url ?? '',
|
|
567
|
+
headers: req.headers,
|
|
568
|
+
body: Buffer.concat(chunks).toString('utf8'),
|
|
569
|
+
};
|
|
570
|
+
try {
|
|
571
|
+
handler(rec, res);
|
|
572
|
+
} catch (err) {
|
|
573
|
+
res.statusCode = 500;
|
|
574
|
+
res.end(String((err as Error).message));
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
});
|
|
578
|
+
server.on('error', rejectPromise);
|
|
579
|
+
// 随机端口(0 = 系统分配)
|
|
580
|
+
server.listen(0, '127.0.0.1', () => {
|
|
581
|
+
const addr = server.address();
|
|
582
|
+
if (addr === null || typeof addr === 'string') {
|
|
583
|
+
rejectPromise(new Error('无法获取测试服务器端口'));
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
resolvePromise({ server, port: addr.port });
|
|
587
|
+
});
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** 记录 Rec 的服务器变体:返回 recs(测试断言语) */
|
|
592
|
+
function startRecordingServer(
|
|
593
|
+
handler: (rec: ServerRec, res: http.ServerResponse) => void,
|
|
594
|
+
): Promise<{ server: Server; port: number; recs: ServerRec[] }> {
|
|
595
|
+
const recs: ServerRec[] = [];
|
|
596
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
597
|
+
const server = http.createServer((req, res) => {
|
|
598
|
+
const chunks: Buffer[] = [];
|
|
599
|
+
req.on('data', (c) => chunks.push(Buffer.from(c)));
|
|
600
|
+
req.on('end', () => {
|
|
601
|
+
const rec: ServerRec = {
|
|
602
|
+
method: req.method ?? '',
|
|
603
|
+
url: req.url ?? '',
|
|
604
|
+
headers: req.headers,
|
|
605
|
+
body: Buffer.concat(chunks).toString('utf8'),
|
|
606
|
+
};
|
|
607
|
+
recs.push(rec);
|
|
608
|
+
try {
|
|
609
|
+
handler(rec, res);
|
|
610
|
+
} catch (err) {
|
|
611
|
+
res.statusCode = 500;
|
|
612
|
+
res.end(String((err as Error).message));
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
});
|
|
616
|
+
server.on('error', rejectPromise);
|
|
617
|
+
server.listen(0, '127.0.0.1', () => {
|
|
618
|
+
const addr = server.address();
|
|
619
|
+
if (addr === null || typeof addr === 'string') {
|
|
620
|
+
rejectPromise(new Error('无法获取测试服务器端口'));
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
resolvePromise({ server, port: addr.port, recs });
|
|
624
|
+
});
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function closeServer(server: Server): Promise<void> {
|
|
629
|
+
return new Promise((resolvePromise) => {
|
|
630
|
+
server.close(() => resolvePromise());
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** WebDAVTransport 走默认 request:真实网络 + 默认重定向跟随 */
|
|
635
|
+
function makeRealTransport(baseUrl: string): WebDavTransport {
|
|
636
|
+
return new WebDavTransport({
|
|
637
|
+
baseUrl,
|
|
638
|
+
username: 'alice',
|
|
639
|
+
credentials: { getPassword: async () => TEST_PASSWORD },
|
|
640
|
+
timeoutMs: 10_000,
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
test('重定向:GET index.json 302 → 200(同源,123pan 场景)→ list 正常解析', async () => {
|
|
645
|
+
const meta = computeSnapshotMeta(sampleSnapshot());
|
|
646
|
+
const { server, port } = await startTestServer((rec, res) => {
|
|
647
|
+
assert.equal(rec.method, 'GET');
|
|
648
|
+
if (rec.url === '/dav/dsh-config-manager/index.json') {
|
|
649
|
+
res.writeHead(302, { Location: '/dl/index.json?sig=abc' }); // 相对 Location
|
|
650
|
+
res.end();
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
if (rec.url === '/dl/index.json?sig=abc') {
|
|
654
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
655
|
+
res.end(JSON.stringify([meta]));
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
res.writeHead(404);
|
|
659
|
+
res.end('not found');
|
|
660
|
+
});
|
|
661
|
+
try {
|
|
662
|
+
const t = makeRealTransport(`http://127.0.0.1:${port}/dav`);
|
|
663
|
+
const listed = await t.list();
|
|
664
|
+
assert.deepEqual(listed.map((x) => x.id), ['snap-001'], '302 跟随后应解析出索引');
|
|
665
|
+
} finally {
|
|
666
|
+
await closeServer(server);
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
test('重定向:GET 跨源 302 → 目标请求不带 Authorization(CDN 直链泄露隔离)', async () => {
|
|
671
|
+
// 源服务器:index.json → 302 到目标服务器(不同端口 = 跨源)
|
|
672
|
+
const target = await startRecordingServer((rec, res) => {
|
|
673
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
674
|
+
res.end('[]');
|
|
675
|
+
});
|
|
676
|
+
try {
|
|
677
|
+
const { server: sourceServer, port: sourcePort } = await startTestServer((rec, res) => {
|
|
678
|
+
assert.equal(rec.method, 'GET');
|
|
679
|
+
res.writeHead(302, { Location: `http://127.0.0.1:${target.port}/cdn/index.json?sig=xyz` });
|
|
680
|
+
res.end();
|
|
681
|
+
});
|
|
682
|
+
try {
|
|
683
|
+
const t = makeRealTransport(`http://127.0.0.1:${sourcePort}/dav`);
|
|
684
|
+
await t.list();
|
|
685
|
+
assert.equal(target.recs.length, 1, '目标服务器应收到一次请求');
|
|
686
|
+
const rec = target.recs[0]!;
|
|
687
|
+
assert.equal(rec.headers.authorization, undefined, '跨源重定向必须剥离 Authorization');
|
|
688
|
+
assert.ok(!rec.url.includes(TEST_PASSWORD), 'URL 不得含凭据');
|
|
689
|
+
} finally {
|
|
690
|
+
await closeServer(sourceServer);
|
|
691
|
+
}
|
|
692
|
+
} finally {
|
|
693
|
+
await closeServer(target.server);
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
test('重定向:同源 302 → 保留 Authorization(服务器可能按 Basic 认证续用)', async () => {
|
|
698
|
+
const { server, port, recs } = await startRecordingServer((rec, res) => {
|
|
699
|
+
assert.equal(rec.method, 'GET');
|
|
700
|
+
if (rec.url === '/dav/dsh-config-manager/index.json') {
|
|
701
|
+
res.writeHead(302, { Location: '/dav/dsh-config-manager/index2.json' });
|
|
702
|
+
res.end();
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
if (rec.url === '/dav/dsh-config-manager/index2.json') {
|
|
706
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
707
|
+
res.end('[]');
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
res.writeHead(404);
|
|
711
|
+
res.end();
|
|
712
|
+
});
|
|
713
|
+
try {
|
|
714
|
+
const t = makeRealTransport(`http://127.0.0.1:${port}/dav`);
|
|
715
|
+
await t.list();
|
|
716
|
+
const redirectTarget = recs.find((r) => r.url.includes('index2.json'));
|
|
717
|
+
assert.ok(redirectTarget, '应发生第二次 GET(重定向目标)');
|
|
718
|
+
assert.ok(
|
|
719
|
+
redirectTarget!.headers.authorization,
|
|
720
|
+
'同源重定向应保留 Authorization(Basic 认证续用)',
|
|
721
|
+
);
|
|
722
|
+
} finally {
|
|
723
|
+
await closeServer(server);
|
|
724
|
+
}
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
test('重定向:PUT 302(同源)→ 保持方法与请求体(WebDAV upload 全流程)', async () => {
|
|
728
|
+
const { server, port, recs } = await startRecordingServer((rec, res) => {
|
|
729
|
+
if (rec.method === 'MKCOL') {
|
|
730
|
+
res.writeHead(201);
|
|
731
|
+
res.end();
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
if (rec.method === 'GET') {
|
|
735
|
+
res.writeHead(404);
|
|
736
|
+
res.end();
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
if (rec.method === 'PUT' && rec.url.startsWith('/dav/dsh-config-manager/snap-001.json')) {
|
|
740
|
+
if (!rec.url.includes('hop=2')) {
|
|
741
|
+
// 302 到同源(含 query 的绝对路径)→ 必须保持 PUT + 完整 body
|
|
742
|
+
res.writeHead(302, { Location: rec.url + '?hop=2' });
|
|
743
|
+
res.end();
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
res.writeHead(201);
|
|
747
|
+
res.end();
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
if (rec.method === 'PUT' && rec.url.endsWith('/index.json')) {
|
|
751
|
+
res.writeHead(201);
|
|
752
|
+
res.end();
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
res.writeHead(405);
|
|
756
|
+
res.end();
|
|
757
|
+
});
|
|
758
|
+
try {
|
|
759
|
+
const t = makeRealTransport(`http://127.0.0.1:${port}/dav`);
|
|
760
|
+
await t.upload(sampleSnapshot());
|
|
761
|
+
const snapPuts = recs.filter(
|
|
762
|
+
(r) => r.method === 'PUT' && r.url.startsWith('/dav/dsh-config-manager/snap-001.json'),
|
|
763
|
+
);
|
|
764
|
+
assert.ok(snapPuts.length >= 2, '302 后应重发 PUT');
|
|
765
|
+
for (const p of snapPuts) {
|
|
766
|
+
assert.equal(p.method, 'PUT', '302 必须保持 PUT 方法');
|
|
767
|
+
assert.ok(p.body.length > 0, '302 必须保持请求体');
|
|
768
|
+
assert.deepEqual(JSON.parse(p.body), sampleSnapshot(), '重发体应为完整快照');
|
|
769
|
+
}
|
|
770
|
+
} finally {
|
|
771
|
+
await closeServer(server);
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
test('重定向:303(非 GET)→ 降级为 GET 并丢弃请求体', async () => {
|
|
776
|
+
const { server, port, recs } = await startRecordingServer((rec, res) => {
|
|
777
|
+
if (rec.method === 'MKCOL') {
|
|
778
|
+
res.writeHead(201);
|
|
779
|
+
res.end();
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
if (rec.method === 'GET' && rec.url.endsWith('/index.json')) {
|
|
783
|
+
res.writeHead(404);
|
|
784
|
+
res.end();
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
if (rec.method === 'PUT' && rec.url.startsWith('/dav/dsh-config-manager/snap-001.json')) {
|
|
788
|
+
if (!rec.url.includes('see-other')) {
|
|
789
|
+
// 303 See Other:非 GET 方法必须降级为 GET(语义:结果已在别处)
|
|
790
|
+
res.writeHead(303, { Location: '/dav/dsh-config-manager/snap-001.json?see-other=1' });
|
|
791
|
+
res.end();
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
795
|
+
res.end('{}');
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
if (rec.method === 'PUT' && rec.url.endsWith('/index.json')) {
|
|
799
|
+
res.writeHead(201);
|
|
800
|
+
res.end();
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
if (rec.method === 'GET' && rec.url.includes('see-other=1')) {
|
|
804
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
805
|
+
res.end('"ok"');
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
res.writeHead(405);
|
|
809
|
+
res.end();
|
|
810
|
+
});
|
|
811
|
+
try {
|
|
812
|
+
const t = makeRealTransport(`http://127.0.0.1:${port}/dav`);
|
|
813
|
+
await t.upload(sampleSnapshot());
|
|
814
|
+
const firstPut = recs.find(
|
|
815
|
+
(r) => r.method === 'PUT' && r.url.startsWith('/dav/dsh-config-manager/snap-001.json') && !r.url.includes('see-other'),
|
|
816
|
+
);
|
|
817
|
+
assert.ok(firstPut, '首次请求应为 PUT(带完整 body)');
|
|
818
|
+
assert.ok(firstPut!.body.length > 0, '303 前 PUT 应携带请求体');
|
|
819
|
+
// 303 目标请求:方法降级为 GET、无请求体、无 Content-Length
|
|
820
|
+
const degraded = recs.find((r) => r.url.includes('see-other=1'));
|
|
821
|
+
assert.ok(degraded, '应发生 303 目标请求');
|
|
822
|
+
assert.equal(degraded!.method, 'GET', '303(原 PUT)后必须降级为 GET');
|
|
823
|
+
assert.equal(degraded!.body, '', '降级请求不得携带原始 body');
|
|
824
|
+
assert.equal(degraded!.headers['content-length'], undefined, '降级 GET 不得带 Content-Length');
|
|
825
|
+
} finally {
|
|
826
|
+
await closeServer(server);
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
test('重定向:302 循环(同源)→ 超 5 跳上限抛 WebDavTransportError(归一消息)', async () => {
|
|
831
|
+
const { server, port } = await startTestServer((rec, res) => {
|
|
832
|
+
res.writeHead(302, { Location: '/loop' });
|
|
833
|
+
res.end();
|
|
834
|
+
});
|
|
835
|
+
try {
|
|
836
|
+
const t = makeRealTransport(`http://127.0.0.1:${port}/dav`);
|
|
837
|
+
await assert.rejects(
|
|
838
|
+
t.list(),
|
|
839
|
+
(err: unknown) => {
|
|
840
|
+
assert.ok(err instanceof WebDavTransportError);
|
|
841
|
+
assert.match(err.message, /重定向|redirect/i);
|
|
842
|
+
assert.match(err.message, /5/);
|
|
843
|
+
return true;
|
|
844
|
+
},
|
|
845
|
+
);
|
|
846
|
+
} finally {
|
|
847
|
+
await closeServer(server);
|
|
848
|
+
}
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
test('重定向:300(非跟随 3xx)→ 按失败处理(明确报 HTTP 状态)', async () => {
|
|
852
|
+
const { server, port } = await startTestServer((rec, res) => {
|
|
853
|
+
res.writeHead(300, { Location: '/nowhere' });
|
|
854
|
+
res.end('multiple choices');
|
|
855
|
+
});
|
|
856
|
+
try {
|
|
857
|
+
const t = makeRealTransport(`http://127.0.0.1:${port}/dav`);
|
|
858
|
+
await assert.rejects(
|
|
859
|
+
t.list(),
|
|
860
|
+
(err: unknown) => {
|
|
861
|
+
assert.ok(err instanceof WebDavTransportError);
|
|
862
|
+
assert.match(err.message, /300/);
|
|
863
|
+
return true;
|
|
864
|
+
},
|
|
865
|
+
);
|
|
866
|
+
} finally {
|
|
867
|
+
await closeServer(server);
|
|
868
|
+
}
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
|