@taskforcehq/taskforce 0.3.304 → 0.3.306
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/README.md +140 -247
- package/dist/Taskforce.module.css +295 -89
- package/dist/TaskforceCore.js +378 -111
- package/dist/TaskforceCore.test.js +1221 -107
- package/dist/assets/fonts/CourierPrime-Bold.woff2 +0 -0
- package/dist/assets/fonts/CourierPrime-BoldItalic.woff2 +0 -0
- package/dist/assets/fonts/CourierPrime-Italic.woff2 +0 -0
- package/dist/assets/fonts/CourierPrime-Regular.woff2 +0 -0
- package/dist/assets/fonts/Noir_medium.woff2 +0 -0
- package/dist/assets/fonts/Noir_regular.woff2 +0 -0
- package/dist/assets/fonts/RussoOne-Regular.woff2 +0 -0
- package/dist/assets/fonts/SpecialElite-Regular.woff2 +0 -0
- package/dist/compat/workspaceSyncCompat.js +52 -2
- package/dist/compat/workspaceSyncCompat.test.js +38 -1
- package/dist/components/context/ContextAttachmentManager.d.ts +14 -0
- package/dist/components/context/ContextAttachmentManager.js +549 -0
- package/dist/components/features/AgentsModule.d.ts +10 -1
- package/dist/components/features/AgentsModule.js +260 -15
- package/dist/components/features/AgentsModule.test.js +255 -5
- package/dist/components/features/DocumentIndex.d.ts +1 -1
- package/dist/components/features/DocumentIndex.js +1 -1
- package/dist/components/features/DocumentViewer.d.ts +6 -2
- package/dist/components/features/DocumentViewer.js +90 -10
- package/dist/components/features/DocumentWorkspace.d.ts +5 -22
- package/dist/components/features/DocumentWorkspace.js +12 -155
- package/dist/components/features/DocumentWorkspace.test.js +226 -6
- package/dist/components/features/TaskSettings.js +29 -3
- package/dist/components/features/TaskSettings.test.js +42 -1
- package/dist/components/features/documentWorkspaceModel.d.ts +24 -0
- package/dist/components/features/documentWorkspaceModel.js +57 -0
- package/dist/components/features/useDocumentWorkspaceController.d.ts +34 -0
- package/dist/components/features/useDocumentWorkspaceController.js +113 -0
- package/dist/components/task/TaskCard.js +19 -28
- package/dist/components/task/TaskCard.test.js +38 -3
- package/dist/components/task/TaskContextUpload.js +4 -526
- package/dist/components/task/TaskForm.d.ts +8 -8
- package/dist/components/task/TaskForm.js +362 -230
- package/dist/components/task/TaskForm.test.js +558 -381
- package/dist/components/task/TaskKanban.d.ts +2 -1
- package/dist/components/task/TaskKanban.js +96 -14
- package/dist/components/task/TaskKanban.test.js +36 -0
- package/dist/components/ui/EditableAvatarButton.d.ts +18 -0
- package/dist/components/ui/EditableAvatarButton.js +18 -0
- package/dist/components/views/PlanComparisonPage.d.ts +9 -1
- package/dist/components/views/PlanComparisonPage.js +70 -17
- package/dist/components/views/PlanComparisonPage.test.js +289 -41
- package/dist/components/views/PlansPage.js +239 -106
- package/dist/components/views/PlansPage.test.js +593 -42
- package/dist/components/views/StandaloneLayout.js +310 -554
- package/dist/components/views/panels/FilterToolbar.test.js +6 -4
- package/dist/components/views/panels/PlanningDrawer.d.ts +8 -1
- package/dist/components/views/panels/PlanningDrawer.js +18 -11
- package/dist/components/views/panels/PlanningDrawer.test.d.ts +1 -0
- package/dist/components/views/panels/PlanningDrawer.test.js +141 -0
- package/dist/components/views/planningScope.d.ts +20 -0
- package/dist/components/views/planningScope.js +25 -0
- package/dist/components/views/planningScope.test.d.ts +1 -0
- package/dist/components/views/planningScope.test.js +60 -0
- package/dist/components/views/standalone/modals/AccountHubModal.d.ts +27 -14
- package/dist/components/views/standalone/modals/AccountHubModal.js +87 -15
- package/dist/components/views/standalone/modals/AccountHubModal.test.js +42 -2
- package/dist/components/views/standalone/modals/AvatarImageManagerModal.d.ts +20 -0
- package/dist/components/views/standalone/modals/AvatarImageManagerModal.js +127 -0
- package/dist/components/views/standalone/modals/EditProfileModal.d.ts +2 -9
- package/dist/components/views/standalone/modals/EditProfileModal.js +3 -2
- package/dist/components/views/standalone/modals/SyncEnableWarningModal.d.ts +9 -0
- package/dist/components/views/standalone/modals/SyncEnableWarningModal.js +6 -0
- package/dist/components/views/standalone/modals/SyncEnableWarningModal.test.d.ts +1 -0
- package/dist/components/views/standalone/modals/SyncEnableWarningModal.test.js +24 -0
- package/dist/components/views/standalone/modals/SyncStatusModal.d.ts +2 -1
- package/dist/components/views/standalone/modals/SyncStatusModal.js +2 -2
- package/dist/config/envSchema.js +1 -1
- package/dist/core/AiProfileService.d.ts +45 -3
- package/dist/core/AiProfileService.js +774 -63
- package/dist/core/AiProfileService.test.js +22 -2
- package/dist/core/AiProfiles.test.js +761 -2
- package/dist/core/AttachmentLinkService.js +57 -15
- package/dist/core/AuthIdentityService.d.ts +107 -0
- package/dist/core/AuthIdentityService.js +284 -0
- package/dist/core/AuthTokenService.d.ts +4 -0
- package/dist/core/AuthTokenService.js +34 -6
- package/dist/core/AuthTokenService.test.d.ts +1 -0
- package/dist/core/AuthTokenService.test.js +78 -0
- package/dist/core/EntitlementsPolicy.test.js +77 -15
- package/dist/core/GlobalSettingsService.js +115 -6
- package/dist/core/PlanEntitlementService.d.ts +30 -3
- package/dist/core/PlanEntitlementService.js +454 -93
- package/dist/core/PlanFeatureCatalog.test.d.ts +1 -0
- package/dist/core/PlanFeatureCatalog.test.js +84 -0
- package/dist/core/PlanVersionPolicy.test.js +54 -28
- package/dist/core/PlanningEntities.test.js +52 -0
- package/dist/core/SignupMonetizationSettings.test.js +241 -44
- package/dist/core/SyncReconciliationService.js +2 -65
- package/dist/core/SystemAdmin.test.js +244 -88
- package/dist/core/TaskAttachmentsCanonical.test.js +51 -1
- package/dist/core/TaskTaxonomyValidation.test.js +53 -0
- package/dist/core/Taskforce.d.ts +228 -14
- package/dist/core/Taskforce.js +814 -201
- package/dist/core/Taskforce.listTasksSlim.test.d.ts +1 -0
- package/dist/core/Taskforce.listTasksSlim.test.js +91 -0
- package/dist/core/UserProfileAvatarDrafts.test.js +30 -3
- package/dist/core/WorkspaceLifecycleService.js +0 -5
- package/dist/core/WorkspacePermissions.test.js +25 -1
- package/dist/core/WorkspacePlanMode.test.js +128 -27
- package/dist/core/shared.d.ts +3 -1
- package/dist/core/shared.js +16 -1
- package/dist/core/types.d.ts +68 -2
- package/dist/hooks/auth/useTaskforceAuthBootstrap.js +26 -1
- package/dist/hooks/sync/auth.js +3 -1
- package/dist/hooks/sync/bootstrap.js +2 -0
- package/dist/hooks/sync/controlPlane.d.ts +37 -0
- package/dist/hooks/sync/controlPlane.js +78 -0
- package/dist/hooks/sync/controlPlane.test.d.ts +1 -0
- package/dist/hooks/sync/controlPlane.test.js +121 -0
- package/dist/hooks/sync/lifecyclePolicy.d.ts +45 -0
- package/dist/hooks/sync/lifecyclePolicy.js +93 -0
- package/dist/hooks/sync/lifecyclePolicy.test.d.ts +1 -0
- package/dist/hooks/sync/lifecyclePolicy.test.js +124 -0
- package/dist/hooks/sync/orchestratorShared.d.ts +10 -1
- package/dist/hooks/sync/orchestratorShared.js +68 -47
- package/dist/hooks/sync/orchestratorShared.test.js +50 -1
- package/dist/hooks/sync/pullLifecycle.d.ts +31 -0
- package/dist/hooks/sync/pullLifecycle.js +24 -0
- package/dist/hooks/sync/pullLifecycle.test.d.ts +1 -0
- package/dist/hooks/sync/pullLifecycle.test.js +80 -0
- package/dist/hooks/sync/recovery.d.ts +16 -2
- package/dist/hooks/sync/recovery.js +171 -53
- package/dist/hooks/sync/recovery.test.d.ts +1 -0
- package/dist/hooks/sync/recovery.test.js +292 -0
- package/dist/hooks/sync/transfers.d.ts +16 -3
- package/dist/hooks/sync/transfers.js +186 -65
- package/dist/hooks/sync/transfers.test.d.ts +1 -0
- package/dist/hooks/sync/transfers.test.js +396 -0
- package/dist/hooks/sync/useRecentSyncEvents.d.ts +21 -0
- package/dist/hooks/sync/useRecentSyncEvents.js +92 -0
- package/dist/hooks/sync/useRecentSyncEvents.test.d.ts +1 -0
- package/dist/hooks/sync/useRecentSyncEvents.test.js +124 -0
- package/dist/hooks/sync/useSyncStatusActions.d.ts +30 -0
- package/dist/hooks/sync/useSyncStatusActions.js +88 -0
- package/dist/hooks/sync/useSyncStatusActions.test.d.ts +1 -0
- package/dist/hooks/sync/useSyncStatusActions.test.js +133 -0
- package/dist/hooks/sync/useSyncStatusControls.d.ts +25 -0
- package/dist/hooks/sync/useSyncStatusControls.js +60 -0
- package/dist/hooks/sync/useSyncStatusControls.test.d.ts +1 -0
- package/dist/hooks/sync/useSyncStatusControls.test.js +88 -0
- package/dist/hooks/useSyncOrchestrator.aiProfiles.test.js +12 -0
- package/dist/hooks/useSyncOrchestrator.d.ts +16 -6
- package/dist/hooks/useSyncOrchestrator.js +424 -175
- package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +487 -10
- package/dist/hooks/useTaskData.js +8 -3
- package/dist/hooks/useTaskData.test.js +45 -0
- package/dist/hooks/useTaskMutations.d.ts +2 -1
- package/dist/hooks/useTaskMutations.js +12 -1
- package/dist/hooks/useTaskMutations.test.js +30 -1
- package/dist/hooks/useTaskforce.d.ts +65 -4
- package/dist/hooks/useTaskforce.js +564 -243
- package/dist/hooks/useTaskforce.runtime-routing.test.d.ts +1 -0
- package/dist/hooks/useTaskforce.runtime-routing.test.js +152 -0
- package/dist/hooks/useTaskforce.sync-behavior.test.js +2454 -208
- package/dist/hooks/useWorkspaceSyncController.d.ts +4 -1
- package/dist/hooks/useWorkspaceSyncController.js +17 -2
- package/dist/hooks/useWorkspaceSyncController.test.js +1 -0
- package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.d.ts +16 -0
- package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +316 -35
- package/dist/localization/locales/en-US.d.ts +0 -1
- package/dist/localization/locales/en-US.js +0 -1
- package/dist/localization/locales/es-419.js +0 -1
- package/dist/localization/locales/pt-BR.js +0 -1
- package/dist/mcp/adminWorkspaceRegistrar.d.ts +2 -0
- package/dist/mcp/adminWorkspaceRegistrar.js +154 -0
- package/dist/mcp/collaborationRegistrar.d.ts +2 -0
- package/dist/mcp/collaborationRegistrar.js +71 -0
- package/dist/mcp/documentAssetRegistrar.d.ts +2 -0
- package/dist/mcp/documentAssetRegistrar.js +346 -0
- package/dist/mcp/runtime.d.ts +2 -0
- package/dist/mcp/runtime.js +2433 -3534
- package/dist/mcp/runtime.test.js +440 -2
- package/dist/mcp/taskPlanningRegistrar.d.ts +2 -0
- package/dist/mcp/taskPlanningRegistrar.js +418 -0
- package/dist/mcp/toolCatalog.d.ts +35 -0
- package/dist/mcp/toolCatalog.js +3 -0
- package/dist/migrations/taskSchemaMigrations.d.ts +1 -1
- package/dist/migrations/taskSchemaMigrations.js +171 -61
- package/dist/migrations/taskSchemaMigrations.test.js +15 -0
- package/dist/resources/templates/workflow-sources/workflowDocs.mjs +6 -6
- package/dist/resources/templates/workflows/collaborate.yaml +1 -1
- package/dist/resources/templates/workflows/evaluate.yaml +2 -2
- package/dist/resources/templates/workflows/plan.yaml +1 -1
- package/dist/resources/templates/workflows/review.yaml +2 -2
- package/dist/server/annotatedAttachmentsRoutes.test.js +3 -3
- package/dist/server/auth/providers/apple.d.ts +33 -0
- package/dist/server/auth/providers/apple.js +82 -0
- package/dist/server/auth/providers/appleClientSecret.d.ts +37 -0
- package/dist/server/auth/providers/appleClientSecret.js +65 -0
- package/dist/server/auth/providers/github.d.ts +26 -0
- package/dist/server/auth/providers/github.js +86 -0
- package/dist/server/auth/providers/google.d.ts +21 -0
- package/dist/server/auth/providers/google.js +56 -0
- package/dist/server/auth/providers/types.d.ts +21 -0
- package/dist/server/auth/providers/types.js +1 -0
- package/dist/server/auth.d.ts +2 -0
- package/dist/server/auth.js +6 -3
- package/dist/server/documentReviewRoutes.test.js +36 -3
- package/dist/server/index.cookieProxy.test.js +1 -0
- package/dist/server/index.d.ts +12 -0
- package/dist/server/index.js +120 -9
- package/dist/server/index.rateLimit.test.js +3 -0
- package/dist/server/index.test.js +106 -3
- package/dist/server/routes/admin.d.ts +4 -5
- package/dist/server/routes/admin.js +410 -80
- package/dist/server/routes/annotatedAttachments.d.ts +1 -1
- package/dist/server/routes/annotatedAttachments.js +1 -1
- package/dist/server/routes/auth.d.ts +16 -6
- package/dist/server/routes/auth.js +1015 -118
- package/dist/server/routes/authSupport.d.ts +30 -0
- package/dist/server/routes/authSupport.js +81 -0
- package/dist/server/routes/billing.d.ts +1 -1
- package/dist/server/routes/billing.js +276 -34
- package/dist/server/routes/billing.test.js +760 -52
- package/dist/server/routes/documentReviews.d.ts +1 -1
- package/dist/server/routes/documentReviews.js +1 -1
- package/dist/server/routes/documents.d.ts +5 -2
- package/dist/server/routes/documents.js +242 -74
- package/dist/server/routes/primitives.d.ts +11 -0
- package/dist/server/routes/primitives.js +73 -0
- package/dist/server/routes/resources.d.ts +1 -1
- package/dist/server/routes/resources.js +1 -1
- package/dist/server/routes/shared.d.ts +29 -3
- package/dist/server/routes/shared.js +69 -4
- package/dist/server/routes/sync.d.ts +1 -1
- package/dist/server/routes/sync.integration.test.js +437 -39
- package/dist/server/routes/sync.js +62 -606
- package/dist/server/routes/syncAuxRoutes.d.ts +15 -0
- package/dist/server/routes/syncAuxRoutes.js +91 -0
- package/dist/server/routes/syncAuxRoutes.test.d.ts +1 -0
- package/dist/server/routes/syncAuxRoutes.test.js +158 -0
- package/dist/server/routes/syncPullApplyRoutes.d.ts +36 -0
- package/dist/server/routes/syncPullApplyRoutes.js +204 -0
- package/dist/server/routes/syncPushRoutes.d.ts +24 -0
- package/dist/server/routes/syncPushRoutes.js +212 -0
- package/dist/server/routes/syncRouteGuards.d.ts +36 -0
- package/dist/server/routes/syncRouteGuards.js +67 -0
- package/dist/server/routes/syncRouteGuards.test.d.ts +1 -0
- package/dist/server/routes/syncRouteGuards.test.js +94 -0
- package/dist/server/routes/syncRouteTypes.d.ts +13 -0
- package/dist/server/routes/syncRouteTypes.js +1 -0
- package/dist/server/routes/syncSnapshotRoutes.d.ts +66 -0
- package/dist/server/routes/syncSnapshotRoutes.js +180 -0
- package/dist/server/routes/tasks.d.ts +1 -1
- package/dist/server/routes/tasks.js +44 -2
- package/dist/server/routes/workspaces.d.ts +1 -1
- package/dist/server/routes/workspaces.js +37 -6
- package/dist/server/routes.d.ts +2 -9
- package/dist/server/routes.js +136 -165
- package/dist/server/routes.test.js +2457 -162
- package/dist/services/workflowExportSnapshots.test.js +2 -0
- package/dist/shared/aiProfileSeatScope.d.ts +5 -0
- package/dist/shared/aiProfileSeatScope.js +21 -0
- package/dist/shared/passwordPolicy.d.ts +13 -0
- package/dist/shared/passwordPolicy.js +84 -0
- package/dist/shared/passwordPolicy.test.d.ts +1 -0
- package/dist/shared/passwordPolicy.test.js +34 -0
- package/dist/storage/workspaceAssetStore.d.ts +15 -3
- package/dist/storage/workspaceAssetStore.js +132 -44
- package/dist/storage/workspaceAssetStore.test.js +93 -6
- package/dist/styles/fonts.css +70 -0
- package/dist/sync/collaborationSyncPayload.d.ts +6 -1
- package/dist/sync/collaborationSyncPayload.js +17 -11
- package/dist/sync/collaborationSyncPayload.test.js +13 -1
- package/dist/sync/contentSyncState.d.ts +4 -8
- package/dist/sync/contentSyncState.js +33 -77
- package/dist/sync/syncApplyHandlers.d.ts +7 -0
- package/dist/sync/syncApplyHandlers.js +28 -13
- package/dist/sync/syncApplyHandlers.test.js +72 -3
- package/dist/sync/syncFieldSemantics.d.ts +12 -0
- package/dist/sync/syncFieldSemantics.js +55 -0
- package/dist/sync/syncResourceAdapters.d.ts +44 -0
- package/dist/sync/syncResourceAdapters.js +77 -0
- package/dist/sync/syncService.d.ts +6 -0
- package/dist/sync/syncService.js +60 -0
- package/dist/sync/syncService.test.js +71 -3
- package/dist/sync/taskSyncChangeKey.d.ts +2 -0
- package/dist/sync/taskSyncChangeKey.js +143 -0
- package/dist/sync/taskSyncPayload.js +3 -8
- package/dist/sync/workspacePullFeed.js +4 -3
- package/dist/sync/workspacePullFeed.test.js +43 -0
- package/dist/sync/workspaceRepair.js +5 -2
- package/dist/sync/workspaceSyncModel.d.ts +48 -0
- package/dist/sync/workspaceSyncModel.js +195 -31
- package/dist/sync/workspaceSyncModel.test.js +325 -12
- package/dist/sync/workspaceSyncState.d.ts +19 -0
- package/dist/sync/workspaceSyncState.js +3 -1
- package/dist/sync/workspaceSyncSurface.js +3 -8
- package/dist/types.d.ts +17 -0
- package/dist/ui/assets/AgentsModule-DrLawwNl.js +1 -0
- package/dist/ui/assets/{AnnotatedAttachmentWorkspace-BlS-cqnl.js → AnnotatedAttachmentWorkspace-Z9_whf7F.js} +1 -1
- package/dist/ui/assets/CourierPrime-Bold-BuLj_dpw.woff2 +0 -0
- package/dist/ui/assets/CourierPrime-BoldItalic-BrK2pUkZ.woff2 +0 -0
- package/dist/ui/assets/CourierPrime-Italic-DFUZK5Ey.woff2 +0 -0
- package/dist/ui/assets/CourierPrime-Regular-BsKphzVf.woff2 +0 -0
- package/dist/ui/assets/DocumentWorkspace-B03MaSkw.js +252 -0
- package/dist/ui/assets/DocumentWorkspace-Dhdso07p.css +1 -0
- package/dist/ui/assets/{InitiativesModule-BjR6iBf9.js → InitiativesModule-9E6ParrY.js} +1 -1
- package/dist/ui/assets/Noir_medium-bxQwKGzB.woff2 +0 -0
- package/dist/ui/assets/Noir_regular-ojf2kxlG.woff2 +0 -0
- package/dist/ui/assets/PlansPage-CvfnMiWq.css +1 -0
- package/dist/ui/assets/PlansPage-DRXscYxH.js +1 -0
- package/dist/ui/assets/RussoOne-Regular-Cu_qq_qC.woff2 +0 -0
- package/dist/ui/assets/SpecialElite-Regular-Di6gSvAS.woff2 +0 -0
- package/dist/ui/assets/TaskSettings-DZX4jk7e.js +9 -0
- package/dist/ui/assets/{WorkflowsModule-DnFqdUME.js → WorkflowsModule-BA7jcEpH.js} +1 -1
- package/dist/ui/assets/index-9eT8e0Lu.css +1 -0
- package/dist/ui/assets/index-C6k75jr8.js +6 -0
- package/dist/ui/assets/{vendor-icons-I7ZwG1z_.js → vendor-icons-DuEd_65c.js} +1 -1
- package/dist/ui/fonts/CourierPrime-Bold.ttf +0 -0
- package/dist/ui/fonts/CourierPrime-Bold.woff2 +0 -0
- package/dist/ui/fonts/CourierPrime-BoldItalic.ttf +0 -0
- package/dist/ui/fonts/CourierPrime-BoldItalic.woff2 +0 -0
- package/dist/ui/fonts/CourierPrime-Italic.ttf +0 -0
- package/dist/ui/fonts/CourierPrime-Italic.woff2 +0 -0
- package/dist/ui/fonts/CourierPrime-Regular.ttf +0 -0
- package/dist/ui/fonts/CourierPrime-Regular.woff2 +0 -0
- package/dist/ui/fonts/Noir_medium.otf +0 -0
- package/dist/ui/fonts/Noir_medium.woff2 +0 -0
- package/dist/ui/fonts/Noir_regular.otf +0 -0
- package/dist/ui/fonts/Noir_regular.woff2 +0 -0
- package/dist/ui/fonts/RussoOne-Regular.woff2 +0 -0
- package/dist/ui/fonts/SpecialElite-Regular.ttf +0 -0
- package/dist/ui/fonts/SpecialElite-Regular.woff2 +0 -0
- package/dist/ui/index.html +3 -3
- package/dist/utils/accountProfileSummaryCache.d.ts +5 -0
- package/dist/utils/assignees.d.ts +2 -0
- package/dist/utils/assignees.js +2 -0
- package/dist/utils/avatarUpload.d.ts +27 -0
- package/dist/utils/avatarUpload.js +132 -0
- package/dist/utils/avatarUpload.test.d.ts +1 -0
- package/dist/utils/avatarUpload.test.js +40 -0
- package/dist/utils/bootstrapTrace.d.ts +7 -0
- package/dist/utils/bootstrapTrace.js +29 -0
- package/dist/utils/commercialLifecyclePresentation.d.ts +19 -0
- package/dist/utils/commercialLifecyclePresentation.js +199 -0
- package/dist/utils/contextAssetEvents.d.ts +2 -0
- package/dist/utils/syncEventDetails.d.ts +3 -0
- package/dist/utils/syncEventDetails.js +64 -0
- package/dist/utils/syncEventDetails.test.d.ts +1 -0
- package/dist/utils/syncEventDetails.test.js +23 -0
- package/dist/utils/syncEventPresentation.d.ts +15 -0
- package/dist/utils/syncEventPresentation.js +129 -0
- package/dist/utils/syncEventPresentation.test.d.ts +1 -0
- package/dist/utils/syncEventPresentation.test.js +64 -0
- package/dist/utils/syncStatusPresentation.d.ts +72 -0
- package/dist/utils/syncStatusPresentation.js +217 -0
- package/dist/utils/syncStatusPresentation.test.d.ts +1 -0
- package/dist/utils/syncStatusPresentation.test.js +164 -0
- package/dist/utils/taskActivity.d.ts +2 -0
- package/dist/utils/taskActivity.js +228 -22
- package/dist/utils/taskActivity.test.js +119 -8
- package/dist/utils/workspaceSyncPresentation.d.ts +4 -0
- package/dist/utils/workspaceSyncPresentation.js +68 -2
- package/dist/utils/workspaceSyncPresentation.test.js +36 -1
- package/package.json +10 -2
- package/scripts/check-cloud-mcp.mjs +83 -0
- package/scripts/export-sqlite-to-postgres.mjs +7 -9
- package/scripts/playwright-auth.sh +5 -1
- package/scripts/run-billing-performance-smoke.sh +24 -0
- package/scripts/run-e2e-realtime.sh +17 -3
- package/scripts/run-smoke.sh +17 -5
- package/scripts/run-soak.sh +17 -5
- package/src/resources/templates/workflow-sources/workflowDocs.mjs +6 -6
- package/src/resources/templates/workflows/collaborate.yaml +1 -1
- package/src/resources/templates/workflows/evaluate.yaml +2 -2
- package/src/resources/templates/workflows/plan.yaml +1 -1
- package/src/resources/templates/workflows/review.yaml +2 -2
- package/dist/ui/assets/AgentsModule-BLWVbuKP.js +0 -1
- package/dist/ui/assets/DocumentWorkspace-BH_6DF6x.js +0 -250
- package/dist/ui/assets/DocumentWorkspace-C_8T8oz-.css +0 -1
- package/dist/ui/assets/PlansPage-C2dd2n1X.css +0 -1
- package/dist/ui/assets/PlansPage-CSDQ4sLa.js +0 -1
- package/dist/ui/assets/RussoOne-Regular-C3BxZIj7.ttf +0 -0
- package/dist/ui/assets/TaskSettings-CEDy34Jo.js +0 -9
- package/dist/ui/assets/index-DXUdtH1B.js +0 -6
- package/dist/ui/assets/index-Ii0B0rTO.css +0 -1
- package/scripts/migrate-planning-model.ts +0 -233
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
2
|
import { createRoutes, matchRoute, parseJsonBody } from './routes';
|
|
3
|
-
import { createSessionTokenCookie } from './auth';
|
|
3
|
+
import { createSessionTokenCookie, resolveRequestAccess } from './auth';
|
|
4
4
|
import { TaskforceCore } from '../core/Taskforce';
|
|
5
5
|
import { Readable, Writable } from 'stream';
|
|
6
6
|
import * as fs from 'fs';
|
|
@@ -102,6 +102,11 @@ vi.mock('../core/Taskforce', () => {
|
|
|
102
102
|
});
|
|
103
103
|
listInitiatives = vi.fn().mockReturnValue([]);
|
|
104
104
|
listWorkstreams = vi.fn().mockReturnValue([]);
|
|
105
|
+
listPlanningBootstrap = vi.fn().mockReturnValue({
|
|
106
|
+
initiatives: [],
|
|
107
|
+
workstreams: [],
|
|
108
|
+
workstreamTaskSummaries: []
|
|
109
|
+
});
|
|
105
110
|
getInitiativeTemplates = vi.fn();
|
|
106
111
|
createInitiativeFromTemplate = vi.fn();
|
|
107
112
|
getCategories = vi.fn();
|
|
@@ -156,6 +161,9 @@ vi.mock('../core/Taskforce', () => {
|
|
|
156
161
|
listWorkspaceUsers = vi.fn().mockReturnValue([]);
|
|
157
162
|
listAssignableActors = vi.fn();
|
|
158
163
|
listAiProfiles = vi.fn().mockReturnValue([]);
|
|
164
|
+
countAiProfileUsage = vi.fn().mockReturnValue({ used: 0, limit: null, remaining: null });
|
|
165
|
+
mergeAiProfiles = vi.fn();
|
|
166
|
+
archiveAiProfile = vi.fn();
|
|
159
167
|
replaceWorkspaceUsersForSync = vi.fn().mockReturnValue([]);
|
|
160
168
|
listDocumentReviewSessionsForSync = vi.fn().mockReturnValue([]);
|
|
161
169
|
listDocumentReviewCommentsForSync = vi.fn().mockReturnValue([]);
|
|
@@ -235,6 +243,7 @@ vi.mock('../core/Taskforce', () => {
|
|
|
235
243
|
updatedAt: '2026-01-01T00:00:00.000Z'
|
|
236
244
|
}
|
|
237
245
|
]);
|
|
246
|
+
countSystemUsers = vi.fn().mockReturnValue(1);
|
|
238
247
|
getSystemRole = vi.fn().mockReturnValue('user');
|
|
239
248
|
getUserBetaAccess = vi.fn().mockReturnValue(true);
|
|
240
249
|
setSystemRole = vi.fn().mockReturnValue(true);
|
|
@@ -411,7 +420,7 @@ vi.mock('../core/Taskforce', () => {
|
|
|
411
420
|
}
|
|
412
421
|
},
|
|
413
422
|
{
|
|
414
|
-
featureKey: 'workspace.
|
|
423
|
+
featureKey: 'workspace.workspaces',
|
|
415
424
|
label: 'Workspaces',
|
|
416
425
|
description: 'Allow multiple workspaces with a configurable workspace limit.',
|
|
417
426
|
allowedAccessModes: ['enabled', 'disabled'],
|
|
@@ -624,6 +633,12 @@ describe('Server Routes', () => {
|
|
|
624
633
|
if (typeof body === 'string') {
|
|
625
634
|
req.push(body);
|
|
626
635
|
}
|
|
636
|
+
else if (Buffer.isBuffer(body)) {
|
|
637
|
+
req.push(body);
|
|
638
|
+
}
|
|
639
|
+
else if (body instanceof Uint8Array) {
|
|
640
|
+
req.push(Buffer.from(body));
|
|
641
|
+
}
|
|
627
642
|
else {
|
|
628
643
|
req.push(body ? JSON.stringify(body) : null);
|
|
629
644
|
}
|
|
@@ -684,6 +699,33 @@ describe('Server Routes', () => {
|
|
|
684
699
|
res.__getBody = () => Buffer.concat(bodyChunks).toString('utf8');
|
|
685
700
|
return res;
|
|
686
701
|
};
|
|
702
|
+
const withDevAuthTokenExposure = async (value, run) => {
|
|
703
|
+
const previous = process.env.TASKFORCE_AUTH_EXPOSE_DEV_TOKENS;
|
|
704
|
+
if (value === undefined)
|
|
705
|
+
delete process.env.TASKFORCE_AUTH_EXPOSE_DEV_TOKENS;
|
|
706
|
+
else
|
|
707
|
+
process.env.TASKFORCE_AUTH_EXPOSE_DEV_TOKENS = value;
|
|
708
|
+
try {
|
|
709
|
+
await run();
|
|
710
|
+
}
|
|
711
|
+
finally {
|
|
712
|
+
if (previous === undefined)
|
|
713
|
+
delete process.env.TASKFORCE_AUTH_EXPOSE_DEV_TOKENS;
|
|
714
|
+
else
|
|
715
|
+
process.env.TASKFORCE_AUTH_EXPOSE_DEV_TOKENS = previous;
|
|
716
|
+
}
|
|
717
|
+
};
|
|
718
|
+
const createCloudAuthConfig = () => ({
|
|
719
|
+
runtimeMode: 'cloud',
|
|
720
|
+
enabled: true,
|
|
721
|
+
requiredForApi: true,
|
|
722
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
723
|
+
defaultRole: 'member',
|
|
724
|
+
defaultWorkspaceId: 'default',
|
|
725
|
+
sessionCookieName: 'taskforce_session',
|
|
726
|
+
sessionSecret: 'test-secret',
|
|
727
|
+
sessionTtlSeconds: 3600
|
|
728
|
+
});
|
|
687
729
|
it('GET /api/taskforce/tasks should return tasks', async () => {
|
|
688
730
|
const mockTasks = { tasks: [], config: {} };
|
|
689
731
|
core.listTasks.mockReturnValue(mockTasks);
|
|
@@ -695,6 +737,30 @@ describe('Server Routes', () => {
|
|
|
695
737
|
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
696
738
|
expect(res.end).toHaveBeenCalledWith(JSON.stringify(mockTasks));
|
|
697
739
|
});
|
|
740
|
+
it('GET /api/taskforce/planning/bootstrap should return lightweight planning bootstrap data', async () => {
|
|
741
|
+
const payload = {
|
|
742
|
+
initiatives: [{ id: 'initiative-1', title: 'Launch', description: null, createdAt: '2026-04-22T00:00:00.000Z' }],
|
|
743
|
+
workstreams: [{ id: 'workstream-1', title: 'Billing', description: null, createdAt: '2026-04-22T00:00:00.000Z' }],
|
|
744
|
+
workstreamTaskSummaries: [{
|
|
745
|
+
workstreamId: 'workstream-1',
|
|
746
|
+
taskCount: 2,
|
|
747
|
+
completedTaskCount: 1,
|
|
748
|
+
tasks: [
|
|
749
|
+
{ id: 'task-1', title: 'Draft copy', referenceNumber: 12, status: 'task' },
|
|
750
|
+
{ id: 'task-2', title: 'Review copy', referenceNumber: 13, status: 'done' }
|
|
751
|
+
]
|
|
752
|
+
}]
|
|
753
|
+
};
|
|
754
|
+
core.listPlanningBootstrap.mockReturnValue(payload);
|
|
755
|
+
const match = matchRoute('GET', '/api/taskforce/planning/bootstrap', routes);
|
|
756
|
+
const req = createMockReq('GET', '/api/taskforce/planning/bootstrap');
|
|
757
|
+
const res = createMockRes();
|
|
758
|
+
await match?.handler(req, res, {});
|
|
759
|
+
expect(core.listPlanningBootstrap).toHaveBeenCalledWith('default', expect.any(Object));
|
|
760
|
+
expect(res.setHeader).toHaveBeenCalledWith('Server-Timing', expect.stringContaining('planning-bootstrap'));
|
|
761
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
762
|
+
expect(res.end).toHaveBeenCalledWith(JSON.stringify(payload));
|
|
763
|
+
});
|
|
698
764
|
it('GET /mcp should return MCP JSON error instead of falling through when unauthenticated in cloud runtime', async () => {
|
|
699
765
|
const match = matchRoute('GET', '/mcp', routes);
|
|
700
766
|
const req = createMockReq('GET', '/mcp', undefined, {
|
|
@@ -1914,6 +1980,20 @@ describe('Server Routes', () => {
|
|
|
1914
1980
|
});
|
|
1915
1981
|
});
|
|
1916
1982
|
it('POST /api/taskforce/settings/mcp/test-connection should validate the token against workspace and scopes', async () => {
|
|
1983
|
+
const previousCloudEnvironment = process.env.TASKFORCE_CLOUD_ENVIRONMENT;
|
|
1984
|
+
process.env.TASKFORCE_CLOUD_ENVIRONMENT = 'performance';
|
|
1985
|
+
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
|
|
1986
|
+
jsonrpc: '2.0',
|
|
1987
|
+
id: 'mcp-connection-test',
|
|
1988
|
+
result: {
|
|
1989
|
+
protocolVersion: '2025-03-26',
|
|
1990
|
+
serverInfo: { name: 'taskforce-cloud' }
|
|
1991
|
+
}
|
|
1992
|
+
}), {
|
|
1993
|
+
status: 200,
|
|
1994
|
+
headers: { 'Content-Type': 'application/json' }
|
|
1995
|
+
}));
|
|
1996
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
1917
1997
|
core.inspectMcpAccessToken.mockReturnValue({
|
|
1918
1998
|
tokenId: 'mcp-token-1',
|
|
1919
1999
|
tokenPrefix: 'tfmcp_abc123',
|
|
@@ -1940,12 +2020,136 @@ describe('Server Routes', () => {
|
|
|
1940
2020
|
});
|
|
1941
2021
|
const res = createMockRes();
|
|
1942
2022
|
res.req = req;
|
|
1943
|
-
|
|
2023
|
+
try {
|
|
2024
|
+
await match?.handler(req, res, {});
|
|
2025
|
+
}
|
|
2026
|
+
finally {
|
|
2027
|
+
vi.unstubAllGlobals();
|
|
2028
|
+
if (previousCloudEnvironment === undefined)
|
|
2029
|
+
delete process.env.TASKFORCE_CLOUD_ENVIRONMENT;
|
|
2030
|
+
else
|
|
2031
|
+
process.env.TASKFORCE_CLOUD_ENVIRONMENT = previousCloudEnvironment;
|
|
2032
|
+
}
|
|
1944
2033
|
expect(core.inspectMcpAccessToken).toHaveBeenCalledWith('tfmcp_abc123_secret');
|
|
2034
|
+
expect(fetchMock).toHaveBeenCalledWith('https://performance-mcp.taskforcehq.ai/mcp', expect.objectContaining({
|
|
2035
|
+
method: 'POST',
|
|
2036
|
+
headers: expect.objectContaining({
|
|
2037
|
+
Authorization: 'Bearer tfmcp_abc123_secret'
|
|
2038
|
+
})
|
|
2039
|
+
}));
|
|
1945
2040
|
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
1946
2041
|
expect(payload.success).toBe(true);
|
|
1947
2042
|
expect(payload.result.workspaceName).toBe('Workspace One');
|
|
1948
2043
|
expect(payload.result.scopes).toEqual(['tasks:read', 'tasks:write']);
|
|
2044
|
+
expect(payload.result.endpoint).toBe('https://performance-mcp.taskforcehq.ai/mcp');
|
|
2045
|
+
expect(payload.result.serverName).toBe('taskforce-cloud');
|
|
2046
|
+
expect(payload.result.protocolVersion).toBe('2025-03-26');
|
|
2047
|
+
});
|
|
2048
|
+
it('POST /api/taskforce/settings/mcp/test-connection should return a clear error when the dedicated MCP host returns a gateway failure', async () => {
|
|
2049
|
+
const previousCloudEnvironment = process.env.TASKFORCE_CLOUD_ENVIRONMENT;
|
|
2050
|
+
process.env.TASKFORCE_CLOUD_ENVIRONMENT = 'performance';
|
|
2051
|
+
const fetchMock = vi.fn(async () => new Response('Bad gateway', {
|
|
2052
|
+
status: 502,
|
|
2053
|
+
headers: { 'Content-Type': 'text/plain' }
|
|
2054
|
+
}));
|
|
2055
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
2056
|
+
core.inspectMcpAccessToken.mockReturnValue({
|
|
2057
|
+
tokenId: 'mcp-token-1',
|
|
2058
|
+
tokenPrefix: 'tfmcp_abc123',
|
|
2059
|
+
workspaceId: 'workspace-1',
|
|
2060
|
+
userId: 'user-1',
|
|
2061
|
+
name: 'Workspace MCP',
|
|
2062
|
+
scopes: ['tasks:read', 'tasks:write'],
|
|
2063
|
+
status: 'active'
|
|
2064
|
+
});
|
|
2065
|
+
const match = matchRoute('POST', '/api/taskforce/settings/mcp/test-connection', routes);
|
|
2066
|
+
const req = createMockReq('POST', '/api/taskforce/settings/mcp/test-connection', {
|
|
2067
|
+
token: 'tfmcp_abc123_secret',
|
|
2068
|
+
requiredScopes: ['tasks:read', 'tasks:write']
|
|
2069
|
+
}, {
|
|
2070
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
2071
|
+
'x-taskforce-user-id': 'user-1',
|
|
2072
|
+
'x-taskforce-workspace-id': 'workspace-1'
|
|
2073
|
+
});
|
|
2074
|
+
const res = createMockRes();
|
|
2075
|
+
res.req = req;
|
|
2076
|
+
try {
|
|
2077
|
+
await match?.handler(req, res, {});
|
|
2078
|
+
}
|
|
2079
|
+
finally {
|
|
2080
|
+
vi.unstubAllGlobals();
|
|
2081
|
+
if (previousCloudEnvironment === undefined)
|
|
2082
|
+
delete process.env.TASKFORCE_CLOUD_ENVIRONMENT;
|
|
2083
|
+
else
|
|
2084
|
+
process.env.TASKFORCE_CLOUD_ENVIRONMENT = previousCloudEnvironment;
|
|
2085
|
+
}
|
|
2086
|
+
expect(res.writeHead).toHaveBeenCalledWith(502, { 'Content-Type': 'application/json' });
|
|
2087
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
2088
|
+
expect(payload.success).toBe(false);
|
|
2089
|
+
expect(payload.code).toBe('MCP_ENDPOINT_GATEWAY_ERROR');
|
|
2090
|
+
expect(payload.error).toContain('https://performance-mcp.taskforcehq.ai/mcp');
|
|
2091
|
+
expect(payload.details).toEqual(expect.objectContaining({
|
|
2092
|
+
endpoint: 'https://performance-mcp.taskforcehq.ai/mcp',
|
|
2093
|
+
upstreamStatus: 502
|
|
2094
|
+
}));
|
|
2095
|
+
});
|
|
2096
|
+
it('POST /api/taskforce/settings/mcp/test-connection should prefer the dedicated MCP hostname when cloudMcpBaseUrl points at the app host', async () => {
|
|
2097
|
+
const previousCloudEnvironment = process.env.TASKFORCE_CLOUD_ENVIRONMENT;
|
|
2098
|
+
const previousCloudMcpBaseUrl = process.env.VITE_TASKFORCE_CLOUD_MCP_BASE_URL;
|
|
2099
|
+
process.env.TASKFORCE_CLOUD_ENVIRONMENT = 'performance';
|
|
2100
|
+
process.env.VITE_TASKFORCE_CLOUD_MCP_BASE_URL = 'https://performance-app.taskforcehq.ai';
|
|
2101
|
+
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
|
|
2102
|
+
jsonrpc: '2.0',
|
|
2103
|
+
id: 'mcp-connection-test',
|
|
2104
|
+
result: {
|
|
2105
|
+
serverInfo: { name: 'taskforce-cloud' }
|
|
2106
|
+
}
|
|
2107
|
+
}), {
|
|
2108
|
+
status: 200,
|
|
2109
|
+
headers: { 'Content-Type': 'application/json' }
|
|
2110
|
+
}));
|
|
2111
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
2112
|
+
core.inspectMcpAccessToken.mockReturnValue({
|
|
2113
|
+
tokenId: 'mcp-token-1',
|
|
2114
|
+
tokenPrefix: 'tfmcp_abc123',
|
|
2115
|
+
workspaceId: 'workspace-1',
|
|
2116
|
+
userId: 'user-1',
|
|
2117
|
+
name: 'Workspace MCP',
|
|
2118
|
+
scopes: ['tasks:read', 'tasks:write'],
|
|
2119
|
+
status: 'active'
|
|
2120
|
+
});
|
|
2121
|
+
core.getWorkspaceProfile.mockReturnValue({
|
|
2122
|
+
id: 'workspace-1',
|
|
2123
|
+
slug: 'workspace-1',
|
|
2124
|
+
name: 'Workspace One',
|
|
2125
|
+
description: null
|
|
2126
|
+
});
|
|
2127
|
+
const match = matchRoute('POST', '/api/taskforce/settings/mcp/test-connection', routes);
|
|
2128
|
+
const req = createMockReq('POST', '/api/taskforce/settings/mcp/test-connection', {
|
|
2129
|
+
token: 'tfmcp_abc123_secret',
|
|
2130
|
+
requiredScopes: ['tasks:read']
|
|
2131
|
+
}, {
|
|
2132
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
2133
|
+
'x-taskforce-user-id': 'user-1',
|
|
2134
|
+
'x-taskforce-workspace-id': 'workspace-1'
|
|
2135
|
+
});
|
|
2136
|
+
const res = createMockRes();
|
|
2137
|
+
res.req = req;
|
|
2138
|
+
try {
|
|
2139
|
+
await match?.handler(req, res, {});
|
|
2140
|
+
}
|
|
2141
|
+
finally {
|
|
2142
|
+
vi.unstubAllGlobals();
|
|
2143
|
+
if (previousCloudEnvironment === undefined)
|
|
2144
|
+
delete process.env.TASKFORCE_CLOUD_ENVIRONMENT;
|
|
2145
|
+
else
|
|
2146
|
+
process.env.TASKFORCE_CLOUD_ENVIRONMENT = previousCloudEnvironment;
|
|
2147
|
+
if (previousCloudMcpBaseUrl === undefined)
|
|
2148
|
+
delete process.env.VITE_TASKFORCE_CLOUD_MCP_BASE_URL;
|
|
2149
|
+
else
|
|
2150
|
+
process.env.VITE_TASKFORCE_CLOUD_MCP_BASE_URL = previousCloudMcpBaseUrl;
|
|
2151
|
+
}
|
|
2152
|
+
expect(fetchMock).toHaveBeenCalledWith('https://performance-mcp.taskforcehq.ai/mcp', expect.any(Object));
|
|
1949
2153
|
});
|
|
1950
2154
|
it('POST /api/taskforce/settings/mcp/test-connection should report missing scopes', async () => {
|
|
1951
2155
|
core.inspectMcpAccessToken.mockReturnValue({
|
|
@@ -2003,6 +2207,96 @@ describe('Server Routes', () => {
|
|
|
2003
2207
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
2004
2208
|
vi.unstubAllGlobals();
|
|
2005
2209
|
});
|
|
2210
|
+
it('GET /api/taskforce/context/:filename should proxy R2 user avatars inline instead of redirecting', async () => {
|
|
2211
|
+
const fetchMock = vi.fn().mockResolvedValue(new Response('avatar-bytes', {
|
|
2212
|
+
status: 200,
|
|
2213
|
+
headers: { 'Content-Type': 'image/webp' }
|
|
2214
|
+
}));
|
|
2215
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
2216
|
+
const routesWithStorage = createRoutes(core, {
|
|
2217
|
+
objectStorage: {
|
|
2218
|
+
provider: 'r2',
|
|
2219
|
+
r2: {
|
|
2220
|
+
accountId: 'acct-1',
|
|
2221
|
+
bucket: 'taskforce-docs',
|
|
2222
|
+
endpoint: 'https://example.r2.cloudflarestorage.com',
|
|
2223
|
+
accessKeyId: 'access-key-1',
|
|
2224
|
+
secretAccessKey: 'secret-key-1',
|
|
2225
|
+
keyPrefix: 'taskforce',
|
|
2226
|
+
signedUploadTtlSeconds: 600,
|
|
2227
|
+
signedDownloadTtlSeconds: 300
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
});
|
|
2231
|
+
const encoded = encodeURIComponent('users/user-1/profile/avatar/avatar-draft-1-headshot.webp');
|
|
2232
|
+
const match = matchRoute('GET', `/api/taskforce/context/${encoded}`, routesWithStorage);
|
|
2233
|
+
const req = createMockReq('GET', `/api/taskforce/context/${encoded}`);
|
|
2234
|
+
const res = createMockRes();
|
|
2235
|
+
await match?.handler(req, res, { filename: encoded });
|
|
2236
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.objectContaining({
|
|
2237
|
+
'Content-Type': 'image/webp',
|
|
2238
|
+
'Content-Disposition': 'inline'
|
|
2239
|
+
}));
|
|
2240
|
+
expect(String(res.getHeader('location') || '')).toBe('');
|
|
2241
|
+
expect(res.__getBody()).toBe('avatar-bytes');
|
|
2242
|
+
expect(String(fetchMock.mock.calls[0][0])).toContain('https://example.r2.cloudflarestorage.com/taskforce-docs/taskforce/users/user-1/profile/avatar/avatar-draft-1-headshot.webp');
|
|
2243
|
+
vi.unstubAllGlobals();
|
|
2244
|
+
});
|
|
2245
|
+
it('GET /api/taskforce/context/:filename should proxy missing cloud-owned user avatars in local runtime', async () => {
|
|
2246
|
+
const previousCloudProxyBaseUrl = process.env.TASKFORCE_CLOUD_PROXY_BASE_URL;
|
|
2247
|
+
const previousClientId = process.env.TASKFORCE_CF_ACCESS_CLIENT_ID;
|
|
2248
|
+
const previousClientSecret = process.env.TASKFORCE_CF_ACCESS_CLIENT_SECRET;
|
|
2249
|
+
process.env.TASKFORCE_CLOUD_PROXY_BASE_URL = 'https://performance-app.taskforcehq.ai';
|
|
2250
|
+
process.env.TASKFORCE_CF_ACCESS_CLIENT_ID = 'cf-client-id';
|
|
2251
|
+
process.env.TASKFORCE_CF_ACCESS_CLIENT_SECRET = 'cf-client-secret';
|
|
2252
|
+
const fetchMock = vi.fn().mockResolvedValue(new Response('avatar-bytes', {
|
|
2253
|
+
status: 200,
|
|
2254
|
+
headers: {
|
|
2255
|
+
'Content-Type': 'image/webp',
|
|
2256
|
+
'Cache-Control': 'private, max-age=60'
|
|
2257
|
+
}
|
|
2258
|
+
}));
|
|
2259
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
2260
|
+
try {
|
|
2261
|
+
const relativePath = 'users/user-1/profile/avatar/avatar-draft-1-headshot.webp';
|
|
2262
|
+
const encoded = encodeURIComponent(relativePath);
|
|
2263
|
+
const match = matchRoute('GET', `/api/taskforce/context/${encoded}`, routes);
|
|
2264
|
+
const req = createMockReq('GET', `/api/taskforce/context/${encoded}`, undefined, {
|
|
2265
|
+
host: 'localhost:5174',
|
|
2266
|
+
cookie: 'taskforce_session=session-1',
|
|
2267
|
+
accept: 'image/webp'
|
|
2268
|
+
});
|
|
2269
|
+
const res = createMockRes();
|
|
2270
|
+
await match?.handler(req, res, { filename: encoded });
|
|
2271
|
+
expect(fetchMock).toHaveBeenCalledWith(`https://performance-app.taskforcehq.ai/api/taskforce/context/${encoded}`, expect.objectContaining({
|
|
2272
|
+
method: 'GET',
|
|
2273
|
+
headers: expect.any(Headers)
|
|
2274
|
+
}));
|
|
2275
|
+
const proxiedHeaders = fetchMock.mock.calls[0][1].headers;
|
|
2276
|
+
expect(proxiedHeaders.get('cookie')).toBe('taskforce_session=session-1');
|
|
2277
|
+
expect(proxiedHeaders.get('CF-Access-Client-Id')).toBe('cf-client-id');
|
|
2278
|
+
expect(proxiedHeaders.get('CF-Access-Client-Secret')).toBe('cf-client-secret');
|
|
2279
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.objectContaining({
|
|
2280
|
+
'content-type': 'image/webp'
|
|
2281
|
+
}));
|
|
2282
|
+
expect(res.__getBody()).toBe('avatar-bytes');
|
|
2283
|
+
}
|
|
2284
|
+
finally {
|
|
2285
|
+
vi.unstubAllGlobals();
|
|
2286
|
+
if (previousCloudProxyBaseUrl === undefined)
|
|
2287
|
+
delete process.env.TASKFORCE_CLOUD_PROXY_BASE_URL;
|
|
2288
|
+
else
|
|
2289
|
+
process.env.TASKFORCE_CLOUD_PROXY_BASE_URL = previousCloudProxyBaseUrl;
|
|
2290
|
+
if (previousClientId === undefined)
|
|
2291
|
+
delete process.env.TASKFORCE_CF_ACCESS_CLIENT_ID;
|
|
2292
|
+
else
|
|
2293
|
+
process.env.TASKFORCE_CF_ACCESS_CLIENT_ID = previousClientId;
|
|
2294
|
+
if (previousClientSecret === undefined)
|
|
2295
|
+
delete process.env.TASKFORCE_CF_ACCESS_CLIENT_SECRET;
|
|
2296
|
+
else
|
|
2297
|
+
process.env.TASKFORCE_CF_ACCESS_CLIENT_SECRET = previousClientSecret;
|
|
2298
|
+
}
|
|
2299
|
+
});
|
|
2006
2300
|
it('GET /api/taskforce/context/:filename should proxy inline R2 markdown documents instead of redirecting', async () => {
|
|
2007
2301
|
const fetchMock = vi.fn().mockResolvedValue({
|
|
2008
2302
|
status: 200,
|
|
@@ -2230,6 +2524,63 @@ describe('Server Routes', () => {
|
|
|
2230
2524
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
2231
2525
|
vi.unstubAllGlobals();
|
|
2232
2526
|
});
|
|
2527
|
+
it('GET /api/taskforce/documents/:assetId/content should fall back to object storage when local canonical metadata points at a missing file', async () => {
|
|
2528
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
2529
|
+
status: 200,
|
|
2530
|
+
ok: true,
|
|
2531
|
+
headers: new Headers({ 'content-type': 'text/markdown' }),
|
|
2532
|
+
arrayBuffer: async () => Buffer.from('# Recovered canonical doc\n').buffer,
|
|
2533
|
+
});
|
|
2534
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
2535
|
+
const routesWithStorage = createRoutes(core, {
|
|
2536
|
+
objectStorage: {
|
|
2537
|
+
provider: 'r2',
|
|
2538
|
+
r2: {
|
|
2539
|
+
accountId: 'acct-1',
|
|
2540
|
+
bucket: 'taskforce-docs',
|
|
2541
|
+
endpoint: 'https://example.r2.cloudflarestorage.com',
|
|
2542
|
+
accessKeyId: 'access-key-1',
|
|
2543
|
+
secretAccessKey: 'secret-key-1',
|
|
2544
|
+
keyPrefix: 'taskforce',
|
|
2545
|
+
signedUploadTtlSeconds: 600,
|
|
2546
|
+
signedDownloadTtlSeconds: 300
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
});
|
|
2550
|
+
const store = new WorkspaceAssetStore(core.getDatabaseAdapter(), core.getTenantId());
|
|
2551
|
+
store.upsertAsset({
|
|
2552
|
+
assetId: 'asset-doc-content-fallback-1',
|
|
2553
|
+
workspaceId: 'default',
|
|
2554
|
+
kind: 'document',
|
|
2555
|
+
logicalName: 'Recovered canonical doc',
|
|
2556
|
+
originalFilename: 'recovered-canonical-doc.md',
|
|
2557
|
+
mimeType: 'text/markdown',
|
|
2558
|
+
storageProvider: 'local',
|
|
2559
|
+
storageKey: 'workspaces/default/assets/documents/asset-doc-content-fallback-1-recovered-canonical-doc.md',
|
|
2560
|
+
contentSha256: 'sha-canonical-doc-fallback-1',
|
|
2561
|
+
sizeBytes: 25,
|
|
2562
|
+
documentId: 'doc-content-fallback-1',
|
|
2563
|
+
version: 1,
|
|
2564
|
+
isLatest: true,
|
|
2565
|
+
scanStatus: 'clean',
|
|
2566
|
+
scanEngine: null,
|
|
2567
|
+
scanDetails: null,
|
|
2568
|
+
deletedAt: null,
|
|
2569
|
+
purgeAfter: null
|
|
2570
|
+
});
|
|
2571
|
+
const req = createMockReq('GET', '/api/taskforce/documents/asset-doc-content-fallback-1/content');
|
|
2572
|
+
const res = createMockRes();
|
|
2573
|
+
await matchRoute('GET', '/api/taskforce/documents/asset-doc-content-fallback-1/content', routesWithStorage)?.handler(req, res, {
|
|
2574
|
+
assetId: 'asset-doc-content-fallback-1'
|
|
2575
|
+
});
|
|
2576
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.objectContaining({
|
|
2577
|
+
'Content-Type': 'text/markdown',
|
|
2578
|
+
'Content-Disposition': 'inline'
|
|
2579
|
+
}));
|
|
2580
|
+
expect(res.end).toHaveBeenCalledWith(expect.any(Buffer));
|
|
2581
|
+
expect(fetchMock).toHaveBeenCalled();
|
|
2582
|
+
vi.unstubAllGlobals();
|
|
2583
|
+
});
|
|
2233
2584
|
it('GET /api/taskforce/documents/:assetId/content should reject non-document assets', async () => {
|
|
2234
2585
|
const routesWithStorage = createRoutes(core, {
|
|
2235
2586
|
objectStorage: {
|
|
@@ -3236,6 +3587,53 @@ describe('Server Routes', () => {
|
|
|
3236
3587
|
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
3237
3588
|
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
3238
3589
|
});
|
|
3590
|
+
it('GET /api/taskforce/documents should exclude raw .taskforce/docs markdown files from the document manager', async () => {
|
|
3591
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-exclude-raw-docs-'));
|
|
3592
|
+
const basePath = path.join(tmpRoot, '.taskforce');
|
|
3593
|
+
fs.mkdirSync(path.join(basePath, 'docs'), { recursive: true });
|
|
3594
|
+
fs.writeFileSync(path.join(basePath, 'docs', 'legacy-plan.md'), '# legacy\n', 'utf8');
|
|
3595
|
+
core.getPaths.mockReturnValue({
|
|
3596
|
+
projectRoot: tmpRoot,
|
|
3597
|
+
basePath,
|
|
3598
|
+
});
|
|
3599
|
+
const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
|
|
3600
|
+
core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
|
|
3601
|
+
core.getTenantId = vi.fn().mockReturnValue('default');
|
|
3602
|
+
store.upsertAsset({
|
|
3603
|
+
assetId: 'asset-doc-local-2',
|
|
3604
|
+
workspaceId: 'default',
|
|
3605
|
+
kind: 'document',
|
|
3606
|
+
originalFilename: 'managed-plan.md',
|
|
3607
|
+
mimeType: 'text/markdown',
|
|
3608
|
+
storageProvider: 'local',
|
|
3609
|
+
storageKey: 'workspaces/default/assets/documents/asset-doc-local-2-managed-plan.md',
|
|
3610
|
+
contentSha256: 'sha-doc-local-2',
|
|
3611
|
+
sizeBytes: 99,
|
|
3612
|
+
documentId: 'doc-local-2',
|
|
3613
|
+
version: 1,
|
|
3614
|
+
updatedAt: '2026-04-19T12:00:00.000Z'
|
|
3615
|
+
});
|
|
3616
|
+
const routesWithDb = createRoutes(core);
|
|
3617
|
+
const match = matchRoute('GET', '/api/taskforce/documents', routesWithDb);
|
|
3618
|
+
const req = createMockReq('GET', '/api/taskforce/documents', undefined, {
|
|
3619
|
+
'x-taskforce-workspace-id': 'default',
|
|
3620
|
+
'x-taskforce-runtime-mode': 'local'
|
|
3621
|
+
});
|
|
3622
|
+
const res = createMockRes();
|
|
3623
|
+
await match?.handler(req, res, {});
|
|
3624
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
3625
|
+
expect(Array.isArray(payload.documents)).toBe(true);
|
|
3626
|
+
expect(payload.documents).toHaveLength(1);
|
|
3627
|
+
expect(payload.documents[0]).toEqual(expect.objectContaining({
|
|
3628
|
+
id: 'asset-doc-local-2',
|
|
3629
|
+
assetId: 'asset-doc-local-2',
|
|
3630
|
+
fsPath: 'workspaces/default/assets/documents/asset-doc-local-2-managed-plan.md',
|
|
3631
|
+
attachmentState: 'unattached'
|
|
3632
|
+
}));
|
|
3633
|
+
expect(payload.documents.some((doc) => String(doc.fsPath || '').includes('docs/legacy-plan.md'))).toBe(false);
|
|
3634
|
+
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
3635
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
3636
|
+
});
|
|
3239
3637
|
it('GET /api/taskforce/documents should mark canonical docs without links as unattached', async () => {
|
|
3240
3638
|
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-unattached-local-'));
|
|
3241
3639
|
core.getPaths.mockReturnValue({
|
|
@@ -3332,8 +3730,8 @@ describe('Server Routes', () => {
|
|
|
3332
3730
|
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
3333
3731
|
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
3334
3732
|
});
|
|
3335
|
-
it('GET /api/taskforce/documents should
|
|
3336
|
-
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-
|
|
3733
|
+
it('GET /api/taskforce/documents should count non-task links in attachment metadata while only surfacing task titles for task links', async () => {
|
|
3734
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-generic-links-'));
|
|
3337
3735
|
core.getPaths.mockReturnValue({
|
|
3338
3736
|
projectRoot: tmpRoot,
|
|
3339
3737
|
basePath: path.join(tmpRoot, '.taskforce'),
|
|
@@ -3342,25 +3740,130 @@ describe('Server Routes', () => {
|
|
|
3342
3740
|
core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
|
|
3343
3741
|
core.getTenantId = vi.fn().mockReturnValue('default');
|
|
3344
3742
|
store.upsertAsset({
|
|
3345
|
-
assetId: 'asset-doc-
|
|
3346
|
-
workspaceId: '
|
|
3743
|
+
assetId: 'asset-doc-generic-link-1',
|
|
3744
|
+
workspaceId: 'default',
|
|
3347
3745
|
kind: 'document',
|
|
3348
|
-
|
|
3349
|
-
originalFilename: 'asset-doc-sparse-1-implementation-plan-1773922162143.md',
|
|
3746
|
+
originalFilename: 'shared-notes.md',
|
|
3350
3747
|
mimeType: 'text/markdown',
|
|
3351
|
-
storageProvider: '
|
|
3352
|
-
storageKey: 'workspaces/
|
|
3353
|
-
contentSha256: 'sha-doc-
|
|
3354
|
-
sizeBytes:
|
|
3355
|
-
documentId: 'doc-
|
|
3748
|
+
storageProvider: 'local',
|
|
3749
|
+
storageKey: 'workspaces/default/assets/documents/asset-doc-generic-link-1-shared-notes.md',
|
|
3750
|
+
contentSha256: 'sha-doc-generic-link-1',
|
|
3751
|
+
sizeBytes: 64,
|
|
3752
|
+
documentId: 'doc-generic-link-1',
|
|
3356
3753
|
version: 1,
|
|
3357
|
-
updatedAt: '2026-03-
|
|
3754
|
+
updatedAt: '2026-03-18T13:30:00.000Z'
|
|
3358
3755
|
});
|
|
3359
3756
|
store.upsertLink({
|
|
3360
|
-
workspaceId: '
|
|
3361
|
-
assetId: 'asset-doc-
|
|
3362
|
-
|
|
3363
|
-
|
|
3757
|
+
workspaceId: 'default',
|
|
3758
|
+
assetId: 'asset-doc-generic-link-1',
|
|
3759
|
+
targetType: 'initiative',
|
|
3760
|
+
targetId: 'initiative-1',
|
|
3761
|
+
role: 'attachment'
|
|
3762
|
+
});
|
|
3763
|
+
const routesWithDb = createRoutes(core);
|
|
3764
|
+
const match = matchRoute('GET', '/api/taskforce/documents', routesWithDb);
|
|
3765
|
+
const req = createMockReq('GET', '/api/taskforce/documents', undefined, {
|
|
3766
|
+
'x-taskforce-workspace-id': 'default',
|
|
3767
|
+
'x-taskforce-runtime-mode': 'local'
|
|
3768
|
+
});
|
|
3769
|
+
const res = createMockRes();
|
|
3770
|
+
await match?.handler(req, res, {});
|
|
3771
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
3772
|
+
expect(payload.documents).toEqual([
|
|
3773
|
+
expect.objectContaining({
|
|
3774
|
+
id: 'asset-doc-generic-link-1',
|
|
3775
|
+
assetId: 'asset-doc-generic-link-1',
|
|
3776
|
+
attachmentCount: 1,
|
|
3777
|
+
attachmentState: 'attached',
|
|
3778
|
+
taskId: null
|
|
3779
|
+
})
|
|
3780
|
+
]);
|
|
3781
|
+
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
3782
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
3783
|
+
});
|
|
3784
|
+
it('POST /api/taskforce/documents/:assetId/attach should attach a canonical document to a task target', async () => {
|
|
3785
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-attach-task-'));
|
|
3786
|
+
core.getPaths.mockReturnValue({
|
|
3787
|
+
projectRoot: tmpRoot,
|
|
3788
|
+
basePath: path.join(tmpRoot, '.taskforce'),
|
|
3789
|
+
});
|
|
3790
|
+
const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
|
|
3791
|
+
core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
|
|
3792
|
+
core.getTenantId = vi.fn().mockReturnValue('default');
|
|
3793
|
+
store.upsertAsset({
|
|
3794
|
+
assetId: 'asset-doc-attach-route-1',
|
|
3795
|
+
workspaceId: 'default',
|
|
3796
|
+
kind: 'document',
|
|
3797
|
+
logicalName: 'Attach Me',
|
|
3798
|
+
originalFilename: 'attach-me.md',
|
|
3799
|
+
mimeType: 'text/markdown',
|
|
3800
|
+
storageProvider: 'local',
|
|
3801
|
+
storageKey: 'workspaces/default/assets/documents/asset-doc-attach-route-1-attach-me.md',
|
|
3802
|
+
contentSha256: 'sha-doc-attach-route-1',
|
|
3803
|
+
sizeBytes: 28,
|
|
3804
|
+
documentId: 'doc-attach-route-1',
|
|
3805
|
+
version: 1,
|
|
3806
|
+
updatedAt: '2026-03-18T13:30:00.000Z'
|
|
3807
|
+
});
|
|
3808
|
+
core.getTask.mockImplementation((id) => id === 'task-attach-1'
|
|
3809
|
+
? { id: 'task-attach-1', title: 'Attach target', attachments: [] }
|
|
3810
|
+
: null);
|
|
3811
|
+
const routesWithDb = createRoutes(core);
|
|
3812
|
+
const match = matchRoute('POST', '/api/taskforce/documents/asset-doc-attach-route-1/attach', routesWithDb);
|
|
3813
|
+
const req = createMockReq('POST', '/api/taskforce/documents/asset-doc-attach-route-1/attach', {
|
|
3814
|
+
targetType: 'task',
|
|
3815
|
+
targetId: 'task-attach-1',
|
|
3816
|
+
}, {
|
|
3817
|
+
'x-taskforce-workspace-id': 'default'
|
|
3818
|
+
});
|
|
3819
|
+
const res = createMockRes();
|
|
3820
|
+
await match?.handler(req, res, { assetId: 'asset-doc-attach-route-1' });
|
|
3821
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, { 'Content-Type': 'application/json' });
|
|
3822
|
+
expect(core.updateTask).toHaveBeenCalledWith('task-attach-1', expect.objectContaining({
|
|
3823
|
+
attachments: [
|
|
3824
|
+
expect.objectContaining({
|
|
3825
|
+
assetId: 'asset-doc-attach-route-1',
|
|
3826
|
+
taskId: 'task-attach-1',
|
|
3827
|
+
path: '/api/taskforce/documents/asset-doc-attach-route-1/content',
|
|
3828
|
+
})
|
|
3829
|
+
]
|
|
3830
|
+
}), 'default', expect.objectContaining({
|
|
3831
|
+
actorRef: 'user',
|
|
3832
|
+
saveSource: 'manual',
|
|
3833
|
+
}));
|
|
3834
|
+
expect(core.notifyDocumentMetadataMutation).toHaveBeenCalledWith('default', ['workspaces/default/assets/documents/asset-doc-attach-route-1-attach-me.md'], 'upsert');
|
|
3835
|
+
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
3836
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
3837
|
+
});
|
|
3838
|
+
it('GET /api/taskforce/documents should normalize readable names and attachment metadata for sparse canonical assets', async () => {
|
|
3839
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-normalized-cloud-'));
|
|
3840
|
+
core.getPaths.mockReturnValue({
|
|
3841
|
+
projectRoot: tmpRoot,
|
|
3842
|
+
basePath: path.join(tmpRoot, '.taskforce'),
|
|
3843
|
+
});
|
|
3844
|
+
const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
|
|
3845
|
+
core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
|
|
3846
|
+
core.getTenantId = vi.fn().mockReturnValue('default');
|
|
3847
|
+
store.upsertAsset({
|
|
3848
|
+
assetId: 'asset-doc-sparse-1',
|
|
3849
|
+
workspaceId: 'workspace-1',
|
|
3850
|
+
kind: 'document',
|
|
3851
|
+
logicalName: 'asset-doc-sparse-1',
|
|
3852
|
+
originalFilename: 'asset-doc-sparse-1-implementation-plan-1773922162143.md',
|
|
3853
|
+
mimeType: 'text/markdown',
|
|
3854
|
+
storageProvider: 'r2',
|
|
3855
|
+
storageKey: 'workspaces/workspace-1/assets/documents/asset-doc-sparse-1-implementation-plan-1773922162143.md',
|
|
3856
|
+
contentSha256: 'sha-doc-sparse-1',
|
|
3857
|
+
sizeBytes: 222,
|
|
3858
|
+
documentId: 'doc-sparse-1',
|
|
3859
|
+
version: 1,
|
|
3860
|
+
updatedAt: '2026-03-18T15:00:00.000Z'
|
|
3861
|
+
});
|
|
3862
|
+
store.upsertLink({
|
|
3863
|
+
workspaceId: 'workspace-1',
|
|
3864
|
+
assetId: 'asset-doc-sparse-1',
|
|
3865
|
+
taskId: 'task-sparse-1',
|
|
3866
|
+
role: 'attachment',
|
|
3364
3867
|
displayName: 'AI Profiles - Implementation Plan v2'
|
|
3365
3868
|
});
|
|
3366
3869
|
core.getTask.mockImplementation((id) => id === 'task-sparse-1'
|
|
@@ -3485,10 +3988,106 @@ describe('Server Routes', () => {
|
|
|
3485
3988
|
const res = createMockRes();
|
|
3486
3989
|
await match?.handler(req, res, { assetId: 'asset-doc-attached-1' });
|
|
3487
3990
|
expect(res.writeHead).toHaveBeenCalledWith(409, { 'Content-Type': 'application/json' });
|
|
3991
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
3992
|
+
expect(payload).toEqual(expect.objectContaining({
|
|
3993
|
+
error: 'Document is still attached to one or more tasks.',
|
|
3994
|
+
activeAttachmentCount: 1,
|
|
3995
|
+
requiresForceDetach: true
|
|
3996
|
+
}));
|
|
3488
3997
|
expect(store.get('asset-doc-attached-1', 'default')?.deletedAt).toBeNull();
|
|
3489
3998
|
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
3490
3999
|
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
3491
4000
|
});
|
|
4001
|
+
it('DELETE /api/taskforce/documents/:assetId should unlink attached canonical documents when forceDetach is true', async () => {
|
|
4002
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-delete-force-attached-'));
|
|
4003
|
+
const basePath = path.join(tmpRoot, '.taskforce');
|
|
4004
|
+
const documentPath = path.join(basePath, 'workspaces', 'default', 'assets', 'documents', 'asset-doc-force-delete-1-notes.md');
|
|
4005
|
+
fs.mkdirSync(path.dirname(documentPath), { recursive: true });
|
|
4006
|
+
fs.writeFileSync(documentPath, '# attached\n', 'utf8');
|
|
4007
|
+
core.getPaths.mockReturnValue({
|
|
4008
|
+
projectRoot: tmpRoot,
|
|
4009
|
+
basePath,
|
|
4010
|
+
});
|
|
4011
|
+
const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
|
|
4012
|
+
core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
|
|
4013
|
+
core.getTenantId = vi.fn().mockReturnValue('default');
|
|
4014
|
+
store.upsertAsset({
|
|
4015
|
+
assetId: 'asset-doc-force-delete-1',
|
|
4016
|
+
workspaceId: 'default',
|
|
4017
|
+
kind: 'document',
|
|
4018
|
+
originalFilename: 'notes.md',
|
|
4019
|
+
mimeType: 'text/markdown',
|
|
4020
|
+
storageProvider: 'local',
|
|
4021
|
+
storageKey: 'workspaces/default/assets/documents/asset-doc-force-delete-1-notes.md',
|
|
4022
|
+
contentSha256: 'sha-doc-force-delete-1',
|
|
4023
|
+
sizeBytes: 16,
|
|
4024
|
+
documentId: 'doc-force-delete-1',
|
|
4025
|
+
version: 1,
|
|
4026
|
+
updatedAt: '2026-03-18T14:00:00.000Z'
|
|
4027
|
+
});
|
|
4028
|
+
store.upsertLink({
|
|
4029
|
+
workspaceId: 'default',
|
|
4030
|
+
assetId: 'asset-doc-force-delete-1',
|
|
4031
|
+
taskId: 'task-keep',
|
|
4032
|
+
role: 'attachment'
|
|
4033
|
+
});
|
|
4034
|
+
core.getTask.mockImplementation((id) => id === 'task-keep'
|
|
4035
|
+
? {
|
|
4036
|
+
id: 'task-keep',
|
|
4037
|
+
title: 'Task Keep',
|
|
4038
|
+
attachments: [{
|
|
4039
|
+
path: '/api/taskforce/documents/asset-doc-force-delete-1/content',
|
|
4040
|
+
fsPath: 'workspaces/default/assets/documents/asset-doc-force-delete-1-notes.md',
|
|
4041
|
+
caption: 'notes.md',
|
|
4042
|
+
assetId: 'asset-doc-force-delete-1'
|
|
4043
|
+
}, {
|
|
4044
|
+
path: '/api/taskforce/context/other-file.md',
|
|
4045
|
+
fsPath: 'workspaces/default/assets/files/other-file.md',
|
|
4046
|
+
caption: 'other-file.md'
|
|
4047
|
+
}]
|
|
4048
|
+
}
|
|
4049
|
+
: null);
|
|
4050
|
+
core.updateTask.mockImplementation((_id, patch) => ({
|
|
4051
|
+
id: 'task-keep',
|
|
4052
|
+
attachments: patch.attachments
|
|
4053
|
+
}));
|
|
4054
|
+
const routesWithDb = createRoutes(core);
|
|
4055
|
+
const match = matchRoute('DELETE', '/api/taskforce/documents/asset-doc-force-delete-1', routesWithDb);
|
|
4056
|
+
const req = createMockReq('DELETE', '/api/taskforce/documents/asset-doc-force-delete-1', {
|
|
4057
|
+
forceDetach: true
|
|
4058
|
+
}, {
|
|
4059
|
+
'x-taskforce-workspace-id': 'default'
|
|
4060
|
+
});
|
|
4061
|
+
const res = createMockRes();
|
|
4062
|
+
await match?.handler(req, res, { assetId: 'asset-doc-force-delete-1' });
|
|
4063
|
+
expect(core.updateTask).toHaveBeenCalledWith('task-keep', {
|
|
4064
|
+
attachments: [{
|
|
4065
|
+
path: '/api/taskforce/context/other-file.md',
|
|
4066
|
+
fsPath: 'workspaces/default/assets/files/other-file.md',
|
|
4067
|
+
caption: 'other-file.md'
|
|
4068
|
+
}]
|
|
4069
|
+
}, 'default', expect.objectContaining({ actorRef: 'user', saveSource: 'manual' }));
|
|
4070
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, { 'Content-Type': 'application/json' });
|
|
4071
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
4072
|
+
expect(payload).toEqual(expect.objectContaining({
|
|
4073
|
+
success: true,
|
|
4074
|
+
softDeleted: true,
|
|
4075
|
+
detachedTaskCount: 1
|
|
4076
|
+
}));
|
|
4077
|
+
expect(store.get('asset-doc-force-delete-1', 'default')).toEqual(expect.objectContaining({
|
|
4078
|
+
deletedAt: expect.any(String),
|
|
4079
|
+
purgeAfter: expect.any(String)
|
|
4080
|
+
}));
|
|
4081
|
+
expect(store.listLinksForAsset('asset-doc-force-delete-1', 'default', { includeDeleted: true })).toEqual([
|
|
4082
|
+
expect.objectContaining({
|
|
4083
|
+
taskId: 'task-keep',
|
|
4084
|
+
role: 'attachment',
|
|
4085
|
+
deletedAt: expect.any(String)
|
|
4086
|
+
})
|
|
4087
|
+
]);
|
|
4088
|
+
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
4089
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
4090
|
+
});
|
|
3492
4091
|
it('PATCH /api/taskforce/documents/:assetId should update the shared display name without changing storage identity', async () => {
|
|
3493
4092
|
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-rename-'));
|
|
3494
4093
|
core.getPaths.mockReturnValue({
|
|
@@ -3726,7 +4325,85 @@ describe('Server Routes', () => {
|
|
|
3726
4325
|
}));
|
|
3727
4326
|
expect(res.setHeader).not.toHaveBeenCalledWith('Set-Cookie', expect.any(String));
|
|
3728
4327
|
expect(res.writeHead).toHaveBeenCalledWith(201, expect.any(Object));
|
|
3729
|
-
|
|
4328
|
+
const payload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
|
|
4329
|
+
expect(payload).toEqual(expect.objectContaining({
|
|
4330
|
+
success: true,
|
|
4331
|
+
verificationRequired: true
|
|
4332
|
+
}));
|
|
4333
|
+
expect(payload.verificationToken).toBeUndefined();
|
|
4334
|
+
});
|
|
4335
|
+
it('POST /api/taskforce/auth/register should not depend on onboarding policy lookups', async () => {
|
|
4336
|
+
const authConfig = {
|
|
4337
|
+
runtimeMode: 'cloud',
|
|
4338
|
+
enabled: true,
|
|
4339
|
+
requiredForApi: true,
|
|
4340
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
4341
|
+
defaultRole: 'member',
|
|
4342
|
+
defaultWorkspaceId: 'default',
|
|
4343
|
+
sessionCookieName: 'taskforce_session',
|
|
4344
|
+
sessionSecret: 'test-secret',
|
|
4345
|
+
sessionTtlSeconds: 3600
|
|
4346
|
+
};
|
|
4347
|
+
core.getOnboardingPolicy.mockImplementationOnce(() => {
|
|
4348
|
+
throw new Error('for SELECT DISTINCT, ORDER BY expressions must appear in select list');
|
|
4349
|
+
});
|
|
4350
|
+
core.getGlobalSettings.mockReturnValue({
|
|
4351
|
+
auth: {
|
|
4352
|
+
allowSelfRegistration: true,
|
|
4353
|
+
requireVerifiedEmail: false
|
|
4354
|
+
}
|
|
4355
|
+
});
|
|
4356
|
+
routes = createRoutes(core, { authConfig });
|
|
4357
|
+
core.registerPasswordUser.mockReturnValueOnce({
|
|
4358
|
+
userId: 'u-new',
|
|
4359
|
+
workspaceId: 'default',
|
|
4360
|
+
role: 'member',
|
|
4361
|
+
planSelectionRequired: false
|
|
4362
|
+
});
|
|
4363
|
+
const match = matchRoute('POST', '/api/taskforce/auth/register', routes);
|
|
4364
|
+
const req = createMockReq('POST', '/api/taskforce/auth/register', {
|
|
4365
|
+
email: 'newuser@example.com',
|
|
4366
|
+
password: 'password123'
|
|
4367
|
+
});
|
|
4368
|
+
const res = createMockRes();
|
|
4369
|
+
await match?.handler(req, res, {});
|
|
4370
|
+
expect(core.registerPasswordUser).toHaveBeenCalledWith(expect.objectContaining({
|
|
4371
|
+
email: 'newuser@example.com'
|
|
4372
|
+
}));
|
|
4373
|
+
expect(res.writeHead).toHaveBeenCalledWith(201, expect.any(Object));
|
|
4374
|
+
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":true'));
|
|
4375
|
+
});
|
|
4376
|
+
it('POST /api/taskforce/auth/register should only expose verificationToken when explicitly enabled', async () => {
|
|
4377
|
+
const authConfig = {
|
|
4378
|
+
runtimeMode: 'cloud',
|
|
4379
|
+
enabled: true,
|
|
4380
|
+
requiredForApi: true,
|
|
4381
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
4382
|
+
defaultRole: 'member',
|
|
4383
|
+
defaultWorkspaceId: 'default',
|
|
4384
|
+
sessionCookieName: 'taskforce_session',
|
|
4385
|
+
sessionSecret: 'test-secret',
|
|
4386
|
+
sessionTtlSeconds: 3600
|
|
4387
|
+
};
|
|
4388
|
+
await withDevAuthTokenExposure('true', async () => {
|
|
4389
|
+
routes = createRoutes(core, { authConfig });
|
|
4390
|
+
core.registerPasswordUser.mockReturnValueOnce({
|
|
4391
|
+
userId: 'user-opt-in',
|
|
4392
|
+
workspaceId: null,
|
|
4393
|
+
role: 'member',
|
|
4394
|
+
planSelectionRequired: false
|
|
4395
|
+
});
|
|
4396
|
+
core.requestEmailVerification.mockReturnValueOnce({ token: 'verify-token-opt-in' });
|
|
4397
|
+
const match = matchRoute('POST', '/api/taskforce/auth/register', routes);
|
|
4398
|
+
const req = createMockReq('POST', '/api/taskforce/auth/register', {
|
|
4399
|
+
email: 'opt-in@example.com',
|
|
4400
|
+
password: 'password123'
|
|
4401
|
+
});
|
|
4402
|
+
const res = createMockRes();
|
|
4403
|
+
await match?.handler(req, res, {});
|
|
4404
|
+
const payload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
|
|
4405
|
+
expect(payload.verificationToken).toBe('verify-token-opt-in');
|
|
4406
|
+
});
|
|
3730
4407
|
});
|
|
3731
4408
|
it('POST /api/taskforce/auth/register should allow first-time registration without workspaceId', async () => {
|
|
3732
4409
|
const authConfig = {
|
|
@@ -3851,35 +4528,37 @@ describe('Server Routes', () => {
|
|
|
3851
4528
|
sessionSecret: 'test-secret',
|
|
3852
4529
|
sessionTtlSeconds: 3600
|
|
3853
4530
|
};
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
4531
|
+
await withDevAuthTokenExposure('true', async () => {
|
|
4532
|
+
routes = createRoutes(core, { authConfig });
|
|
4533
|
+
core.hasWorkspaceAdmin.mockReturnValueOnce(true);
|
|
4534
|
+
core.isEmailVerified.mockReturnValueOnce(false);
|
|
4535
|
+
core.requestEmailVerification.mockReturnValueOnce({ token: 'verify-token-existing' });
|
|
4536
|
+
const error = new Error('Email is already registered');
|
|
4537
|
+
error.statusCode = 409;
|
|
4538
|
+
error.code = 'EMAIL_ALREADY_EXISTS';
|
|
4539
|
+
core.registerPasswordUser.mockImplementationOnce(() => {
|
|
4540
|
+
throw error;
|
|
4541
|
+
});
|
|
4542
|
+
const match = matchRoute('POST', '/api/taskforce/auth/register', routes);
|
|
4543
|
+
const req = createMockReq('POST', '/api/taskforce/auth/register', {
|
|
4544
|
+
email: 'owner@example.com',
|
|
4545
|
+
password: 'password123',
|
|
4546
|
+
workspaceId: 'workspace-1'
|
|
4547
|
+
});
|
|
4548
|
+
const res = createMockRes();
|
|
4549
|
+
await match?.handler(req, res, {});
|
|
4550
|
+
expect(core.isEmailVerified).toHaveBeenCalledWith('owner@example.com');
|
|
4551
|
+
expect(core.requestEmailVerification).toHaveBeenCalledWith({ email: 'owner@example.com' });
|
|
4552
|
+
expect(res.writeHead).toHaveBeenCalledWith(409, expect.any(Object));
|
|
4553
|
+
const responsePayload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
|
|
4554
|
+
expect(responsePayload).toEqual(expect.objectContaining({
|
|
4555
|
+
success: false,
|
|
4556
|
+
code: 'EMAIL_ALREADY_EXISTS_UNVERIFIED',
|
|
4557
|
+
verificationRequired: true,
|
|
4558
|
+
emailSent: true,
|
|
4559
|
+
verificationToken: 'verify-token-existing'
|
|
4560
|
+
}));
|
|
3869
4561
|
});
|
|
3870
|
-
const res = createMockRes();
|
|
3871
|
-
await match?.handler(req, res, {});
|
|
3872
|
-
expect(core.isEmailVerified).toHaveBeenCalledWith('owner@example.com');
|
|
3873
|
-
expect(core.requestEmailVerification).toHaveBeenCalledWith({ email: 'owner@example.com' });
|
|
3874
|
-
expect(res.writeHead).toHaveBeenCalledWith(409, expect.any(Object));
|
|
3875
|
-
const responsePayload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
|
|
3876
|
-
expect(responsePayload).toEqual(expect.objectContaining({
|
|
3877
|
-
success: false,
|
|
3878
|
-
code: 'EMAIL_ALREADY_EXISTS_UNVERIFIED',
|
|
3879
|
-
verificationRequired: true,
|
|
3880
|
-
emailSent: true,
|
|
3881
|
-
verificationToken: 'verify-token-existing'
|
|
3882
|
-
}));
|
|
3883
4562
|
});
|
|
3884
4563
|
it('POST /api/taskforce/auth/register should return conflict when verified email exists', async () => {
|
|
3885
4564
|
const authConfig = {
|
|
@@ -3952,6 +4631,124 @@ describe('Server Routes', () => {
|
|
|
3952
4631
|
code: 'SIGNUP_PLAN_VERSION_NOT_FOUND'
|
|
3953
4632
|
}));
|
|
3954
4633
|
});
|
|
4634
|
+
it('POST /api/taskforce/auth/verify-email/request should report no-send cases without claiming delivery', async () => {
|
|
4635
|
+
const authConfig = {
|
|
4636
|
+
runtimeMode: 'cloud',
|
|
4637
|
+
enabled: true,
|
|
4638
|
+
requiredForApi: true,
|
|
4639
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
4640
|
+
defaultRole: 'member',
|
|
4641
|
+
defaultWorkspaceId: 'default',
|
|
4642
|
+
sessionCookieName: 'taskforce_session',
|
|
4643
|
+
sessionSecret: 'test-secret',
|
|
4644
|
+
sessionTtlSeconds: 3600
|
|
4645
|
+
};
|
|
4646
|
+
routes = createRoutes(core, { authConfig });
|
|
4647
|
+
core.requestEmailVerification.mockReturnValueOnce({});
|
|
4648
|
+
const match = matchRoute('POST', '/api/taskforce/auth/verify-email/request', routes);
|
|
4649
|
+
const req = createMockReq('POST', '/api/taskforce/auth/verify-email/request', { email: 'nobody@example.com' });
|
|
4650
|
+
const res = createMockRes();
|
|
4651
|
+
await match?.handler(req, res, {});
|
|
4652
|
+
const payload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
|
|
4653
|
+
expect(payload).toMatchObject({
|
|
4654
|
+
success: true,
|
|
4655
|
+
emailSent: false,
|
|
4656
|
+
deliveryAttempted: false
|
|
4657
|
+
});
|
|
4658
|
+
expect(payload.emailError).toBeUndefined();
|
|
4659
|
+
});
|
|
4660
|
+
it('POST /api/taskforce/auth/verify-email/request should only expose verificationToken when explicitly enabled', async () => {
|
|
4661
|
+
const authConfig = {
|
|
4662
|
+
runtimeMode: 'cloud',
|
|
4663
|
+
enabled: true,
|
|
4664
|
+
requiredForApi: true,
|
|
4665
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
4666
|
+
defaultRole: 'member',
|
|
4667
|
+
defaultWorkspaceId: 'default',
|
|
4668
|
+
sessionCookieName: 'taskforce_session',
|
|
4669
|
+
sessionSecret: 'test-secret',
|
|
4670
|
+
sessionTtlSeconds: 3600
|
|
4671
|
+
};
|
|
4672
|
+
await withDevAuthTokenExposure(undefined, async () => {
|
|
4673
|
+
routes = createRoutes(core, { authConfig });
|
|
4674
|
+
core.requestEmailVerification.mockReturnValueOnce({ token: 'verify-token-hidden' });
|
|
4675
|
+
const match = matchRoute('POST', '/api/taskforce/auth/verify-email/request', routes);
|
|
4676
|
+
const req = createMockReq('POST', '/api/taskforce/auth/verify-email/request', { email: 'owner@example.com' });
|
|
4677
|
+
const res = createMockRes();
|
|
4678
|
+
await match?.handler(req, res, {});
|
|
4679
|
+
const payload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
|
|
4680
|
+
expect(payload.verificationToken).toBeUndefined();
|
|
4681
|
+
});
|
|
4682
|
+
await withDevAuthTokenExposure('true', async () => {
|
|
4683
|
+
routes = createRoutes(core, { authConfig });
|
|
4684
|
+
core.requestEmailVerification.mockReturnValueOnce({ token: 'verify-token-visible' });
|
|
4685
|
+
const match = matchRoute('POST', '/api/taskforce/auth/verify-email/request', routes);
|
|
4686
|
+
const req = createMockReq('POST', '/api/taskforce/auth/verify-email/request', { email: 'owner@example.com' });
|
|
4687
|
+
const res = createMockRes();
|
|
4688
|
+
await match?.handler(req, res, {});
|
|
4689
|
+
const payload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
|
|
4690
|
+
expect(payload.verificationToken).toBe('verify-token-visible');
|
|
4691
|
+
});
|
|
4692
|
+
});
|
|
4693
|
+
it('POST /api/taskforce/auth/password-reset/request should report no-send cases without claiming delivery', async () => {
|
|
4694
|
+
const authConfig = {
|
|
4695
|
+
runtimeMode: 'cloud',
|
|
4696
|
+
enabled: true,
|
|
4697
|
+
requiredForApi: true,
|
|
4698
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
4699
|
+
defaultRole: 'member',
|
|
4700
|
+
defaultWorkspaceId: 'default',
|
|
4701
|
+
sessionCookieName: 'taskforce_session',
|
|
4702
|
+
sessionSecret: 'test-secret',
|
|
4703
|
+
sessionTtlSeconds: 3600
|
|
4704
|
+
};
|
|
4705
|
+
routes = createRoutes(core, { authConfig });
|
|
4706
|
+
core.requestPasswordReset.mockReturnValueOnce({});
|
|
4707
|
+
const match = matchRoute('POST', '/api/taskforce/auth/password-reset/request', routes);
|
|
4708
|
+
const req = createMockReq('POST', '/api/taskforce/auth/password-reset/request', { email: 'nobody@example.com' });
|
|
4709
|
+
const res = createMockRes();
|
|
4710
|
+
await match?.handler(req, res, {});
|
|
4711
|
+
const payload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
|
|
4712
|
+
expect(payload).toMatchObject({
|
|
4713
|
+
success: true,
|
|
4714
|
+
emailSent: false,
|
|
4715
|
+
deliveryAttempted: false
|
|
4716
|
+
});
|
|
4717
|
+
expect(payload.emailError).toBeUndefined();
|
|
4718
|
+
});
|
|
4719
|
+
it('POST /api/taskforce/auth/password-reset/request should only expose resetToken when explicitly enabled', async () => {
|
|
4720
|
+
const authConfig = {
|
|
4721
|
+
runtimeMode: 'cloud',
|
|
4722
|
+
enabled: true,
|
|
4723
|
+
requiredForApi: true,
|
|
4724
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
4725
|
+
defaultRole: 'member',
|
|
4726
|
+
defaultWorkspaceId: 'default',
|
|
4727
|
+
sessionCookieName: 'taskforce_session',
|
|
4728
|
+
sessionSecret: 'test-secret',
|
|
4729
|
+
sessionTtlSeconds: 3600
|
|
4730
|
+
};
|
|
4731
|
+
await withDevAuthTokenExposure(undefined, async () => {
|
|
4732
|
+
routes = createRoutes(core, { authConfig });
|
|
4733
|
+
core.requestPasswordReset.mockReturnValueOnce({ token: 'reset-token-hidden' });
|
|
4734
|
+
const match = matchRoute('POST', '/api/taskforce/auth/password-reset/request', routes);
|
|
4735
|
+
const req = createMockReq('POST', '/api/taskforce/auth/password-reset/request', { email: 'owner@example.com' });
|
|
4736
|
+
const res = createMockRes();
|
|
4737
|
+
await match?.handler(req, res, {});
|
|
4738
|
+
const payload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
|
|
4739
|
+
expect(payload.resetToken).toBeUndefined();
|
|
4740
|
+
});
|
|
4741
|
+
await withDevAuthTokenExposure('true', async () => {
|
|
4742
|
+
routes = createRoutes(core, { authConfig });
|
|
4743
|
+
core.requestPasswordReset.mockReturnValueOnce({ token: 'reset-token-visible' });
|
|
4744
|
+
const match = matchRoute('POST', '/api/taskforce/auth/password-reset/request', routes);
|
|
4745
|
+
const req = createMockReq('POST', '/api/taskforce/auth/password-reset/request', { email: 'owner@example.com' });
|
|
4746
|
+
const res = createMockRes();
|
|
4747
|
+
await match?.handler(req, res, {});
|
|
4748
|
+
const payload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
|
|
4749
|
+
expect(payload.resetToken).toBe('reset-token-visible');
|
|
4750
|
+
});
|
|
4751
|
+
});
|
|
3955
4752
|
it('POST /api/taskforce/auth/verify-email/confirm should verify token', async () => {
|
|
3956
4753
|
const authConfig = {
|
|
3957
4754
|
runtimeMode: 'cloud',
|
|
@@ -4054,6 +4851,40 @@ describe('Server Routes', () => {
|
|
|
4054
4851
|
expect(payload.inviteeKind).toBe('existing_user');
|
|
4055
4852
|
expect(payload.passwordRequired).toBe(false);
|
|
4056
4853
|
});
|
|
4854
|
+
it('POST /api/taskforce/auth/invite/accept should preserve the created identity in the session cookie', async () => {
|
|
4855
|
+
const authConfig = {
|
|
4856
|
+
runtimeMode: 'cloud',
|
|
4857
|
+
enabled: true,
|
|
4858
|
+
requiredForApi: true,
|
|
4859
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
4860
|
+
defaultRole: 'member',
|
|
4861
|
+
defaultWorkspaceId: 'default',
|
|
4862
|
+
sessionCookieName: 'taskforce_session',
|
|
4863
|
+
sessionSecret: 'test-secret',
|
|
4864
|
+
sessionTtlSeconds: 3600
|
|
4865
|
+
};
|
|
4866
|
+
routes = createRoutes(core, { authConfig });
|
|
4867
|
+
core.acceptInviteWithToken.mockReturnValueOnce({
|
|
4868
|
+
userId: 'u1',
|
|
4869
|
+
email: 'u1@example.com',
|
|
4870
|
+
workspaceId: 'workspace-1',
|
|
4871
|
+
role: 'member',
|
|
4872
|
+
identityId: 'identity-password-u1'
|
|
4873
|
+
});
|
|
4874
|
+
const match = matchRoute('POST', '/api/taskforce/auth/invite/accept', routes);
|
|
4875
|
+
const req = createMockReq('POST', '/api/taskforce/auth/invite/accept', { token: 'accept-token', password: 'password123' }, {
|
|
4876
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
4877
|
+
host: 'localhost'
|
|
4878
|
+
});
|
|
4879
|
+
const res = createMockRes();
|
|
4880
|
+
await match?.handler(req, res, {});
|
|
4881
|
+
const setCookie = res.setHeader.mock.calls.find(([name]) => name === 'Set-Cookie')?.[1];
|
|
4882
|
+
expect(typeof setCookie).toBe('string');
|
|
4883
|
+
const cookieReq = createMockReq('GET', '/api/taskforce/me', undefined, { cookie: setCookie });
|
|
4884
|
+
const access = resolveRequestAccess(cookieReq, authConfig);
|
|
4885
|
+
expect(access.identityId).toBe('identity-password-u1');
|
|
4886
|
+
expect(access.userId).toBe('u1');
|
|
4887
|
+
});
|
|
4057
4888
|
it('POST /api/taskforce/auth/invite/accept should return expired code and audit action', async () => {
|
|
4058
4889
|
const authConfig = {
|
|
4059
4890
|
runtimeMode: 'cloud',
|
|
@@ -4144,7 +4975,8 @@ describe('Server Routes', () => {
|
|
|
4144
4975
|
const cookie = createSessionTokenCookie('session-join', authConfig, {
|
|
4145
4976
|
userId: 'u1',
|
|
4146
4977
|
role: 'member',
|
|
4147
|
-
workspaceId: 'workspace-1'
|
|
4978
|
+
workspaceId: 'workspace-1',
|
|
4979
|
+
identityId: 'identity-u1'
|
|
4148
4980
|
});
|
|
4149
4981
|
const match = matchRoute('POST', '/api/taskforce/auth/invite/join', routes);
|
|
4150
4982
|
const req = createMockReq('POST', '/api/taskforce/auth/invite/join', { token: 'join-token' }, {
|
|
@@ -4158,6 +4990,11 @@ describe('Server Routes', () => {
|
|
|
4158
4990
|
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
4159
4991
|
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
4160
4992
|
expect(payload.workspaceId).toBe('workspace-1');
|
|
4993
|
+
const setCookie = res.setHeader.mock.calls.find(([name]) => name === 'Set-Cookie')?.[1];
|
|
4994
|
+
expect(typeof setCookie).toBe('string');
|
|
4995
|
+
const cookieReq = createMockReq('GET', '/api/taskforce/me', undefined, { cookie: setCookie });
|
|
4996
|
+
const access = resolveRequestAccess(cookieReq, authConfig);
|
|
4997
|
+
expect(access.identityId).toBe('identity-u1');
|
|
4161
4998
|
});
|
|
4162
4999
|
it('POST /api/taskforce/auth/login should auto-verify bootstrap admin and sign in', async () => {
|
|
4163
5000
|
const authConfig = {
|
|
@@ -4390,22 +5227,56 @@ describe('Server Routes', () => {
|
|
|
4390
5227
|
};
|
|
4391
5228
|
routes = createRoutes(core, { authConfig });
|
|
4392
5229
|
core.authenticatePasswordUserDetailed.mockReturnValueOnce({
|
|
4393
|
-
ok: true,
|
|
4394
|
-
access: { userId: 'u4', workspaceId: 'default', role: 'member' }
|
|
5230
|
+
ok: true,
|
|
5231
|
+
access: { userId: 'u4', workspaceId: 'default', role: 'member' }
|
|
5232
|
+
});
|
|
5233
|
+
const match = matchRoute('POST', '/api/taskforce/auth/login', routes);
|
|
5234
|
+
const req = createMockReq('POST', '/api/taskforce/auth/login', {
|
|
5235
|
+
email: 'returning@example.com',
|
|
5236
|
+
password: 'password123'
|
|
5237
|
+
});
|
|
5238
|
+
const res = createMockRes();
|
|
5239
|
+
await match?.handler(req, res, {});
|
|
5240
|
+
expect(core.authenticatePasswordUserDetailed).toHaveBeenCalledWith(expect.objectContaining({
|
|
5241
|
+
email: 'returning@example.com',
|
|
5242
|
+
workspaceId: 'default'
|
|
5243
|
+
}));
|
|
5244
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
5245
|
+
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":true'));
|
|
5246
|
+
});
|
|
5247
|
+
it('POST /api/taskforce/auth/login should not depend on onboarding policy lookups', async () => {
|
|
5248
|
+
const authConfig = {
|
|
5249
|
+
runtimeMode: 'cloud',
|
|
5250
|
+
enabled: true,
|
|
5251
|
+
requiredForApi: true,
|
|
5252
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
5253
|
+
defaultRole: 'member',
|
|
5254
|
+
defaultWorkspaceId: 'default',
|
|
5255
|
+
sessionCookieName: 'taskforce_session',
|
|
5256
|
+
sessionSecret: 'test-secret',
|
|
5257
|
+
sessionTtlSeconds: 3600
|
|
5258
|
+
};
|
|
5259
|
+
core.getOnboardingPolicy.mockImplementationOnce(() => {
|
|
5260
|
+
throw new Error('for SELECT DISTINCT, ORDER BY expressions must appear in select list');
|
|
5261
|
+
});
|
|
5262
|
+
routes = createRoutes(core, { authConfig });
|
|
5263
|
+
core.authenticatePasswordUserDetailed.mockReturnValueOnce({
|
|
5264
|
+
ok: false,
|
|
5265
|
+
code: 'INVALID_CREDENTIALS'
|
|
4395
5266
|
});
|
|
4396
5267
|
const match = matchRoute('POST', '/api/taskforce/auth/login', routes);
|
|
4397
5268
|
const req = createMockReq('POST', '/api/taskforce/auth/login', {
|
|
4398
|
-
email: '
|
|
4399
|
-
password: '
|
|
5269
|
+
email: 'user@example.com',
|
|
5270
|
+
password: 'wrongpass'
|
|
4400
5271
|
});
|
|
4401
5272
|
const res = createMockRes();
|
|
4402
5273
|
await match?.handler(req, res, {});
|
|
5274
|
+
expect(res.writeHead).toHaveBeenCalledWith(401, expect.any(Object));
|
|
5275
|
+
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('Invalid email/password.'));
|
|
4403
5276
|
expect(core.authenticatePasswordUserDetailed).toHaveBeenCalledWith(expect.objectContaining({
|
|
4404
|
-
email: '
|
|
4405
|
-
|
|
5277
|
+
email: 'user@example.com',
|
|
5278
|
+
requireVerifiedEmail: true
|
|
4406
5279
|
}));
|
|
4407
|
-
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
4408
|
-
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":true'));
|
|
4409
5280
|
});
|
|
4410
5281
|
it('GET /api/taskforce/workspaces should list authenticated user workspaces', async () => {
|
|
4411
5282
|
const authConfig = {
|
|
@@ -4452,6 +5323,7 @@ describe('Server Routes', () => {
|
|
|
4452
5323
|
const res = createMockRes();
|
|
4453
5324
|
await match?.handler(req, res, {});
|
|
4454
5325
|
expect(core.listUserWorkspaces).toHaveBeenCalledWith('user-1');
|
|
5326
|
+
expect(res.setHeader).toHaveBeenCalledWith('Server-Timing', expect.stringContaining('workspaces'));
|
|
4455
5327
|
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
4456
5328
|
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"workspace-2"'));
|
|
4457
5329
|
});
|
|
@@ -5345,6 +6217,9 @@ describe('Server Routes', () => {
|
|
|
5345
6217
|
await match?.handler(req, res, {});
|
|
5346
6218
|
const payloadRaw = res.end.mock.calls[0]?.[0];
|
|
5347
6219
|
const payload = JSON.parse(String(payloadRaw || '{}'));
|
|
6220
|
+
expect(payload.knownPaths).toEqual([
|
|
6221
|
+
'workspaces/workspace-local/assets/documents/asset-doc-snapshot-1-sync-notes.md'
|
|
6222
|
+
]);
|
|
5348
6223
|
expect(payload.documents).toEqual(expect.arrayContaining([
|
|
5349
6224
|
expect.objectContaining({
|
|
5350
6225
|
path: 'workspaces/workspace-local/assets/documents/asset-doc-snapshot-1-sync-notes.md',
|
|
@@ -5359,7 +6234,56 @@ describe('Server Routes', () => {
|
|
|
5359
6234
|
expect(String(payload.fingerprint || '')).toContain('asset-doc-snapshot-1');
|
|
5360
6235
|
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
5361
6236
|
});
|
|
5362
|
-
it('GET /api/taskforce/sync/workspace/documents should
|
|
6237
|
+
it('GET /api/taskforce/sync/workspace/documents should retain canonical knownPaths even when content is temporarily unreadable', async () => {
|
|
6238
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-documents-known-paths-'));
|
|
6239
|
+
const projectRoot = fs.realpathSync(tmpRoot);
|
|
6240
|
+
const basePath = path.join(tmpRoot, '.taskforce');
|
|
6241
|
+
const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
|
|
6242
|
+
store.upsertAsset({
|
|
6243
|
+
assetId: 'asset-doc-known-path-1',
|
|
6244
|
+
workspaceId: 'workspace-local',
|
|
6245
|
+
kind: 'document',
|
|
6246
|
+
originalFilename: 'missing-content.md',
|
|
6247
|
+
mimeType: 'text/markdown',
|
|
6248
|
+
storageProvider: 'local',
|
|
6249
|
+
storageKey: 'workspaces/workspace-local/assets/documents/asset-doc-known-path-1-missing-content.md',
|
|
6250
|
+
contentSha256: 'missing-content-hash',
|
|
6251
|
+
sizeBytes: 17,
|
|
6252
|
+
documentId: 'doc-known-path-1',
|
|
6253
|
+
version: 1,
|
|
6254
|
+
isLatest: true,
|
|
6255
|
+
createdAt: '2026-03-17T09:00:00.000Z',
|
|
6256
|
+
updatedAt: '2026-03-17T10:00:00.000Z'
|
|
6257
|
+
});
|
|
6258
|
+
store.upsertLink({
|
|
6259
|
+
workspaceId: 'workspace-local',
|
|
6260
|
+
assetId: 'asset-doc-known-path-1',
|
|
6261
|
+
taskId: 'task-1',
|
|
6262
|
+
role: 'attachment'
|
|
6263
|
+
});
|
|
6264
|
+
core.getPaths.mockReturnValue({
|
|
6265
|
+
projectRoot,
|
|
6266
|
+
basePath,
|
|
6267
|
+
});
|
|
6268
|
+
core.resolvePreferredWorkspaceId.mockReturnValue('workspace-local');
|
|
6269
|
+
core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
|
|
6270
|
+
core.getTenantId = vi.fn().mockReturnValue('default');
|
|
6271
|
+
routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
6272
|
+
const match = matchRoute('GET', '/api/taskforce/sync/workspace/documents', routes);
|
|
6273
|
+
const req = createMockReq('GET', '/api/taskforce/sync/workspace/documents?workspaceId=workspace-local', undefined, {
|
|
6274
|
+
'x-taskforce-workspace-id': 'workspace-local'
|
|
6275
|
+
});
|
|
6276
|
+
const res = createMockRes();
|
|
6277
|
+
await match?.handler(req, res, {});
|
|
6278
|
+
const payloadRaw = res.end.mock.calls[0]?.[0];
|
|
6279
|
+
const payload = JSON.parse(String(payloadRaw || '{}'));
|
|
6280
|
+
expect(payload.documents).toEqual([]);
|
|
6281
|
+
expect(payload.knownPaths).toEqual([
|
|
6282
|
+
'workspaces/workspace-local/assets/documents/asset-doc-known-path-1-missing-content.md'
|
|
6283
|
+
]);
|
|
6284
|
+
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
6285
|
+
});
|
|
6286
|
+
it('GET /api/taskforce/sync/workspace/documents should exclude docs folder markdown files that are not canonical assets', async () => {
|
|
5363
6287
|
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-documents-legacy-ignore-'));
|
|
5364
6288
|
const projectRoot = fs.realpathSync(tmpRoot);
|
|
5365
6289
|
const basePath = path.join(tmpRoot, '.taskforce');
|
|
@@ -5384,12 +6308,7 @@ describe('Server Routes', () => {
|
|
|
5384
6308
|
const payloadRaw = res.end.mock.calls[0]?.[0];
|
|
5385
6309
|
const payload = JSON.parse(String(payloadRaw || '{}'));
|
|
5386
6310
|
expect(Array.isArray(payload.documents)).toBe(true);
|
|
5387
|
-
expect(payload.documents).toEqual([
|
|
5388
|
-
expect.objectContaining({
|
|
5389
|
-
path: 'docs/legacy-notes.md',
|
|
5390
|
-
content: '# legacy\n'
|
|
5391
|
-
})
|
|
5392
|
-
]);
|
|
6311
|
+
expect(payload.documents).toEqual([]);
|
|
5393
6312
|
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
5394
6313
|
});
|
|
5395
6314
|
it('GET /api/taskforce/sync/workspace/document-reviews should include document review sessions for the requested workspace', async () => {
|
|
@@ -5726,6 +6645,109 @@ describe('Server Routes', () => {
|
|
|
5726
6645
|
]));
|
|
5727
6646
|
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
5728
6647
|
});
|
|
6648
|
+
it('GET /api/taskforce/sync/workspace/pull should hydrate canonical markdown documents from object storage', async () => {
|
|
6649
|
+
const authConfig = {
|
|
6650
|
+
runtimeMode: 'cloud',
|
|
6651
|
+
enabled: true,
|
|
6652
|
+
requiredForApi: true,
|
|
6653
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
6654
|
+
defaultRole: 'member',
|
|
6655
|
+
defaultWorkspaceId: 'default',
|
|
6656
|
+
sessionCookieName: 'taskforce_session',
|
|
6657
|
+
sessionSecret: 'test-secret',
|
|
6658
|
+
sessionTtlSeconds: 3600
|
|
6659
|
+
};
|
|
6660
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
6661
|
+
status: 200,
|
|
6662
|
+
ok: true,
|
|
6663
|
+
headers: new Headers({ 'content-type': 'text/markdown' }),
|
|
6664
|
+
arrayBuffer: async () => Uint8Array.from(Buffer.from('# Canonical R2 doc\n')).buffer,
|
|
6665
|
+
});
|
|
6666
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
6667
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-pull-doc-r2-'));
|
|
6668
|
+
const basePath = path.join(tmpRoot, '.taskforce');
|
|
6669
|
+
fs.mkdirSync(basePath, { recursive: true });
|
|
6670
|
+
const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
|
|
6671
|
+
store.upsertAsset({
|
|
6672
|
+
assetId: 'asset-doc-r2-1',
|
|
6673
|
+
workspaceId: 'workspace-1',
|
|
6674
|
+
kind: 'document',
|
|
6675
|
+
originalFilename: 'canonical-r2-doc.md',
|
|
6676
|
+
mimeType: 'text/markdown',
|
|
6677
|
+
storageProvider: 'r2',
|
|
6678
|
+
storageKey: 'workspaces/workspace-1/assets/documents/asset-doc-r2-1-canonical-r2-doc.md',
|
|
6679
|
+
contentSha256: 'sha-doc-r2-1',
|
|
6680
|
+
sizeBytes: 19,
|
|
6681
|
+
documentId: 'doc-r2-1',
|
|
6682
|
+
referenceNumber: 201,
|
|
6683
|
+
version: 2,
|
|
6684
|
+
isLatest: true,
|
|
6685
|
+
createdAt: '2026-03-17T09:00:00.000Z',
|
|
6686
|
+
updatedAt: '2026-03-17T10:00:00.000Z'
|
|
6687
|
+
});
|
|
6688
|
+
store.upsertLink({
|
|
6689
|
+
workspaceId: 'workspace-1',
|
|
6690
|
+
assetId: 'asset-doc-r2-1',
|
|
6691
|
+
taskId: 'task-1',
|
|
6692
|
+
role: 'attachment',
|
|
6693
|
+
createdAt: '2026-03-17T10:00:00.000Z',
|
|
6694
|
+
updatedAt: '2026-03-17T10:00:00.000Z'
|
|
6695
|
+
});
|
|
6696
|
+
core.getPaths.mockReturnValue({
|
|
6697
|
+
projectRoot: tmpRoot,
|
|
6698
|
+
basePath,
|
|
6699
|
+
});
|
|
6700
|
+
core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
|
|
6701
|
+
core.getTenantId = vi.fn().mockReturnValue('default');
|
|
6702
|
+
routes = createRoutes(core, {
|
|
6703
|
+
authConfig,
|
|
6704
|
+
objectStorage: {
|
|
6705
|
+
provider: 'r2',
|
|
6706
|
+
r2: {
|
|
6707
|
+
accountId: 'acct-1',
|
|
6708
|
+
bucket: 'taskforce-docs',
|
|
6709
|
+
endpoint: 'https://example.r2.cloudflarestorage.com',
|
|
6710
|
+
accessKeyId: 'access-key-1',
|
|
6711
|
+
secretAccessKey: 'secret-key-1',
|
|
6712
|
+
keyPrefix: 'taskforce',
|
|
6713
|
+
signedUploadTtlSeconds: 600,
|
|
6714
|
+
signedDownloadTtlSeconds: 300
|
|
6715
|
+
}
|
|
6716
|
+
}
|
|
6717
|
+
});
|
|
6718
|
+
core.listUserWorkspaces.mockReturnValueOnce([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
|
|
6719
|
+
const cookie = createSessionTokenCookie('session-sync-pull-doc-r2', authConfig, {
|
|
6720
|
+
role: 'owner',
|
|
6721
|
+
workspaceId: 'workspace-1',
|
|
6722
|
+
userId: 'user-1'
|
|
6723
|
+
});
|
|
6724
|
+
core.listTasks.mockReturnValueOnce({ tasks: [] });
|
|
6725
|
+
core.listArchive.mockReturnValueOnce({ archived: [] });
|
|
6726
|
+
const match = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
|
|
6727
|
+
const req = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-1&limit=20', undefined, { cookie });
|
|
6728
|
+
const res = createMockRes();
|
|
6729
|
+
await match?.handler(req, res, {});
|
|
6730
|
+
const payloadRaw = res.end.mock.calls[0]?.[0];
|
|
6731
|
+
const payload = JSON.parse(String(payloadRaw || '{}'));
|
|
6732
|
+
expect(payload.changes).toEqual(expect.arrayContaining([
|
|
6733
|
+
expect.objectContaining({
|
|
6734
|
+
op: 'document-upsert',
|
|
6735
|
+
path: 'workspaces/workspace-1/assets/documents/asset-doc-r2-1-canonical-r2-doc.md',
|
|
6736
|
+
content: '# Canonical R2 doc\n',
|
|
6737
|
+
assetId: 'asset-doc-r2-1',
|
|
6738
|
+
documentId: 'doc-r2-1',
|
|
6739
|
+
referenceNumber: 201,
|
|
6740
|
+
version: 2,
|
|
6741
|
+
taskId: 'task-1',
|
|
6742
|
+
originalFilename: 'canonical-r2-doc.md',
|
|
6743
|
+
linkRole: 'attachment'
|
|
6744
|
+
})
|
|
6745
|
+
]));
|
|
6746
|
+
expect(fetchMock).toHaveBeenCalled();
|
|
6747
|
+
vi.unstubAllGlobals();
|
|
6748
|
+
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
6749
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
6750
|
+
});
|
|
5729
6751
|
it('GET /api/taskforce/sync/workspace/pull should include document review session upserts and deletes', async () => {
|
|
5730
6752
|
const authConfig = {
|
|
5731
6753
|
runtimeMode: 'cloud',
|
|
@@ -6376,29 +7398,144 @@ describe('Server Routes', () => {
|
|
|
6376
7398
|
workspaceId: 'workspace-cloud-selected',
|
|
6377
7399
|
userId: 'user-1'
|
|
6378
7400
|
});
|
|
6379
|
-
core.listUserWorkspaces.mockReturnValueOnce([
|
|
6380
|
-
{
|
|
6381
|
-
id: 'workspace-local-active',
|
|
6382
|
-
slug: 'workspace-local-active',
|
|
6383
|
-
name: 'Workspace Local Active',
|
|
6384
|
-
description: null,
|
|
6385
|
-
role: 'owner',
|
|
6386
|
-
status: 'active',
|
|
6387
|
-
betaAccess: true
|
|
6388
|
-
}
|
|
6389
|
-
]);
|
|
6390
|
-
core.listTasks.mockReturnValueOnce({ tasks: [] });
|
|
6391
|
-
core.listArchive.mockReturnValueOnce({ archived: [] });
|
|
7401
|
+
core.listUserWorkspaces.mockReturnValueOnce([
|
|
7402
|
+
{
|
|
7403
|
+
id: 'workspace-local-active',
|
|
7404
|
+
slug: 'workspace-local-active',
|
|
7405
|
+
name: 'Workspace Local Active',
|
|
7406
|
+
description: null,
|
|
7407
|
+
role: 'owner',
|
|
7408
|
+
status: 'active',
|
|
7409
|
+
betaAccess: true
|
|
7410
|
+
}
|
|
7411
|
+
]);
|
|
7412
|
+
core.listTasks.mockReturnValueOnce({ tasks: [] });
|
|
7413
|
+
core.listArchive.mockReturnValueOnce({ archived: [] });
|
|
7414
|
+
const match = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
|
|
7415
|
+
const req = createMockReq('GET', '/api/taskforce/sync/workspace/pull?limit=10&workspaceId=workspace-local-active', undefined, { cookie });
|
|
7416
|
+
const res = createMockRes();
|
|
7417
|
+
await match?.handler(req, res, {});
|
|
7418
|
+
expect(core.listUserWorkspaces).toHaveBeenCalledWith('user-1');
|
|
7419
|
+
expect(core.listTasks).toHaveBeenCalledWith('workspace-local-active');
|
|
7420
|
+
expect(core.listArchive).toHaveBeenCalledWith('workspace-local-active');
|
|
7421
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
7422
|
+
});
|
|
7423
|
+
it('GET /api/taskforce/sync/workspace/pull should maintain stable cursor pagination for equal watermarks', async () => {
|
|
7424
|
+
const authConfig = {
|
|
7425
|
+
runtimeMode: 'cloud',
|
|
7426
|
+
enabled: true,
|
|
7427
|
+
requiredForApi: true,
|
|
7428
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
7429
|
+
defaultRole: 'member',
|
|
7430
|
+
defaultWorkspaceId: 'default',
|
|
7431
|
+
sessionCookieName: 'taskforce_session',
|
|
7432
|
+
sessionSecret: 'test-secret',
|
|
7433
|
+
sessionTtlSeconds: 3600
|
|
7434
|
+
};
|
|
7435
|
+
routes = createRoutes(core, { authConfig });
|
|
7436
|
+
core.listUserWorkspaces.mockReturnValue([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
|
|
7437
|
+
const cookie = createSessionTokenCookie('session-sync-pull-cursor-contract', authConfig, {
|
|
7438
|
+
role: 'owner',
|
|
7439
|
+
workspaceId: 'workspace-1',
|
|
7440
|
+
userId: 'user-1'
|
|
7441
|
+
});
|
|
7442
|
+
core.listTasks.mockReturnValue({
|
|
7443
|
+
tasks: [
|
|
7444
|
+
{ id: 'task-b', title: 'Task B', createdAt: '2026-02-19T16:00:00.000Z', updatedAt: '2026-02-19T16:10:00.000Z' },
|
|
7445
|
+
{ id: 'task-a', title: 'Task A', createdAt: '2026-02-19T16:00:00.000Z', updatedAt: '2026-02-19T16:10:00.000Z' },
|
|
7446
|
+
{ id: 'task-c', title: 'Task C', createdAt: '2026-02-19T16:00:00.000Z', updatedAt: '2026-02-19T16:11:00.000Z' }
|
|
7447
|
+
]
|
|
7448
|
+
});
|
|
7449
|
+
core.listArchive.mockReturnValue({ archived: [] });
|
|
7450
|
+
const match = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
|
|
7451
|
+
const firstReq = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-1&limit=1', undefined, { cookie });
|
|
7452
|
+
const firstRes = createMockRes();
|
|
7453
|
+
await match?.handler(firstReq, firstRes, {});
|
|
7454
|
+
const firstBody = JSON.parse(String(firstRes.end.mock.calls[0]?.[0] || '{}'));
|
|
7455
|
+
expect(firstBody?.changes?.[0]).toEqual(expect.objectContaining({
|
|
7456
|
+
op: 'upsert',
|
|
7457
|
+
task: expect.objectContaining({ id: 'task-a' })
|
|
7458
|
+
}));
|
|
7459
|
+
expect(typeof firstBody?.nextCursor).toBe('string');
|
|
7460
|
+
const secondReq = createMockReq('GET', `/api/taskforce/sync/workspace/pull?workspaceId=workspace-1&limit=1&cursor=${encodeURIComponent(String(firstBody.nextCursor || ''))}`, undefined, { cookie });
|
|
7461
|
+
const secondRes = createMockRes();
|
|
7462
|
+
await match?.handler(secondReq, secondRes, {});
|
|
7463
|
+
const secondBody = JSON.parse(String(secondRes.end.mock.calls[0]?.[0] || '{}'));
|
|
7464
|
+
expect(secondBody?.changes?.[0]).toEqual(expect.objectContaining({
|
|
7465
|
+
op: 'upsert',
|
|
7466
|
+
task: expect.objectContaining({ id: 'task-b' })
|
|
7467
|
+
}));
|
|
7468
|
+
});
|
|
7469
|
+
it('GET /api/taskforce/sync/workspace/pull should reject an invalid sync cursor instead of replaying from the beginning', async () => {
|
|
7470
|
+
const authConfig = {
|
|
7471
|
+
runtimeMode: 'cloud',
|
|
7472
|
+
enabled: true,
|
|
7473
|
+
requiredForApi: true,
|
|
7474
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
7475
|
+
defaultRole: 'member',
|
|
7476
|
+
defaultWorkspaceId: 'default',
|
|
7477
|
+
sessionCookieName: 'taskforce_session',
|
|
7478
|
+
sessionSecret: 'test-secret',
|
|
7479
|
+
sessionTtlSeconds: 3600
|
|
7480
|
+
};
|
|
7481
|
+
routes = createRoutes(core, { authConfig });
|
|
7482
|
+
core.listUserWorkspaces.mockReturnValue([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
|
|
7483
|
+
const cookie = createSessionTokenCookie('session-sync-pull-invalid-cursor', authConfig, {
|
|
7484
|
+
role: 'owner',
|
|
7485
|
+
workspaceId: 'workspace-1',
|
|
7486
|
+
userId: 'user-1'
|
|
7487
|
+
});
|
|
7488
|
+
const match = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
|
|
7489
|
+
const req = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-1&limit=10&cursor=not-a-real-cursor', undefined, { cookie });
|
|
7490
|
+
const res = createMockRes();
|
|
7491
|
+
await match?.handler(req, res, {});
|
|
7492
|
+
expect(core.listTasks).not.toHaveBeenCalled();
|
|
7493
|
+
expect(res.writeHead).toHaveBeenCalledWith(400, expect.any(Object));
|
|
7494
|
+
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"code":"INVALID_SYNC_CURSOR"'));
|
|
7495
|
+
expect(core.addAdminAuditLog).toHaveBeenCalledWith(expect.objectContaining({
|
|
7496
|
+
action: 'sync-workspace-pull-invalid-cursor'
|
|
7497
|
+
}));
|
|
7498
|
+
});
|
|
7499
|
+
it('GET /api/taskforce/sync/workspace/pull should ignore cursors during repair', async () => {
|
|
7500
|
+
const authConfig = {
|
|
7501
|
+
runtimeMode: 'cloud',
|
|
7502
|
+
enabled: true,
|
|
7503
|
+
requiredForApi: true,
|
|
7504
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
7505
|
+
defaultRole: 'member',
|
|
7506
|
+
defaultWorkspaceId: 'default',
|
|
7507
|
+
sessionCookieName: 'taskforce_session',
|
|
7508
|
+
sessionSecret: 'test-secret',
|
|
7509
|
+
sessionTtlSeconds: 3600
|
|
7510
|
+
};
|
|
7511
|
+
routes = createRoutes(core, { authConfig });
|
|
7512
|
+
core.listUserWorkspaces.mockReturnValue([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
|
|
7513
|
+
core.listTasks.mockReturnValue({
|
|
7514
|
+
tasks: [
|
|
7515
|
+
{ id: 'task-repair-1', title: 'Repair me', createdAt: '2026-02-19T16:00:00.000Z', updatedAt: '2026-02-19T16:10:00.000Z' }
|
|
7516
|
+
]
|
|
7517
|
+
});
|
|
7518
|
+
core.listArchive.mockReturnValue({ archived: [] });
|
|
7519
|
+
const cookie = createSessionTokenCookie('session-sync-pull-repair-cursor', authConfig, {
|
|
7520
|
+
role: 'owner',
|
|
7521
|
+
workspaceId: 'workspace-1',
|
|
7522
|
+
userId: 'user-1'
|
|
7523
|
+
});
|
|
6392
7524
|
const match = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
|
|
6393
|
-
const req = createMockReq('GET', '/api/taskforce/sync/workspace/pull?limit=10&
|
|
7525
|
+
const req = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-1&limit=10&cursor=not-a-real-cursor&repair=1', undefined, { cookie });
|
|
6394
7526
|
const res = createMockRes();
|
|
6395
7527
|
await match?.handler(req, res, {});
|
|
6396
|
-
expect(core.listUserWorkspaces).toHaveBeenCalledWith('user-1');
|
|
6397
|
-
expect(core.listTasks).toHaveBeenCalledWith('workspace-local-active');
|
|
6398
|
-
expect(core.listArchive).toHaveBeenCalledWith('workspace-local-active');
|
|
6399
7528
|
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
7529
|
+
expect(core.listTasks).toHaveBeenCalled();
|
|
7530
|
+
const body = JSON.parse(String(res.end.mock.calls[0]?.[0] || '{}'));
|
|
7531
|
+
expect(body.changes).toEqual(expect.arrayContaining([
|
|
7532
|
+
expect.objectContaining({
|
|
7533
|
+
op: 'upsert',
|
|
7534
|
+
task: expect.objectContaining({ id: 'task-repair-1' })
|
|
7535
|
+
})
|
|
7536
|
+
]));
|
|
6400
7537
|
});
|
|
6401
|
-
it('GET /api/taskforce/sync/workspace/pull should
|
|
7538
|
+
it('GET /api/taskforce/sync/workspace/pull should still return success when success audit throws', async () => {
|
|
6402
7539
|
const authConfig = {
|
|
6403
7540
|
runtimeMode: 'cloud',
|
|
6404
7541
|
enabled: true,
|
|
@@ -6411,38 +7548,27 @@ describe('Server Routes', () => {
|
|
|
6411
7548
|
sessionTtlSeconds: 3600
|
|
6412
7549
|
};
|
|
6413
7550
|
routes = createRoutes(core, { authConfig });
|
|
6414
|
-
core.listUserWorkspaces.
|
|
6415
|
-
const cookie = createSessionTokenCookie('session-sync-pull-
|
|
7551
|
+
core.listUserWorkspaces.mockReturnValueOnce([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
|
|
7552
|
+
const cookie = createSessionTokenCookie('session-sync-pull-audit-throw', authConfig, {
|
|
6416
7553
|
role: 'owner',
|
|
6417
7554
|
workspaceId: 'workspace-1',
|
|
6418
7555
|
userId: 'user-1'
|
|
6419
7556
|
});
|
|
6420
|
-
core.
|
|
6421
|
-
|
|
6422
|
-
|
|
6423
|
-
|
|
6424
|
-
|
|
6425
|
-
|
|
7557
|
+
const originalAddAdminAuditLog = core.addAdminAuditLog.getMockImplementation();
|
|
7558
|
+
core.addAdminAuditLog.mockImplementation((entry) => {
|
|
7559
|
+
if (entry?.action === 'sync-workspace-pull-success') {
|
|
7560
|
+
throw new Error('audit unavailable');
|
|
7561
|
+
}
|
|
7562
|
+
return originalAddAdminAuditLog ? originalAddAdminAuditLog(entry) : undefined;
|
|
6426
7563
|
});
|
|
6427
|
-
core.listArchive.mockReturnValue({ archived: [] });
|
|
6428
7564
|
const match = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
|
|
6429
|
-
const
|
|
6430
|
-
const
|
|
6431
|
-
await match?.handler(
|
|
6432
|
-
|
|
6433
|
-
expect(
|
|
6434
|
-
|
|
6435
|
-
|
|
6436
|
-
}));
|
|
6437
|
-
expect(typeof firstBody?.nextCursor).toBe('string');
|
|
6438
|
-
const secondReq = createMockReq('GET', `/api/taskforce/sync/workspace/pull?workspaceId=workspace-1&limit=1&cursor=${encodeURIComponent(String(firstBody.nextCursor || ''))}`, undefined, { cookie });
|
|
6439
|
-
const secondRes = createMockRes();
|
|
6440
|
-
await match?.handler(secondReq, secondRes, {});
|
|
6441
|
-
const secondBody = JSON.parse(String(secondRes.end.mock.calls[0]?.[0] || '{}'));
|
|
6442
|
-
expect(secondBody?.changes?.[0]).toEqual(expect.objectContaining({
|
|
6443
|
-
op: 'upsert',
|
|
6444
|
-
task: expect.objectContaining({ id: 'task-b' })
|
|
6445
|
-
}));
|
|
7565
|
+
const req = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-1&limit=10', undefined, { cookie });
|
|
7566
|
+
const res = createMockRes();
|
|
7567
|
+
await match?.handler(req, res, {});
|
|
7568
|
+
expect(res.writeHead).toHaveBeenCalledTimes(1);
|
|
7569
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
7570
|
+
expect(res.end).toHaveBeenCalledTimes(1);
|
|
7571
|
+
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":true'));
|
|
6446
7572
|
});
|
|
6447
7573
|
it('GET /api/taskforce/sync/workspace/pull should reject unauthorized workspace override', async () => {
|
|
6448
7574
|
const authConfig = {
|
|
@@ -6505,6 +7631,53 @@ describe('Server Routes', () => {
|
|
|
6505
7631
|
}), 'workspace-local');
|
|
6506
7632
|
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
6507
7633
|
});
|
|
7634
|
+
it('POST /api/taskforce/sync/workspace/apply-local should still return success when success sync telemetry throws after apply', async () => {
|
|
7635
|
+
core.resolvePreferredWorkspaceId.mockReturnValue('workspace-local');
|
|
7636
|
+
routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
7637
|
+
core.recordSyncEvent.mockImplementationOnce(() => {
|
|
7638
|
+
throw new Error('telemetry unavailable');
|
|
7639
|
+
});
|
|
7640
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
|
|
7641
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
|
|
7642
|
+
changes: [
|
|
7643
|
+
{ op: 'upsert', archived: false, task: { id: 'task-1', title: 'Task 1' } }
|
|
7644
|
+
]
|
|
7645
|
+
}, {
|
|
7646
|
+
'x-taskforce-workspace-id': 'workspace-local'
|
|
7647
|
+
});
|
|
7648
|
+
const res = createMockRes();
|
|
7649
|
+
await match?.handler(req, res, {});
|
|
7650
|
+
expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledTimes(1);
|
|
7651
|
+
expect(res.writeHead).toHaveBeenCalledTimes(1);
|
|
7652
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
7653
|
+
expect(res.end).toHaveBeenCalledTimes(1);
|
|
7654
|
+
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":true'));
|
|
7655
|
+
});
|
|
7656
|
+
it('POST /api/taskforce/sync/workspace/apply-local should still return error response when failure-path telemetry throws', async () => {
|
|
7657
|
+
core.resolvePreferredWorkspaceId.mockReturnValue('workspace-local');
|
|
7658
|
+
routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
7659
|
+
core.applyWorkspaceSyncSnapshot.mockImplementationOnce(() => {
|
|
7660
|
+
throw new Error('apply failed');
|
|
7661
|
+
});
|
|
7662
|
+
core.recordSyncEvent.mockImplementationOnce(() => {
|
|
7663
|
+
throw new Error('telemetry unavailable');
|
|
7664
|
+
});
|
|
7665
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
|
|
7666
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
|
|
7667
|
+
changes: [
|
|
7668
|
+
{ op: 'upsert', archived: false, task: { id: 'task-1', title: 'Task 1' } }
|
|
7669
|
+
]
|
|
7670
|
+
}, {
|
|
7671
|
+
'x-taskforce-workspace-id': 'workspace-local'
|
|
7672
|
+
});
|
|
7673
|
+
const res = createMockRes();
|
|
7674
|
+
await match?.handler(req, res, {});
|
|
7675
|
+
expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledTimes(1);
|
|
7676
|
+
expect(res.writeHead).toHaveBeenCalledTimes(1);
|
|
7677
|
+
expect(res.writeHead).toHaveBeenCalledWith(400, expect.any(Object));
|
|
7678
|
+
expect(res.end).toHaveBeenCalledTimes(1);
|
|
7679
|
+
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":false'));
|
|
7680
|
+
});
|
|
6508
7681
|
it('POST /api/taskforce/sync/workspace/apply-local should apply document review session upserts and deletes', async () => {
|
|
6509
7682
|
core.resolvePreferredWorkspaceId.mockReturnValue('workspace-local');
|
|
6510
7683
|
routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
@@ -7249,7 +8422,7 @@ describe('Server Routes', () => {
|
|
|
7249
8422
|
]);
|
|
7250
8423
|
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
7251
8424
|
});
|
|
7252
|
-
it('POST /api/taskforce/sync/workspace/apply-local should
|
|
8425
|
+
it('POST /api/taskforce/sync/workspace/apply-local should adopt cloud document references during normal sync even when content is unchanged', async () => {
|
|
7253
8426
|
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-apply-doc-metadata-'));
|
|
7254
8427
|
const projectRoot = fs.realpathSync(tmpRoot);
|
|
7255
8428
|
const basePath = path.join(tmpRoot, '.taskforce');
|
|
@@ -7303,7 +8476,7 @@ describe('Server Routes', () => {
|
|
|
7303
8476
|
const res = createMockRes();
|
|
7304
8477
|
await match?.handler(req, res, {});
|
|
7305
8478
|
expect(store.get('asset-sync-doc-meta-1', 'workspace-local')).toEqual(expect.objectContaining({
|
|
7306
|
-
referenceNumber:
|
|
8479
|
+
referenceNumber: 42
|
|
7307
8480
|
}));
|
|
7308
8481
|
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
7309
8482
|
});
|
|
@@ -7366,7 +8539,7 @@ describe('Server Routes', () => {
|
|
|
7366
8539
|
}));
|
|
7367
8540
|
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
7368
8541
|
});
|
|
7369
|
-
it('POST /api/taskforce/sync/workspace/apply-local should
|
|
8542
|
+
it('POST /api/taskforce/sync/workspace/apply-local should adopt cloud image references during normal sync even when content is unchanged', async () => {
|
|
7370
8543
|
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-apply-image-metadata-'));
|
|
7371
8544
|
const projectRoot = fs.realpathSync(tmpRoot);
|
|
7372
8545
|
const basePath = path.join(tmpRoot, '.taskforce');
|
|
@@ -7418,7 +8591,7 @@ describe('Server Routes', () => {
|
|
|
7418
8591
|
const res = createMockRes();
|
|
7419
8592
|
await match?.handler(req, res, {});
|
|
7420
8593
|
expect(store.get('asset-sync-image-meta-1', 'workspace-local')).toEqual(expect.objectContaining({
|
|
7421
|
-
referenceNumber:
|
|
8594
|
+
referenceNumber: 42
|
|
7422
8595
|
}));
|
|
7423
8596
|
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
7424
8597
|
});
|
|
@@ -7735,15 +8908,213 @@ describe('Server Routes', () => {
|
|
|
7735
8908
|
const res2 = createMockRes();
|
|
7736
8909
|
await match?.handler(req2, res2, {});
|
|
7737
8910
|
expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledTimes(1);
|
|
7738
|
-
expect(res2.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
7739
|
-
const secondPayload = JSON.parse(String(res2.end.mock.calls.at(-1)?.[0] || '{}'));
|
|
7740
|
-
expect(secondPayload.emittedEventIds).toEqual(firstPayload.emittedEventIds);
|
|
7741
|
-
expect(core.addAdminAuditLog).toHaveBeenCalledWith(expect.objectContaining({
|
|
7742
|
-
action: 'sync-workspace-push-success'
|
|
7743
|
-
}));
|
|
7744
|
-
expect(core.addAdminAuditLog).toHaveBeenCalledWith(expect.objectContaining({
|
|
7745
|
-
action: 'sync-workspace-push-idempotent-replay'
|
|
7746
|
-
}));
|
|
8911
|
+
expect(res2.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
8912
|
+
const secondPayload = JSON.parse(String(res2.end.mock.calls.at(-1)?.[0] || '{}'));
|
|
8913
|
+
expect(secondPayload.emittedEventIds).toEqual(firstPayload.emittedEventIds);
|
|
8914
|
+
expect(core.addAdminAuditLog).toHaveBeenCalledWith(expect.objectContaining({
|
|
8915
|
+
action: 'sync-workspace-push-success'
|
|
8916
|
+
}));
|
|
8917
|
+
expect(core.addAdminAuditLog).toHaveBeenCalledWith(expect.objectContaining({
|
|
8918
|
+
action: 'sync-workspace-push-idempotent-replay'
|
|
8919
|
+
}));
|
|
8920
|
+
});
|
|
8921
|
+
it('POST /api/taskforce/sync/workspace/push should still return success when idempotency persistence fails after apply', async () => {
|
|
8922
|
+
const authConfig = {
|
|
8923
|
+
runtimeMode: 'cloud',
|
|
8924
|
+
enabled: true,
|
|
8925
|
+
requiredForApi: true,
|
|
8926
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
8927
|
+
defaultRole: 'member',
|
|
8928
|
+
defaultWorkspaceId: 'default',
|
|
8929
|
+
sessionCookieName: 'taskforce_session',
|
|
8930
|
+
sessionSecret: 'test-secret',
|
|
8931
|
+
sessionTtlSeconds: 3600
|
|
8932
|
+
};
|
|
8933
|
+
routes = createRoutes(core, { authConfig });
|
|
8934
|
+
core.listUserWorkspaces.mockReturnValue([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
|
|
8935
|
+
const cookie = createSessionTokenCookie('session-sync-push-idempotency-write-fail', authConfig, {
|
|
8936
|
+
role: 'owner',
|
|
8937
|
+
workspaceId: 'workspace-1',
|
|
8938
|
+
userId: 'user-1'
|
|
8939
|
+
});
|
|
8940
|
+
core.applyWorkspaceSyncSnapshot.mockReturnValueOnce({
|
|
8941
|
+
total: 1,
|
|
8942
|
+
created: 1,
|
|
8943
|
+
updated: 0,
|
|
8944
|
+
archived: 0,
|
|
8945
|
+
unarchived: 0,
|
|
8946
|
+
skipped: 0
|
|
8947
|
+
});
|
|
8948
|
+
core.writePushIdempotency.mockImplementationOnce(() => {
|
|
8949
|
+
throw new Error('disk full');
|
|
8950
|
+
});
|
|
8951
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/push', routes);
|
|
8952
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/push', {
|
|
8953
|
+
workspaceId: 'workspace-1',
|
|
8954
|
+
idempotencyKey: 'push-idempotency-write-fail-1',
|
|
8955
|
+
changes: [
|
|
8956
|
+
{ op: 'upsert', task: { id: 'task-1', title: 'Task 1' }, archived: false }
|
|
8957
|
+
]
|
|
8958
|
+
}, { cookie });
|
|
8959
|
+
const res = createMockRes();
|
|
8960
|
+
await match?.handler(req, res, {});
|
|
8961
|
+
expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledTimes(1);
|
|
8962
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
8963
|
+
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":true'));
|
|
8964
|
+
expect(core.addAdminAuditLog).toHaveBeenCalledWith(expect.objectContaining({
|
|
8965
|
+
action: 'sync-workspace-push-idempotency-write-failed'
|
|
8966
|
+
}));
|
|
8967
|
+
expect(core.recordSyncEvent).toHaveBeenCalledWith('workspace-1', expect.objectContaining({
|
|
8968
|
+
eventType: 'push',
|
|
8969
|
+
status: 'success'
|
|
8970
|
+
}));
|
|
8971
|
+
});
|
|
8972
|
+
it('POST /api/taskforce/sync/workspace/push should still return success when applied-task readback fails after apply', async () => {
|
|
8973
|
+
const authConfig = {
|
|
8974
|
+
runtimeMode: 'cloud',
|
|
8975
|
+
enabled: true,
|
|
8976
|
+
requiredForApi: true,
|
|
8977
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
8978
|
+
defaultRole: 'member',
|
|
8979
|
+
defaultWorkspaceId: 'default',
|
|
8980
|
+
sessionCookieName: 'taskforce_session',
|
|
8981
|
+
sessionSecret: 'test-secret',
|
|
8982
|
+
sessionTtlSeconds: 3600
|
|
8983
|
+
};
|
|
8984
|
+
routes = createRoutes(core, { authConfig });
|
|
8985
|
+
core.listUserWorkspaces.mockReturnValue([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
|
|
8986
|
+
const cookie = createSessionTokenCookie('session-sync-push-upsert-echo-fail', authConfig, {
|
|
8987
|
+
role: 'owner',
|
|
8988
|
+
workspaceId: 'workspace-1',
|
|
8989
|
+
userId: 'user-1'
|
|
8990
|
+
});
|
|
8991
|
+
core.applyWorkspaceSyncSnapshot.mockReturnValueOnce({
|
|
8992
|
+
total: 1,
|
|
8993
|
+
created: 1,
|
|
8994
|
+
updated: 0,
|
|
8995
|
+
archived: 0,
|
|
8996
|
+
unarchived: 0,
|
|
8997
|
+
skipped: 0
|
|
8998
|
+
});
|
|
8999
|
+
core.getTask.mockImplementationOnce(() => {
|
|
9000
|
+
throw new Error('readback unavailable');
|
|
9001
|
+
});
|
|
9002
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/push', routes);
|
|
9003
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/push', {
|
|
9004
|
+
workspaceId: 'workspace-1',
|
|
9005
|
+
idempotencyKey: 'push-upsert-echo-fail-1',
|
|
9006
|
+
changes: [
|
|
9007
|
+
{ op: 'upsert', task: { id: 'task-1', title: 'Task 1' }, archived: false }
|
|
9008
|
+
]
|
|
9009
|
+
}, { cookie });
|
|
9010
|
+
const res = createMockRes();
|
|
9011
|
+
await match?.handler(req, res, {});
|
|
9012
|
+
expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledTimes(1);
|
|
9013
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
9014
|
+
const payload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
|
|
9015
|
+
expect(payload.success).toBe(true);
|
|
9016
|
+
expect(payload.appliedTaskUpserts).toEqual([]);
|
|
9017
|
+
expect(core.addAdminAuditLog).toHaveBeenCalledWith(expect.objectContaining({
|
|
9018
|
+
action: 'sync-workspace-push-upsert-echo-failed'
|
|
9019
|
+
}));
|
|
9020
|
+
expect(core.recordSyncEvent).toHaveBeenCalledWith('workspace-1', expect.objectContaining({
|
|
9021
|
+
eventType: 'push',
|
|
9022
|
+
status: 'success'
|
|
9023
|
+
}));
|
|
9024
|
+
});
|
|
9025
|
+
it('POST /api/taskforce/sync/workspace/push should still return success when success-path audit logging throws after apply', async () => {
|
|
9026
|
+
const authConfig = {
|
|
9027
|
+
runtimeMode: 'cloud',
|
|
9028
|
+
enabled: true,
|
|
9029
|
+
requiredForApi: true,
|
|
9030
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
9031
|
+
defaultRole: 'member',
|
|
9032
|
+
defaultWorkspaceId: 'default',
|
|
9033
|
+
sessionCookieName: 'taskforce_session',
|
|
9034
|
+
sessionSecret: 'test-secret',
|
|
9035
|
+
sessionTtlSeconds: 3600
|
|
9036
|
+
};
|
|
9037
|
+
routes = createRoutes(core, { authConfig });
|
|
9038
|
+
core.listUserWorkspaces.mockReturnValue([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
|
|
9039
|
+
const cookie = createSessionTokenCookie('session-sync-push-success-audit-fail', authConfig, {
|
|
9040
|
+
role: 'owner',
|
|
9041
|
+
workspaceId: 'workspace-1',
|
|
9042
|
+
userId: 'user-1'
|
|
9043
|
+
});
|
|
9044
|
+
core.applyWorkspaceSyncSnapshot.mockReturnValueOnce({
|
|
9045
|
+
total: 1,
|
|
9046
|
+
created: 1,
|
|
9047
|
+
updated: 0,
|
|
9048
|
+
archived: 0,
|
|
9049
|
+
unarchived: 0,
|
|
9050
|
+
skipped: 0
|
|
9051
|
+
});
|
|
9052
|
+
const originalAddAdminAuditLog = core.addAdminAuditLog.getMockImplementation();
|
|
9053
|
+
core.addAdminAuditLog.mockImplementation((entry) => {
|
|
9054
|
+
if (entry?.action === 'sync-workspace-push-success') {
|
|
9055
|
+
throw new Error('audit unavailable');
|
|
9056
|
+
}
|
|
9057
|
+
return originalAddAdminAuditLog ? originalAddAdminAuditLog(entry) : undefined;
|
|
9058
|
+
});
|
|
9059
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/push', routes);
|
|
9060
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/push', {
|
|
9061
|
+
workspaceId: 'workspace-1',
|
|
9062
|
+
idempotencyKey: 'push-success-audit-fail-1',
|
|
9063
|
+
changes: [
|
|
9064
|
+
{ op: 'upsert', task: { id: 'task-1', title: 'Task 1' }, archived: false }
|
|
9065
|
+
]
|
|
9066
|
+
}, { cookie });
|
|
9067
|
+
const res = createMockRes();
|
|
9068
|
+
await match?.handler(req, res, {});
|
|
9069
|
+
expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledTimes(1);
|
|
9070
|
+
expect(res.writeHead).toHaveBeenCalledTimes(1);
|
|
9071
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
9072
|
+
expect(res.end).toHaveBeenCalledTimes(1);
|
|
9073
|
+
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":true'));
|
|
9074
|
+
expect(core.recordSyncEvent).toHaveBeenCalledWith('workspace-1', expect.objectContaining({
|
|
9075
|
+
eventType: 'push',
|
|
9076
|
+
status: 'success'
|
|
9077
|
+
}));
|
|
9078
|
+
});
|
|
9079
|
+
it('POST /api/taskforce/sync/workspace/push should still return error response when failure-path telemetry throws', async () => {
|
|
9080
|
+
const authConfig = {
|
|
9081
|
+
runtimeMode: 'cloud',
|
|
9082
|
+
enabled: true,
|
|
9083
|
+
requiredForApi: true,
|
|
9084
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
9085
|
+
defaultRole: 'member',
|
|
9086
|
+
defaultWorkspaceId: 'default',
|
|
9087
|
+
sessionCookieName: 'taskforce_session',
|
|
9088
|
+
sessionSecret: 'test-secret',
|
|
9089
|
+
sessionTtlSeconds: 3600
|
|
9090
|
+
};
|
|
9091
|
+
routes = createRoutes(core, { authConfig });
|
|
9092
|
+
core.listUserWorkspaces.mockReturnValue([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
|
|
9093
|
+
const cookie = createSessionTokenCookie('session-sync-push-failure-telemetry-throw', authConfig, {
|
|
9094
|
+
role: 'owner',
|
|
9095
|
+
workspaceId: 'workspace-1',
|
|
9096
|
+
userId: 'user-1'
|
|
9097
|
+
});
|
|
9098
|
+
core.applyWorkspaceSyncSnapshot.mockImplementationOnce(() => {
|
|
9099
|
+
throw new Error('apply failed');
|
|
9100
|
+
});
|
|
9101
|
+
core.recordSyncEvent.mockImplementationOnce(() => {
|
|
9102
|
+
throw new Error('telemetry unavailable');
|
|
9103
|
+
});
|
|
9104
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/push', routes);
|
|
9105
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/push', {
|
|
9106
|
+
workspaceId: 'workspace-1',
|
|
9107
|
+
changes: [
|
|
9108
|
+
{ op: 'upsert', task: { id: 'task-1', title: 'Task 1' }, archived: false }
|
|
9109
|
+
]
|
|
9110
|
+
}, { cookie });
|
|
9111
|
+
const res = createMockRes();
|
|
9112
|
+
await match?.handler(req, res, {});
|
|
9113
|
+
expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledTimes(1);
|
|
9114
|
+
expect(res.writeHead).toHaveBeenCalledTimes(1);
|
|
9115
|
+
expect(res.writeHead).toHaveBeenCalledWith(400, expect.any(Object));
|
|
9116
|
+
expect(res.end).toHaveBeenCalledTimes(1);
|
|
9117
|
+
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":false'));
|
|
7747
9118
|
});
|
|
7748
9119
|
it('POST /api/taskforce/sync/workspace/push should persist synced cloud attachments into R2-backed canonical assets', async () => {
|
|
7749
9120
|
const authConfig = {
|
|
@@ -8775,6 +10146,127 @@ describe('Server Routes', () => {
|
|
|
8775
10146
|
expect(res.setHeader).toHaveBeenCalledWith('Set-Cookie', expect.stringContaining('taskforce_session='));
|
|
8776
10147
|
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
8777
10148
|
});
|
|
10149
|
+
it('POST /api/taskforce/session/workspace should allow a read-only user to switch to another active workspace', async () => {
|
|
10150
|
+
const authConfig = {
|
|
10151
|
+
runtimeMode: 'cloud',
|
|
10152
|
+
enabled: true,
|
|
10153
|
+
requiredForApi: true,
|
|
10154
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
10155
|
+
defaultRole: 'member',
|
|
10156
|
+
defaultWorkspaceId: 'default',
|
|
10157
|
+
sessionCookieName: 'taskforce_session',
|
|
10158
|
+
sessionSecret: 'test-secret',
|
|
10159
|
+
sessionTtlSeconds: 3600
|
|
10160
|
+
};
|
|
10161
|
+
routes = createRoutes(core, { authConfig });
|
|
10162
|
+
const cookie = createSessionTokenCookie('session-read-only', authConfig, {
|
|
10163
|
+
role: 'read-only',
|
|
10164
|
+
workspaceId: 'workspace-shared',
|
|
10165
|
+
userId: 'user-1'
|
|
10166
|
+
});
|
|
10167
|
+
core.listUserWorkspaces.mockReturnValue([
|
|
10168
|
+
{
|
|
10169
|
+
id: 'workspace-shared',
|
|
10170
|
+
slug: 'workspace-shared',
|
|
10171
|
+
name: 'Shared Workspace',
|
|
10172
|
+
description: null,
|
|
10173
|
+
role: 'read-only',
|
|
10174
|
+
status: 'active',
|
|
10175
|
+
betaAccess: true
|
|
10176
|
+
},
|
|
10177
|
+
{
|
|
10178
|
+
id: 'workspace-home',
|
|
10179
|
+
slug: 'workspace-home',
|
|
10180
|
+
name: 'Home Workspace',
|
|
10181
|
+
description: null,
|
|
10182
|
+
role: 'owner',
|
|
10183
|
+
status: 'active',
|
|
10184
|
+
betaAccess: true
|
|
10185
|
+
}
|
|
10186
|
+
]);
|
|
10187
|
+
core.resolveUserAccess.mockReturnValue({
|
|
10188
|
+
userId: 'user-1',
|
|
10189
|
+
workspaceId: 'workspace-home',
|
|
10190
|
+
role: 'owner',
|
|
10191
|
+
permissionMode: 'read-write',
|
|
10192
|
+
disabled: false,
|
|
10193
|
+
betaAccess: true
|
|
10194
|
+
});
|
|
10195
|
+
const match = matchRoute('POST', '/api/taskforce/session/workspace', routes);
|
|
10196
|
+
const req = createMockReq('POST', '/api/taskforce/session/workspace', {
|
|
10197
|
+
workspaceId: 'workspace-home'
|
|
10198
|
+
}, {
|
|
10199
|
+
cookie
|
|
10200
|
+
});
|
|
10201
|
+
const res = createMockRes();
|
|
10202
|
+
res.setHeader = vi.fn();
|
|
10203
|
+
await match?.handler(req, res, {});
|
|
10204
|
+
expect(core.resolveUserAccess).toHaveBeenCalledWith('user-1', 'workspace-home', 'read-only');
|
|
10205
|
+
expect(res.setHeader).toHaveBeenCalledWith('Set-Cookie', expect.stringContaining('taskforce_session='));
|
|
10206
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
10207
|
+
const payload = JSON.parse(String(res.end.mock.calls[0]?.[0] || '{}'));
|
|
10208
|
+
expect(payload).toMatchObject({ success: true, workspaceId: 'workspace-home', role: 'owner' });
|
|
10209
|
+
});
|
|
10210
|
+
it('POST /api/taskforce/session/workspace should allow switching into an active read-only workspace', async () => {
|
|
10211
|
+
const authConfig = {
|
|
10212
|
+
runtimeMode: 'cloud',
|
|
10213
|
+
enabled: true,
|
|
10214
|
+
requiredForApi: true,
|
|
10215
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
10216
|
+
defaultRole: 'member',
|
|
10217
|
+
defaultWorkspaceId: 'default',
|
|
10218
|
+
sessionCookieName: 'taskforce_session',
|
|
10219
|
+
sessionSecret: 'test-secret',
|
|
10220
|
+
sessionTtlSeconds: 3600
|
|
10221
|
+
};
|
|
10222
|
+
routes = createRoutes(core, { authConfig });
|
|
10223
|
+
const cookie = createSessionTokenCookie('session-member', authConfig, {
|
|
10224
|
+
role: 'owner',
|
|
10225
|
+
workspaceId: 'workspace-home',
|
|
10226
|
+
userId: 'user-1'
|
|
10227
|
+
});
|
|
10228
|
+
core.listUserWorkspaces.mockReturnValue([
|
|
10229
|
+
{
|
|
10230
|
+
id: 'workspace-home',
|
|
10231
|
+
slug: 'workspace-home',
|
|
10232
|
+
name: 'Home Workspace',
|
|
10233
|
+
description: null,
|
|
10234
|
+
role: 'owner',
|
|
10235
|
+
status: 'active',
|
|
10236
|
+
betaAccess: true
|
|
10237
|
+
},
|
|
10238
|
+
{
|
|
10239
|
+
id: 'workspace-shared',
|
|
10240
|
+
slug: 'workspace-shared',
|
|
10241
|
+
name: 'Shared Workspace',
|
|
10242
|
+
description: null,
|
|
10243
|
+
role: 'read-only',
|
|
10244
|
+
status: 'active',
|
|
10245
|
+
betaAccess: true
|
|
10246
|
+
}
|
|
10247
|
+
]);
|
|
10248
|
+
core.resolveUserAccess.mockReturnValue({
|
|
10249
|
+
userId: 'user-1',
|
|
10250
|
+
workspaceId: 'workspace-shared',
|
|
10251
|
+
role: 'read-only',
|
|
10252
|
+
permissionMode: 'read-only',
|
|
10253
|
+
disabled: false,
|
|
10254
|
+
betaAccess: true
|
|
10255
|
+
});
|
|
10256
|
+
const match = matchRoute('POST', '/api/taskforce/session/workspace', routes);
|
|
10257
|
+
const req = createMockReq('POST', '/api/taskforce/session/workspace', {
|
|
10258
|
+
workspaceId: 'workspace-shared'
|
|
10259
|
+
}, {
|
|
10260
|
+
cookie
|
|
10261
|
+
});
|
|
10262
|
+
const res = createMockRes();
|
|
10263
|
+
res.setHeader = vi.fn();
|
|
10264
|
+
await match?.handler(req, res, {});
|
|
10265
|
+
expect(res.setHeader).toHaveBeenCalledWith('Set-Cookie', expect.stringContaining('taskforce_session='));
|
|
10266
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
10267
|
+
const payload = JSON.parse(String(res.end.mock.calls[0]?.[0] || '{}'));
|
|
10268
|
+
expect(payload).toMatchObject({ success: true, workspaceId: 'workspace-shared', role: 'read-only' });
|
|
10269
|
+
});
|
|
8778
10270
|
it('POST /api/taskforce/session/workspace should be disabled in local runtime', async () => {
|
|
8779
10271
|
const authConfig = {
|
|
8780
10272
|
runtimeMode: 'local',
|
|
@@ -8871,6 +10363,7 @@ describe('Server Routes', () => {
|
|
|
8871
10363
|
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
8872
10364
|
expect(payload.authenticated).toBe(true);
|
|
8873
10365
|
expect(payload.betaAccess).toBe(false);
|
|
10366
|
+
expect(res.setHeader).toHaveBeenCalledWith('Server-Timing', expect.stringContaining('auth-session'));
|
|
8874
10367
|
expect(core.getUserBetaAccess).toHaveBeenCalledWith('u-no-workspace');
|
|
8875
10368
|
});
|
|
8876
10369
|
it('GET /api/taskforce/auth/session should expose avatarUrl from the resolved profile', async () => {
|
|
@@ -9081,6 +10574,74 @@ describe('Server Routes', () => {
|
|
|
9081
10574
|
mimeType: 'image/png'
|
|
9082
10575
|
}));
|
|
9083
10576
|
});
|
|
10577
|
+
it('POST /api/taskforce/auth/profile/avatar/upload should store the avatar body through object storage', async () => {
|
|
10578
|
+
const authConfig = {
|
|
10579
|
+
runtimeMode: 'cloud',
|
|
10580
|
+
enabled: true,
|
|
10581
|
+
requiredForApi: true,
|
|
10582
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
10583
|
+
defaultRole: 'member',
|
|
10584
|
+
defaultWorkspaceId: 'default',
|
|
10585
|
+
sessionCookieName: 'taskforce_session',
|
|
10586
|
+
sessionSecret: 'test-secret',
|
|
10587
|
+
sessionTtlSeconds: 3600
|
|
10588
|
+
};
|
|
10589
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
10590
|
+
ok: true,
|
|
10591
|
+
status: 200,
|
|
10592
|
+
statusText: 'OK',
|
|
10593
|
+
text: async () => ''
|
|
10594
|
+
});
|
|
10595
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
10596
|
+
const routesWithStorage = createRoutes(core, {
|
|
10597
|
+
authConfig,
|
|
10598
|
+
objectStorage: {
|
|
10599
|
+
provider: 'r2',
|
|
10600
|
+
r2: {
|
|
10601
|
+
accountId: 'acct-1',
|
|
10602
|
+
bucket: 'taskforce-docs',
|
|
10603
|
+
endpoint: 'https://example.r2.cloudflarestorage.com',
|
|
10604
|
+
accessKeyId: 'access-key-1',
|
|
10605
|
+
secretAccessKey: 'secret-key-1',
|
|
10606
|
+
keyPrefix: 'taskforce',
|
|
10607
|
+
signedUploadTtlSeconds: 600,
|
|
10608
|
+
signedDownloadTtlSeconds: 300
|
|
10609
|
+
}
|
|
10610
|
+
}
|
|
10611
|
+
});
|
|
10612
|
+
core.getUserAvatarUploadDraft.mockReturnValueOnce({
|
|
10613
|
+
draftId: 'avatar-draft-1',
|
|
10614
|
+
userId: 'u1',
|
|
10615
|
+
storageKey: 'users/u1/profile/avatar/avatar-draft-1-headshot.png',
|
|
10616
|
+
mimeType: 'image/png',
|
|
10617
|
+
finalizedAt: null,
|
|
10618
|
+
discardedAt: null
|
|
10619
|
+
});
|
|
10620
|
+
const cookie = createSessionTokenCookie('session-avatar-upload', authConfig, {
|
|
10621
|
+
userId: 'u1',
|
|
10622
|
+
role: 'member',
|
|
10623
|
+
workspaceId: 'workspace-1'
|
|
10624
|
+
});
|
|
10625
|
+
const match = matchRoute('POST', '/api/taskforce/auth/profile/avatar/upload', routesWithStorage);
|
|
10626
|
+
const req = createMockReq('POST', '/api/taskforce/auth/profile/avatar/upload', Buffer.from([1, 2, 3, 4]), {
|
|
10627
|
+
cookie,
|
|
10628
|
+
'content-type': 'image/png',
|
|
10629
|
+
'x-taskforce-avatar-draft-id': 'avatar-draft-1'
|
|
10630
|
+
});
|
|
10631
|
+
const res = createMockRes();
|
|
10632
|
+
await match?.handler(req, res, {});
|
|
10633
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
10634
|
+
expect(payload.success).toBe(true);
|
|
10635
|
+
expect(payload.draftId).toBe('avatar-draft-1');
|
|
10636
|
+
const r2Call = fetchMock.mock.calls[0];
|
|
10637
|
+
expect(String(r2Call?.[0])).toContain('https://example.r2.cloudflarestorage.com/taskforce-docs/taskforce/users/u1/profile/avatar/avatar-draft-1-headshot.png');
|
|
10638
|
+
expect(r2Call?.[1]).toEqual(expect.objectContaining({
|
|
10639
|
+
method: 'PUT',
|
|
10640
|
+
body: expect.any(Uint8Array)
|
|
10641
|
+
}));
|
|
10642
|
+
expect((r2Call?.[1]?.headers)['content-type']).toBe('image/png');
|
|
10643
|
+
vi.unstubAllGlobals();
|
|
10644
|
+
});
|
|
9084
10645
|
it('POST /api/taskforce/auth/profile/avatar/finalize should validate and finalize the uploaded draft', async () => {
|
|
9085
10646
|
const authConfig = {
|
|
9086
10647
|
runtimeMode: 'cloud',
|
|
@@ -9205,6 +10766,56 @@ describe('Server Routes', () => {
|
|
|
9205
10766
|
allowed: true
|
|
9206
10767
|
});
|
|
9207
10768
|
});
|
|
10769
|
+
it('GET /api/taskforce/account/team-management-access should resolve against the authenticated session user and workspace', async () => {
|
|
10770
|
+
const authConfig = {
|
|
10771
|
+
runtimeMode: 'cloud',
|
|
10772
|
+
enabled: true,
|
|
10773
|
+
requiredForApi: true,
|
|
10774
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
10775
|
+
defaultRole: 'member',
|
|
10776
|
+
defaultWorkspaceId: 'default',
|
|
10777
|
+
sessionCookieName: 'taskforce_session',
|
|
10778
|
+
sessionSecret: 'test-secret',
|
|
10779
|
+
sessionTtlSeconds: 3600
|
|
10780
|
+
};
|
|
10781
|
+
routes = createRoutes(core, { authConfig });
|
|
10782
|
+
core.listUserWorkspaces.mockReturnValueOnce([
|
|
10783
|
+
{ id: 'workspace-auth-existing-home', slug: 'home', name: 'Home', description: null, role: 'owner', status: 'active', betaAccess: true },
|
|
10784
|
+
{ id: 'workspace-invited', slug: 'invited', name: 'Invited', description: null, role: 'member', status: 'active', betaAccess: true }
|
|
10785
|
+
]);
|
|
10786
|
+
core.resolveUserAccess.mockImplementation((userId, workspaceId, role) => ({
|
|
10787
|
+
userId,
|
|
10788
|
+
workspaceId,
|
|
10789
|
+
role: userId === 'joined-member' && workspaceId === 'workspace-invited' ? 'member' : role,
|
|
10790
|
+
disabled: false,
|
|
10791
|
+
betaAccess: true
|
|
10792
|
+
}));
|
|
10793
|
+
const cookie = createSessionTokenCookie('session-joined-member', authConfig, {
|
|
10794
|
+
userId: 'joined-member',
|
|
10795
|
+
role: 'member',
|
|
10796
|
+
workspaceId: 'workspace-invited'
|
|
10797
|
+
});
|
|
10798
|
+
const match = matchRoute('GET', '/api/taskforce/account/team-management-access', routes);
|
|
10799
|
+
const req = createMockReq('GET', '/api/taskforce/account/team-management-access', undefined, {
|
|
10800
|
+
cookie,
|
|
10801
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
10802
|
+
'x-taskforce-user-id': 'workspace-auth-existing-home-owner',
|
|
10803
|
+
'x-taskforce-workspace-id': 'workspace-auth-existing-home',
|
|
10804
|
+
'x-taskforce-role': 'owner'
|
|
10805
|
+
});
|
|
10806
|
+
const res = createMockRes();
|
|
10807
|
+
await match?.handler(req, res, {});
|
|
10808
|
+
expect(core.resolveUserAccess).toHaveBeenCalledWith('joined-member', 'workspace-invited', 'member');
|
|
10809
|
+
expect(core.getAccountEntitlementsPayload).toHaveBeenCalledWith({ userId: 'joined-member', workspaceId: 'workspace-invited' });
|
|
10810
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
10811
|
+
expect(payload).toMatchObject({
|
|
10812
|
+
workspaceId: 'workspace-invited',
|
|
10813
|
+
role: 'member',
|
|
10814
|
+
canManageWorkspace: false,
|
|
10815
|
+
teamPlanMode: 'team',
|
|
10816
|
+
allowed: false
|
|
10817
|
+
});
|
|
10818
|
+
});
|
|
9208
10819
|
it('GET /api/taskforce/account/team-management-access should fall back to the hinted workspace role when local access cannot resolve the cloud user id', async () => {
|
|
9209
10820
|
core.resolveUserAccess.mockReturnValueOnce(null);
|
|
9210
10821
|
const match = matchRoute('GET', '/api/taskforce/account/team-management-access', routes);
|
|
@@ -9281,6 +10892,40 @@ describe('Server Routes', () => {
|
|
|
9281
10892
|
planSelectionRequired: false
|
|
9282
10893
|
});
|
|
9283
10894
|
});
|
|
10895
|
+
it('GET /api/taskforce/account/ai-profile-seat-usage should return cloud-authoritative ai seat usage', async () => {
|
|
10896
|
+
const authConfig = {
|
|
10897
|
+
runtimeMode: 'cloud',
|
|
10898
|
+
enabled: true,
|
|
10899
|
+
requiredForApi: true,
|
|
10900
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
10901
|
+
defaultRole: 'member',
|
|
10902
|
+
defaultWorkspaceId: 'default',
|
|
10903
|
+
sessionCookieName: 'taskforce_session',
|
|
10904
|
+
sessionSecret: 'test-secret',
|
|
10905
|
+
sessionTtlSeconds: 3600
|
|
10906
|
+
};
|
|
10907
|
+
routes = createRoutes(core, { authConfig });
|
|
10908
|
+
core.countAiProfileUsage.mockReturnValueOnce({ used: 0, limit: 20, remaining: 20 });
|
|
10909
|
+
const match = matchRoute('GET', '/api/taskforce/account/ai-profile-seat-usage', routes);
|
|
10910
|
+
const cookie = createSessionTokenCookie('session-ai-seat-usage', authConfig, {
|
|
10911
|
+
userId: 'u1',
|
|
10912
|
+
role: 'owner',
|
|
10913
|
+
workspaceId: 'workspace-1'
|
|
10914
|
+
});
|
|
10915
|
+
const req = createMockReq('GET', '/api/taskforce/account/ai-profile-seat-usage?workspaceId=workspace-1', undefined, {
|
|
10916
|
+
cookie,
|
|
10917
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
10918
|
+
'x-taskforce-user-id': 'u1',
|
|
10919
|
+
'x-taskforce-role': 'owner',
|
|
10920
|
+
'x-taskforce-workspace-id': 'workspace-1'
|
|
10921
|
+
});
|
|
10922
|
+
const res = createMockRes();
|
|
10923
|
+
await match?.handler(req, res, {});
|
|
10924
|
+
expect(core.countAiProfileUsage).toHaveBeenCalledWith('workspace-1');
|
|
10925
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
10926
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
10927
|
+
expect(payload.aiProfileSeatUsage).toEqual({ used: 0, limit: 20, remaining: 20 });
|
|
10928
|
+
});
|
|
9284
10929
|
it('GET /api/taskforce/account/access-status should return pending plan-selection gate', async () => {
|
|
9285
10930
|
core.getAccountAccessStatus.mockReturnValueOnce({
|
|
9286
10931
|
gate: 'plan_selection_required',
|
|
@@ -9353,6 +10998,78 @@ describe('Server Routes', () => {
|
|
|
9353
10998
|
error.code = 'ENTITLEMENT_NOT_FOUND';
|
|
9354
10999
|
throw error;
|
|
9355
11000
|
});
|
|
11001
|
+
core.countAiProfileUsage.mockReturnValueOnce({ used: 0, limit: 20, remaining: 20 });
|
|
11002
|
+
const match = matchRoute('GET', '/api/taskforce/account/profile-summary', routes);
|
|
11003
|
+
const req = createMockReq('GET', '/api/taskforce/account/profile-summary?workspaceId=workspace-1', undefined, {
|
|
11004
|
+
'x-taskforce-user-id': 'u1'
|
|
11005
|
+
});
|
|
11006
|
+
const res = createMockRes();
|
|
11007
|
+
await match?.handler(req, res, {});
|
|
11008
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
11009
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
11010
|
+
expect(payload).toMatchObject({
|
|
11011
|
+
planId: 'pro',
|
|
11012
|
+
planVersionId: 'pro-v1',
|
|
11013
|
+
entitlementState: 'checkout_pending',
|
|
11014
|
+
gate: 'checkout_pending',
|
|
11015
|
+
message: 'Complete checkout to continue.',
|
|
11016
|
+
billingConflictCode: null,
|
|
11017
|
+
planSelectionRequired: false,
|
|
11018
|
+
workspaceId: 'workspace-1',
|
|
11019
|
+
workspaceRole: 'owner',
|
|
11020
|
+
canManageWorkspace: true,
|
|
11021
|
+
teamPlanMode: 'personal',
|
|
11022
|
+
teamManagementAllowed: false,
|
|
11023
|
+
aiProfileSeatUsage: {
|
|
11024
|
+
used: 0,
|
|
11025
|
+
limit: 20,
|
|
11026
|
+
remaining: 20
|
|
11027
|
+
}
|
|
11028
|
+
});
|
|
11029
|
+
});
|
|
11030
|
+
it('GET /api/taskforce/account/profile-summary should not leak stale billing mirror plan identity when entitlement is missing', async () => {
|
|
11031
|
+
core.resolveUserAccess.mockReturnValueOnce({
|
|
11032
|
+
userId: 'u1',
|
|
11033
|
+
workspaceId: 'workspace-1',
|
|
11034
|
+
role: 'member',
|
|
11035
|
+
disabled: false,
|
|
11036
|
+
betaAccess: true
|
|
11037
|
+
});
|
|
11038
|
+
core.getAccountAccessStatus.mockReturnValueOnce({
|
|
11039
|
+
gate: 'missing_entitlement',
|
|
11040
|
+
hasEntitlement: false,
|
|
11041
|
+
canAccessApp: false,
|
|
11042
|
+
canAccessPlans: true,
|
|
11043
|
+
planSelectionRequired: false,
|
|
11044
|
+
message: 'No entitlement is linked to this account.',
|
|
11045
|
+
entitlement: null
|
|
11046
|
+
});
|
|
11047
|
+
core.countAiProfileUsage.mockReturnValueOnce({ used: 0, limit: 20, remaining: 20 });
|
|
11048
|
+
const baseDb = core.getDatabaseAdapter();
|
|
11049
|
+
core.getDatabaseAdapter = vi.fn().mockReturnValue({
|
|
11050
|
+
...baseDb,
|
|
11051
|
+
prepare: vi.fn((sql) => {
|
|
11052
|
+
if (sql.includes('FROM user_billing')) {
|
|
11053
|
+
return {
|
|
11054
|
+
get: () => ({
|
|
11055
|
+
stripe_status: 'active',
|
|
11056
|
+
stripe_customer_id: 'cus_stale',
|
|
11057
|
+
stripe_subscription_id: 'sub_stale',
|
|
11058
|
+
stripe_price_id: 'price_stale',
|
|
11059
|
+
billing_interval: 'month',
|
|
11060
|
+
entitlement_state: 'active',
|
|
11061
|
+
tier: 'team-5'
|
|
11062
|
+
})
|
|
11063
|
+
};
|
|
11064
|
+
}
|
|
11065
|
+
if (sql.includes('FROM plan_catalog')) {
|
|
11066
|
+
return {
|
|
11067
|
+
get: () => ({ display_name: 'Team 5' })
|
|
11068
|
+
};
|
|
11069
|
+
}
|
|
11070
|
+
return baseDb.prepare(sql);
|
|
11071
|
+
})
|
|
11072
|
+
});
|
|
9356
11073
|
const match = matchRoute('GET', '/api/taskforce/account/profile-summary', routes);
|
|
9357
11074
|
const req = createMockReq('GET', '/api/taskforce/account/profile-summary?workspaceId=workspace-1', undefined, {
|
|
9358
11075
|
'x-taskforce-user-id': 'u1'
|
|
@@ -9362,18 +11079,15 @@ describe('Server Routes', () => {
|
|
|
9362
11079
|
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
9363
11080
|
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
9364
11081
|
expect(payload).toMatchObject({
|
|
9365
|
-
planId:
|
|
9366
|
-
planVersionId:
|
|
9367
|
-
entitlementState:
|
|
9368
|
-
gate: '
|
|
9369
|
-
message: '
|
|
9370
|
-
billingConflictCode: null,
|
|
11082
|
+
planId: null,
|
|
11083
|
+
planVersionId: null,
|
|
11084
|
+
entitlementState: null,
|
|
11085
|
+
gate: 'missing_entitlement',
|
|
11086
|
+
message: 'No entitlement is linked to this account.',
|
|
9371
11087
|
planSelectionRequired: false,
|
|
9372
|
-
|
|
9373
|
-
|
|
9374
|
-
|
|
9375
|
-
teamPlanMode: 'personal',
|
|
9376
|
-
teamManagementAllowed: false
|
|
11088
|
+
stripeStatus: 'active',
|
|
11089
|
+
stripeCustomerId: 'cus_stale',
|
|
11090
|
+
stripeSubscriptionId: 'sub_stale'
|
|
9377
11091
|
});
|
|
9378
11092
|
});
|
|
9379
11093
|
it('GET /api/taskforce/account/entitlements should surface checkout-pending gate when billing is not complete', async () => {
|
|
@@ -9445,6 +11159,20 @@ describe('Server Routes', () => {
|
|
|
9445
11159
|
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"code":"PLAN_SELECTION_REQUIRED"'));
|
|
9446
11160
|
expect(core.listTasks).not.toHaveBeenCalled();
|
|
9447
11161
|
});
|
|
11162
|
+
it('GET /api/taskforce/tasks should require authenticated cloud users', async () => {
|
|
11163
|
+
const authConfig = createCloudAuthConfig();
|
|
11164
|
+
routes = createRoutes(core, { authConfig });
|
|
11165
|
+
core.listTasks.mockReturnValue({ tasks: [] });
|
|
11166
|
+
const match = matchRoute('GET', '/api/taskforce/tasks', routes);
|
|
11167
|
+
const req = createMockReq('GET', '/api/taskforce/tasks', undefined, {
|
|
11168
|
+
'x-taskforce-runtime-mode': 'cloud'
|
|
11169
|
+
});
|
|
11170
|
+
const res = createMockRes();
|
|
11171
|
+
await match?.handler(req, res, {});
|
|
11172
|
+
expect(res.writeHead).toHaveBeenCalledWith(401, expect.any(Object));
|
|
11173
|
+
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"Authentication required."'));
|
|
11174
|
+
expect(core.listTasks).not.toHaveBeenCalled();
|
|
11175
|
+
});
|
|
9448
11176
|
it('GET /api/taskforce/tasks should block authenticated users with missing entitlements', async () => {
|
|
9449
11177
|
const authConfig = {
|
|
9450
11178
|
runtimeMode: 'cloud',
|
|
@@ -9552,17 +11280,31 @@ describe('Server Routes', () => {
|
|
|
9552
11280
|
try {
|
|
9553
11281
|
core.getTenantId = vi.fn().mockReturnValue('default');
|
|
9554
11282
|
core.getDatabaseAdapter = vi.fn().mockReturnValue({
|
|
9555
|
-
prepare: vi.fn().
|
|
9556
|
-
|
|
9557
|
-
{
|
|
9558
|
-
|
|
9559
|
-
|
|
9560
|
-
|
|
9561
|
-
|
|
9562
|
-
|
|
9563
|
-
|
|
9564
|
-
|
|
9565
|
-
|
|
11283
|
+
prepare: vi.fn().mockImplementation((sql) => {
|
|
11284
|
+
if (sql.includes('FROM billing_price_mappings')) {
|
|
11285
|
+
return {
|
|
11286
|
+
get: vi.fn().mockReturnValue({
|
|
11287
|
+
count: 1,
|
|
11288
|
+
max_updated_at: '2026-01-01T00:00:00.000Z'
|
|
11289
|
+
}),
|
|
11290
|
+
all: vi.fn().mockReturnValue([
|
|
11291
|
+
{
|
|
11292
|
+
plan_version_id: 'free-v1',
|
|
11293
|
+
billing_interval: 'month',
|
|
11294
|
+
stripe_price_id: 'free:free-v1:month',
|
|
11295
|
+
pricing_type: 'free',
|
|
11296
|
+
unit_amount: 0,
|
|
11297
|
+
currency: null
|
|
11298
|
+
}
|
|
11299
|
+
])
|
|
11300
|
+
};
|
|
11301
|
+
}
|
|
11302
|
+
return {
|
|
11303
|
+
get: vi.fn().mockReturnValue({
|
|
11304
|
+
count: 0,
|
|
11305
|
+
max_updated_at: null
|
|
11306
|
+
})
|
|
11307
|
+
};
|
|
9566
11308
|
})
|
|
9567
11309
|
});
|
|
9568
11310
|
core.listPlanCatalog.mockReturnValueOnce([
|
|
@@ -9582,6 +11324,7 @@ describe('Server Routes', () => {
|
|
|
9582
11324
|
}
|
|
9583
11325
|
}
|
|
9584
11326
|
]);
|
|
11327
|
+
core.listPlanFeatureCatalog = vi.fn().mockReturnValue([]);
|
|
9585
11328
|
routes = createRoutes(core);
|
|
9586
11329
|
const match = matchRoute('GET', '/api/taskforce/public/pricing', routes);
|
|
9587
11330
|
const req = createMockReq('GET', '/api/taskforce/public/pricing', undefined, {
|
|
@@ -9593,7 +11336,15 @@ describe('Server Routes', () => {
|
|
|
9593
11336
|
const payload = JSON.parse(String(res.end.mock.calls[0][0]));
|
|
9594
11337
|
expect(payload.plans).toHaveLength(1);
|
|
9595
11338
|
expect(payload.plans[0].features).toEqual([
|
|
9596
|
-
{
|
|
11339
|
+
{
|
|
11340
|
+
featureKey: 'ai.assistance',
|
|
11341
|
+
access: 'enabled',
|
|
11342
|
+
config: {},
|
|
11343
|
+
label: null,
|
|
11344
|
+
description: null,
|
|
11345
|
+
publicLabel: null,
|
|
11346
|
+
publicDescription: null
|
|
11347
|
+
}
|
|
9597
11348
|
]);
|
|
9598
11349
|
}
|
|
9599
11350
|
finally {
|
|
@@ -9625,6 +11376,7 @@ describe('Server Routes', () => {
|
|
|
9625
11376
|
expect(core.upsertPlanCatalog).toHaveBeenCalledWith({
|
|
9626
11377
|
planId: 'enterprise',
|
|
9627
11378
|
displayName: 'Enterprise',
|
|
11379
|
+
description: undefined,
|
|
9628
11380
|
status: 'active',
|
|
9629
11381
|
initialTrialDays: 0,
|
|
9630
11382
|
initialMode: undefined,
|
|
@@ -9644,6 +11396,42 @@ describe('Server Routes', () => {
|
|
|
9644
11396
|
expect(core.listPlanCatalog).toHaveBeenCalled();
|
|
9645
11397
|
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
9646
11398
|
});
|
|
11399
|
+
it('GET /api/platform/onboarding-plan-options should return onboarding plan options for system admin', async () => {
|
|
11400
|
+
core.listOnboardingPlanOptions = vi.fn().mockReturnValue([
|
|
11401
|
+
{
|
|
11402
|
+
planId: 'free',
|
|
11403
|
+
planDisplayName: 'Free',
|
|
11404
|
+
defaultPlanVersionId: 'free-v1',
|
|
11405
|
+
defaultVersionNumber: 1,
|
|
11406
|
+
planStatus: 'active',
|
|
11407
|
+
supportsAutoGrantFree: true,
|
|
11408
|
+
supportsAutoStartTrial: false
|
|
11409
|
+
}
|
|
11410
|
+
]);
|
|
11411
|
+
const match = matchRoute('GET', '/api/platform/onboarding-plan-options', routes);
|
|
11412
|
+
const req = createMockReq('GET', '/api/platform/onboarding-plan-options', undefined, {
|
|
11413
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
11414
|
+
'x-taskforce-system-role': 'system_admin'
|
|
11415
|
+
});
|
|
11416
|
+
const res = createMockRes();
|
|
11417
|
+
await match?.handler(req, res, {});
|
|
11418
|
+
expect(core.listOnboardingPlanOptions).toHaveBeenCalled();
|
|
11419
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
11420
|
+
expect(JSON.parse(res.end.mock.calls[0]?.[0] || '{}')).toEqual({
|
|
11421
|
+
success: true,
|
|
11422
|
+
options: [
|
|
11423
|
+
{
|
|
11424
|
+
planId: 'free',
|
|
11425
|
+
planDisplayName: 'Free',
|
|
11426
|
+
defaultPlanVersionId: 'free-v1',
|
|
11427
|
+
defaultVersionNumber: 1,
|
|
11428
|
+
planStatus: 'active',
|
|
11429
|
+
supportsAutoGrantFree: true,
|
|
11430
|
+
supportsAutoStartTrial: false
|
|
11431
|
+
}
|
|
11432
|
+
]
|
|
11433
|
+
});
|
|
11434
|
+
});
|
|
9647
11435
|
it('GET /api/platform/plans/:planId/versions should list versions', async () => {
|
|
9648
11436
|
const match = matchRoute('GET', '/api/platform/plans/collab/versions', routes);
|
|
9649
11437
|
const req = createMockReq('GET', '/api/platform/plans/collab/versions', undefined, {
|
|
@@ -9702,6 +11490,37 @@ describe('Server Routes', () => {
|
|
|
9702
11490
|
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"featureKey":"collaboration.team_management"'));
|
|
9703
11491
|
expect(res.end).not.toHaveBeenCalledWith(expect.stringContaining('"featureKey":"collaboration.invites"'));
|
|
9704
11492
|
});
|
|
11493
|
+
it('PATCH /api/platform/features/:featureKey should update public marketing copy', async () => {
|
|
11494
|
+
core.updatePlanFeatureCatalogMarketingCopy = vi.fn().mockReturnValue({
|
|
11495
|
+
featureKey: 'collaboration.ai_profiles',
|
|
11496
|
+
label: 'AI Profiles',
|
|
11497
|
+
description: 'Allow registered AI collaborator profiles with an optional per-workspace limit.',
|
|
11498
|
+
publicLabel: 'AI teammates',
|
|
11499
|
+
publicDescription: 'Add reusable AI teammates to your workspace.',
|
|
11500
|
+
publicDisplayOrder: 20,
|
|
11501
|
+
allowedAccessModes: ['enabled', 'limited', 'disabled'],
|
|
11502
|
+
configTemplate: { maxAiProfiles: 1 }
|
|
11503
|
+
});
|
|
11504
|
+
const match = matchRoute('PATCH', '/api/platform/features/collaboration.ai_profiles', routes);
|
|
11505
|
+
const req = createMockReq('PATCH', '/api/platform/features/collaboration.ai_profiles', {
|
|
11506
|
+
publicLabel: 'AI teammates',
|
|
11507
|
+
publicDescription: 'Add reusable AI teammates to your workspace.',
|
|
11508
|
+
publicDisplayOrder: 20
|
|
11509
|
+
}, {
|
|
11510
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
11511
|
+
'x-taskforce-system-role': 'system_admin'
|
|
11512
|
+
});
|
|
11513
|
+
const res = createMockRes();
|
|
11514
|
+
await match?.handler(req, res, { featureKey: 'collaboration.ai_profiles' });
|
|
11515
|
+
expect(core.updatePlanFeatureCatalogMarketingCopy).toHaveBeenCalledWith({
|
|
11516
|
+
featureKey: 'collaboration.ai_profiles',
|
|
11517
|
+
publicLabel: 'AI teammates',
|
|
11518
|
+
publicDescription: 'Add reusable AI teammates to your workspace.',
|
|
11519
|
+
publicDisplayOrder: 20
|
|
11520
|
+
});
|
|
11521
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
11522
|
+
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"publicLabel":"AI teammates"'));
|
|
11523
|
+
});
|
|
9705
11524
|
it('PATCH /api/platform/plans/:planId/versions/:versionId/features should return normalized dependency state', async () => {
|
|
9706
11525
|
core.updatePlanVersionFeatures.mockReturnValueOnce({
|
|
9707
11526
|
planVersionId: 'collab-v1',
|
|
@@ -9831,13 +11650,20 @@ describe('Server Routes', () => {
|
|
|
9831
11650
|
updatedAt: '2026-02-01T00:00:00.000Z'
|
|
9832
11651
|
}
|
|
9833
11652
|
]);
|
|
11653
|
+
core.countSystemUsers.mockReturnValueOnce(1);
|
|
9834
11654
|
const match = matchRoute('GET', '/api/taskforce/admin/system-users', routes);
|
|
9835
|
-
const req = createMockReq('GET', '/api/taskforce/admin/system-users', undefined, { 'x-taskforce-system-role': 'system_admin' });
|
|
11655
|
+
const req = createMockReq('GET', '/api/taskforce/admin/system-users?q=u1&sort=oldest&limit=25&offset=50', undefined, { 'x-taskforce-system-role': 'system_admin' });
|
|
9836
11656
|
const res = createMockRes();
|
|
9837
11657
|
await match?.handler(req, res, {});
|
|
9838
|
-
expect(core.
|
|
11658
|
+
expect(core.countSystemUsers).toHaveBeenCalledWith({ query: 'u1' });
|
|
11659
|
+
expect(core.listSystemUsers).toHaveBeenCalledWith({ query: 'u1', sort: 'oldest', limit: 25, offset: 50 });
|
|
9839
11660
|
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
9840
|
-
|
|
11661
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
11662
|
+
expect(payload.users).toHaveLength(1);
|
|
11663
|
+
expect(payload.total).toBe(1);
|
|
11664
|
+
expect(payload.limit).toBe(25);
|
|
11665
|
+
expect(payload.offset).toBe(50);
|
|
11666
|
+
expect(payload.hasMore).toBe(false);
|
|
9841
11667
|
});
|
|
9842
11668
|
it('GET /api/taskforce/admin/system-users should allow authenticated system admin without role header', async () => {
|
|
9843
11669
|
const authConfig = {
|
|
@@ -9863,7 +11689,8 @@ describe('Server Routes', () => {
|
|
|
9863
11689
|
const res = createMockRes();
|
|
9864
11690
|
await match?.handler(req, res, {});
|
|
9865
11691
|
expect(core.getSystemRole).toHaveBeenCalledWith('admin-user');
|
|
9866
|
-
expect(core.
|
|
11692
|
+
expect(core.countSystemUsers).toHaveBeenCalledWith({ query: '' });
|
|
11693
|
+
expect(core.listSystemUsers).toHaveBeenCalledWith({ query: '', sort: 'newest', limit: 50, offset: 0 });
|
|
9867
11694
|
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
9868
11695
|
});
|
|
9869
11696
|
it('GET /api/taskforce/admin/audit-logs should return events for admins', async () => {
|
|
@@ -10125,6 +11952,266 @@ describe('Server Routes', () => {
|
|
|
10125
11952
|
expect(matchRoute('POST', '/api/taskforce/admin/users/invite', routes)).not.toBeNull();
|
|
10126
11953
|
expect(matchRoute('PATCH', '/api/taskforce/admin/users/u1/role', routes)).not.toBeNull();
|
|
10127
11954
|
expect(matchRoute('GET', '/api/taskforce/admin/workspace-audit-logs', routes)).not.toBeNull();
|
|
11955
|
+
expect(matchRoute('POST', '/api/taskforce/workspace/ai-profiles/avatar', routes)).not.toBeNull();
|
|
11956
|
+
});
|
|
11957
|
+
it('POST /api/taskforce/admin/users/invite should authorize against the target workspace', async () => {
|
|
11958
|
+
const authConfig = createCloudAuthConfig();
|
|
11959
|
+
routes = createRoutes(core, { authConfig });
|
|
11960
|
+
core.listUserWorkspaces.mockReturnValue([
|
|
11961
|
+
{ id: 'workspace-home', slug: 'home', name: 'Home', role: 'owner', status: 'active', betaAccess: true },
|
|
11962
|
+
{ id: 'workspace-shared', slug: 'shared', name: 'Shared', role: 'member', status: 'active', betaAccess: true }
|
|
11963
|
+
]);
|
|
11964
|
+
core.listWorkspaceUsers.mockImplementation((workspaceId) => {
|
|
11965
|
+
if (workspaceId === 'workspace-home') {
|
|
11966
|
+
return [{
|
|
11967
|
+
userId: 'actor-user',
|
|
11968
|
+
email: 'actor@example.com',
|
|
11969
|
+
displayName: 'Actor',
|
|
11970
|
+
role: 'owner',
|
|
11971
|
+
permissionMode: 'read-write',
|
|
11972
|
+
betaAccess: true,
|
|
11973
|
+
status: 'active',
|
|
11974
|
+
disabled: false
|
|
11975
|
+
}];
|
|
11976
|
+
}
|
|
11977
|
+
if (workspaceId === 'workspace-shared') {
|
|
11978
|
+
return [{
|
|
11979
|
+
userId: 'actor-user',
|
|
11980
|
+
email: 'actor@example.com',
|
|
11981
|
+
displayName: 'Actor',
|
|
11982
|
+
role: 'member',
|
|
11983
|
+
permissionMode: 'read-only',
|
|
11984
|
+
betaAccess: true,
|
|
11985
|
+
status: 'active',
|
|
11986
|
+
disabled: false
|
|
11987
|
+
}];
|
|
11988
|
+
}
|
|
11989
|
+
return [];
|
|
11990
|
+
});
|
|
11991
|
+
core.inviteWorkspaceUser.mockReturnValue({
|
|
11992
|
+
userId: 'invited-user',
|
|
11993
|
+
email: 'invited@example.com',
|
|
11994
|
+
role: 'member',
|
|
11995
|
+
status: 'invited',
|
|
11996
|
+
disabled: false
|
|
11997
|
+
});
|
|
11998
|
+
const cookie = createSessionTokenCookie('session-target-invite-deny', authConfig, {
|
|
11999
|
+
userId: 'actor-user',
|
|
12000
|
+
role: 'owner',
|
|
12001
|
+
workspaceId: 'workspace-home'
|
|
12002
|
+
});
|
|
12003
|
+
const match = matchRoute('POST', '/api/taskforce/admin/users/invite', routes);
|
|
12004
|
+
const req = createMockReq('POST', '/api/taskforce/admin/users/invite', {
|
|
12005
|
+
workspaceId: 'workspace-shared',
|
|
12006
|
+
email: 'invited@example.com',
|
|
12007
|
+
role: 'member'
|
|
12008
|
+
}, {
|
|
12009
|
+
cookie,
|
|
12010
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
12011
|
+
'x-taskforce-role': 'owner',
|
|
12012
|
+
'x-taskforce-user-id': 'actor-user',
|
|
12013
|
+
'x-taskforce-workspace-id': 'workspace-home'
|
|
12014
|
+
});
|
|
12015
|
+
const res = createMockRes();
|
|
12016
|
+
await match?.handler(req, res, {});
|
|
12017
|
+
expect(core.inviteWorkspaceUser).not.toHaveBeenCalled();
|
|
12018
|
+
expect(core.evaluateFeatureAccess).not.toHaveBeenCalled();
|
|
12019
|
+
expect(res.writeHead).toHaveBeenCalledWith(403, expect.any(Object));
|
|
12020
|
+
});
|
|
12021
|
+
it('POST /api/taskforce/admin/users/invite should resolve feature access and inviter identity from the authenticated session user', async () => {
|
|
12022
|
+
const authConfig = createCloudAuthConfig();
|
|
12023
|
+
routes = createRoutes(core, { authConfig });
|
|
12024
|
+
core.listWorkspaceUsers.mockImplementation((workspaceId) => {
|
|
12025
|
+
if (workspaceId === 'workspace-shared') {
|
|
12026
|
+
return [{
|
|
12027
|
+
userId: 'joined-admin',
|
|
12028
|
+
email: 'joined-admin@example.com',
|
|
12029
|
+
displayName: 'Joined Admin',
|
|
12030
|
+
role: 'admin',
|
|
12031
|
+
permissionMode: 'read-write',
|
|
12032
|
+
betaAccess: true,
|
|
12033
|
+
status: 'active',
|
|
12034
|
+
disabled: false
|
|
12035
|
+
}];
|
|
12036
|
+
}
|
|
12037
|
+
return [];
|
|
12038
|
+
});
|
|
12039
|
+
core.evaluateFeatureAccess.mockImplementation(({ userId }) => ({
|
|
12040
|
+
allowed: userId === 'joined-admin',
|
|
12041
|
+
reasonCode: userId === 'joined-admin' ? undefined : 'ACCOUNT_ENTITLEMENT_REQUIRED'
|
|
12042
|
+
}));
|
|
12043
|
+
core.inviteWorkspaceUser.mockReturnValue({
|
|
12044
|
+
userId: 'invited-user',
|
|
12045
|
+
email: 'invited@example.com',
|
|
12046
|
+
role: 'member',
|
|
12047
|
+
status: 'invited',
|
|
12048
|
+
disabled: false
|
|
12049
|
+
});
|
|
12050
|
+
core.requestInviteAcceptance.mockReturnValue({ token: 'invite-token-1' });
|
|
12051
|
+
core.getWorkspaceProfile.mockReturnValue({
|
|
12052
|
+
id: 'workspace-shared',
|
|
12053
|
+
name: 'Shared Workspace'
|
|
12054
|
+
});
|
|
12055
|
+
core.resolveUserIdentity.mockReturnValue({
|
|
12056
|
+
userId: 'joined-admin',
|
|
12057
|
+
email: 'joined-admin@example.com',
|
|
12058
|
+
displayName: 'Joined Admin'
|
|
12059
|
+
});
|
|
12060
|
+
const cookie = createSessionTokenCookie('session-target-invite-identity', authConfig, {
|
|
12061
|
+
userId: 'joined-admin',
|
|
12062
|
+
role: 'admin',
|
|
12063
|
+
workspaceId: 'workspace-shared'
|
|
12064
|
+
});
|
|
12065
|
+
const match = matchRoute('POST', '/api/taskforce/admin/users/invite', routes);
|
|
12066
|
+
const req = createMockReq('POST', '/api/taskforce/admin/users/invite', {
|
|
12067
|
+
workspaceId: 'workspace-shared',
|
|
12068
|
+
email: 'invited@example.com',
|
|
12069
|
+
role: 'member'
|
|
12070
|
+
}, {
|
|
12071
|
+
cookie,
|
|
12072
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
12073
|
+
'x-taskforce-role': 'owner',
|
|
12074
|
+
'x-taskforce-user-id': 'workspace-auth-existing-home-owner',
|
|
12075
|
+
'x-taskforce-workspace-id': 'workspace-auth-existing-home'
|
|
12076
|
+
});
|
|
12077
|
+
const res = createMockRes();
|
|
12078
|
+
await match?.handler(req, res, {});
|
|
12079
|
+
expect(core.evaluateFeatureAccess).toHaveBeenCalledWith(expect.objectContaining({
|
|
12080
|
+
userId: 'joined-admin',
|
|
12081
|
+
workspaceId: 'workspace-shared',
|
|
12082
|
+
featureKey: 'collaboration.invites'
|
|
12083
|
+
}));
|
|
12084
|
+
expect(core.resolveUserIdentity).toHaveBeenCalledWith('joined-admin');
|
|
12085
|
+
expect(core.inviteWorkspaceUser).toHaveBeenCalled();
|
|
12086
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
12087
|
+
});
|
|
12088
|
+
it('PATCH /api/taskforce/admin/users/:id/role should reject active-workspace admins who are only target-workspace members', async () => {
|
|
12089
|
+
const authConfig = createCloudAuthConfig();
|
|
12090
|
+
routes = createRoutes(core, { authConfig });
|
|
12091
|
+
core.listUserWorkspaces.mockReturnValue([
|
|
12092
|
+
{ id: 'workspace-home', slug: 'home', name: 'Home', role: 'owner', status: 'active', betaAccess: true },
|
|
12093
|
+
{ id: 'workspace-shared', slug: 'shared', name: 'Shared', role: 'member', status: 'active', betaAccess: true }
|
|
12094
|
+
]);
|
|
12095
|
+
core.listWorkspaceUsers.mockImplementation((workspaceId) => {
|
|
12096
|
+
if (workspaceId === 'workspace-home') {
|
|
12097
|
+
return [{
|
|
12098
|
+
userId: 'actor-user',
|
|
12099
|
+
email: 'actor@example.com',
|
|
12100
|
+
displayName: 'Actor',
|
|
12101
|
+
role: 'owner',
|
|
12102
|
+
permissionMode: 'read-write',
|
|
12103
|
+
betaAccess: true,
|
|
12104
|
+
status: 'active',
|
|
12105
|
+
disabled: false
|
|
12106
|
+
}];
|
|
12107
|
+
}
|
|
12108
|
+
if (workspaceId === 'workspace-shared') {
|
|
12109
|
+
return [{
|
|
12110
|
+
userId: 'actor-user',
|
|
12111
|
+
email: 'actor@example.com',
|
|
12112
|
+
displayName: 'Actor',
|
|
12113
|
+
role: 'member',
|
|
12114
|
+
permissionMode: 'read-only',
|
|
12115
|
+
betaAccess: true,
|
|
12116
|
+
status: 'active',
|
|
12117
|
+
disabled: false
|
|
12118
|
+
}];
|
|
12119
|
+
}
|
|
12120
|
+
return [];
|
|
12121
|
+
});
|
|
12122
|
+
core.updateWorkspaceUserRole.mockReturnValue(true);
|
|
12123
|
+
const cookie = createSessionTokenCookie('session-target-role-deny', authConfig, {
|
|
12124
|
+
userId: 'actor-user',
|
|
12125
|
+
role: 'owner',
|
|
12126
|
+
workspaceId: 'workspace-home'
|
|
12127
|
+
});
|
|
12128
|
+
const match = matchRoute('PATCH', '/api/taskforce/admin/users/target-user/role', routes);
|
|
12129
|
+
const req = createMockReq('PATCH', '/api/taskforce/admin/users/target-user/role', {
|
|
12130
|
+
workspaceId: 'workspace-shared',
|
|
12131
|
+
role: 'admin'
|
|
12132
|
+
}, {
|
|
12133
|
+
cookie,
|
|
12134
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
12135
|
+
'x-taskforce-role': 'owner',
|
|
12136
|
+
'x-taskforce-user-id': 'actor-user',
|
|
12137
|
+
'x-taskforce-workspace-id': 'workspace-home'
|
|
12138
|
+
});
|
|
12139
|
+
const res = createMockRes();
|
|
12140
|
+
await match?.handler(req, res, { id: 'target-user' });
|
|
12141
|
+
expect(core.updateWorkspaceUserRole).not.toHaveBeenCalled();
|
|
12142
|
+
expect(core.evaluateFeatureAccess).not.toHaveBeenCalled();
|
|
12143
|
+
expect(res.writeHead).toHaveBeenCalledWith(403, expect.any(Object));
|
|
12144
|
+
});
|
|
12145
|
+
it('PATCH /api/taskforce/admin/users/:id/role should allow target-workspace admins with a different active workspace', async () => {
|
|
12146
|
+
const authConfig = createCloudAuthConfig();
|
|
12147
|
+
routes = createRoutes(core, { authConfig });
|
|
12148
|
+
core.listUserWorkspaces.mockReturnValue([
|
|
12149
|
+
{ id: 'workspace-home', slug: 'home', name: 'Home', role: 'member', status: 'active', betaAccess: true },
|
|
12150
|
+
{ id: 'workspace-shared', slug: 'shared', name: 'Shared', role: 'admin', status: 'active', betaAccess: true }
|
|
12151
|
+
]);
|
|
12152
|
+
core.listWorkspaceUsers.mockImplementation((workspaceId) => {
|
|
12153
|
+
if (workspaceId === 'workspace-home') {
|
|
12154
|
+
return [{
|
|
12155
|
+
userId: 'actor-user',
|
|
12156
|
+
email: 'actor@example.com',
|
|
12157
|
+
displayName: 'Actor',
|
|
12158
|
+
role: 'member',
|
|
12159
|
+
permissionMode: 'read-write',
|
|
12160
|
+
betaAccess: true,
|
|
12161
|
+
status: 'active',
|
|
12162
|
+
disabled: false
|
|
12163
|
+
}];
|
|
12164
|
+
}
|
|
12165
|
+
if (workspaceId === 'workspace-shared') {
|
|
12166
|
+
return [{
|
|
12167
|
+
userId: 'actor-user',
|
|
12168
|
+
email: 'actor@example.com',
|
|
12169
|
+
displayName: 'Actor',
|
|
12170
|
+
role: 'admin',
|
|
12171
|
+
permissionMode: 'read-write',
|
|
12172
|
+
betaAccess: true,
|
|
12173
|
+
status: 'active',
|
|
12174
|
+
disabled: false
|
|
12175
|
+
}];
|
|
12176
|
+
}
|
|
12177
|
+
return [];
|
|
12178
|
+
});
|
|
12179
|
+
core.updateWorkspaceUserRole.mockReturnValue(true);
|
|
12180
|
+
const cookie = createSessionTokenCookie('session-target-role-allow', authConfig, {
|
|
12181
|
+
userId: 'actor-user',
|
|
12182
|
+
role: 'member',
|
|
12183
|
+
workspaceId: 'workspace-home'
|
|
12184
|
+
});
|
|
12185
|
+
const match = matchRoute('PATCH', '/api/taskforce/admin/users/target-user/role', routes);
|
|
12186
|
+
const req = createMockReq('PATCH', '/api/taskforce/admin/users/target-user/role', {
|
|
12187
|
+
workspaceId: 'workspace-shared',
|
|
12188
|
+
role: 'admin'
|
|
12189
|
+
}, {
|
|
12190
|
+
cookie,
|
|
12191
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
12192
|
+
'x-taskforce-role': 'member',
|
|
12193
|
+
'x-taskforce-user-id': 'actor-user',
|
|
12194
|
+
'x-taskforce-workspace-id': 'workspace-home'
|
|
12195
|
+
});
|
|
12196
|
+
const res = createMockRes();
|
|
12197
|
+
await match?.handler(req, res, { id: 'target-user' });
|
|
12198
|
+
expect(core.evaluateFeatureAccess).toHaveBeenCalledWith({
|
|
12199
|
+
userId: 'actor-user',
|
|
12200
|
+
workspaceId: 'workspace-shared',
|
|
12201
|
+
featureKey: 'collaboration.team_management'
|
|
12202
|
+
});
|
|
12203
|
+
expect(core.updateWorkspaceUserRole).toHaveBeenCalledWith('target-user', 'workspace-shared', 'admin');
|
|
12204
|
+
expect(core.addAdminAuditLog).toHaveBeenCalledWith(expect.objectContaining({
|
|
12205
|
+
action: 'update-workspace-user-role',
|
|
12206
|
+
actorUserId: 'actor-user',
|
|
12207
|
+
actorRole: 'admin',
|
|
12208
|
+
workspaceId: 'workspace-shared',
|
|
12209
|
+
details: expect.objectContaining({
|
|
12210
|
+
workspaceId: 'workspace-shared',
|
|
12211
|
+
targetUserId: 'target-user'
|
|
12212
|
+
})
|
|
12213
|
+
}));
|
|
12214
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
10128
12215
|
});
|
|
10129
12216
|
it('GET /api/taskforce/workspace/assignee-options should reject unauthenticated cloud requests', async () => {
|
|
10130
12217
|
const authConfig = {
|
|
@@ -10219,6 +12306,7 @@ describe('Server Routes', () => {
|
|
|
10219
12306
|
userId: 'member-1',
|
|
10220
12307
|
email: 'member1@example.com',
|
|
10221
12308
|
displayName: 'Member One',
|
|
12309
|
+
avatarUrl: '/api/taskforce/context/users%2Fmember-1%2Fprofile%2Favatar%2Fheadshot.webp',
|
|
10222
12310
|
role: 'member',
|
|
10223
12311
|
permissionMode: 'read-write',
|
|
10224
12312
|
betaAccess: true,
|
|
@@ -10240,11 +12328,14 @@ describe('Server Routes', () => {
|
|
|
10240
12328
|
provider: 'OpenAI',
|
|
10241
12329
|
model: 'gpt-5.4',
|
|
10242
12330
|
surfaceType: 'coding_tool',
|
|
12331
|
+
seatScope: null,
|
|
12332
|
+
archivedAt: null,
|
|
10243
12333
|
createdAt: '2026-03-01T00:00:00.000Z',
|
|
10244
12334
|
updatedAt: '2026-03-01T00:00:00.000Z',
|
|
10245
12335
|
lastActiveAt: null
|
|
10246
12336
|
}
|
|
10247
12337
|
]);
|
|
12338
|
+
core.countAiProfileUsage.mockReturnValue({ used: 1, limit: 3, remaining: 2 });
|
|
10248
12339
|
const match = matchRoute('GET', '/api/taskforce/workspace/assignee-options', routes);
|
|
10249
12340
|
const cookie = createSessionTokenCookie('session-member-assignees', authConfig, {
|
|
10250
12341
|
userId: 'member-user',
|
|
@@ -10261,9 +12352,14 @@ describe('Server Routes', () => {
|
|
|
10261
12352
|
const res = createMockRes();
|
|
10262
12353
|
await match?.handler(req, res, {});
|
|
10263
12354
|
expect(core.listWorkspaceUsers).toHaveBeenCalledWith('workspace-1');
|
|
10264
|
-
expect(core.listAiProfiles).toHaveBeenCalledWith('workspace-1'
|
|
12355
|
+
expect(core.listAiProfiles).toHaveBeenCalledWith('workspace-1', expect.objectContaining({
|
|
12356
|
+
diagnostics: expect.any(Object)
|
|
12357
|
+
}));
|
|
12358
|
+
expect(core.countAiProfileUsage).toHaveBeenCalledWith('workspace-1');
|
|
12359
|
+
expect(res.setHeader).toHaveBeenCalledWith('Server-Timing', expect.stringContaining('assignee-options'));
|
|
10265
12360
|
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
10266
12361
|
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
12362
|
+
expect(payload.aiProfileSeatUsage).toEqual({ used: 1, limit: 3, remaining: 2 });
|
|
10267
12363
|
expect(payload.assignees).toEqual([
|
|
10268
12364
|
{
|
|
10269
12365
|
value: 'member-1',
|
|
@@ -10274,6 +12370,7 @@ describe('Server Routes', () => {
|
|
|
10274
12370
|
userId: 'member-1',
|
|
10275
12371
|
email: 'member1@example.com',
|
|
10276
12372
|
displayName: 'Member One',
|
|
12373
|
+
avatarUrl: '/api/taskforce/context/users%2Fmember-1%2Fprofile%2Favatar%2Fheadshot.webp',
|
|
10277
12374
|
status: 'active',
|
|
10278
12375
|
disabled: false
|
|
10279
12376
|
},
|
|
@@ -10283,12 +12380,16 @@ describe('Server Routes', () => {
|
|
|
10283
12380
|
username: 'codex',
|
|
10284
12381
|
icon: 'Bot',
|
|
10285
12382
|
color: '#ef6c00',
|
|
12383
|
+
avatarUrl: null,
|
|
12384
|
+
avatarSourceUrl: null,
|
|
10286
12385
|
kind: 'agent',
|
|
10287
12386
|
description: 'Builds product code',
|
|
10288
12387
|
role: 'Implementer',
|
|
10289
12388
|
provider: 'OpenAI',
|
|
10290
12389
|
model: 'gpt-5.4',
|
|
10291
12390
|
surfaceType: 'coding_tool',
|
|
12391
|
+
seatScope: null,
|
|
12392
|
+
archivedAt: null,
|
|
10292
12393
|
createdAt: '2026-03-01T00:00:00.000Z',
|
|
10293
12394
|
updatedAt: '2026-03-01T00:00:00.000Z',
|
|
10294
12395
|
lastActiveAt: null
|
|
@@ -10328,6 +12429,7 @@ describe('Server Routes', () => {
|
|
|
10328
12429
|
lastActiveAt: null
|
|
10329
12430
|
}
|
|
10330
12431
|
]);
|
|
12432
|
+
core.countAiProfileUsage.mockReturnValue({ used: 1, limit: 3, remaining: 2 });
|
|
10331
12433
|
const match = matchRoute('GET', '/api/taskforce/workspace/assignee-options', routes);
|
|
10332
12434
|
const cookie = createSessionTokenCookie('session-admin-assignees', authConfig, {
|
|
10333
12435
|
userId: 'admin-user',
|
|
@@ -10344,6 +12446,7 @@ describe('Server Routes', () => {
|
|
|
10344
12446
|
const res = createMockRes();
|
|
10345
12447
|
await match?.handler(req, res, {});
|
|
10346
12448
|
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
12449
|
+
expect(payload.aiProfileSeatUsage).toEqual({ used: 1, limit: 3, remaining: 2 });
|
|
10347
12450
|
expect(payload.assignees).toEqual([
|
|
10348
12451
|
expect.objectContaining({
|
|
10349
12452
|
value: 'ai-profile-1',
|
|
@@ -10351,6 +12454,198 @@ describe('Server Routes', () => {
|
|
|
10351
12454
|
})
|
|
10352
12455
|
]);
|
|
10353
12456
|
});
|
|
12457
|
+
it('POST /api/taskforce/workspace/ai-profiles/archive should archive active profiles for workspace admins', async () => {
|
|
12458
|
+
const authConfig = {
|
|
12459
|
+
runtimeMode: 'cloud',
|
|
12460
|
+
enabled: true,
|
|
12461
|
+
requiredForApi: true,
|
|
12462
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
12463
|
+
defaultRole: 'member',
|
|
12464
|
+
defaultWorkspaceId: 'default',
|
|
12465
|
+
sessionCookieName: 'taskforce_session',
|
|
12466
|
+
sessionSecret: 'test-secret',
|
|
12467
|
+
sessionTtlSeconds: 3600
|
|
12468
|
+
};
|
|
12469
|
+
routes = createRoutes(core, { authConfig });
|
|
12470
|
+
core.archiveAiProfile.mockReturnValue({
|
|
12471
|
+
id: 'ai-profile-1',
|
|
12472
|
+
workspaceId: 'workspace-1',
|
|
12473
|
+
profileToken: 'tfp_codex_sync',
|
|
12474
|
+
name: 'Codex',
|
|
12475
|
+
username: 'codex',
|
|
12476
|
+
icon: 'Bot',
|
|
12477
|
+
color: '#ef6c00',
|
|
12478
|
+
description: 'Builds product code',
|
|
12479
|
+
role: 'Implementer',
|
|
12480
|
+
provider: 'OpenAI',
|
|
12481
|
+
model: 'gpt-5.4',
|
|
12482
|
+
surfaceType: 'coding_tool',
|
|
12483
|
+
archivedAt: '2026-04-19T17:00:00.000Z',
|
|
12484
|
+
archivedBy: 'admin-user',
|
|
12485
|
+
archivedReason: 'manual_archive',
|
|
12486
|
+
mergedIntoProfileId: null,
|
|
12487
|
+
createdAt: '2026-03-01T00:00:00.000Z',
|
|
12488
|
+
updatedAt: '2026-04-19T17:00:00.000Z',
|
|
12489
|
+
lastActiveAt: null
|
|
12490
|
+
});
|
|
12491
|
+
const match = matchRoute('POST', '/api/taskforce/workspace/ai-profiles/archive', routes);
|
|
12492
|
+
const cookie = createSessionTokenCookie('session-admin-archive-ai-profile', authConfig, {
|
|
12493
|
+
userId: 'admin-user',
|
|
12494
|
+
role: 'admin',
|
|
12495
|
+
workspaceId: 'workspace-1'
|
|
12496
|
+
});
|
|
12497
|
+
const req = createMockReq('POST', '/api/taskforce/workspace/ai-profiles/archive', {
|
|
12498
|
+
profileId: 'ai-profile-1',
|
|
12499
|
+
reason: 'manual_archive'
|
|
12500
|
+
}, {
|
|
12501
|
+
cookie,
|
|
12502
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
12503
|
+
'x-taskforce-role': 'admin',
|
|
12504
|
+
'x-taskforce-user-id': 'admin-user',
|
|
12505
|
+
'x-taskforce-workspace-id': 'workspace-1'
|
|
12506
|
+
});
|
|
12507
|
+
const res = createMockRes();
|
|
12508
|
+
await match?.handler(req, res, {});
|
|
12509
|
+
expect(core.archiveAiProfile).toHaveBeenCalledWith({
|
|
12510
|
+
workspaceId: 'workspace-1',
|
|
12511
|
+
profileId: 'ai-profile-1',
|
|
12512
|
+
archivedBy: 'admin-user',
|
|
12513
|
+
reason: 'manual_archive'
|
|
12514
|
+
});
|
|
12515
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
12516
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
12517
|
+
expect(payload.profile).toEqual(expect.objectContaining({
|
|
12518
|
+
id: 'ai-profile-1',
|
|
12519
|
+
archivedReason: 'manual_archive'
|
|
12520
|
+
}));
|
|
12521
|
+
});
|
|
12522
|
+
it('POST /api/taskforce/workspace/ai-profiles/archive should honor actual owner membership even when the request role is stale', async () => {
|
|
12523
|
+
const authConfig = {
|
|
12524
|
+
runtimeMode: 'cloud',
|
|
12525
|
+
enabled: true,
|
|
12526
|
+
requiredForApi: true,
|
|
12527
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
12528
|
+
defaultRole: 'member',
|
|
12529
|
+
defaultWorkspaceId: 'default',
|
|
12530
|
+
sessionCookieName: 'taskforce_session',
|
|
12531
|
+
sessionSecret: 'test-secret',
|
|
12532
|
+
sessionTtlSeconds: 3600
|
|
12533
|
+
};
|
|
12534
|
+
routes = createRoutes(core, { authConfig });
|
|
12535
|
+
core.listUserWorkspaces.mockReturnValue([
|
|
12536
|
+
{
|
|
12537
|
+
id: 'workspace-1',
|
|
12538
|
+
slug: 'workspace-1',
|
|
12539
|
+
name: 'Workspace 1',
|
|
12540
|
+
description: null,
|
|
12541
|
+
role: 'owner',
|
|
12542
|
+
status: 'active',
|
|
12543
|
+
betaAccess: true
|
|
12544
|
+
}
|
|
12545
|
+
]);
|
|
12546
|
+
core.listWorkspaceUsers.mockReturnValue([
|
|
12547
|
+
{
|
|
12548
|
+
userId: 'owner-user',
|
|
12549
|
+
email: 'owner@example.com',
|
|
12550
|
+
displayName: 'Owner User',
|
|
12551
|
+
role: 'owner',
|
|
12552
|
+
permissionMode: 'read-write',
|
|
12553
|
+
betaAccess: true,
|
|
12554
|
+
status: 'active',
|
|
12555
|
+
disabled: false
|
|
12556
|
+
}
|
|
12557
|
+
]);
|
|
12558
|
+
core.archiveAiProfile.mockReturnValue({
|
|
12559
|
+
id: 'ai-profile-1',
|
|
12560
|
+
workspaceId: 'workspace-1',
|
|
12561
|
+
profileToken: 'tfp_codex_sync',
|
|
12562
|
+
name: 'Codex',
|
|
12563
|
+
username: 'codex',
|
|
12564
|
+
icon: 'Bot',
|
|
12565
|
+
color: '#ef6c00',
|
|
12566
|
+
description: 'Builds product code',
|
|
12567
|
+
role: 'Implementer',
|
|
12568
|
+
provider: 'OpenAI',
|
|
12569
|
+
model: 'gpt-5.4',
|
|
12570
|
+
surfaceType: 'coding_tool',
|
|
12571
|
+
archivedAt: '2026-04-19T17:00:00.000Z',
|
|
12572
|
+
archivedBy: 'owner-user',
|
|
12573
|
+
archivedReason: 'manual_archive',
|
|
12574
|
+
mergedIntoProfileId: null,
|
|
12575
|
+
createdAt: '2026-03-01T00:00:00.000Z',
|
|
12576
|
+
updatedAt: '2026-04-19T17:00:00.000Z',
|
|
12577
|
+
lastActiveAt: null
|
|
12578
|
+
});
|
|
12579
|
+
const match = matchRoute('POST', '/api/taskforce/workspace/ai-profiles/archive', routes);
|
|
12580
|
+
const cookie = createSessionTokenCookie('session-owner-archive', authConfig, {
|
|
12581
|
+
userId: 'owner-user',
|
|
12582
|
+
role: 'member',
|
|
12583
|
+
workspaceId: 'workspace-1'
|
|
12584
|
+
});
|
|
12585
|
+
const req = createMockReq('POST', '/api/taskforce/workspace/ai-profiles/archive', {
|
|
12586
|
+
profileId: 'ai-profile-1'
|
|
12587
|
+
}, {
|
|
12588
|
+
cookie,
|
|
12589
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
12590
|
+
'x-taskforce-role': 'member',
|
|
12591
|
+
'x-taskforce-user-id': 'owner-user',
|
|
12592
|
+
'x-taskforce-workspace-id': 'workspace-1'
|
|
12593
|
+
});
|
|
12594
|
+
const res = createMockRes();
|
|
12595
|
+
await match?.handler(req, res, {});
|
|
12596
|
+
expect(core.archiveAiProfile).toHaveBeenCalledWith(expect.objectContaining({
|
|
12597
|
+
workspaceId: 'workspace-1',
|
|
12598
|
+
profileId: 'ai-profile-1',
|
|
12599
|
+
archivedBy: 'owner-user'
|
|
12600
|
+
}));
|
|
12601
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
12602
|
+
});
|
|
12603
|
+
it('POST /api/taskforce/workspace/ai-profiles/archive should surface open-task conflicts', async () => {
|
|
12604
|
+
const authConfig = {
|
|
12605
|
+
runtimeMode: 'cloud',
|
|
12606
|
+
enabled: true,
|
|
12607
|
+
requiredForApi: true,
|
|
12608
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
12609
|
+
defaultRole: 'member',
|
|
12610
|
+
defaultWorkspaceId: 'default',
|
|
12611
|
+
sessionCookieName: 'taskforce_session',
|
|
12612
|
+
sessionSecret: 'test-secret',
|
|
12613
|
+
sessionTtlSeconds: 3600
|
|
12614
|
+
};
|
|
12615
|
+
routes = createRoutes(core, { authConfig });
|
|
12616
|
+
const error = new Error('This AI profile still owns open tasks. Reassign or close them before removing it from the roster.');
|
|
12617
|
+
error.statusCode = 409;
|
|
12618
|
+
error.details = { workspaceId: 'workspace-1', profileId: 'ai-profile-1', openTaskCount: 2 };
|
|
12619
|
+
core.archiveAiProfile.mockImplementation(() => {
|
|
12620
|
+
throw error;
|
|
12621
|
+
});
|
|
12622
|
+
const match = matchRoute('POST', '/api/taskforce/workspace/ai-profiles/archive', routes);
|
|
12623
|
+
const cookie = createSessionTokenCookie('session-admin-archive-ai-profile-conflict', authConfig, {
|
|
12624
|
+
userId: 'admin-user',
|
|
12625
|
+
role: 'admin',
|
|
12626
|
+
workspaceId: 'workspace-1'
|
|
12627
|
+
});
|
|
12628
|
+
const req = createMockReq('POST', '/api/taskforce/workspace/ai-profiles/archive', {
|
|
12629
|
+
profileId: 'ai-profile-1',
|
|
12630
|
+
reason: 'manual_archive'
|
|
12631
|
+
}, {
|
|
12632
|
+
cookie,
|
|
12633
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
12634
|
+
'x-taskforce-role': 'admin',
|
|
12635
|
+
'x-taskforce-user-id': 'admin-user',
|
|
12636
|
+
'x-taskforce-workspace-id': 'workspace-1'
|
|
12637
|
+
});
|
|
12638
|
+
const res = createMockRes();
|
|
12639
|
+
await match?.handler(req, res, {});
|
|
12640
|
+
expect(res.writeHead).toHaveBeenCalledWith(409, expect.any(Object));
|
|
12641
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
12642
|
+
expect(payload.error).toContain('This AI profile still owns open tasks');
|
|
12643
|
+
expect(payload.details).toEqual({
|
|
12644
|
+
workspaceId: 'workspace-1',
|
|
12645
|
+
profileId: 'ai-profile-1',
|
|
12646
|
+
openTaskCount: 2
|
|
12647
|
+
});
|
|
12648
|
+
});
|
|
10354
12649
|
it('GET /api/taskforce/admin/workspace-audit-logs should return events for workspace admins', async () => {
|
|
10355
12650
|
const authConfig = {
|
|
10356
12651
|
runtimeMode: 'cloud',
|
|
@@ -10448,13 +12743,13 @@ describe('Server Routes', () => {
|
|
|
10448
12743
|
billing: {
|
|
10449
12744
|
onboardingPolicy: {
|
|
10450
12745
|
mode: 'auto_grant_free',
|
|
10451
|
-
|
|
12746
|
+
planId: 'starter'
|
|
10452
12747
|
}
|
|
10453
12748
|
}
|
|
10454
12749
|
});
|
|
10455
12750
|
core.getOnboardingPolicy.mockReturnValue({
|
|
10456
12751
|
mode: 'auto_grant_free',
|
|
10457
|
-
|
|
12752
|
+
planId: 'starter'
|
|
10458
12753
|
});
|
|
10459
12754
|
const match = matchRoute('GET', '/api/taskforce/admin/system-settings', routes);
|
|
10460
12755
|
const req = createMockReq('GET', '/api/taskforce/admin/system-settings', undefined, { 'x-taskforce-role': 'admin' });
|
|
@@ -10462,9 +12757,9 @@ describe('Server Routes', () => {
|
|
|
10462
12757
|
await match?.handler(req, res, {});
|
|
10463
12758
|
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
10464
12759
|
expect(payload.status.onboardingPolicyMode).toBe('auto_grant_free');
|
|
10465
|
-
expect(payload.status.
|
|
12760
|
+
expect(payload.status.onboardingPlanId).toBe('starter');
|
|
10466
12761
|
expect(payload.settings.onboardingPolicyMode).toBe('auto_grant_free');
|
|
10467
|
-
expect(payload.settings.
|
|
12762
|
+
expect(payload.settings.onboardingPlanId).toBe('starter');
|
|
10468
12763
|
});
|
|
10469
12764
|
it('POST /api/taskforce/admin/system-settings should persist onboarding policy fields', async () => {
|
|
10470
12765
|
const match = matchRoute('POST', '/api/taskforce/admin/system-settings', routes);
|
|
@@ -10476,13 +12771,13 @@ describe('Server Routes', () => {
|
|
|
10476
12771
|
authRateLimitMaxRequests: 10,
|
|
10477
12772
|
authRateLimitWindowMs: 900000,
|
|
10478
12773
|
onboardingPolicyMode: 'auto_grant_free',
|
|
10479
|
-
|
|
12774
|
+
onboardingPlanId: 'starter'
|
|
10480
12775
|
}, { 'x-taskforce-role': 'admin' });
|
|
10481
12776
|
const res = createMockRes();
|
|
10482
12777
|
await match?.handler(req, res, {});
|
|
10483
12778
|
expect(core.validateOnboardingPolicy).toHaveBeenCalledWith({
|
|
10484
12779
|
mode: 'auto_grant_free',
|
|
10485
|
-
|
|
12780
|
+
planId: 'starter'
|
|
10486
12781
|
});
|
|
10487
12782
|
expect(core.updateGlobalSettings).toHaveBeenCalledWith({
|
|
10488
12783
|
auth: {
|
|
@@ -10496,7 +12791,7 @@ describe('Server Routes', () => {
|
|
|
10496
12791
|
billing: {
|
|
10497
12792
|
onboardingPolicy: {
|
|
10498
12793
|
mode: 'auto_grant_free',
|
|
10499
|
-
|
|
12794
|
+
planId: 'starter'
|
|
10500
12795
|
}
|
|
10501
12796
|
}
|
|
10502
12797
|
});
|
|
@@ -10504,7 +12799,7 @@ describe('Server Routes', () => {
|
|
|
10504
12799
|
it('POST /api/taskforce/admin/system-settings should preserve onboarding policy when onboarding fields are omitted', async () => {
|
|
10505
12800
|
core.getOnboardingPolicy.mockReturnValue({
|
|
10506
12801
|
mode: 'auto_grant_free',
|
|
10507
|
-
|
|
12802
|
+
planId: 'starter'
|
|
10508
12803
|
});
|
|
10509
12804
|
const match = matchRoute('POST', '/api/taskforce/admin/system-settings', routes);
|
|
10510
12805
|
const req = createMockReq('POST', '/api/taskforce/admin/system-settings', {
|
|
@@ -10519,7 +12814,7 @@ describe('Server Routes', () => {
|
|
|
10519
12814
|
await match?.handler(req, res, {});
|
|
10520
12815
|
expect(core.validateOnboardingPolicy).toHaveBeenCalledWith({
|
|
10521
12816
|
mode: 'auto_grant_free',
|
|
10522
|
-
|
|
12817
|
+
planId: 'starter'
|
|
10523
12818
|
});
|
|
10524
12819
|
expect(core.updateGlobalSettings).toHaveBeenCalledWith({
|
|
10525
12820
|
auth: {
|
|
@@ -10533,7 +12828,7 @@ describe('Server Routes', () => {
|
|
|
10533
12828
|
billing: {
|
|
10534
12829
|
onboardingPolicy: {
|
|
10535
12830
|
mode: 'auto_grant_free',
|
|
10536
|
-
|
|
12831
|
+
planId: 'starter'
|
|
10537
12832
|
}
|
|
10538
12833
|
}
|
|
10539
12834
|
});
|
|
@@ -10646,13 +12941,13 @@ describe('Server Routes', () => {
|
|
|
10646
12941
|
it('POST /api/taskforce/admin/system-settings should reject invalid onboarding settings', async () => {
|
|
10647
12942
|
core.validateOnboardingPolicy.mockReturnValueOnce({
|
|
10648
12943
|
valid: false,
|
|
10649
|
-
code: '
|
|
10650
|
-
message: 'An onboarding plan
|
|
12944
|
+
code: 'ONBOARDING_PLAN_REQUIRED',
|
|
12945
|
+
message: 'An onboarding plan is required for the selected onboarding mode.'
|
|
10651
12946
|
});
|
|
10652
12947
|
const match = matchRoute('POST', '/api/taskforce/admin/system-settings', routes);
|
|
10653
12948
|
const req = createMockReq('POST', '/api/taskforce/admin/system-settings', {
|
|
10654
12949
|
onboardingPolicyMode: 'auto_grant_free',
|
|
10655
|
-
|
|
12950
|
+
onboardingPlanId: null
|
|
10656
12951
|
}, { 'x-taskforce-role': 'admin' });
|
|
10657
12952
|
const res = createMockRes();
|
|
10658
12953
|
await match?.handler(req, res, {});
|