@taskforcehq/taskforce 0.3.305 → 0.3.307
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 +0 -2
- package/dist/Taskforce.module.css +603 -107
- package/dist/TaskforceCore.js +216 -43
- package/dist/TaskforceCore.test.js +611 -28
- 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 +46 -2
- package/dist/compat/workspaceSyncCompat.test.js +38 -1
- package/dist/components/activity/EntityActivityTimeline.d.ts +34 -0
- package/dist/components/activity/EntityActivityTimeline.js +81 -0
- package/dist/components/context/ContextAttachmentManager.d.ts +14 -0
- package/dist/components/context/ContextAttachmentManager.js +561 -0
- package/dist/components/features/AgentsModule.d.ts +14 -1
- package/dist/components/features/AgentsModule.js +366 -18
- package/dist/components/features/AgentsModule.test.js +507 -8
- package/dist/components/features/AnnotatedAttachmentWorkspace.d.ts +3 -1
- package/dist/components/features/AnnotatedAttachmentWorkspace.js +118 -4
- package/dist/components/features/AnnotatedAttachmentWorkspace.test.js +52 -0
- package/dist/components/features/DocumentIndex.d.ts +2 -2
- package/dist/components/features/DocumentIndex.js +3 -4
- package/dist/components/features/DocumentViewer.d.ts +7 -1
- package/dist/components/features/DocumentViewer.js +62 -4
- package/dist/components/features/DocumentWorkspace.d.ts +10 -2
- package/dist/components/features/DocumentWorkspace.js +15 -4
- package/dist/components/features/DocumentWorkspace.test.js +51 -1
- package/dist/components/features/TaskSettings.d.ts +2 -0
- package/dist/components/features/TaskSettings.js +83 -78
- package/dist/components/features/TaskSettings.test.js +171 -82
- package/dist/components/features/documentWorkspaceModel.d.ts +1 -1
- package/dist/components/task/TaskActionHeader.js +11 -5
- package/dist/components/task/TaskActionHeader.test.js +10 -0
- package/dist/components/task/TaskCard.js +24 -29
- package/dist/components/task/TaskCard.test.js +38 -3
- package/dist/components/task/TaskContextUpload.js +4 -526
- package/dist/components/task/TaskContextUpload.test.js +3 -3
- package/dist/components/task/TaskForm.js +61 -116
- package/dist/components/task/TaskForm.test.js +110 -8
- package/dist/components/task/TaskKanban.d.ts +2 -1
- package/dist/components/task/TaskKanban.js +94 -21
- package/dist/components/task/TaskKanban.test.js +36 -0
- package/dist/components/task/TaskList.js +5 -1
- package/dist/components/task/TaskList.test.js +4 -0
- package/dist/components/ui/EditableAvatarButton.d.ts +18 -0
- package/dist/components/ui/EditableAvatarButton.js +18 -0
- package/dist/components/ui/HierarchyActionBadge.d.ts +10 -0
- package/dist/components/ui/HierarchyActionBadge.js +5 -0
- package/dist/components/views/AppShellHeader.d.ts +3 -1
- package/dist/components/views/AppShellHeader.js +27 -11
- package/dist/components/views/AppShellHeader.test.js +18 -1
- package/dist/components/views/PlanComparisonPage.d.ts +9 -1
- package/dist/components/views/PlanComparisonPage.js +78 -14
- package/dist/components/views/PlanComparisonPage.test.js +257 -0
- package/dist/components/views/PlansPage.js +127 -36
- package/dist/components/views/PlansPage.test.js +294 -12
- package/dist/components/views/StandaloneLayout.js +529 -592
- package/dist/components/views/WidgetView.js +10 -8
- package/dist/components/views/panels/PlanningDrawer.d.ts +25 -5
- package/dist/components/views/panels/PlanningDrawer.js +270 -55
- package/dist/components/views/panels/PlanningDrawer.test.d.ts +1 -0
- package/dist/components/views/panels/PlanningDrawer.test.js +529 -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 +26 -1
- package/dist/components/views/standalone/modals/AccountHubModal.js +76 -2
- package/dist/components/views/standalone/modals/AccountHubModal.test.js +20 -3
- 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 -0
- package/dist/core/AiProfileService.d.ts +46 -2
- package/dist/core/AiProfileService.js +1073 -82
- package/dist/core/AiProfileService.test.js +19 -1
- package/dist/core/AiProfiles.test.js +1249 -44
- package/dist/core/AttachmentLinkService.d.ts +3 -0
- package/dist/core/AttachmentLinkService.js +110 -23
- 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.js +3 -3
- package/dist/core/EntitlementsPolicy.test.js +2 -2
- package/dist/core/GlobalSettingsService.js +127 -6
- package/dist/core/McpTokenService.d.ts +12 -0
- package/dist/core/McpTokenService.js +44 -1
- package/dist/core/PlanEntitlementService.d.ts +23 -2
- package/dist/core/PlanEntitlementService.js +343 -57
- package/dist/core/PlanFeatureCatalog.test.d.ts +1 -0
- package/dist/core/PlanFeatureCatalog.test.js +84 -0
- package/dist/core/PlanVersionPolicy.test.js +35 -2
- package/dist/core/PlanningEntities.test.js +418 -4
- package/dist/core/SignupMonetizationSettings.test.js +199 -27
- package/dist/core/SyncReconciliationService.js +2 -65
- package/dist/core/SystemAdmin.test.js +42 -42
- package/dist/core/TaskActivityService.d.ts +28 -1
- package/dist/core/TaskActivityService.js +227 -46
- package/dist/core/TaskActivitySync.test.d.ts +1 -0
- package/dist/core/TaskActivitySync.test.js +90 -0
- package/dist/core/TaskAttachmentsCanonical.test.js +51 -1
- package/dist/core/TaskAuditTimeline.test.js +96 -0
- package/dist/core/Taskforce.d.ts +415 -40
- package/dist/core/Taskforce.ids.test.js +58 -0
- package/dist/core/Taskforce.js +1543 -266
- package/dist/core/Taskforce.listTasksSlim.test.d.ts +1 -0
- package/dist/core/Taskforce.listTasksSlim.test.js +91 -0
- package/dist/core/Taskforce.mcpAuth.test.js +109 -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 +117 -16
- package/dist/core/shared.d.ts +1 -1
- package/dist/core/shared.js +5 -1
- package/dist/core/types.d.ts +75 -2
- package/dist/hooks/auth/useTaskforceAuthBootstrap.js +28 -2
- package/dist/hooks/bootstrapTimeouts.d.ts +4 -0
- package/dist/hooks/bootstrapTimeouts.js +8 -0
- package/dist/hooks/bootstrapTimeouts.test.d.ts +1 -0
- package/dist/hooks/bootstrapTimeouts.test.js +12 -0
- 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 +13 -4
- package/dist/hooks/sync/orchestratorShared.js +129 -48
- 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 +179 -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 +210 -66
- package/dist/hooks/sync/transfers.test.d.ts +1 -0
- package/dist/hooks/sync/transfers.test.js +928 -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/useFilterSort.js +13 -13
- package/dist/hooks/useFilterSort.test.js +87 -1
- package/dist/hooks/useSyncOrchestrator.aiProfiles.test.js +12 -0
- package/dist/hooks/useSyncOrchestrator.d.ts +9 -2
- package/dist/hooks/useSyncOrchestrator.js +409 -183
- package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +407 -0
- package/dist/hooks/useTaskData.js +8 -3
- package/dist/hooks/useTaskData.test.js +45 -0
- package/dist/hooks/useTaskforce.d.ts +145 -52
- package/dist/hooks/useTaskforce.js +592 -253
- package/dist/hooks/useTaskforce.runtime-routing.test.d.ts +1 -0
- package/dist/hooks/useTaskforce.runtime-routing.test.js +246 -0
- package/dist/hooks/useTaskforce.sync-behavior.test.js +2468 -201
- package/dist/hooks/useWorkspaceSyncController.d.ts +1 -0
- package/dist/hooks/useWorkspaceSyncController.js +13 -2
- package/dist/hooks/useWorkspaceSyncController.test.js +48 -0
- package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.d.ts +16 -0
- package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +328 -40
- 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/canonicalAssetHelpers.d.ts +16 -4
- package/dist/mcp/canonicalAssetHelpers.js +18 -10
- package/dist/mcp/canonicalAssetHelpers.test.d.ts +1 -0
- package/dist/mcp/canonicalAssetHelpers.test.js +67 -0
- package/dist/mcp/clientIntegrations.d.ts +1 -0
- package/dist/mcp/clientIntegrations.js +25 -0
- package/dist/mcp/clientIntegrations.test.js +14 -3
- package/dist/mcp/clientProfileDefaults.js +3 -3
- package/dist/mcp/collaborationRegistrar.js +7 -7
- package/dist/mcp/documentAssetRegistrar.js +116 -10
- package/dist/mcp/runtime.d.ts +3 -0
- package/dist/mcp/runtime.js +1099 -185
- package/dist/mcp/runtime.test.js +1491 -140
- package/dist/mcp/taskAttachmentTypes.d.ts +1 -1
- package/dist/mcp/taskPlanningRegistrar.js +48 -0
- package/dist/mcp/toolRegistry.d.ts +56 -0
- package/dist/mcp/toolRegistry.js +8 -0
- package/dist/mcp/toolRegistry.test.js +4 -0
- package/dist/migrations/taskSchemaMigrations.d.ts +1 -1
- package/dist/migrations/taskSchemaMigrations.js +192 -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/aiProfileAvatarAssets.d.ts +49 -0
- package/dist/server/aiProfileAvatarAssets.js +319 -0
- package/dist/server/aiProfileAvatarAssets.test.d.ts +1 -0
- package/dist/server/aiProfileAvatarAssets.test.js +213 -0
- package/dist/server/annotatedAttachmentsRoutes.test.js +69 -10
- 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 +124 -9
- package/dist/server/index.rateLimit.test.js +3 -0
- package/dist/server/index.test.js +111 -3
- package/dist/server/routes/admin.d.ts +3 -0
- package/dist/server/routes/admin.js +459 -28
- package/dist/server/routes/annotatedAttachments.js +51 -0
- package/dist/server/routes/auth.d.ts +26 -2
- package/dist/server/routes/auth.js +1170 -71
- package/dist/server/routes/authSupport.d.ts +5 -1
- package/dist/server/routes/authSupport.js +13 -2
- package/dist/server/routes/billing.js +372 -35
- package/dist/server/routes/billing.test.js +595 -12
- package/dist/server/routes/documents.d.ts +1 -0
- package/dist/server/routes/documents.js +182 -70
- package/dist/server/routes/shared.d.ts +28 -2
- package/dist/server/routes/shared.js +69 -14
- package/dist/server/routes/sync.integration.test.js +658 -39
- package/dist/server/routes/sync.js +74 -608
- 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 +182 -0
- package/dist/server/routes/tasks.js +55 -10
- package/dist/server/routes/workspaces.js +36 -5
- package/dist/server/routes.js +147 -11
- package/dist/server/routes.test.js +2704 -183
- 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/aiProfileSurfaceType.d.ts +1 -1
- package/dist/shared/aiProfileSurfaceType.js +10 -8
- 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/objectStorage.d.ts +1 -0
- package/dist/storage/objectStorage.js +2 -1
- package/dist/storage/objectStorageClient.d.ts +1 -0
- package/dist/storage/objectStorageClient.js +41 -8
- package/dist/storage/objectStorageClient.test.js +26 -1
- package/dist/storage/objectStorageConfig.js +3 -1
- package/dist/storage/objectStorageConfig.test.js +13 -1
- 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 +19 -11
- package/dist/sync/collaborationSyncPayload.test.js +14 -1
- package/dist/sync/collaborationSyncState.d.ts +3 -3
- package/dist/sync/collaborationSyncState.js +19 -12
- package/dist/sync/contentSyncPayload.d.ts +5 -1
- package/dist/sync/contentSyncPayload.js +9 -0
- package/dist/sync/contentSyncPayload.test.js +39 -0
- package/dist/sync/contentSyncState.d.ts +9 -9
- package/dist/sync/contentSyncState.js +248 -107
- package/dist/sync/planningSyncPayload.js +5 -0
- package/dist/sync/planningSyncPayload.test.js +24 -0
- package/dist/sync/syncApplyHandlers.d.ts +11 -4
- package/dist/sync/syncApplyHandlers.js +115 -42
- package/dist/sync/syncApplyHandlers.test.js +251 -4
- package/dist/sync/syncFieldSemantics.d.ts +12 -0
- package/dist/sync/syncFieldSemantics.js +55 -0
- package/dist/sync/syncResourceAdapters.d.ts +46 -0
- package/dist/sync/syncResourceAdapters.js +79 -0
- package/dist/sync/syncService.d.ts +6 -0
- package/dist/sync/syncService.js +65 -1
- package/dist/sync/syncService.test.js +96 -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.d.ts +4 -0
- package/dist/sync/workspacePullFeed.js +132 -72
- package/dist/sync/workspacePullFeed.test.js +92 -0
- package/dist/sync/workspaceRepair.js +5 -2
- package/dist/sync/workspaceSyncModel.d.ts +73 -6
- package/dist/sync/workspaceSyncModel.js +342 -73
- package/dist/sync/workspaceSyncModel.test.js +559 -14
- package/dist/sync/workspaceSyncState.d.ts +17 -0
- package/dist/sync/workspaceSyncState.js +2 -1
- package/dist/sync/workspaceSyncSurface.js +3 -8
- package/dist/types.d.ts +50 -2
- package/dist/ui/assets/AgentsModule-2y82_3DO.js +1 -0
- package/dist/ui/assets/AnnotatedAttachmentWorkspace-BTLZZCiQ.js +3 -0
- package/dist/ui/assets/AnnotatedAttachmentWorkspace-BVKew-mH.css +1 -0
- package/dist/ui/assets/ContextAttachmentManager-D2epuNnG.js +3 -0
- 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-D4rJKXzd.css +1 -0
- package/dist/ui/assets/DocumentWorkspace-sVa-3Do6.js +252 -0
- package/dist/ui/assets/EntityActivityTimeline-DyqvvhuF.css +1 -0
- package/dist/ui/assets/EntityActivityTimeline-Zods27y_.js +1 -0
- package/dist/ui/assets/{InitiativesModule-Dhm7M8e7.js → InitiativesModule-D4XseTwW.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-D5AcbO0L.js +1 -0
- package/dist/ui/assets/PlansPage-Dvqsz5zc.css +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/TaskContextUpload-uC5qx4Bv.js +1 -0
- package/dist/ui/assets/TaskSettings-CqvPAsTv.js +9 -0
- package/dist/ui/assets/{WorkflowsModule-BcS4afRn.js → WorkflowsModule-oOA9bcdK.js} +4 -4
- package/dist/ui/assets/documentReferences-DPZuTgHh.js +1 -0
- package/dist/ui/assets/index-607WPo_X.js +4 -0
- package/dist/ui/assets/index-CYyodXsg.css +1 -0
- package/dist/ui/assets/{vendor-icons-BSUaRwF8.js → vendor-icons-B32hGuF2.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/ui/product/agent manager.png +0 -0
- package/dist/ui/product/agent_manager_02.png +0 -0
- package/dist/utils/accountProfileSummaryCache.d.ts +5 -0
- package/dist/utils/aiProfileEvents.d.ts +7 -0
- package/dist/utils/aiProfileEvents.js +6 -0
- package/dist/utils/assignees.d.ts +8 -0
- package/dist/utils/assignees.js +10 -1
- package/dist/utils/assignees.test.d.ts +1 -0
- package/dist/utils/assignees.test.js +25 -0
- package/dist/utils/avatarDisplay.d.ts +1 -0
- package/dist/utils/avatarDisplay.js +15 -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.js +3 -0
- package/dist/utils/contextAssetEvents.d.ts +2 -0
- package/dist/utils/contextFiles.d.ts +3 -1
- package/dist/utils/contextFiles.js +2 -1
- 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 +9 -1
- package/dist/utils/taskActivity.js +290 -22
- package/dist/utils/taskActivity.test.js +141 -8
- package/dist/utils/taskSearch.d.ts +22 -0
- package/dist/utils/taskSearch.js +87 -0
- package/dist/utils/taskSearch.test.d.ts +1 -0
- package/dist/utils/taskSearch.test.js +69 -0
- package/dist/utils/taskUtils.js +10 -3
- package/dist/utils/taskUtils.test.d.ts +1 -0
- package/dist/utils/taskUtils.test.js +19 -0
- 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 +3 -2
- package/scripts/export-sqlite-to-postgres.mjs +7 -9
- 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-N2MnQBZk.js +0 -1
- package/dist/ui/assets/AnnotatedAttachmentWorkspace-BG6P_GVW.js +0 -3
- package/dist/ui/assets/AnnotatedAttachmentWorkspace-BaS2VwIr.css +0 -1
- package/dist/ui/assets/DocumentWorkspace-B3nV_Gc5.css +0 -1
- package/dist/ui/assets/DocumentWorkspace-DRbDMwh8.js +0 -252
- package/dist/ui/assets/PlansPage-C2dd2n1X.css +0 -1
- package/dist/ui/assets/PlansPage-Cq0zXj3I.js +0 -1
- package/dist/ui/assets/RussoOne-Regular-C3BxZIj7.ttf +0 -0
- package/dist/ui/assets/TaskSettings-ln0aF7xc.js +0 -9
- package/dist/ui/assets/index-C2-OXZV7.css +0 -1
- package/dist/ui/assets/index-DcSI5F86.js +0 -6
- 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();
|
|
@@ -133,6 +138,7 @@ vi.mock('../core/Taskforce', () => {
|
|
|
133
138
|
listMcpAccessTokens = vi.fn().mockReturnValue({ items: [], total: 0, page: 1, pageSize: 20 });
|
|
134
139
|
createMcpAccessToken = vi.fn();
|
|
135
140
|
revokeMcpAccessToken = vi.fn();
|
|
141
|
+
deleteMcpAccessToken = vi.fn();
|
|
136
142
|
regenerateMcpAccessToken = vi.fn();
|
|
137
143
|
inspectMcpAccessToken = vi.fn();
|
|
138
144
|
authenticateMcpAccessToken = vi.fn();
|
|
@@ -156,12 +162,17 @@ vi.mock('../core/Taskforce', () => {
|
|
|
156
162
|
listWorkspaceUsers = vi.fn().mockReturnValue([]);
|
|
157
163
|
listAssignableActors = vi.fn();
|
|
158
164
|
listAiProfiles = vi.fn().mockReturnValue([]);
|
|
165
|
+
countAiProfileUsage = vi.fn().mockReturnValue({ used: 0, limit: null, remaining: null });
|
|
166
|
+
mergeAiProfiles = vi.fn();
|
|
167
|
+
archiveAiProfile = vi.fn();
|
|
168
|
+
updateAiProfileAvatar = vi.fn();
|
|
159
169
|
replaceWorkspaceUsersForSync = vi.fn().mockReturnValue([]);
|
|
160
170
|
listDocumentReviewSessionsForSync = vi.fn().mockReturnValue([]);
|
|
161
171
|
listDocumentReviewCommentsForSync = vi.fn().mockReturnValue([]);
|
|
162
172
|
listAnnotatedAttachmentSessionsForSync = vi.fn().mockReturnValue([]);
|
|
163
173
|
listTaskEventsForSync = vi.fn().mockReturnValue([]);
|
|
164
174
|
listTaskEvents = vi.fn().mockReturnValue([]);
|
|
175
|
+
recordTaskEvent = vi.fn();
|
|
165
176
|
getDocumentReviewSession = vi.fn().mockReturnValue(null);
|
|
166
177
|
upsertAiProfileForSync = vi.fn();
|
|
167
178
|
upsertDocumentReviewSessionForSync = vi.fn();
|
|
@@ -235,6 +246,7 @@ vi.mock('../core/Taskforce', () => {
|
|
|
235
246
|
updatedAt: '2026-01-01T00:00:00.000Z'
|
|
236
247
|
}
|
|
237
248
|
]);
|
|
249
|
+
countSystemUsers = vi.fn().mockReturnValue(1);
|
|
238
250
|
getSystemRole = vi.fn().mockReturnValue('user');
|
|
239
251
|
getUserBetaAccess = vi.fn().mockReturnValue(true);
|
|
240
252
|
setSystemRole = vi.fn().mockReturnValue(true);
|
|
@@ -411,7 +423,7 @@ vi.mock('../core/Taskforce', () => {
|
|
|
411
423
|
}
|
|
412
424
|
},
|
|
413
425
|
{
|
|
414
|
-
featureKey: 'workspace.
|
|
426
|
+
featureKey: 'workspace.workspaces',
|
|
415
427
|
label: 'Workspaces',
|
|
416
428
|
description: 'Allow multiple workspaces with a configurable workspace limit.',
|
|
417
429
|
allowedAccessModes: ['enabled', 'disabled'],
|
|
@@ -624,6 +636,12 @@ describe('Server Routes', () => {
|
|
|
624
636
|
if (typeof body === 'string') {
|
|
625
637
|
req.push(body);
|
|
626
638
|
}
|
|
639
|
+
else if (Buffer.isBuffer(body)) {
|
|
640
|
+
req.push(body);
|
|
641
|
+
}
|
|
642
|
+
else if (body instanceof Uint8Array) {
|
|
643
|
+
req.push(Buffer.from(body));
|
|
644
|
+
}
|
|
627
645
|
else {
|
|
628
646
|
req.push(body ? JSON.stringify(body) : null);
|
|
629
647
|
}
|
|
@@ -700,6 +718,17 @@ describe('Server Routes', () => {
|
|
|
700
718
|
process.env.TASKFORCE_AUTH_EXPOSE_DEV_TOKENS = previous;
|
|
701
719
|
}
|
|
702
720
|
};
|
|
721
|
+
const createCloudAuthConfig = () => ({
|
|
722
|
+
runtimeMode: 'cloud',
|
|
723
|
+
enabled: true,
|
|
724
|
+
requiredForApi: true,
|
|
725
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
726
|
+
defaultRole: 'member',
|
|
727
|
+
defaultWorkspaceId: 'default',
|
|
728
|
+
sessionCookieName: 'taskforce_session',
|
|
729
|
+
sessionSecret: 'test-secret',
|
|
730
|
+
sessionTtlSeconds: 3600
|
|
731
|
+
});
|
|
703
732
|
it('GET /api/taskforce/tasks should return tasks', async () => {
|
|
704
733
|
const mockTasks = { tasks: [], config: {} };
|
|
705
734
|
core.listTasks.mockReturnValue(mockTasks);
|
|
@@ -711,6 +740,30 @@ describe('Server Routes', () => {
|
|
|
711
740
|
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
712
741
|
expect(res.end).toHaveBeenCalledWith(JSON.stringify(mockTasks));
|
|
713
742
|
});
|
|
743
|
+
it('GET /api/taskforce/planning/bootstrap should return lightweight planning bootstrap data', async () => {
|
|
744
|
+
const payload = {
|
|
745
|
+
initiatives: [{ id: 'initiative-1', title: 'Launch', description: null, createdAt: '2026-04-22T00:00:00.000Z' }],
|
|
746
|
+
workstreams: [{ id: 'workstream-1', title: 'Billing', description: null, createdAt: '2026-04-22T00:00:00.000Z' }],
|
|
747
|
+
workstreamTaskSummaries: [{
|
|
748
|
+
workstreamId: 'workstream-1',
|
|
749
|
+
taskCount: 2,
|
|
750
|
+
completedTaskCount: 1,
|
|
751
|
+
tasks: [
|
|
752
|
+
{ id: 'task-1', title: 'Draft copy', referenceNumber: 12, status: 'task' },
|
|
753
|
+
{ id: 'task-2', title: 'Review copy', referenceNumber: 13, status: 'done' }
|
|
754
|
+
]
|
|
755
|
+
}]
|
|
756
|
+
};
|
|
757
|
+
core.listPlanningBootstrap.mockReturnValue(payload);
|
|
758
|
+
const match = matchRoute('GET', '/api/taskforce/planning/bootstrap', routes);
|
|
759
|
+
const req = createMockReq('GET', '/api/taskforce/planning/bootstrap');
|
|
760
|
+
const res = createMockRes();
|
|
761
|
+
await match?.handler(req, res, {});
|
|
762
|
+
expect(core.listPlanningBootstrap).toHaveBeenCalledWith('default', expect.any(Object));
|
|
763
|
+
expect(res.setHeader).toHaveBeenCalledWith('Server-Timing', expect.stringContaining('planning-bootstrap'));
|
|
764
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
765
|
+
expect(res.end).toHaveBeenCalledWith(JSON.stringify(payload));
|
|
766
|
+
});
|
|
714
767
|
it('GET /mcp should return MCP JSON error instead of falling through when unauthenticated in cloud runtime', async () => {
|
|
715
768
|
const match = matchRoute('GET', '/mcp', routes);
|
|
716
769
|
const req = createMockReq('GET', '/mcp', undefined, {
|
|
@@ -779,13 +832,15 @@ describe('Server Routes', () => {
|
|
|
779
832
|
}));
|
|
780
833
|
expect(res.end).toHaveBeenCalledWith();
|
|
781
834
|
});
|
|
782
|
-
it('POST /mcp should accept authenticated initialize requests
|
|
835
|
+
it('POST /mcp should accept authenticated initialize requests, record token usage, and close the request runtime', async () => {
|
|
836
|
+
const serverClose = vi.fn().mockResolvedValue(undefined);
|
|
783
837
|
const createServerSpy = vi.spyOn(mcpRuntime, 'createTaskforceMcpServer').mockReturnValue({
|
|
784
838
|
server: {
|
|
785
839
|
connect: vi.fn().mockResolvedValue(undefined),
|
|
786
|
-
close:
|
|
840
|
+
close: serverClose
|
|
787
841
|
}
|
|
788
842
|
});
|
|
843
|
+
const closeTransportSpy = vi.spyOn(StreamableHTTPServerTransport.prototype, 'close').mockResolvedValue(undefined);
|
|
789
844
|
const handleRequestSpy = vi.spyOn(StreamableHTTPServerTransport.prototype, 'handleRequest').mockImplementation(async (_req, res, parsedBody) => {
|
|
790
845
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
791
846
|
res.end(JSON.stringify({
|
|
@@ -828,6 +883,8 @@ describe('Server Routes', () => {
|
|
|
828
883
|
expect(match).not.toBeNull();
|
|
829
884
|
await match.handler(req, res, match.params);
|
|
830
885
|
expect(core.authenticateMcpAccessToken).toHaveBeenCalledWith('tfmcp_test_token');
|
|
886
|
+
const runtimeOptions = createServerSpy.mock.calls[0]?.[0];
|
|
887
|
+
expect(runtimeOptions?.core).toBe(core);
|
|
831
888
|
expect(createServerSpy).toHaveBeenCalledWith(expect.objectContaining({
|
|
832
889
|
workspaceId: 'workspace-1',
|
|
833
890
|
runtimeMode: 'cloud',
|
|
@@ -857,6 +914,62 @@ describe('Server Routes', () => {
|
|
|
857
914
|
})
|
|
858
915
|
})
|
|
859
916
|
}));
|
|
917
|
+
expect(closeTransportSpy).not.toHaveBeenCalled();
|
|
918
|
+
expect(serverClose).toHaveBeenCalledTimes(1);
|
|
919
|
+
closeTransportSpy.mockRestore();
|
|
920
|
+
handleRequestSpy.mockRestore();
|
|
921
|
+
createServerSpy.mockRestore();
|
|
922
|
+
});
|
|
923
|
+
it('POST /mcp should distinguish transport failures from tool backend failures', async () => {
|
|
924
|
+
const serverClose = vi.fn().mockResolvedValue(undefined);
|
|
925
|
+
const createServerSpy = vi.spyOn(mcpRuntime, 'createTaskforceMcpServer').mockReturnValue({
|
|
926
|
+
server: {
|
|
927
|
+
connect: vi.fn().mockResolvedValue(undefined),
|
|
928
|
+
close: serverClose
|
|
929
|
+
}
|
|
930
|
+
});
|
|
931
|
+
const handleRequestSpy = vi.spyOn(StreamableHTTPServerTransport.prototype, 'handleRequest').mockRejectedValue(new Error('transport broke'));
|
|
932
|
+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
933
|
+
core.authenticateMcpAccessToken.mockReturnValue({
|
|
934
|
+
tokenId: 'token-transport-1',
|
|
935
|
+
userId: 'user-1',
|
|
936
|
+
workspaceId: 'workspace-1',
|
|
937
|
+
scopes: ['tasks:read', 'workspace:read'],
|
|
938
|
+
status: 'active'
|
|
939
|
+
});
|
|
940
|
+
const match = matchRoute('POST', '/mcp', routes);
|
|
941
|
+
const req = createMockReq('POST', '/mcp', {
|
|
942
|
+
jsonrpc: '2.0',
|
|
943
|
+
id: 2,
|
|
944
|
+
method: 'tools/call',
|
|
945
|
+
params: {
|
|
946
|
+
name: 'get_workstream',
|
|
947
|
+
arguments: { id: 'WS-1' }
|
|
948
|
+
}
|
|
949
|
+
}, {
|
|
950
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
951
|
+
authorization: 'Bearer tfmcp_transport_token',
|
|
952
|
+
accept: 'application/json, text/event-stream',
|
|
953
|
+
'content-type': 'application/json',
|
|
954
|
+
'x-request-id': 'req-mcp-transport'
|
|
955
|
+
});
|
|
956
|
+
const res = createMockRes();
|
|
957
|
+
expect(match).not.toBeNull();
|
|
958
|
+
await match.handler(req, res, match.params);
|
|
959
|
+
expect(res.statusCode).toBe(500);
|
|
960
|
+
expect(JSON.parse(res.__getBody())).toEqual(expect.objectContaining({
|
|
961
|
+
jsonrpc: '2.0',
|
|
962
|
+
error: expect.objectContaining({
|
|
963
|
+
message: 'MCP transport failure.',
|
|
964
|
+
data: expect.objectContaining({
|
|
965
|
+
kind: 'tool_transport_failure',
|
|
966
|
+
retryable: true,
|
|
967
|
+
requestId: 'req-mcp-transport'
|
|
968
|
+
})
|
|
969
|
+
})
|
|
970
|
+
}));
|
|
971
|
+
expect(serverClose).toHaveBeenCalledTimes(1);
|
|
972
|
+
consoleErrorSpy.mockRestore();
|
|
860
973
|
handleRequestSpy.mockRestore();
|
|
861
974
|
createServerSpy.mockRestore();
|
|
862
975
|
});
|
|
@@ -1040,7 +1153,10 @@ describe('Server Routes', () => {
|
|
|
1040
1153
|
clientId: 'client-1',
|
|
1041
1154
|
provider: 'chatgpt',
|
|
1042
1155
|
scopes: ['tasks:read'],
|
|
1043
|
-
status: 'active'
|
|
1156
|
+
status: 'active',
|
|
1157
|
+
aiProfileId: 'ai-profile-1',
|
|
1158
|
+
aiProfileToken: 'tfp_connector_codex',
|
|
1159
|
+
aiProfileName: 'Codex Connector'
|
|
1044
1160
|
});
|
|
1045
1161
|
const match = matchRoute('POST', '/mcp', routes);
|
|
1046
1162
|
const req = createMockReq('POST', '/mcp', {
|
|
@@ -1068,7 +1184,9 @@ describe('Server Routes', () => {
|
|
|
1068
1184
|
scopes: ['tasks:read'],
|
|
1069
1185
|
actorType: 'oauth',
|
|
1070
1186
|
tokenId: 'grant-1',
|
|
1071
|
-
userId: 'user-oauth'
|
|
1187
|
+
userId: 'user-oauth',
|
|
1188
|
+
aiProfileToken: 'tfp_connector_codex',
|
|
1189
|
+
aiProfileName: 'Codex Connector'
|
|
1072
1190
|
})
|
|
1073
1191
|
}));
|
|
1074
1192
|
handleRequestSpy.mockRestore();
|
|
@@ -1522,6 +1640,43 @@ describe('Server Routes', () => {
|
|
|
1522
1640
|
items: expect.any(Array)
|
|
1523
1641
|
}));
|
|
1524
1642
|
});
|
|
1643
|
+
it('DELETE /api/taskforce/settings/mcp/tokens/:id should delete an inactive token for the signed-in user', async () => {
|
|
1644
|
+
const authConfig = {
|
|
1645
|
+
runtimeMode: 'cloud',
|
|
1646
|
+
enabled: true,
|
|
1647
|
+
requiredForApi: true,
|
|
1648
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
1649
|
+
defaultRole: 'member',
|
|
1650
|
+
defaultWorkspaceId: 'workspace-1',
|
|
1651
|
+
sessionCookieName: 'taskforce_session',
|
|
1652
|
+
sessionSecret: 'test-secret',
|
|
1653
|
+
sessionTtlSeconds: 3600
|
|
1654
|
+
};
|
|
1655
|
+
routes = createRoutes(core, { authConfig });
|
|
1656
|
+
core.deleteMcpAccessToken.mockReturnValue(true);
|
|
1657
|
+
const cookie = createSessionTokenCookie('session-mcp-token-delete', authConfig, {
|
|
1658
|
+
role: 'member',
|
|
1659
|
+
workspaceId: 'workspace-1',
|
|
1660
|
+
userId: 'user-1'
|
|
1661
|
+
});
|
|
1662
|
+
const match = matchRoute('DELETE', '/api/taskforce/settings/mcp/tokens/token-1', routes);
|
|
1663
|
+
const req = createMockReq('DELETE', '/api/taskforce/settings/mcp/tokens/token-1', undefined, {
|
|
1664
|
+
cookie,
|
|
1665
|
+
'x-taskforce-runtime-mode': 'cloud'
|
|
1666
|
+
});
|
|
1667
|
+
const res = createMockRes();
|
|
1668
|
+
expect(match).not.toBeNull();
|
|
1669
|
+
await match.handler(req, res, { id: 'token-1' });
|
|
1670
|
+
expect(core.deleteMcpAccessToken).toHaveBeenCalledWith({
|
|
1671
|
+
actorUserId: 'user-1',
|
|
1672
|
+
workspaceId: 'workspace-1',
|
|
1673
|
+
tokenId: 'token-1'
|
|
1674
|
+
});
|
|
1675
|
+
expect(JSON.parse(res.__getBody())).toEqual({
|
|
1676
|
+
success: true,
|
|
1677
|
+
deleted: true
|
|
1678
|
+
});
|
|
1679
|
+
});
|
|
1525
1680
|
it('POST /api/taskforce/global-settings should update global settings', async () => {
|
|
1526
1681
|
const payload = { theme: 'light', shortcut: 'Ctrl+Shift+L' };
|
|
1527
1682
|
const match = matchRoute('POST', '/api/taskforce/global-settings', routes);
|
|
@@ -1589,6 +1744,49 @@ describe('Server Routes', () => {
|
|
|
1589
1744
|
});
|
|
1590
1745
|
expect(getAfterRes.end).toHaveBeenCalledWith(expect.stringContaining('"sourceOfTruth":"cloud"'));
|
|
1591
1746
|
});
|
|
1747
|
+
it('POST /api/taskforce/ui-state should accept large workspace-sync baselines', async () => {
|
|
1748
|
+
const match = matchRoute('POST', '/api/taskforce/ui-state', routes);
|
|
1749
|
+
const largeWatermark = 'x'.repeat(1024 * 1024 + 1);
|
|
1750
|
+
const req = createMockReq('POST', '/api/taskforce/ui-state', {
|
|
1751
|
+
stateKey: 'workspace-sync',
|
|
1752
|
+
workspaceId: 'workspace-large-baseline',
|
|
1753
|
+
patch: {
|
|
1754
|
+
version: 2,
|
|
1755
|
+
enabled: true,
|
|
1756
|
+
phase: 'active',
|
|
1757
|
+
pushBaseline: {
|
|
1758
|
+
taskIds: ['task-1'],
|
|
1759
|
+
taskWatermarks: [['task-1', largeWatermark]],
|
|
1760
|
+
taskChangeKeys: [],
|
|
1761
|
+
initiativeWatermarks: [],
|
|
1762
|
+
workstreamWatermarks: [],
|
|
1763
|
+
aiProfileWatermarks: [],
|
|
1764
|
+
aiProfileChangeKeys: [],
|
|
1765
|
+
documentWatermarks: [],
|
|
1766
|
+
assetWatermarks: [],
|
|
1767
|
+
documentReviewSessionWatermarks: [],
|
|
1768
|
+
documentReviewCommentWatermarks: [],
|
|
1769
|
+
annotatedAttachmentSessionWatermarks: [],
|
|
1770
|
+
taskEventWatermarks: [],
|
|
1771
|
+
taxonomyStateFingerprint: ''
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
}, {
|
|
1775
|
+
'x-taskforce-runtime-mode': 'local',
|
|
1776
|
+
'x-taskforce-workspace-id': 'workspace-current'
|
|
1777
|
+
});
|
|
1778
|
+
const res = createMockRes();
|
|
1779
|
+
await match?.handler(req, res, {});
|
|
1780
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
1781
|
+
expect(core.updateUiState).toHaveBeenCalledWith('workspace-large-baseline', 'anonymous', {
|
|
1782
|
+
stateKey: 'workspace-sync',
|
|
1783
|
+
patch: expect.objectContaining({
|
|
1784
|
+
pushBaseline: expect.objectContaining({
|
|
1785
|
+
taskWatermarks: [['task-1', largeWatermark]]
|
|
1786
|
+
})
|
|
1787
|
+
})
|
|
1788
|
+
});
|
|
1789
|
+
});
|
|
1592
1790
|
it('GET /api/taskforce/ui-state should ignore query workspace overrides in cloud mode', async () => {
|
|
1593
1791
|
core.getUiState.mockReturnValue({ activeWorkspaceModule: 'tasks' });
|
|
1594
1792
|
const match = matchRoute('GET', '/api/taskforce/ui-state', routes);
|
|
@@ -2157,6 +2355,140 @@ describe('Server Routes', () => {
|
|
|
2157
2355
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
2158
2356
|
vi.unstubAllGlobals();
|
|
2159
2357
|
});
|
|
2358
|
+
it('GET /api/taskforce/context/:filename should proxy R2 user avatars inline instead of redirecting', async () => {
|
|
2359
|
+
const fetchMock = vi.fn().mockResolvedValue(new Response('avatar-bytes', {
|
|
2360
|
+
status: 200,
|
|
2361
|
+
headers: { 'Content-Type': 'image/webp' }
|
|
2362
|
+
}));
|
|
2363
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
2364
|
+
const routesWithStorage = createRoutes(core, {
|
|
2365
|
+
objectStorage: {
|
|
2366
|
+
provider: 'r2',
|
|
2367
|
+
r2: {
|
|
2368
|
+
accountId: 'acct-1',
|
|
2369
|
+
bucket: 'taskforce-docs',
|
|
2370
|
+
endpoint: 'https://example.r2.cloudflarestorage.com',
|
|
2371
|
+
accessKeyId: 'access-key-1',
|
|
2372
|
+
secretAccessKey: 'secret-key-1',
|
|
2373
|
+
keyPrefix: 'taskforce',
|
|
2374
|
+
signedUploadTtlSeconds: 600,
|
|
2375
|
+
signedDownloadTtlSeconds: 300
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
});
|
|
2379
|
+
const encoded = encodeURIComponent('users/user-1/profile/avatar/avatar-draft-1-headshot.webp');
|
|
2380
|
+
const match = matchRoute('GET', `/api/taskforce/context/${encoded}`, routesWithStorage);
|
|
2381
|
+
const req = createMockReq('GET', `/api/taskforce/context/${encoded}`);
|
|
2382
|
+
const res = createMockRes();
|
|
2383
|
+
await match?.handler(req, res, { filename: encoded });
|
|
2384
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.objectContaining({
|
|
2385
|
+
'Content-Type': 'image/webp',
|
|
2386
|
+
'Content-Disposition': 'inline'
|
|
2387
|
+
}));
|
|
2388
|
+
expect(String(res.getHeader('location') || '')).toBe('');
|
|
2389
|
+
expect(res.__getBody()).toBe('avatar-bytes');
|
|
2390
|
+
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');
|
|
2391
|
+
vi.unstubAllGlobals();
|
|
2392
|
+
});
|
|
2393
|
+
it('GET /api/taskforce/context/:filename should serve synced local AI avatars when R2 object is missing', async () => {
|
|
2394
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-ai-avatar-r2-fallback-'));
|
|
2395
|
+
const projectRoot = fs.realpathSync(tmpRoot);
|
|
2396
|
+
const basePath = path.join(projectRoot, '.taskforce');
|
|
2397
|
+
const relativePath = 'workspaces/workspace-1/assets/images/ai-profiles/ai-profile-codex/avatar/asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-cropped.webp';
|
|
2398
|
+
const absolutePath = path.join(basePath, relativePath);
|
|
2399
|
+
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
|
2400
|
+
fs.writeFileSync(absolutePath, Buffer.from('synced-ai-avatar'));
|
|
2401
|
+
core.getPaths.mockReturnValue({ projectRoot, basePath });
|
|
2402
|
+
const fetchMock = vi.fn().mockResolvedValue(new Response('', { status: 404 }));
|
|
2403
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
2404
|
+
try {
|
|
2405
|
+
const routesWithStorage = createRoutes(core, {
|
|
2406
|
+
objectStorage: {
|
|
2407
|
+
provider: 'r2',
|
|
2408
|
+
r2: {
|
|
2409
|
+
accountId: 'acct-1',
|
|
2410
|
+
bucket: 'taskforce-docs',
|
|
2411
|
+
endpoint: 'https://example.r2.cloudflarestorage.com',
|
|
2412
|
+
accessKeyId: 'access-key-1',
|
|
2413
|
+
secretAccessKey: 'secret-key-1',
|
|
2414
|
+
keyPrefix: 'taskforce',
|
|
2415
|
+
signedUploadTtlSeconds: 600,
|
|
2416
|
+
signedDownloadTtlSeconds: 300
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
});
|
|
2420
|
+
const encoded = encodeURIComponent(relativePath);
|
|
2421
|
+
const req = createMockReq('GET', `/api/taskforce/context/${encoded}`);
|
|
2422
|
+
const res = createMockRes();
|
|
2423
|
+
await matchRoute('GET', `/api/taskforce/context/${encoded}`, routesWithStorage)?.handler(req, res, { filename: encoded });
|
|
2424
|
+
expect(fetchMock).toHaveBeenCalled();
|
|
2425
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.objectContaining({
|
|
2426
|
+
'Content-Type': 'image/webp',
|
|
2427
|
+
'Content-Disposition': 'inline'
|
|
2428
|
+
}));
|
|
2429
|
+
expect(String(res.getHeader('location') || '')).toBe('');
|
|
2430
|
+
expect(res.__getBody()).toBe('synced-ai-avatar');
|
|
2431
|
+
}
|
|
2432
|
+
finally {
|
|
2433
|
+
vi.unstubAllGlobals();
|
|
2434
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
2435
|
+
}
|
|
2436
|
+
});
|
|
2437
|
+
it('GET /api/taskforce/context/:filename should proxy missing cloud-owned user avatars in local runtime', async () => {
|
|
2438
|
+
const previousCloudProxyBaseUrl = process.env.TASKFORCE_CLOUD_PROXY_BASE_URL;
|
|
2439
|
+
const previousClientId = process.env.TASKFORCE_CF_ACCESS_CLIENT_ID;
|
|
2440
|
+
const previousClientSecret = process.env.TASKFORCE_CF_ACCESS_CLIENT_SECRET;
|
|
2441
|
+
process.env.TASKFORCE_CLOUD_PROXY_BASE_URL = 'https://performance-app.taskforcehq.ai';
|
|
2442
|
+
process.env.TASKFORCE_CF_ACCESS_CLIENT_ID = 'cf-client-id';
|
|
2443
|
+
process.env.TASKFORCE_CF_ACCESS_CLIENT_SECRET = 'cf-client-secret';
|
|
2444
|
+
const fetchMock = vi.fn().mockResolvedValue(new Response('avatar-bytes', {
|
|
2445
|
+
status: 200,
|
|
2446
|
+
headers: {
|
|
2447
|
+
'Content-Type': 'image/webp',
|
|
2448
|
+
'Cache-Control': 'private, max-age=60'
|
|
2449
|
+
}
|
|
2450
|
+
}));
|
|
2451
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
2452
|
+
try {
|
|
2453
|
+
const relativePath = 'users/user-1/profile/avatar/avatar-draft-1-headshot.webp';
|
|
2454
|
+
const encoded = encodeURIComponent(relativePath);
|
|
2455
|
+
const match = matchRoute('GET', `/api/taskforce/context/${encoded}`, routes);
|
|
2456
|
+
const req = createMockReq('GET', `/api/taskforce/context/${encoded}`, undefined, {
|
|
2457
|
+
host: 'localhost:5174',
|
|
2458
|
+
cookie: 'taskforce_session=session-1',
|
|
2459
|
+
accept: 'image/webp'
|
|
2460
|
+
});
|
|
2461
|
+
const res = createMockRes();
|
|
2462
|
+
await match?.handler(req, res, { filename: encoded });
|
|
2463
|
+
expect(fetchMock).toHaveBeenCalledWith(`https://performance-app.taskforcehq.ai/api/taskforce/context/${encoded}`, expect.objectContaining({
|
|
2464
|
+
method: 'GET',
|
|
2465
|
+
headers: expect.any(Headers)
|
|
2466
|
+
}));
|
|
2467
|
+
const proxiedHeaders = fetchMock.mock.calls[0][1].headers;
|
|
2468
|
+
expect(proxiedHeaders.get('cookie')).toBe('taskforce_session=session-1');
|
|
2469
|
+
expect(proxiedHeaders.get('CF-Access-Client-Id')).toBe('cf-client-id');
|
|
2470
|
+
expect(proxiedHeaders.get('CF-Access-Client-Secret')).toBe('cf-client-secret');
|
|
2471
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.objectContaining({
|
|
2472
|
+
'content-type': 'image/webp'
|
|
2473
|
+
}));
|
|
2474
|
+
expect(res.__getBody()).toBe('avatar-bytes');
|
|
2475
|
+
}
|
|
2476
|
+
finally {
|
|
2477
|
+
vi.unstubAllGlobals();
|
|
2478
|
+
if (previousCloudProxyBaseUrl === undefined)
|
|
2479
|
+
delete process.env.TASKFORCE_CLOUD_PROXY_BASE_URL;
|
|
2480
|
+
else
|
|
2481
|
+
process.env.TASKFORCE_CLOUD_PROXY_BASE_URL = previousCloudProxyBaseUrl;
|
|
2482
|
+
if (previousClientId === undefined)
|
|
2483
|
+
delete process.env.TASKFORCE_CF_ACCESS_CLIENT_ID;
|
|
2484
|
+
else
|
|
2485
|
+
process.env.TASKFORCE_CF_ACCESS_CLIENT_ID = previousClientId;
|
|
2486
|
+
if (previousClientSecret === undefined)
|
|
2487
|
+
delete process.env.TASKFORCE_CF_ACCESS_CLIENT_SECRET;
|
|
2488
|
+
else
|
|
2489
|
+
process.env.TASKFORCE_CF_ACCESS_CLIENT_SECRET = previousClientSecret;
|
|
2490
|
+
}
|
|
2491
|
+
});
|
|
2160
2492
|
it('GET /api/taskforce/context/:filename should proxy inline R2 markdown documents instead of redirecting', async () => {
|
|
2161
2493
|
const fetchMock = vi.fn().mockResolvedValue({
|
|
2162
2494
|
status: 200,
|
|
@@ -3075,6 +3407,53 @@ describe('Server Routes', () => {
|
|
|
3075
3407
|
vi.unstubAllGlobals();
|
|
3076
3408
|
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
3077
3409
|
});
|
|
3410
|
+
it('POST /api/taskforce/context-upload/finalize should record direct AI avatar uploads instead of fast-finalizing', async () => {
|
|
3411
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
3412
|
+
ok: true,
|
|
3413
|
+
status: 200,
|
|
3414
|
+
statusText: 'OK',
|
|
3415
|
+
headers: { get: () => 'image/webp' },
|
|
3416
|
+
arrayBuffer: async () => new TextEncoder().encode('avatar-bytes').buffer
|
|
3417
|
+
});
|
|
3418
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
3419
|
+
const routesWithStorage = createRoutes(core, {
|
|
3420
|
+
objectStorage: {
|
|
3421
|
+
provider: 'r2',
|
|
3422
|
+
r2: {
|
|
3423
|
+
accountId: 'acct-1',
|
|
3424
|
+
bucket: 'taskforce-docs',
|
|
3425
|
+
endpoint: 'https://example.r2.cloudflarestorage.com',
|
|
3426
|
+
accessKeyId: 'access-key-1',
|
|
3427
|
+
secretAccessKey: 'secret-key-1',
|
|
3428
|
+
keyPrefix: 'taskforce',
|
|
3429
|
+
signedUploadTtlSeconds: 600,
|
|
3430
|
+
signedDownloadTtlSeconds: 300
|
|
3431
|
+
}
|
|
3432
|
+
}
|
|
3433
|
+
});
|
|
3434
|
+
const relativePath = 'workspaces/workspace-1/assets/images/ai-profiles/ai-profile-codex/avatar/asset-avatar.webp';
|
|
3435
|
+
const match = matchRoute('POST', '/api/taskforce/context-upload/finalize', routesWithStorage);
|
|
3436
|
+
const req = createMockReq('POST', '/api/taskforce/context-upload/finalize', {
|
|
3437
|
+
relativePath,
|
|
3438
|
+
mimeType: 'image/webp',
|
|
3439
|
+
originalName: 'avatar.webp',
|
|
3440
|
+
fastFinalize: true
|
|
3441
|
+
}, {
|
|
3442
|
+
'x-taskforce-workspace-id': 'workspace-1'
|
|
3443
|
+
});
|
|
3444
|
+
const res = createMockRes();
|
|
3445
|
+
await match?.handler(req, res, {});
|
|
3446
|
+
expect(fetchMock).toHaveBeenCalled();
|
|
3447
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, { 'Content-Type': 'application/json' });
|
|
3448
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
3449
|
+
expect(payload).toEqual(expect.objectContaining({
|
|
3450
|
+
success: true,
|
|
3451
|
+
fsPath: relativePath,
|
|
3452
|
+
path: `/api/taskforce/context/${encodeURIComponent(relativePath)}`
|
|
3453
|
+
}));
|
|
3454
|
+
expect(payload.mode).not.toBe('direct-fast');
|
|
3455
|
+
vi.unstubAllGlobals();
|
|
3456
|
+
});
|
|
3078
3457
|
it('POST /api/taskforce/context-upload/finalize should mirror upload metadata into workspace_assets', async () => {
|
|
3079
3458
|
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-context-finalize-canonical-'));
|
|
3080
3459
|
core.getPaths.mockReturnValue({
|
|
@@ -3108,8 +3487,21 @@ describe('Server Routes', () => {
|
|
|
3108
3487
|
}
|
|
3109
3488
|
});
|
|
3110
3489
|
const match = matchRoute('POST', '/api/taskforce/context-upload/finalize', routesWithStorage);
|
|
3490
|
+
const attachmentPath = '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Ffiles%2Fasset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-spec.pdf';
|
|
3491
|
+
const attachmentFsPath = 'workspaces/workspace-1/assets/files/asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-spec.pdf';
|
|
3492
|
+
core.getTask
|
|
3493
|
+
.mockImplementationOnce(() => ({ id: 'task-1', attachments: [] }))
|
|
3494
|
+
.mockImplementationOnce(() => ({
|
|
3495
|
+
id: 'task-1',
|
|
3496
|
+
attachments: [{
|
|
3497
|
+
path: attachmentPath,
|
|
3498
|
+
fsPath: attachmentFsPath,
|
|
3499
|
+
caption: 'spec.pdf',
|
|
3500
|
+
assetId: 'asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
|
3501
|
+
}]
|
|
3502
|
+
}));
|
|
3111
3503
|
const req = createMockReq('POST', '/api/taskforce/context-upload/finalize', {
|
|
3112
|
-
relativePath:
|
|
3504
|
+
relativePath: attachmentFsPath,
|
|
3113
3505
|
mimeType: 'application/pdf',
|
|
3114
3506
|
taskId: 'task-1',
|
|
3115
3507
|
originalName: 'spec.pdf'
|
|
@@ -3121,8 +3513,8 @@ describe('Server Routes', () => {
|
|
|
3121
3513
|
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
3122
3514
|
expect(payload).toEqual(expect.objectContaining({
|
|
3123
3515
|
success: true,
|
|
3124
|
-
path:
|
|
3125
|
-
fsPath:
|
|
3516
|
+
path: attachmentPath,
|
|
3517
|
+
fsPath: attachmentFsPath,
|
|
3126
3518
|
taskId: 'task-1'
|
|
3127
3519
|
}));
|
|
3128
3520
|
const assets = store.listByWorkspace('workspace-1', { includeDeleted: true });
|
|
@@ -3136,6 +3528,25 @@ describe('Server Routes', () => {
|
|
|
3136
3528
|
expect(store.listLinksForTask('task-1', 'workspace-1')).toEqual([
|
|
3137
3529
|
expect.objectContaining({ role: 'attachment' })
|
|
3138
3530
|
]);
|
|
3531
|
+
expect(core.recordTaskEvent).toHaveBeenCalledWith(expect.objectContaining({
|
|
3532
|
+
taskId: 'task-1',
|
|
3533
|
+
workspaceId: 'workspace-1',
|
|
3534
|
+
action: 'task-attachments-changed',
|
|
3535
|
+
actor: 'user',
|
|
3536
|
+
createdAt: expect.any(String),
|
|
3537
|
+
details: {
|
|
3538
|
+
changes: {
|
|
3539
|
+
attachments: {
|
|
3540
|
+
from: [],
|
|
3541
|
+
to: [expect.objectContaining({
|
|
3542
|
+
path: attachmentPath,
|
|
3543
|
+
fsPath: attachmentFsPath,
|
|
3544
|
+
assetId: 'asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
|
3545
|
+
})],
|
|
3546
|
+
},
|
|
3547
|
+
},
|
|
3548
|
+
},
|
|
3549
|
+
}));
|
|
3139
3550
|
vi.unstubAllGlobals();
|
|
3140
3551
|
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
3141
3552
|
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
@@ -3447,6 +3858,53 @@ describe('Server Routes', () => {
|
|
|
3447
3858
|
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
3448
3859
|
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
3449
3860
|
});
|
|
3861
|
+
it('GET /api/taskforce/documents should exclude raw .taskforce/docs markdown files from the document manager', async () => {
|
|
3862
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-exclude-raw-docs-'));
|
|
3863
|
+
const basePath = path.join(tmpRoot, '.taskforce');
|
|
3864
|
+
fs.mkdirSync(path.join(basePath, 'docs'), { recursive: true });
|
|
3865
|
+
fs.writeFileSync(path.join(basePath, 'docs', 'legacy-plan.md'), '# legacy\n', 'utf8');
|
|
3866
|
+
core.getPaths.mockReturnValue({
|
|
3867
|
+
projectRoot: tmpRoot,
|
|
3868
|
+
basePath,
|
|
3869
|
+
});
|
|
3870
|
+
const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
|
|
3871
|
+
core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
|
|
3872
|
+
core.getTenantId = vi.fn().mockReturnValue('default');
|
|
3873
|
+
store.upsertAsset({
|
|
3874
|
+
assetId: 'asset-doc-local-2',
|
|
3875
|
+
workspaceId: 'default',
|
|
3876
|
+
kind: 'document',
|
|
3877
|
+
originalFilename: 'managed-plan.md',
|
|
3878
|
+
mimeType: 'text/markdown',
|
|
3879
|
+
storageProvider: 'local',
|
|
3880
|
+
storageKey: 'workspaces/default/assets/documents/asset-doc-local-2-managed-plan.md',
|
|
3881
|
+
contentSha256: 'sha-doc-local-2',
|
|
3882
|
+
sizeBytes: 99,
|
|
3883
|
+
documentId: 'doc-local-2',
|
|
3884
|
+
version: 1,
|
|
3885
|
+
updatedAt: '2026-04-19T12:00:00.000Z'
|
|
3886
|
+
});
|
|
3887
|
+
const routesWithDb = createRoutes(core);
|
|
3888
|
+
const match = matchRoute('GET', '/api/taskforce/documents', routesWithDb);
|
|
3889
|
+
const req = createMockReq('GET', '/api/taskforce/documents', undefined, {
|
|
3890
|
+
'x-taskforce-workspace-id': 'default',
|
|
3891
|
+
'x-taskforce-runtime-mode': 'local'
|
|
3892
|
+
});
|
|
3893
|
+
const res = createMockRes();
|
|
3894
|
+
await match?.handler(req, res, {});
|
|
3895
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
3896
|
+
expect(Array.isArray(payload.documents)).toBe(true);
|
|
3897
|
+
expect(payload.documents).toHaveLength(1);
|
|
3898
|
+
expect(payload.documents[0]).toEqual(expect.objectContaining({
|
|
3899
|
+
id: 'asset-doc-local-2',
|
|
3900
|
+
assetId: 'asset-doc-local-2',
|
|
3901
|
+
fsPath: 'workspaces/default/assets/documents/asset-doc-local-2-managed-plan.md',
|
|
3902
|
+
attachmentState: 'unattached'
|
|
3903
|
+
}));
|
|
3904
|
+
expect(payload.documents.some((doc) => String(doc.fsPath || '').includes('docs/legacy-plan.md'))).toBe(false);
|
|
3905
|
+
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
3906
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
3907
|
+
});
|
|
3450
3908
|
it('GET /api/taskforce/documents should mark canonical docs without links as unattached', async () => {
|
|
3451
3909
|
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-unattached-local-'));
|
|
3452
3910
|
core.getPaths.mockReturnValue({
|
|
@@ -3543,8 +4001,8 @@ describe('Server Routes', () => {
|
|
|
3543
4001
|
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
3544
4002
|
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
3545
4003
|
});
|
|
3546
|
-
it('GET /api/taskforce/documents should
|
|
3547
|
-
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-
|
|
4004
|
+
it('GET /api/taskforce/documents should count non-task links in attachment metadata while only surfacing task titles for task links', async () => {
|
|
4005
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-generic-links-'));
|
|
3548
4006
|
core.getPaths.mockReturnValue({
|
|
3549
4007
|
projectRoot: tmpRoot,
|
|
3550
4008
|
basePath: path.join(tmpRoot, '.taskforce'),
|
|
@@ -3553,29 +4011,142 @@ describe('Server Routes', () => {
|
|
|
3553
4011
|
core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
|
|
3554
4012
|
core.getTenantId = vi.fn().mockReturnValue('default');
|
|
3555
4013
|
store.upsertAsset({
|
|
3556
|
-
assetId: 'asset-doc-
|
|
3557
|
-
workspaceId: '
|
|
4014
|
+
assetId: 'asset-doc-generic-link-1',
|
|
4015
|
+
workspaceId: 'default',
|
|
3558
4016
|
kind: 'document',
|
|
3559
|
-
|
|
3560
|
-
originalFilename: 'asset-doc-sparse-1-implementation-plan-1773922162143.md',
|
|
4017
|
+
originalFilename: 'shared-notes.md',
|
|
3561
4018
|
mimeType: 'text/markdown',
|
|
3562
|
-
storageProvider: '
|
|
3563
|
-
storageKey: 'workspaces/
|
|
3564
|
-
contentSha256: 'sha-doc-
|
|
3565
|
-
sizeBytes:
|
|
3566
|
-
documentId: 'doc-
|
|
4019
|
+
storageProvider: 'local',
|
|
4020
|
+
storageKey: 'workspaces/default/assets/documents/asset-doc-generic-link-1-shared-notes.md',
|
|
4021
|
+
contentSha256: 'sha-doc-generic-link-1',
|
|
4022
|
+
sizeBytes: 64,
|
|
4023
|
+
documentId: 'doc-generic-link-1',
|
|
3567
4024
|
version: 1,
|
|
3568
|
-
updatedAt: '2026-03-
|
|
4025
|
+
updatedAt: '2026-03-18T13:30:00.000Z'
|
|
3569
4026
|
});
|
|
3570
4027
|
store.upsertLink({
|
|
3571
|
-
workspaceId: '
|
|
3572
|
-
assetId: 'asset-doc-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
4028
|
+
workspaceId: 'default',
|
|
4029
|
+
assetId: 'asset-doc-generic-link-1',
|
|
4030
|
+
targetType: 'initiative',
|
|
4031
|
+
targetId: 'initiative-1',
|
|
4032
|
+
role: 'attachment'
|
|
3576
4033
|
});
|
|
3577
|
-
|
|
3578
|
-
|
|
4034
|
+
const routesWithDb = createRoutes(core);
|
|
4035
|
+
const match = matchRoute('GET', '/api/taskforce/documents', routesWithDb);
|
|
4036
|
+
const req = createMockReq('GET', '/api/taskforce/documents', undefined, {
|
|
4037
|
+
'x-taskforce-workspace-id': 'default',
|
|
4038
|
+
'x-taskforce-runtime-mode': 'local'
|
|
4039
|
+
});
|
|
4040
|
+
const res = createMockRes();
|
|
4041
|
+
await match?.handler(req, res, {});
|
|
4042
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
4043
|
+
expect(payload.documents).toEqual([
|
|
4044
|
+
expect.objectContaining({
|
|
4045
|
+
id: 'asset-doc-generic-link-1',
|
|
4046
|
+
assetId: 'asset-doc-generic-link-1',
|
|
4047
|
+
attachmentCount: 1,
|
|
4048
|
+
attachmentState: 'attached',
|
|
4049
|
+
taskId: null
|
|
4050
|
+
})
|
|
4051
|
+
]);
|
|
4052
|
+
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
4053
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
4054
|
+
});
|
|
4055
|
+
it('POST /api/taskforce/documents/:assetId/attach should attach a canonical document to a task target', async () => {
|
|
4056
|
+
vi.useFakeTimers();
|
|
4057
|
+
vi.setSystemTime(new Date('2026-03-18T14:45:00.000Z'));
|
|
4058
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-attach-task-'));
|
|
4059
|
+
core.getPaths.mockReturnValue({
|
|
4060
|
+
projectRoot: tmpRoot,
|
|
4061
|
+
basePath: path.join(tmpRoot, '.taskforce'),
|
|
4062
|
+
});
|
|
4063
|
+
const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
|
|
4064
|
+
core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
|
|
4065
|
+
core.getTenantId = vi.fn().mockReturnValue('default');
|
|
4066
|
+
store.upsertAsset({
|
|
4067
|
+
assetId: 'asset-doc-attach-route-1',
|
|
4068
|
+
workspaceId: 'default',
|
|
4069
|
+
kind: 'document',
|
|
4070
|
+
logicalName: 'Attach Me',
|
|
4071
|
+
originalFilename: 'attach-me.md',
|
|
4072
|
+
mimeType: 'text/markdown',
|
|
4073
|
+
storageProvider: 'local',
|
|
4074
|
+
storageKey: 'workspaces/default/assets/documents/asset-doc-attach-route-1-attach-me.md',
|
|
4075
|
+
contentSha256: 'sha-doc-attach-route-1',
|
|
4076
|
+
sizeBytes: 28,
|
|
4077
|
+
documentId: 'doc-attach-route-1',
|
|
4078
|
+
version: 1,
|
|
4079
|
+
updatedAt: '2026-03-18T13:30:00.000Z'
|
|
4080
|
+
});
|
|
4081
|
+
core.getTask.mockImplementation((id) => id === 'task-attach-1'
|
|
4082
|
+
? { id: 'task-attach-1', title: 'Attach target', attachments: [] }
|
|
4083
|
+
: null);
|
|
4084
|
+
const routesWithDb = createRoutes(core);
|
|
4085
|
+
const match = matchRoute('POST', '/api/taskforce/documents/asset-doc-attach-route-1/attach', routesWithDb);
|
|
4086
|
+
const req = createMockReq('POST', '/api/taskforce/documents/asset-doc-attach-route-1/attach', {
|
|
4087
|
+
targetType: 'task',
|
|
4088
|
+
targetId: 'task-attach-1',
|
|
4089
|
+
}, {
|
|
4090
|
+
'x-taskforce-workspace-id': 'default'
|
|
4091
|
+
});
|
|
4092
|
+
const res = createMockRes();
|
|
4093
|
+
try {
|
|
4094
|
+
await match?.handler(req, res, { assetId: 'asset-doc-attach-route-1' });
|
|
4095
|
+
}
|
|
4096
|
+
finally {
|
|
4097
|
+
vi.useRealTimers();
|
|
4098
|
+
}
|
|
4099
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, { 'Content-Type': 'application/json' });
|
|
4100
|
+
expect(core.updateTask).toHaveBeenCalledWith('task-attach-1', expect.objectContaining({
|
|
4101
|
+
attachments: [
|
|
4102
|
+
expect.objectContaining({
|
|
4103
|
+
assetId: 'asset-doc-attach-route-1',
|
|
4104
|
+
taskId: 'task-attach-1',
|
|
4105
|
+
path: '/api/taskforce/documents/asset-doc-attach-route-1/content',
|
|
4106
|
+
timestamp: '2026-03-18T14:45:00.000Z',
|
|
4107
|
+
})
|
|
4108
|
+
]
|
|
4109
|
+
}), 'default', expect.objectContaining({
|
|
4110
|
+
actorRef: 'user',
|
|
4111
|
+
saveSource: 'manual',
|
|
4112
|
+
}));
|
|
4113
|
+
expect(core.notifyDocumentMetadataMutation).toHaveBeenCalledWith('default', ['workspaces/default/assets/documents/asset-doc-attach-route-1-attach-me.md'], 'upsert');
|
|
4114
|
+
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
4115
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
4116
|
+
});
|
|
4117
|
+
it('GET /api/taskforce/documents should normalize readable names and attachment metadata for sparse canonical assets', async () => {
|
|
4118
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-normalized-cloud-'));
|
|
4119
|
+
core.getPaths.mockReturnValue({
|
|
4120
|
+
projectRoot: tmpRoot,
|
|
4121
|
+
basePath: path.join(tmpRoot, '.taskforce'),
|
|
4122
|
+
});
|
|
4123
|
+
const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
|
|
4124
|
+
core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
|
|
4125
|
+
core.getTenantId = vi.fn().mockReturnValue('default');
|
|
4126
|
+
store.upsertAsset({
|
|
4127
|
+
assetId: 'asset-doc-sparse-1',
|
|
4128
|
+
workspaceId: 'workspace-1',
|
|
4129
|
+
kind: 'document',
|
|
4130
|
+
logicalName: 'asset-doc-sparse-1',
|
|
4131
|
+
originalFilename: 'asset-doc-sparse-1-implementation-plan-1773922162143.md',
|
|
4132
|
+
mimeType: 'text/markdown',
|
|
4133
|
+
storageProvider: 'r2',
|
|
4134
|
+
storageKey: 'workspaces/workspace-1/assets/documents/asset-doc-sparse-1-implementation-plan-1773922162143.md',
|
|
4135
|
+
contentSha256: 'sha-doc-sparse-1',
|
|
4136
|
+
sizeBytes: 222,
|
|
4137
|
+
documentId: 'doc-sparse-1',
|
|
4138
|
+
version: 1,
|
|
4139
|
+
updatedAt: '2026-03-18T15:00:00.000Z'
|
|
4140
|
+
});
|
|
4141
|
+
store.upsertLink({
|
|
4142
|
+
workspaceId: 'workspace-1',
|
|
4143
|
+
assetId: 'asset-doc-sparse-1',
|
|
4144
|
+
taskId: 'task-sparse-1',
|
|
4145
|
+
role: 'attachment',
|
|
4146
|
+
displayName: 'AI Profiles - Implementation Plan v2'
|
|
4147
|
+
});
|
|
4148
|
+
core.getTask.mockImplementation((id) => id === 'task-sparse-1'
|
|
4149
|
+
? { id: 'task-sparse-1', title: 'Task Sparse 1' }
|
|
3579
4150
|
: null);
|
|
3580
4151
|
const routesWithDb = createRoutes(core, {
|
|
3581
4152
|
authConfig: { runtimeMode: 'cloud' }
|
|
@@ -4559,6 +5130,40 @@ describe('Server Routes', () => {
|
|
|
4559
5130
|
expect(payload.inviteeKind).toBe('existing_user');
|
|
4560
5131
|
expect(payload.passwordRequired).toBe(false);
|
|
4561
5132
|
});
|
|
5133
|
+
it('POST /api/taskforce/auth/invite/accept should preserve the created identity in the session cookie', async () => {
|
|
5134
|
+
const authConfig = {
|
|
5135
|
+
runtimeMode: 'cloud',
|
|
5136
|
+
enabled: true,
|
|
5137
|
+
requiredForApi: true,
|
|
5138
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
5139
|
+
defaultRole: 'member',
|
|
5140
|
+
defaultWorkspaceId: 'default',
|
|
5141
|
+
sessionCookieName: 'taskforce_session',
|
|
5142
|
+
sessionSecret: 'test-secret',
|
|
5143
|
+
sessionTtlSeconds: 3600
|
|
5144
|
+
};
|
|
5145
|
+
routes = createRoutes(core, { authConfig });
|
|
5146
|
+
core.acceptInviteWithToken.mockReturnValueOnce({
|
|
5147
|
+
userId: 'u1',
|
|
5148
|
+
email: 'u1@example.com',
|
|
5149
|
+
workspaceId: 'workspace-1',
|
|
5150
|
+
role: 'member',
|
|
5151
|
+
identityId: 'identity-password-u1'
|
|
5152
|
+
});
|
|
5153
|
+
const match = matchRoute('POST', '/api/taskforce/auth/invite/accept', routes);
|
|
5154
|
+
const req = createMockReq('POST', '/api/taskforce/auth/invite/accept', { token: 'accept-token', password: 'password123' }, {
|
|
5155
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
5156
|
+
host: 'localhost'
|
|
5157
|
+
});
|
|
5158
|
+
const res = createMockRes();
|
|
5159
|
+
await match?.handler(req, res, {});
|
|
5160
|
+
const setCookie = res.setHeader.mock.calls.find(([name]) => name === 'Set-Cookie')?.[1];
|
|
5161
|
+
expect(typeof setCookie).toBe('string');
|
|
5162
|
+
const cookieReq = createMockReq('GET', '/api/taskforce/me', undefined, { cookie: setCookie });
|
|
5163
|
+
const access = resolveRequestAccess(cookieReq, authConfig);
|
|
5164
|
+
expect(access.identityId).toBe('identity-password-u1');
|
|
5165
|
+
expect(access.userId).toBe('u1');
|
|
5166
|
+
});
|
|
4562
5167
|
it('POST /api/taskforce/auth/invite/accept should return expired code and audit action', async () => {
|
|
4563
5168
|
const authConfig = {
|
|
4564
5169
|
runtimeMode: 'cloud',
|
|
@@ -4649,7 +5254,8 @@ describe('Server Routes', () => {
|
|
|
4649
5254
|
const cookie = createSessionTokenCookie('session-join', authConfig, {
|
|
4650
5255
|
userId: 'u1',
|
|
4651
5256
|
role: 'member',
|
|
4652
|
-
workspaceId: 'workspace-1'
|
|
5257
|
+
workspaceId: 'workspace-1',
|
|
5258
|
+
identityId: 'identity-u1'
|
|
4653
5259
|
});
|
|
4654
5260
|
const match = matchRoute('POST', '/api/taskforce/auth/invite/join', routes);
|
|
4655
5261
|
const req = createMockReq('POST', '/api/taskforce/auth/invite/join', { token: 'join-token' }, {
|
|
@@ -4663,6 +5269,11 @@ describe('Server Routes', () => {
|
|
|
4663
5269
|
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
4664
5270
|
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
4665
5271
|
expect(payload.workspaceId).toBe('workspace-1');
|
|
5272
|
+
const setCookie = res.setHeader.mock.calls.find(([name]) => name === 'Set-Cookie')?.[1];
|
|
5273
|
+
expect(typeof setCookie).toBe('string');
|
|
5274
|
+
const cookieReq = createMockReq('GET', '/api/taskforce/me', undefined, { cookie: setCookie });
|
|
5275
|
+
const access = resolveRequestAccess(cookieReq, authConfig);
|
|
5276
|
+
expect(access.identityId).toBe('identity-u1');
|
|
4666
5277
|
});
|
|
4667
5278
|
it('POST /api/taskforce/auth/login should auto-verify bootstrap admin and sign in', async () => {
|
|
4668
5279
|
const authConfig = {
|
|
@@ -4991,6 +5602,7 @@ describe('Server Routes', () => {
|
|
|
4991
5602
|
const res = createMockRes();
|
|
4992
5603
|
await match?.handler(req, res, {});
|
|
4993
5604
|
expect(core.listUserWorkspaces).toHaveBeenCalledWith('user-1');
|
|
5605
|
+
expect(res.setHeader).toHaveBeenCalledWith('Server-Timing', expect.stringContaining('workspaces'));
|
|
4994
5606
|
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
4995
5607
|
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"workspace-2"'));
|
|
4996
5608
|
});
|
|
@@ -5884,6 +6496,9 @@ describe('Server Routes', () => {
|
|
|
5884
6496
|
await match?.handler(req, res, {});
|
|
5885
6497
|
const payloadRaw = res.end.mock.calls[0]?.[0];
|
|
5886
6498
|
const payload = JSON.parse(String(payloadRaw || '{}'));
|
|
6499
|
+
expect(payload.knownPaths).toEqual([
|
|
6500
|
+
'workspaces/workspace-local/assets/documents/asset-doc-snapshot-1-sync-notes.md'
|
|
6501
|
+
]);
|
|
5887
6502
|
expect(payload.documents).toEqual(expect.arrayContaining([
|
|
5888
6503
|
expect.objectContaining({
|
|
5889
6504
|
path: 'workspaces/workspace-local/assets/documents/asset-doc-snapshot-1-sync-notes.md',
|
|
@@ -5898,7 +6513,56 @@ describe('Server Routes', () => {
|
|
|
5898
6513
|
expect(String(payload.fingerprint || '')).toContain('asset-doc-snapshot-1');
|
|
5899
6514
|
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
5900
6515
|
});
|
|
5901
|
-
it('GET /api/taskforce/sync/workspace/documents should
|
|
6516
|
+
it('GET /api/taskforce/sync/workspace/documents should retain canonical knownPaths even when content is temporarily unreadable', async () => {
|
|
6517
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-documents-known-paths-'));
|
|
6518
|
+
const projectRoot = fs.realpathSync(tmpRoot);
|
|
6519
|
+
const basePath = path.join(tmpRoot, '.taskforce');
|
|
6520
|
+
const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
|
|
6521
|
+
store.upsertAsset({
|
|
6522
|
+
assetId: 'asset-doc-known-path-1',
|
|
6523
|
+
workspaceId: 'workspace-local',
|
|
6524
|
+
kind: 'document',
|
|
6525
|
+
originalFilename: 'missing-content.md',
|
|
6526
|
+
mimeType: 'text/markdown',
|
|
6527
|
+
storageProvider: 'local',
|
|
6528
|
+
storageKey: 'workspaces/workspace-local/assets/documents/asset-doc-known-path-1-missing-content.md',
|
|
6529
|
+
contentSha256: 'missing-content-hash',
|
|
6530
|
+
sizeBytes: 17,
|
|
6531
|
+
documentId: 'doc-known-path-1',
|
|
6532
|
+
version: 1,
|
|
6533
|
+
isLatest: true,
|
|
6534
|
+
createdAt: '2026-03-17T09:00:00.000Z',
|
|
6535
|
+
updatedAt: '2026-03-17T10:00:00.000Z'
|
|
6536
|
+
});
|
|
6537
|
+
store.upsertLink({
|
|
6538
|
+
workspaceId: 'workspace-local',
|
|
6539
|
+
assetId: 'asset-doc-known-path-1',
|
|
6540
|
+
taskId: 'task-1',
|
|
6541
|
+
role: 'attachment'
|
|
6542
|
+
});
|
|
6543
|
+
core.getPaths.mockReturnValue({
|
|
6544
|
+
projectRoot,
|
|
6545
|
+
basePath,
|
|
6546
|
+
});
|
|
6547
|
+
core.resolvePreferredWorkspaceId.mockReturnValue('workspace-local');
|
|
6548
|
+
core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
|
|
6549
|
+
core.getTenantId = vi.fn().mockReturnValue('default');
|
|
6550
|
+
routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
6551
|
+
const match = matchRoute('GET', '/api/taskforce/sync/workspace/documents', routes);
|
|
6552
|
+
const req = createMockReq('GET', '/api/taskforce/sync/workspace/documents?workspaceId=workspace-local', undefined, {
|
|
6553
|
+
'x-taskforce-workspace-id': 'workspace-local'
|
|
6554
|
+
});
|
|
6555
|
+
const res = createMockRes();
|
|
6556
|
+
await match?.handler(req, res, {});
|
|
6557
|
+
const payloadRaw = res.end.mock.calls[0]?.[0];
|
|
6558
|
+
const payload = JSON.parse(String(payloadRaw || '{}'));
|
|
6559
|
+
expect(payload.documents).toEqual([]);
|
|
6560
|
+
expect(payload.knownPaths).toEqual([
|
|
6561
|
+
'workspaces/workspace-local/assets/documents/asset-doc-known-path-1-missing-content.md'
|
|
6562
|
+
]);
|
|
6563
|
+
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
6564
|
+
});
|
|
6565
|
+
it('GET /api/taskforce/sync/workspace/documents should exclude docs folder markdown files that are not canonical assets', async () => {
|
|
5902
6566
|
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-documents-legacy-ignore-'));
|
|
5903
6567
|
const projectRoot = fs.realpathSync(tmpRoot);
|
|
5904
6568
|
const basePath = path.join(tmpRoot, '.taskforce');
|
|
@@ -5923,12 +6587,7 @@ describe('Server Routes', () => {
|
|
|
5923
6587
|
const payloadRaw = res.end.mock.calls[0]?.[0];
|
|
5924
6588
|
const payload = JSON.parse(String(payloadRaw || '{}'));
|
|
5925
6589
|
expect(Array.isArray(payload.documents)).toBe(true);
|
|
5926
|
-
expect(payload.documents).toEqual([
|
|
5927
|
-
expect.objectContaining({
|
|
5928
|
-
path: 'docs/legacy-notes.md',
|
|
5929
|
-
content: '# legacy\n'
|
|
5930
|
-
})
|
|
5931
|
-
]);
|
|
6590
|
+
expect(payload.documents).toEqual([]);
|
|
5932
6591
|
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
5933
6592
|
});
|
|
5934
6593
|
it('GET /api/taskforce/sync/workspace/document-reviews should include document review sessions for the requested workspace', async () => {
|
|
@@ -6063,6 +6722,58 @@ describe('Server Routes', () => {
|
|
|
6063
6722
|
]));
|
|
6064
6723
|
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
6065
6724
|
});
|
|
6725
|
+
it('GET /api/taskforce/sync/workspace/assets should include AI profile avatar files referenced without canonical metadata', async () => {
|
|
6726
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-ai-avatar-assets-'));
|
|
6727
|
+
const projectRoot = fs.realpathSync(tmpRoot);
|
|
6728
|
+
const basePath = path.join(tmpRoot, '.taskforce');
|
|
6729
|
+
const avatarStorageKey = 'workspaces/workspace-local/assets/images/ai-profiles/ai-profile-codex/avatar/asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-cropped.webp';
|
|
6730
|
+
const avatarDir = path.join(basePath, 'workspaces/workspace-local/assets/images/ai-profiles/ai-profile-codex/avatar');
|
|
6731
|
+
fs.mkdirSync(avatarDir, { recursive: true });
|
|
6732
|
+
const fileBuffer = Buffer.from('ai-avatar-bytes');
|
|
6733
|
+
fs.writeFileSync(path.join(avatarDir, 'asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-cropped.webp'), fileBuffer);
|
|
6734
|
+
const { tmpRoot: dbRoot, db } = createWorkspaceAssetTestStore();
|
|
6735
|
+
core.getPaths.mockReturnValue({
|
|
6736
|
+
projectRoot,
|
|
6737
|
+
basePath,
|
|
6738
|
+
});
|
|
6739
|
+
core.resolvePreferredWorkspaceId.mockReturnValue('workspace-local');
|
|
6740
|
+
core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
|
|
6741
|
+
core.getTenantId = vi.fn().mockReturnValue('default');
|
|
6742
|
+
core.listAiProfiles.mockReturnValue([
|
|
6743
|
+
{
|
|
6744
|
+
id: 'ai-profile-codex',
|
|
6745
|
+
workspaceId: 'workspace-local',
|
|
6746
|
+
name: 'Codex',
|
|
6747
|
+
username: 'codex',
|
|
6748
|
+
avatarUrl: `/api/taskforce/context/${encodeURIComponent(avatarStorageKey)}`,
|
|
6749
|
+
avatarSourceUrl: null,
|
|
6750
|
+
createdAt: '2026-03-17T09:00:00.000Z',
|
|
6751
|
+
updatedAt: '2026-03-17T10:00:00.000Z'
|
|
6752
|
+
}
|
|
6753
|
+
]);
|
|
6754
|
+
routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
6755
|
+
const match = matchRoute('GET', '/api/taskforce/sync/workspace/assets', routes);
|
|
6756
|
+
const req = createMockReq('GET', '/api/taskforce/sync/workspace/assets?workspaceId=workspace-local', undefined, {
|
|
6757
|
+
'x-taskforce-workspace-id': 'workspace-local'
|
|
6758
|
+
});
|
|
6759
|
+
const res = createMockRes();
|
|
6760
|
+
await match?.handler(req, res, {});
|
|
6761
|
+
const payloadRaw = res.end.mock.calls[0]?.[0];
|
|
6762
|
+
const payload = JSON.parse(String(payloadRaw || '{}'));
|
|
6763
|
+
expect(payload.knownPaths).toContain(avatarStorageKey);
|
|
6764
|
+
expect(payload.assets).toEqual(expect.arrayContaining([
|
|
6765
|
+
expect.objectContaining({
|
|
6766
|
+
path: avatarStorageKey,
|
|
6767
|
+
assetId: 'asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
|
6768
|
+
kind: 'image',
|
|
6769
|
+
mimeType: 'image/webp',
|
|
6770
|
+
originalFilename: 'asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-cropped.webp',
|
|
6771
|
+
contentBase64: fileBuffer.toString('base64')
|
|
6772
|
+
})
|
|
6773
|
+
]));
|
|
6774
|
+
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
6775
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
6776
|
+
});
|
|
6066
6777
|
it('GET /api/taskforce/sync/workspace/assets should ignore malformed external workspace asset rows', async () => {
|
|
6067
6778
|
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-assets-external-'));
|
|
6068
6779
|
const projectRoot = fs.realpathSync(tmpRoot);
|
|
@@ -6729,6 +7440,82 @@ describe('Server Routes', () => {
|
|
|
6729
7440
|
]));
|
|
6730
7441
|
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
6731
7442
|
});
|
|
7443
|
+
it('GET /api/taskforce/sync/workspace/pull should include referenced AI profile avatar assets without canonical metadata', async () => {
|
|
7444
|
+
const authConfig = {
|
|
7445
|
+
runtimeMode: 'cloud',
|
|
7446
|
+
enabled: true,
|
|
7447
|
+
requiredForApi: true,
|
|
7448
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
7449
|
+
defaultRole: 'member',
|
|
7450
|
+
defaultWorkspaceId: 'default',
|
|
7451
|
+
sessionCookieName: 'taskforce_session',
|
|
7452
|
+
sessionSecret: 'test-secret',
|
|
7453
|
+
sessionTtlSeconds: 3600
|
|
7454
|
+
};
|
|
7455
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-pull-ai-avatar-'));
|
|
7456
|
+
const basePath = path.join(tmpRoot, '.taskforce');
|
|
7457
|
+
const avatarStorageKey = 'workspaces/workspace-1/assets/images/ai-profiles/ai-profile-modesty/avatar/asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-cropped.webp';
|
|
7458
|
+
const avatarPath = path.join(basePath, avatarStorageKey);
|
|
7459
|
+
const avatarBuffer = Buffer.from('ai-avatar-bytes');
|
|
7460
|
+
fs.mkdirSync(path.dirname(avatarPath), { recursive: true });
|
|
7461
|
+
fs.writeFileSync(avatarPath, avatarBuffer);
|
|
7462
|
+
const { tmpRoot: dbRoot, db } = createWorkspaceAssetTestStore();
|
|
7463
|
+
core.getPaths.mockReturnValue({
|
|
7464
|
+
projectRoot: tmpRoot,
|
|
7465
|
+
basePath,
|
|
7466
|
+
});
|
|
7467
|
+
core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
|
|
7468
|
+
core.getTenantId = vi.fn().mockReturnValue('default');
|
|
7469
|
+
core.listAiProfiles.mockReturnValue([
|
|
7470
|
+
{
|
|
7471
|
+
id: 'ai-profile-modesty',
|
|
7472
|
+
workspaceId: 'workspace-1',
|
|
7473
|
+
name: 'Modesty',
|
|
7474
|
+
username: 'modesty',
|
|
7475
|
+
icon: 'Bot',
|
|
7476
|
+
color: '#0ea5e9',
|
|
7477
|
+
avatarUrl: `/api/taskforce/context/${encodeURIComponent(avatarStorageKey)}`,
|
|
7478
|
+
avatarSourceUrl: null,
|
|
7479
|
+
createdAt: '2026-03-17T09:00:00.000Z',
|
|
7480
|
+
updatedAt: '2026-03-17T10:00:00.000Z'
|
|
7481
|
+
}
|
|
7482
|
+
]);
|
|
7483
|
+
routes = createRoutes(core, { authConfig });
|
|
7484
|
+
core.listUserWorkspaces.mockReturnValueOnce([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
|
|
7485
|
+
const cookie = createSessionTokenCookie('session-sync-pull-ai-avatar', authConfig, {
|
|
7486
|
+
role: 'owner',
|
|
7487
|
+
workspaceId: 'workspace-1',
|
|
7488
|
+
userId: 'user-1'
|
|
7489
|
+
});
|
|
7490
|
+
core.listTasks.mockReturnValueOnce({ tasks: [] });
|
|
7491
|
+
core.listArchive.mockReturnValueOnce({ archived: [] });
|
|
7492
|
+
const match = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
|
|
7493
|
+
const req = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-1&limit=20', undefined, { cookie });
|
|
7494
|
+
const res = createMockRes();
|
|
7495
|
+
await match?.handler(req, res, {});
|
|
7496
|
+
const payload = JSON.parse(String(res.end.mock.calls[0]?.[0] || '{}'));
|
|
7497
|
+
expect(payload.changes).toEqual(expect.arrayContaining([
|
|
7498
|
+
expect.objectContaining({
|
|
7499
|
+
op: 'ai-profile-upsert',
|
|
7500
|
+
profile: expect.objectContaining({
|
|
7501
|
+
id: 'ai-profile-modesty',
|
|
7502
|
+
avatarUrl: `/api/taskforce/context/${encodeURIComponent(avatarStorageKey)}`
|
|
7503
|
+
})
|
|
7504
|
+
}),
|
|
7505
|
+
expect.objectContaining({
|
|
7506
|
+
op: 'asset-upsert',
|
|
7507
|
+
path: avatarStorageKey,
|
|
7508
|
+
assetId: 'asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
|
7509
|
+
kind: 'image',
|
|
7510
|
+
mimeType: 'image/webp',
|
|
7511
|
+
originalFilename: 'asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-cropped.webp',
|
|
7512
|
+
linkRole: 'image',
|
|
7513
|
+
contentBase64: avatarBuffer.toString('base64')
|
|
7514
|
+
})
|
|
7515
|
+
]));
|
|
7516
|
+
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
7517
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
7518
|
+
});
|
|
6732
7519
|
it('GET /api/taskforce/sync/workspace/pull should include mixed document and binary attachment upserts for the same task', async () => {
|
|
6733
7520
|
const authConfig = {
|
|
6734
7521
|
runtimeMode: 'cloud',
|
|
@@ -7086,7 +7873,7 @@ describe('Server Routes', () => {
|
|
|
7086
7873
|
task: expect.objectContaining({ id: 'task-b' })
|
|
7087
7874
|
}));
|
|
7088
7875
|
});
|
|
7089
|
-
it('GET /api/taskforce/sync/workspace/pull should reject
|
|
7876
|
+
it('GET /api/taskforce/sync/workspace/pull should reject an invalid sync cursor instead of replaying from the beginning', async () => {
|
|
7090
7877
|
const authConfig = {
|
|
7091
7878
|
runtimeMode: 'cloud',
|
|
7092
7879
|
enabled: true,
|
|
@@ -7099,21 +7886,179 @@ describe('Server Routes', () => {
|
|
|
7099
7886
|
sessionTtlSeconds: 3600
|
|
7100
7887
|
};
|
|
7101
7888
|
routes = createRoutes(core, { authConfig });
|
|
7102
|
-
|
|
7889
|
+
core.listUserWorkspaces.mockReturnValue([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
|
|
7890
|
+
const cookie = createSessionTokenCookie('session-sync-pull-invalid-cursor', authConfig, {
|
|
7103
7891
|
role: 'owner',
|
|
7104
|
-
workspaceId: 'workspace-
|
|
7892
|
+
workspaceId: 'workspace-1',
|
|
7105
7893
|
userId: 'user-1'
|
|
7106
7894
|
});
|
|
7107
|
-
|
|
7108
|
-
|
|
7109
|
-
|
|
7110
|
-
|
|
7111
|
-
|
|
7112
|
-
|
|
7113
|
-
|
|
7114
|
-
|
|
7115
|
-
|
|
7116
|
-
|
|
7895
|
+
const match = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
|
|
7896
|
+
const req = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-1&limit=10&cursor=not-a-real-cursor', undefined, { cookie });
|
|
7897
|
+
const res = createMockRes();
|
|
7898
|
+
await match?.handler(req, res, {});
|
|
7899
|
+
expect(core.listTasks).not.toHaveBeenCalled();
|
|
7900
|
+
expect(res.writeHead).toHaveBeenCalledWith(400, expect.any(Object));
|
|
7901
|
+
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"code":"INVALID_SYNC_CURSOR"'));
|
|
7902
|
+
expect(core.addAdminAuditLog).toHaveBeenCalledWith(expect.objectContaining({
|
|
7903
|
+
action: 'sync-workspace-pull-invalid-cursor'
|
|
7904
|
+
}));
|
|
7905
|
+
});
|
|
7906
|
+
it('GET /api/taskforce/sync/workspace/pull should ignore cursors during repair', async () => {
|
|
7907
|
+
const authConfig = {
|
|
7908
|
+
runtimeMode: 'cloud',
|
|
7909
|
+
enabled: true,
|
|
7910
|
+
requiredForApi: true,
|
|
7911
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
7912
|
+
defaultRole: 'member',
|
|
7913
|
+
defaultWorkspaceId: 'default',
|
|
7914
|
+
sessionCookieName: 'taskforce_session',
|
|
7915
|
+
sessionSecret: 'test-secret',
|
|
7916
|
+
sessionTtlSeconds: 3600
|
|
7917
|
+
};
|
|
7918
|
+
routes = createRoutes(core, { authConfig });
|
|
7919
|
+
core.listUserWorkspaces.mockReturnValue([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
|
|
7920
|
+
core.listTasks.mockReturnValue({
|
|
7921
|
+
tasks: [
|
|
7922
|
+
{ id: 'task-repair-1', title: 'Repair me', createdAt: '2026-02-19T16:00:00.000Z', updatedAt: '2026-02-19T16:10:00.000Z' }
|
|
7923
|
+
]
|
|
7924
|
+
});
|
|
7925
|
+
core.listArchive.mockReturnValue({ archived: [] });
|
|
7926
|
+
const cookie = createSessionTokenCookie('session-sync-pull-repair-cursor', authConfig, {
|
|
7927
|
+
role: 'owner',
|
|
7928
|
+
workspaceId: 'workspace-1',
|
|
7929
|
+
userId: 'user-1'
|
|
7930
|
+
});
|
|
7931
|
+
const match = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
|
|
7932
|
+
const req = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-1&limit=10&cursor=not-a-real-cursor&repair=1', undefined, { cookie });
|
|
7933
|
+
const res = createMockRes();
|
|
7934
|
+
await match?.handler(req, res, {});
|
|
7935
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
7936
|
+
expect(core.listTasks).toHaveBeenCalled();
|
|
7937
|
+
const body = JSON.parse(String(res.end.mock.calls[0]?.[0] || '{}'));
|
|
7938
|
+
expect(body.changes).toEqual(expect.arrayContaining([
|
|
7939
|
+
expect.objectContaining({
|
|
7940
|
+
op: 'upsert',
|
|
7941
|
+
task: expect.objectContaining({ id: 'task-repair-1' })
|
|
7942
|
+
})
|
|
7943
|
+
]));
|
|
7944
|
+
});
|
|
7945
|
+
it('GET /api/taskforce/sync/workspace/pull should still return success when success audit throws', async () => {
|
|
7946
|
+
const authConfig = {
|
|
7947
|
+
runtimeMode: 'cloud',
|
|
7948
|
+
enabled: true,
|
|
7949
|
+
requiredForApi: true,
|
|
7950
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
7951
|
+
defaultRole: 'member',
|
|
7952
|
+
defaultWorkspaceId: 'default',
|
|
7953
|
+
sessionCookieName: 'taskforce_session',
|
|
7954
|
+
sessionSecret: 'test-secret',
|
|
7955
|
+
sessionTtlSeconds: 3600
|
|
7956
|
+
};
|
|
7957
|
+
routes = createRoutes(core, { authConfig });
|
|
7958
|
+
core.listUserWorkspaces.mockReturnValueOnce([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
|
|
7959
|
+
const cookie = createSessionTokenCookie('session-sync-pull-audit-throw', authConfig, {
|
|
7960
|
+
role: 'owner',
|
|
7961
|
+
workspaceId: 'workspace-1',
|
|
7962
|
+
userId: 'user-1'
|
|
7963
|
+
});
|
|
7964
|
+
const originalAddAdminAuditLog = core.addAdminAuditLog.getMockImplementation();
|
|
7965
|
+
core.addAdminAuditLog.mockImplementation((entry) => {
|
|
7966
|
+
if (entry?.action === 'sync-workspace-pull-success') {
|
|
7967
|
+
throw new Error('audit unavailable');
|
|
7968
|
+
}
|
|
7969
|
+
return originalAddAdminAuditLog ? originalAddAdminAuditLog(entry) : undefined;
|
|
7970
|
+
});
|
|
7971
|
+
const match = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
|
|
7972
|
+
const req = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-1&limit=10', undefined, { cookie });
|
|
7973
|
+
const res = createMockRes();
|
|
7974
|
+
await match?.handler(req, res, {});
|
|
7975
|
+
expect(res.writeHead).toHaveBeenCalledTimes(1);
|
|
7976
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
7977
|
+
expect(res.end).toHaveBeenCalledTimes(1);
|
|
7978
|
+
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":true'));
|
|
7979
|
+
});
|
|
7980
|
+
it('GET /api/taskforce/sync/workspace/pull should degrade optional snapshot failures into diagnostics instead of 500', async () => {
|
|
7981
|
+
const authConfig = {
|
|
7982
|
+
runtimeMode: 'cloud',
|
|
7983
|
+
enabled: true,
|
|
7984
|
+
requiredForApi: true,
|
|
7985
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
7986
|
+
defaultRole: 'member',
|
|
7987
|
+
defaultWorkspaceId: 'default',
|
|
7988
|
+
sessionCookieName: 'taskforce_session',
|
|
7989
|
+
sessionSecret: 'test-secret',
|
|
7990
|
+
sessionTtlSeconds: 3600
|
|
7991
|
+
};
|
|
7992
|
+
routes = createRoutes(core, { authConfig });
|
|
7993
|
+
core.listUserWorkspaces.mockReturnValueOnce([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
|
|
7994
|
+
core.listTasks.mockReturnValueOnce({
|
|
7995
|
+
tasks: [
|
|
7996
|
+
{
|
|
7997
|
+
id: 'task-survives',
|
|
7998
|
+
title: 'Survives partial feed failure',
|
|
7999
|
+
status: 'task',
|
|
8000
|
+
category: 'general',
|
|
8001
|
+
type: 'task',
|
|
8002
|
+
createdAt: '2026-04-10T10:00:00.000Z'
|
|
8003
|
+
}
|
|
8004
|
+
]
|
|
8005
|
+
});
|
|
8006
|
+
core.listDocumentReviewSessionsForSync.mockImplementationOnce(() => {
|
|
8007
|
+
throw new Error('document review session snapshot exploded');
|
|
8008
|
+
});
|
|
8009
|
+
const cookie = createSessionTokenCookie('session-sync-pull-partial', authConfig, {
|
|
8010
|
+
role: 'owner',
|
|
8011
|
+
workspaceId: 'workspace-1',
|
|
8012
|
+
userId: 'user-1'
|
|
8013
|
+
});
|
|
8014
|
+
const match = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
|
|
8015
|
+
const req = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-1&limit=10', undefined, { cookie });
|
|
8016
|
+
const res = createMockRes();
|
|
8017
|
+
await match?.handler(req, res, {});
|
|
8018
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
8019
|
+
const body = JSON.parse(String(res.end.mock.calls[0]?.[0] || '{}'));
|
|
8020
|
+
expect(body.success).toBe(true);
|
|
8021
|
+
expect(body.changes).toEqual(expect.arrayContaining([
|
|
8022
|
+
expect.objectContaining({
|
|
8023
|
+
op: 'upsert',
|
|
8024
|
+
task: expect.objectContaining({ id: 'task-survives' })
|
|
8025
|
+
})
|
|
8026
|
+
]));
|
|
8027
|
+
expect(body.diagnostics?.snapshotErrors).toEqual(expect.arrayContaining([
|
|
8028
|
+
expect.objectContaining({
|
|
8029
|
+
domain: 'document-review-sessions',
|
|
8030
|
+
error: 'document review session snapshot exploded'
|
|
8031
|
+
})
|
|
8032
|
+
]));
|
|
8033
|
+
});
|
|
8034
|
+
it('GET /api/taskforce/sync/workspace/pull should reject unauthorized workspace override', async () => {
|
|
8035
|
+
const authConfig = {
|
|
8036
|
+
runtimeMode: 'cloud',
|
|
8037
|
+
enabled: true,
|
|
8038
|
+
requiredForApi: true,
|
|
8039
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
8040
|
+
defaultRole: 'member',
|
|
8041
|
+
defaultWorkspaceId: 'default',
|
|
8042
|
+
sessionCookieName: 'taskforce_session',
|
|
8043
|
+
sessionSecret: 'test-secret',
|
|
8044
|
+
sessionTtlSeconds: 3600
|
|
8045
|
+
};
|
|
8046
|
+
routes = createRoutes(core, { authConfig });
|
|
8047
|
+
const cookie = createSessionTokenCookie('session-sync-pull-forbidden', authConfig, {
|
|
8048
|
+
role: 'owner',
|
|
8049
|
+
workspaceId: 'workspace-cloud-selected',
|
|
8050
|
+
userId: 'user-1'
|
|
8051
|
+
});
|
|
8052
|
+
core.listUserWorkspaces.mockReturnValueOnce([
|
|
8053
|
+
{
|
|
8054
|
+
id: 'workspace-a',
|
|
8055
|
+
slug: 'workspace-a',
|
|
8056
|
+
name: 'Workspace A',
|
|
8057
|
+
description: null,
|
|
8058
|
+
role: 'owner',
|
|
8059
|
+
status: 'active',
|
|
8060
|
+
betaAccess: true
|
|
8061
|
+
}
|
|
7117
8062
|
]);
|
|
7118
8063
|
const match = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
|
|
7119
8064
|
const req = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-denied', undefined, { cookie });
|
|
@@ -7147,6 +8092,53 @@ describe('Server Routes', () => {
|
|
|
7147
8092
|
}), 'workspace-local');
|
|
7148
8093
|
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
7149
8094
|
});
|
|
8095
|
+
it('POST /api/taskforce/sync/workspace/apply-local should still return success when success sync telemetry throws after apply', async () => {
|
|
8096
|
+
core.resolvePreferredWorkspaceId.mockReturnValue('workspace-local');
|
|
8097
|
+
routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
8098
|
+
core.recordSyncEvent.mockImplementationOnce(() => {
|
|
8099
|
+
throw new Error('telemetry unavailable');
|
|
8100
|
+
});
|
|
8101
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
|
|
8102
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
|
|
8103
|
+
changes: [
|
|
8104
|
+
{ op: 'upsert', archived: false, task: { id: 'task-1', title: 'Task 1' } }
|
|
8105
|
+
]
|
|
8106
|
+
}, {
|
|
8107
|
+
'x-taskforce-workspace-id': 'workspace-local'
|
|
8108
|
+
});
|
|
8109
|
+
const res = createMockRes();
|
|
8110
|
+
await match?.handler(req, res, {});
|
|
8111
|
+
expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledTimes(1);
|
|
8112
|
+
expect(res.writeHead).toHaveBeenCalledTimes(1);
|
|
8113
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
8114
|
+
expect(res.end).toHaveBeenCalledTimes(1);
|
|
8115
|
+
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":true'));
|
|
8116
|
+
});
|
|
8117
|
+
it('POST /api/taskforce/sync/workspace/apply-local should still return error response when failure-path telemetry throws', async () => {
|
|
8118
|
+
core.resolvePreferredWorkspaceId.mockReturnValue('workspace-local');
|
|
8119
|
+
routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
8120
|
+
core.applyWorkspaceSyncSnapshot.mockImplementationOnce(() => {
|
|
8121
|
+
throw new Error('apply failed');
|
|
8122
|
+
});
|
|
8123
|
+
core.recordSyncEvent.mockImplementationOnce(() => {
|
|
8124
|
+
throw new Error('telemetry unavailable');
|
|
8125
|
+
});
|
|
8126
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
|
|
8127
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
|
|
8128
|
+
changes: [
|
|
8129
|
+
{ op: 'upsert', archived: false, task: { id: 'task-1', title: 'Task 1' } }
|
|
8130
|
+
]
|
|
8131
|
+
}, {
|
|
8132
|
+
'x-taskforce-workspace-id': 'workspace-local'
|
|
8133
|
+
});
|
|
8134
|
+
const res = createMockRes();
|
|
8135
|
+
await match?.handler(req, res, {});
|
|
8136
|
+
expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledTimes(1);
|
|
8137
|
+
expect(res.writeHead).toHaveBeenCalledTimes(1);
|
|
8138
|
+
expect(res.writeHead).toHaveBeenCalledWith(400, expect.any(Object));
|
|
8139
|
+
expect(res.end).toHaveBeenCalledTimes(1);
|
|
8140
|
+
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":false'));
|
|
8141
|
+
});
|
|
7150
8142
|
it('POST /api/taskforce/sync/workspace/apply-local should apply document review session upserts and deletes', async () => {
|
|
7151
8143
|
core.resolvePreferredWorkspaceId.mockReturnValue('workspace-local');
|
|
7152
8144
|
routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
@@ -7891,7 +8883,7 @@ describe('Server Routes', () => {
|
|
|
7891
8883
|
]);
|
|
7892
8884
|
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
7893
8885
|
});
|
|
7894
|
-
it('POST /api/taskforce/sync/workspace/apply-local should
|
|
8886
|
+
it('POST /api/taskforce/sync/workspace/apply-local should adopt cloud document references during normal sync even when content is unchanged', async () => {
|
|
7895
8887
|
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-apply-doc-metadata-'));
|
|
7896
8888
|
const projectRoot = fs.realpathSync(tmpRoot);
|
|
7897
8889
|
const basePath = path.join(tmpRoot, '.taskforce');
|
|
@@ -7945,7 +8937,7 @@ describe('Server Routes', () => {
|
|
|
7945
8937
|
const res = createMockRes();
|
|
7946
8938
|
await match?.handler(req, res, {});
|
|
7947
8939
|
expect(store.get('asset-sync-doc-meta-1', 'workspace-local')).toEqual(expect.objectContaining({
|
|
7948
|
-
referenceNumber:
|
|
8940
|
+
referenceNumber: 42
|
|
7949
8941
|
}));
|
|
7950
8942
|
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
7951
8943
|
});
|
|
@@ -8008,7 +9000,7 @@ describe('Server Routes', () => {
|
|
|
8008
9000
|
}));
|
|
8009
9001
|
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
8010
9002
|
});
|
|
8011
|
-
it('POST /api/taskforce/sync/workspace/apply-local should
|
|
9003
|
+
it('POST /api/taskforce/sync/workspace/apply-local should adopt cloud image references during normal sync even when content is unchanged', async () => {
|
|
8012
9004
|
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-apply-image-metadata-'));
|
|
8013
9005
|
const projectRoot = fs.realpathSync(tmpRoot);
|
|
8014
9006
|
const basePath = path.join(tmpRoot, '.taskforce');
|
|
@@ -8060,7 +9052,7 @@ describe('Server Routes', () => {
|
|
|
8060
9052
|
const res = createMockRes();
|
|
8061
9053
|
await match?.handler(req, res, {});
|
|
8062
9054
|
expect(store.get('asset-sync-image-meta-1', 'workspace-local')).toEqual(expect.objectContaining({
|
|
8063
|
-
referenceNumber:
|
|
9055
|
+
referenceNumber: 42
|
|
8064
9056
|
}));
|
|
8065
9057
|
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
8066
9058
|
});
|
|
@@ -8387,7 +9379,7 @@ describe('Server Routes', () => {
|
|
|
8387
9379
|
action: 'sync-workspace-push-idempotent-replay'
|
|
8388
9380
|
}));
|
|
8389
9381
|
});
|
|
8390
|
-
it('POST /api/taskforce/sync/workspace/push should
|
|
9382
|
+
it('POST /api/taskforce/sync/workspace/push should still return success when idempotency persistence fails after apply', async () => {
|
|
8391
9383
|
const authConfig = {
|
|
8392
9384
|
runtimeMode: 'cloud',
|
|
8393
9385
|
enabled: true,
|
|
@@ -8399,101 +9391,299 @@ describe('Server Routes', () => {
|
|
|
8399
9391
|
sessionSecret: 'test-secret',
|
|
8400
9392
|
sessionTtlSeconds: 3600
|
|
8401
9393
|
};
|
|
8402
|
-
|
|
8403
|
-
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-push-r2-'));
|
|
8404
|
-
core.getPaths.mockReturnValue({
|
|
8405
|
-
projectRoot: tmpRoot,
|
|
8406
|
-
basePath: path.join(tmpRoot, '.taskforce')
|
|
8407
|
-
});
|
|
8408
|
-
core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
|
|
8409
|
-
core.getTenantId = vi.fn().mockReturnValue('default');
|
|
9394
|
+
routes = createRoutes(core, { authConfig });
|
|
8410
9395
|
core.listUserWorkspaces.mockReturnValue([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
|
|
8411
|
-
|
|
8412
|
-
|
|
8413
|
-
|
|
9396
|
+
const cookie = createSessionTokenCookie('session-sync-push-idempotency-write-fail', authConfig, {
|
|
9397
|
+
role: 'owner',
|
|
9398
|
+
workspaceId: 'workspace-1',
|
|
9399
|
+
userId: 'user-1'
|
|
9400
|
+
});
|
|
9401
|
+
core.applyWorkspaceSyncSnapshot.mockReturnValueOnce({
|
|
9402
|
+
total: 1,
|
|
9403
|
+
created: 1,
|
|
8414
9404
|
updated: 0,
|
|
8415
9405
|
archived: 0,
|
|
8416
9406
|
unarchived: 0,
|
|
8417
9407
|
skipped: 0
|
|
8418
9408
|
});
|
|
8419
|
-
|
|
8420
|
-
|
|
8421
|
-
status: 200,
|
|
8422
|
-
statusText: 'OK',
|
|
8423
|
-
headers: new Headers(),
|
|
8424
|
-
text: async () => '',
|
|
8425
|
-
json: async () => ({})
|
|
8426
|
-
});
|
|
8427
|
-
vi.stubGlobal('fetch', fetchMock);
|
|
8428
|
-
routes = createRoutes(core, {
|
|
8429
|
-
authConfig,
|
|
8430
|
-
objectStorage: {
|
|
8431
|
-
provider: 'r2',
|
|
8432
|
-
r2: {
|
|
8433
|
-
accountId: 'acct-1',
|
|
8434
|
-
bucket: 'taskforce-perf-assets',
|
|
8435
|
-
endpoint: 'https://acct-1.r2.cloudflarestorage.com',
|
|
8436
|
-
accessKeyId: 'access-key-1',
|
|
8437
|
-
secretAccessKey: 'secret-key-1',
|
|
8438
|
-
keyPrefix: '',
|
|
8439
|
-
signedUploadTtlSeconds: 600,
|
|
8440
|
-
signedDownloadTtlSeconds: 300
|
|
8441
|
-
}
|
|
8442
|
-
}
|
|
8443
|
-
});
|
|
8444
|
-
const cookie = createSessionTokenCookie('session-sync-push-r2', authConfig, {
|
|
8445
|
-
role: 'owner',
|
|
8446
|
-
workspaceId: 'workspace-1',
|
|
8447
|
-
userId: 'user-1'
|
|
9409
|
+
core.writePushIdempotency.mockImplementationOnce(() => {
|
|
9410
|
+
throw new Error('disk full');
|
|
8448
9411
|
});
|
|
8449
|
-
const markdownPath = 'workspaces/workspace-1/assets/documents/asset-doc-sync-1-plan.md';
|
|
8450
|
-
const imagePath = 'workspaces/workspace-1/assets/images/asset-image-sync-1-proof.png';
|
|
8451
9412
|
const match = matchRoute('POST', '/api/taskforce/sync/workspace/push', routes);
|
|
8452
9413
|
const req = createMockReq('POST', '/api/taskforce/sync/workspace/push', {
|
|
8453
9414
|
workspaceId: 'workspace-1',
|
|
9415
|
+
idempotencyKey: 'push-idempotency-write-fail-1',
|
|
8454
9416
|
changes: [
|
|
8455
|
-
{
|
|
8456
|
-
op: 'document-upsert',
|
|
8457
|
-
path: markdownPath,
|
|
8458
|
-
updatedAt: '2026-03-18T23:20:00.000Z',
|
|
8459
|
-
content: '# Plan',
|
|
8460
|
-
assetId: 'asset-doc-sync-1',
|
|
8461
|
-
documentId: 'doc-sync-1',
|
|
8462
|
-
taskId: 'task-sync-1',
|
|
8463
|
-
originalFilename: 'plan.md',
|
|
8464
|
-
linkRole: 'attachment'
|
|
8465
|
-
},
|
|
8466
|
-
{
|
|
8467
|
-
op: 'asset-upsert',
|
|
8468
|
-
path: imagePath,
|
|
8469
|
-
updatedAt: '2026-03-18T23:20:01.000Z',
|
|
8470
|
-
contentBase64: Buffer.from('png-bytes').toString('base64'),
|
|
8471
|
-
assetId: 'asset-image-sync-1',
|
|
8472
|
-
kind: 'image',
|
|
8473
|
-
mimeType: 'image/png',
|
|
8474
|
-
taskId: 'task-sync-1',
|
|
8475
|
-
originalFilename: 'proof.png',
|
|
8476
|
-
linkRole: 'image'
|
|
8477
|
-
}
|
|
9417
|
+
{ op: 'upsert', task: { id: 'task-1', title: 'Task 1' }, archived: false }
|
|
8478
9418
|
]
|
|
8479
9419
|
}, { cookie });
|
|
8480
9420
|
const res = createMockRes();
|
|
8481
9421
|
await match?.handler(req, res, {});
|
|
9422
|
+
expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledTimes(1);
|
|
8482
9423
|
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
8483
|
-
expect(
|
|
8484
|
-
|
|
8485
|
-
|
|
8486
|
-
expect(docAsset).toEqual(expect.objectContaining({
|
|
8487
|
-
assetId: 'asset-doc-sync-1',
|
|
8488
|
-
storageProvider: 'r2',
|
|
8489
|
-
storageKey: markdownPath
|
|
9424
|
+
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":true'));
|
|
9425
|
+
expect(core.addAdminAuditLog).toHaveBeenCalledWith(expect.objectContaining({
|
|
9426
|
+
action: 'sync-workspace-push-idempotency-write-failed'
|
|
8490
9427
|
}));
|
|
8491
|
-
expect(
|
|
8492
|
-
|
|
8493
|
-
|
|
8494
|
-
storageKey: imagePath
|
|
9428
|
+
expect(core.recordSyncEvent).toHaveBeenCalledWith('workspace-1', expect.objectContaining({
|
|
9429
|
+
eventType: 'push',
|
|
9430
|
+
status: 'success'
|
|
8495
9431
|
}));
|
|
8496
|
-
|
|
9432
|
+
});
|
|
9433
|
+
it('POST /api/taskforce/sync/workspace/push should still return success when applied-task readback fails after apply', async () => {
|
|
9434
|
+
const authConfig = {
|
|
9435
|
+
runtimeMode: 'cloud',
|
|
9436
|
+
enabled: true,
|
|
9437
|
+
requiredForApi: true,
|
|
9438
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
9439
|
+
defaultRole: 'member',
|
|
9440
|
+
defaultWorkspaceId: 'default',
|
|
9441
|
+
sessionCookieName: 'taskforce_session',
|
|
9442
|
+
sessionSecret: 'test-secret',
|
|
9443
|
+
sessionTtlSeconds: 3600
|
|
9444
|
+
};
|
|
9445
|
+
routes = createRoutes(core, { authConfig });
|
|
9446
|
+
core.listUserWorkspaces.mockReturnValue([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
|
|
9447
|
+
const cookie = createSessionTokenCookie('session-sync-push-upsert-echo-fail', authConfig, {
|
|
9448
|
+
role: 'owner',
|
|
9449
|
+
workspaceId: 'workspace-1',
|
|
9450
|
+
userId: 'user-1'
|
|
9451
|
+
});
|
|
9452
|
+
core.applyWorkspaceSyncSnapshot.mockReturnValueOnce({
|
|
9453
|
+
total: 1,
|
|
9454
|
+
created: 1,
|
|
9455
|
+
updated: 0,
|
|
9456
|
+
archived: 0,
|
|
9457
|
+
unarchived: 0,
|
|
9458
|
+
skipped: 0
|
|
9459
|
+
});
|
|
9460
|
+
core.getTask.mockImplementationOnce(() => {
|
|
9461
|
+
throw new Error('readback unavailable');
|
|
9462
|
+
});
|
|
9463
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/push', routes);
|
|
9464
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/push', {
|
|
9465
|
+
workspaceId: 'workspace-1',
|
|
9466
|
+
idempotencyKey: 'push-upsert-echo-fail-1',
|
|
9467
|
+
changes: [
|
|
9468
|
+
{ op: 'upsert', task: { id: 'task-1', title: 'Task 1' }, archived: false }
|
|
9469
|
+
]
|
|
9470
|
+
}, { cookie });
|
|
9471
|
+
const res = createMockRes();
|
|
9472
|
+
await match?.handler(req, res, {});
|
|
9473
|
+
expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledTimes(1);
|
|
9474
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
9475
|
+
const payload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
|
|
9476
|
+
expect(payload.success).toBe(true);
|
|
9477
|
+
expect(payload.appliedTaskUpserts).toEqual([]);
|
|
9478
|
+
expect(core.addAdminAuditLog).toHaveBeenCalledWith(expect.objectContaining({
|
|
9479
|
+
action: 'sync-workspace-push-upsert-echo-failed'
|
|
9480
|
+
}));
|
|
9481
|
+
expect(core.recordSyncEvent).toHaveBeenCalledWith('workspace-1', expect.objectContaining({
|
|
9482
|
+
eventType: 'push',
|
|
9483
|
+
status: 'success'
|
|
9484
|
+
}));
|
|
9485
|
+
});
|
|
9486
|
+
it('POST /api/taskforce/sync/workspace/push should still return success when success-path audit logging throws after apply', async () => {
|
|
9487
|
+
const authConfig = {
|
|
9488
|
+
runtimeMode: 'cloud',
|
|
9489
|
+
enabled: true,
|
|
9490
|
+
requiredForApi: true,
|
|
9491
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
9492
|
+
defaultRole: 'member',
|
|
9493
|
+
defaultWorkspaceId: 'default',
|
|
9494
|
+
sessionCookieName: 'taskforce_session',
|
|
9495
|
+
sessionSecret: 'test-secret',
|
|
9496
|
+
sessionTtlSeconds: 3600
|
|
9497
|
+
};
|
|
9498
|
+
routes = createRoutes(core, { authConfig });
|
|
9499
|
+
core.listUserWorkspaces.mockReturnValue([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
|
|
9500
|
+
const cookie = createSessionTokenCookie('session-sync-push-success-audit-fail', authConfig, {
|
|
9501
|
+
role: 'owner',
|
|
9502
|
+
workspaceId: 'workspace-1',
|
|
9503
|
+
userId: 'user-1'
|
|
9504
|
+
});
|
|
9505
|
+
core.applyWorkspaceSyncSnapshot.mockReturnValueOnce({
|
|
9506
|
+
total: 1,
|
|
9507
|
+
created: 1,
|
|
9508
|
+
updated: 0,
|
|
9509
|
+
archived: 0,
|
|
9510
|
+
unarchived: 0,
|
|
9511
|
+
skipped: 0
|
|
9512
|
+
});
|
|
9513
|
+
const originalAddAdminAuditLog = core.addAdminAuditLog.getMockImplementation();
|
|
9514
|
+
core.addAdminAuditLog.mockImplementation((entry) => {
|
|
9515
|
+
if (entry?.action === 'sync-workspace-push-success') {
|
|
9516
|
+
throw new Error('audit unavailable');
|
|
9517
|
+
}
|
|
9518
|
+
return originalAddAdminAuditLog ? originalAddAdminAuditLog(entry) : undefined;
|
|
9519
|
+
});
|
|
9520
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/push', routes);
|
|
9521
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/push', {
|
|
9522
|
+
workspaceId: 'workspace-1',
|
|
9523
|
+
idempotencyKey: 'push-success-audit-fail-1',
|
|
9524
|
+
changes: [
|
|
9525
|
+
{ op: 'upsert', task: { id: 'task-1', title: 'Task 1' }, archived: false }
|
|
9526
|
+
]
|
|
9527
|
+
}, { cookie });
|
|
9528
|
+
const res = createMockRes();
|
|
9529
|
+
await match?.handler(req, res, {});
|
|
9530
|
+
expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledTimes(1);
|
|
9531
|
+
expect(res.writeHead).toHaveBeenCalledTimes(1);
|
|
9532
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
9533
|
+
expect(res.end).toHaveBeenCalledTimes(1);
|
|
9534
|
+
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":true'));
|
|
9535
|
+
expect(core.recordSyncEvent).toHaveBeenCalledWith('workspace-1', expect.objectContaining({
|
|
9536
|
+
eventType: 'push',
|
|
9537
|
+
status: 'success'
|
|
9538
|
+
}));
|
|
9539
|
+
});
|
|
9540
|
+
it('POST /api/taskforce/sync/workspace/push should still return error response when failure-path telemetry throws', async () => {
|
|
9541
|
+
const authConfig = {
|
|
9542
|
+
runtimeMode: 'cloud',
|
|
9543
|
+
enabled: true,
|
|
9544
|
+
requiredForApi: true,
|
|
9545
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
9546
|
+
defaultRole: 'member',
|
|
9547
|
+
defaultWorkspaceId: 'default',
|
|
9548
|
+
sessionCookieName: 'taskforce_session',
|
|
9549
|
+
sessionSecret: 'test-secret',
|
|
9550
|
+
sessionTtlSeconds: 3600
|
|
9551
|
+
};
|
|
9552
|
+
routes = createRoutes(core, { authConfig });
|
|
9553
|
+
core.listUserWorkspaces.mockReturnValue([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
|
|
9554
|
+
const cookie = createSessionTokenCookie('session-sync-push-failure-telemetry-throw', authConfig, {
|
|
9555
|
+
role: 'owner',
|
|
9556
|
+
workspaceId: 'workspace-1',
|
|
9557
|
+
userId: 'user-1'
|
|
9558
|
+
});
|
|
9559
|
+
core.applyWorkspaceSyncSnapshot.mockImplementationOnce(() => {
|
|
9560
|
+
throw new Error('apply failed');
|
|
9561
|
+
});
|
|
9562
|
+
core.recordSyncEvent.mockImplementationOnce(() => {
|
|
9563
|
+
throw new Error('telemetry unavailable');
|
|
9564
|
+
});
|
|
9565
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/push', routes);
|
|
9566
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/push', {
|
|
9567
|
+
workspaceId: 'workspace-1',
|
|
9568
|
+
changes: [
|
|
9569
|
+
{ op: 'upsert', task: { id: 'task-1', title: 'Task 1' }, archived: false }
|
|
9570
|
+
]
|
|
9571
|
+
}, { cookie });
|
|
9572
|
+
const res = createMockRes();
|
|
9573
|
+
await match?.handler(req, res, {});
|
|
9574
|
+
expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledTimes(1);
|
|
9575
|
+
expect(res.writeHead).toHaveBeenCalledTimes(1);
|
|
9576
|
+
expect(res.writeHead).toHaveBeenCalledWith(400, expect.any(Object));
|
|
9577
|
+
expect(res.end).toHaveBeenCalledTimes(1);
|
|
9578
|
+
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":false'));
|
|
9579
|
+
});
|
|
9580
|
+
it('POST /api/taskforce/sync/workspace/push should persist synced cloud attachments into R2-backed canonical assets', async () => {
|
|
9581
|
+
const authConfig = {
|
|
9582
|
+
runtimeMode: 'cloud',
|
|
9583
|
+
enabled: true,
|
|
9584
|
+
requiredForApi: true,
|
|
9585
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
9586
|
+
defaultRole: 'member',
|
|
9587
|
+
defaultWorkspaceId: 'default',
|
|
9588
|
+
sessionCookieName: 'taskforce_session',
|
|
9589
|
+
sessionSecret: 'test-secret',
|
|
9590
|
+
sessionTtlSeconds: 3600
|
|
9591
|
+
};
|
|
9592
|
+
const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
|
|
9593
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-push-r2-'));
|
|
9594
|
+
core.getPaths.mockReturnValue({
|
|
9595
|
+
projectRoot: tmpRoot,
|
|
9596
|
+
basePath: path.join(tmpRoot, '.taskforce')
|
|
9597
|
+
});
|
|
9598
|
+
core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
|
|
9599
|
+
core.getTenantId = vi.fn().mockReturnValue('default');
|
|
9600
|
+
core.listUserWorkspaces.mockReturnValue([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
|
|
9601
|
+
core.applyWorkspaceSyncSnapshot.mockReturnValue({
|
|
9602
|
+
total: 0,
|
|
9603
|
+
created: 0,
|
|
9604
|
+
updated: 0,
|
|
9605
|
+
archived: 0,
|
|
9606
|
+
unarchived: 0,
|
|
9607
|
+
skipped: 0
|
|
9608
|
+
});
|
|
9609
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
9610
|
+
ok: true,
|
|
9611
|
+
status: 200,
|
|
9612
|
+
statusText: 'OK',
|
|
9613
|
+
headers: new Headers(),
|
|
9614
|
+
text: async () => '',
|
|
9615
|
+
json: async () => ({})
|
|
9616
|
+
});
|
|
9617
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
9618
|
+
routes = createRoutes(core, {
|
|
9619
|
+
authConfig,
|
|
9620
|
+
objectStorage: {
|
|
9621
|
+
provider: 'r2',
|
|
9622
|
+
r2: {
|
|
9623
|
+
accountId: 'acct-1',
|
|
9624
|
+
bucket: 'taskforce-perf-assets',
|
|
9625
|
+
endpoint: 'https://acct-1.r2.cloudflarestorage.com',
|
|
9626
|
+
accessKeyId: 'access-key-1',
|
|
9627
|
+
secretAccessKey: 'secret-key-1',
|
|
9628
|
+
keyPrefix: '',
|
|
9629
|
+
signedUploadTtlSeconds: 600,
|
|
9630
|
+
signedDownloadTtlSeconds: 300
|
|
9631
|
+
}
|
|
9632
|
+
}
|
|
9633
|
+
});
|
|
9634
|
+
const cookie = createSessionTokenCookie('session-sync-push-r2', authConfig, {
|
|
9635
|
+
role: 'owner',
|
|
9636
|
+
workspaceId: 'workspace-1',
|
|
9637
|
+
userId: 'user-1'
|
|
9638
|
+
});
|
|
9639
|
+
const markdownPath = 'workspaces/workspace-1/assets/documents/asset-doc-sync-1-plan.md';
|
|
9640
|
+
const imagePath = 'workspaces/workspace-1/assets/images/asset-image-sync-1-proof.png';
|
|
9641
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/push', routes);
|
|
9642
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/push', {
|
|
9643
|
+
workspaceId: 'workspace-1',
|
|
9644
|
+
changes: [
|
|
9645
|
+
{
|
|
9646
|
+
op: 'document-upsert',
|
|
9647
|
+
path: markdownPath,
|
|
9648
|
+
updatedAt: '2026-03-18T23:20:00.000Z',
|
|
9649
|
+
content: '# Plan',
|
|
9650
|
+
assetId: 'asset-doc-sync-1',
|
|
9651
|
+
documentId: 'doc-sync-1',
|
|
9652
|
+
taskId: 'task-sync-1',
|
|
9653
|
+
originalFilename: 'plan.md',
|
|
9654
|
+
linkRole: 'attachment'
|
|
9655
|
+
},
|
|
9656
|
+
{
|
|
9657
|
+
op: 'asset-upsert',
|
|
9658
|
+
path: imagePath,
|
|
9659
|
+
updatedAt: '2026-03-18T23:20:01.000Z',
|
|
9660
|
+
contentBase64: Buffer.from('png-bytes').toString('base64'),
|
|
9661
|
+
assetId: 'asset-image-sync-1',
|
|
9662
|
+
kind: 'image',
|
|
9663
|
+
mimeType: 'image/png',
|
|
9664
|
+
taskId: 'task-sync-1',
|
|
9665
|
+
originalFilename: 'proof.png',
|
|
9666
|
+
linkRole: 'image'
|
|
9667
|
+
}
|
|
9668
|
+
]
|
|
9669
|
+
}, { cookie });
|
|
9670
|
+
const res = createMockRes();
|
|
9671
|
+
await match?.handler(req, res, {});
|
|
9672
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
9673
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
9674
|
+
const docAsset = store.getByStorageKey(markdownPath, 'workspace-1');
|
|
9675
|
+
const imageAsset = store.getByStorageKey(imagePath, 'workspace-1');
|
|
9676
|
+
expect(docAsset).toEqual(expect.objectContaining({
|
|
9677
|
+
assetId: 'asset-doc-sync-1',
|
|
9678
|
+
storageProvider: 'r2',
|
|
9679
|
+
storageKey: markdownPath
|
|
9680
|
+
}));
|
|
9681
|
+
expect(imageAsset).toEqual(expect.objectContaining({
|
|
9682
|
+
assetId: 'asset-image-sync-1',
|
|
9683
|
+
storageProvider: 'r2',
|
|
9684
|
+
storageKey: imagePath
|
|
9685
|
+
}));
|
|
9686
|
+
expect(store.listLinksForTask('task-sync-1', 'workspace-1')).toEqual(expect.arrayContaining([
|
|
8497
9687
|
expect.objectContaining({ assetId: 'asset-doc-sync-1', role: 'attachment', deletedAt: null }),
|
|
8498
9688
|
expect.objectContaining({ assetId: 'asset-image-sync-1', role: 'image', deletedAt: null })
|
|
8499
9689
|
]));
|
|
@@ -9417,11 +10607,11 @@ describe('Server Routes', () => {
|
|
|
9417
10607
|
expect(res.setHeader).toHaveBeenCalledWith('Set-Cookie', expect.stringContaining('taskforce_session='));
|
|
9418
10608
|
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
9419
10609
|
});
|
|
9420
|
-
it('POST /api/taskforce/session/workspace should
|
|
10610
|
+
it('POST /api/taskforce/session/workspace should allow a read-only user to switch to another active workspace', async () => {
|
|
9421
10611
|
const authConfig = {
|
|
9422
|
-
runtimeMode: '
|
|
10612
|
+
runtimeMode: 'cloud',
|
|
9423
10613
|
enabled: true,
|
|
9424
|
-
requiredForApi:
|
|
10614
|
+
requiredForApi: true,
|
|
9425
10615
|
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
9426
10616
|
defaultRole: 'member',
|
|
9427
10617
|
defaultWorkspaceId: 'default',
|
|
@@ -9430,13 +10620,134 @@ describe('Server Routes', () => {
|
|
|
9430
10620
|
sessionTtlSeconds: 3600
|
|
9431
10621
|
};
|
|
9432
10622
|
routes = createRoutes(core, { authConfig });
|
|
10623
|
+
const cookie = createSessionTokenCookie('session-read-only', authConfig, {
|
|
10624
|
+
role: 'read-only',
|
|
10625
|
+
workspaceId: 'workspace-shared',
|
|
10626
|
+
userId: 'user-1'
|
|
10627
|
+
});
|
|
10628
|
+
core.listUserWorkspaces.mockReturnValue([
|
|
10629
|
+
{
|
|
10630
|
+
id: 'workspace-shared',
|
|
10631
|
+
slug: 'workspace-shared',
|
|
10632
|
+
name: 'Shared Workspace',
|
|
10633
|
+
description: null,
|
|
10634
|
+
role: 'read-only',
|
|
10635
|
+
status: 'active',
|
|
10636
|
+
betaAccess: true
|
|
10637
|
+
},
|
|
10638
|
+
{
|
|
10639
|
+
id: 'workspace-home',
|
|
10640
|
+
slug: 'workspace-home',
|
|
10641
|
+
name: 'Home Workspace',
|
|
10642
|
+
description: null,
|
|
10643
|
+
role: 'owner',
|
|
10644
|
+
status: 'active',
|
|
10645
|
+
betaAccess: true
|
|
10646
|
+
}
|
|
10647
|
+
]);
|
|
10648
|
+
core.resolveUserAccess.mockReturnValue({
|
|
10649
|
+
userId: 'user-1',
|
|
10650
|
+
workspaceId: 'workspace-home',
|
|
10651
|
+
role: 'owner',
|
|
10652
|
+
permissionMode: 'read-write',
|
|
10653
|
+
disabled: false,
|
|
10654
|
+
betaAccess: true
|
|
10655
|
+
});
|
|
9433
10656
|
const match = matchRoute('POST', '/api/taskforce/session/workspace', routes);
|
|
9434
10657
|
const req = createMockReq('POST', '/api/taskforce/session/workspace', {
|
|
9435
|
-
workspaceId: 'workspace-
|
|
10658
|
+
workspaceId: 'workspace-home'
|
|
10659
|
+
}, {
|
|
10660
|
+
cookie
|
|
9436
10661
|
});
|
|
9437
10662
|
const res = createMockRes();
|
|
10663
|
+
res.setHeader = vi.fn();
|
|
9438
10664
|
await match?.handler(req, res, {});
|
|
9439
|
-
expect(
|
|
10665
|
+
expect(core.resolveUserAccess).toHaveBeenCalledWith('user-1', 'workspace-home', 'read-only');
|
|
10666
|
+
expect(res.setHeader).toHaveBeenCalledWith('Set-Cookie', expect.stringContaining('taskforce_session='));
|
|
10667
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
10668
|
+
const payload = JSON.parse(String(res.end.mock.calls[0]?.[0] || '{}'));
|
|
10669
|
+
expect(payload).toMatchObject({ success: true, workspaceId: 'workspace-home', role: 'owner' });
|
|
10670
|
+
});
|
|
10671
|
+
it('POST /api/taskforce/session/workspace should allow switching into an active read-only workspace', async () => {
|
|
10672
|
+
const authConfig = {
|
|
10673
|
+
runtimeMode: 'cloud',
|
|
10674
|
+
enabled: true,
|
|
10675
|
+
requiredForApi: true,
|
|
10676
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
10677
|
+
defaultRole: 'member',
|
|
10678
|
+
defaultWorkspaceId: 'default',
|
|
10679
|
+
sessionCookieName: 'taskforce_session',
|
|
10680
|
+
sessionSecret: 'test-secret',
|
|
10681
|
+
sessionTtlSeconds: 3600
|
|
10682
|
+
};
|
|
10683
|
+
routes = createRoutes(core, { authConfig });
|
|
10684
|
+
const cookie = createSessionTokenCookie('session-member', authConfig, {
|
|
10685
|
+
role: 'owner',
|
|
10686
|
+
workspaceId: 'workspace-home',
|
|
10687
|
+
userId: 'user-1'
|
|
10688
|
+
});
|
|
10689
|
+
core.listUserWorkspaces.mockReturnValue([
|
|
10690
|
+
{
|
|
10691
|
+
id: 'workspace-home',
|
|
10692
|
+
slug: 'workspace-home',
|
|
10693
|
+
name: 'Home Workspace',
|
|
10694
|
+
description: null,
|
|
10695
|
+
role: 'owner',
|
|
10696
|
+
status: 'active',
|
|
10697
|
+
betaAccess: true
|
|
10698
|
+
},
|
|
10699
|
+
{
|
|
10700
|
+
id: 'workspace-shared',
|
|
10701
|
+
slug: 'workspace-shared',
|
|
10702
|
+
name: 'Shared Workspace',
|
|
10703
|
+
description: null,
|
|
10704
|
+
role: 'read-only',
|
|
10705
|
+
status: 'active',
|
|
10706
|
+
betaAccess: true
|
|
10707
|
+
}
|
|
10708
|
+
]);
|
|
10709
|
+
core.resolveUserAccess.mockReturnValue({
|
|
10710
|
+
userId: 'user-1',
|
|
10711
|
+
workspaceId: 'workspace-shared',
|
|
10712
|
+
role: 'read-only',
|
|
10713
|
+
permissionMode: 'read-only',
|
|
10714
|
+
disabled: false,
|
|
10715
|
+
betaAccess: true
|
|
10716
|
+
});
|
|
10717
|
+
const match = matchRoute('POST', '/api/taskforce/session/workspace', routes);
|
|
10718
|
+
const req = createMockReq('POST', '/api/taskforce/session/workspace', {
|
|
10719
|
+
workspaceId: 'workspace-shared'
|
|
10720
|
+
}, {
|
|
10721
|
+
cookie
|
|
10722
|
+
});
|
|
10723
|
+
const res = createMockRes();
|
|
10724
|
+
res.setHeader = vi.fn();
|
|
10725
|
+
await match?.handler(req, res, {});
|
|
10726
|
+
expect(res.setHeader).toHaveBeenCalledWith('Set-Cookie', expect.stringContaining('taskforce_session='));
|
|
10727
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
10728
|
+
const payload = JSON.parse(String(res.end.mock.calls[0]?.[0] || '{}'));
|
|
10729
|
+
expect(payload).toMatchObject({ success: true, workspaceId: 'workspace-shared', role: 'read-only' });
|
|
10730
|
+
});
|
|
10731
|
+
it('POST /api/taskforce/session/workspace should be disabled in local runtime', async () => {
|
|
10732
|
+
const authConfig = {
|
|
10733
|
+
runtimeMode: 'local',
|
|
10734
|
+
enabled: true,
|
|
10735
|
+
requiredForApi: false,
|
|
10736
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
10737
|
+
defaultRole: 'member',
|
|
10738
|
+
defaultWorkspaceId: 'default',
|
|
10739
|
+
sessionCookieName: 'taskforce_session',
|
|
10740
|
+
sessionSecret: 'test-secret',
|
|
10741
|
+
sessionTtlSeconds: 3600
|
|
10742
|
+
};
|
|
10743
|
+
routes = createRoutes(core, { authConfig });
|
|
10744
|
+
const match = matchRoute('POST', '/api/taskforce/session/workspace', routes);
|
|
10745
|
+
const req = createMockReq('POST', '/api/taskforce/session/workspace', {
|
|
10746
|
+
workspaceId: 'workspace-2'
|
|
10747
|
+
});
|
|
10748
|
+
const res = createMockRes();
|
|
10749
|
+
await match?.handler(req, res, {});
|
|
10750
|
+
expect(res.writeHead).toHaveBeenCalledWith(501, expect.any(Object));
|
|
9440
10751
|
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('Workspace switching is unavailable in local mode.'));
|
|
9441
10752
|
expect(core.listUserWorkspaces).not.toHaveBeenCalled();
|
|
9442
10753
|
});
|
|
@@ -9512,7 +10823,10 @@ describe('Server Routes', () => {
|
|
|
9512
10823
|
await match?.handler(req, res, {});
|
|
9513
10824
|
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
9514
10825
|
expect(payload.authenticated).toBe(true);
|
|
10826
|
+
expect(payload.authRequiredForApi).toBe(true);
|
|
10827
|
+
expect(payload.runtimeMode).toBe('cloud');
|
|
9515
10828
|
expect(payload.betaAccess).toBe(false);
|
|
10829
|
+
expect(res.setHeader).toHaveBeenCalledWith('Server-Timing', expect.stringContaining('auth-session'));
|
|
9516
10830
|
expect(core.getUserBetaAccess).toHaveBeenCalledWith('u-no-workspace');
|
|
9517
10831
|
});
|
|
9518
10832
|
it('GET /api/taskforce/auth/session should expose avatarUrl from the resolved profile', async () => {
|
|
@@ -9723,6 +11037,74 @@ describe('Server Routes', () => {
|
|
|
9723
11037
|
mimeType: 'image/png'
|
|
9724
11038
|
}));
|
|
9725
11039
|
});
|
|
11040
|
+
it('POST /api/taskforce/auth/profile/avatar/upload should store the avatar body through object storage', async () => {
|
|
11041
|
+
const authConfig = {
|
|
11042
|
+
runtimeMode: 'cloud',
|
|
11043
|
+
enabled: true,
|
|
11044
|
+
requiredForApi: true,
|
|
11045
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
11046
|
+
defaultRole: 'member',
|
|
11047
|
+
defaultWorkspaceId: 'default',
|
|
11048
|
+
sessionCookieName: 'taskforce_session',
|
|
11049
|
+
sessionSecret: 'test-secret',
|
|
11050
|
+
sessionTtlSeconds: 3600
|
|
11051
|
+
};
|
|
11052
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
11053
|
+
ok: true,
|
|
11054
|
+
status: 200,
|
|
11055
|
+
statusText: 'OK',
|
|
11056
|
+
text: async () => ''
|
|
11057
|
+
});
|
|
11058
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
11059
|
+
const routesWithStorage = createRoutes(core, {
|
|
11060
|
+
authConfig,
|
|
11061
|
+
objectStorage: {
|
|
11062
|
+
provider: 'r2',
|
|
11063
|
+
r2: {
|
|
11064
|
+
accountId: 'acct-1',
|
|
11065
|
+
bucket: 'taskforce-docs',
|
|
11066
|
+
endpoint: 'https://example.r2.cloudflarestorage.com',
|
|
11067
|
+
accessKeyId: 'access-key-1',
|
|
11068
|
+
secretAccessKey: 'secret-key-1',
|
|
11069
|
+
keyPrefix: 'taskforce',
|
|
11070
|
+
signedUploadTtlSeconds: 600,
|
|
11071
|
+
signedDownloadTtlSeconds: 300
|
|
11072
|
+
}
|
|
11073
|
+
}
|
|
11074
|
+
});
|
|
11075
|
+
core.getUserAvatarUploadDraft.mockReturnValueOnce({
|
|
11076
|
+
draftId: 'avatar-draft-1',
|
|
11077
|
+
userId: 'u1',
|
|
11078
|
+
storageKey: 'users/u1/profile/avatar/avatar-draft-1-headshot.png',
|
|
11079
|
+
mimeType: 'image/png',
|
|
11080
|
+
finalizedAt: null,
|
|
11081
|
+
discardedAt: null
|
|
11082
|
+
});
|
|
11083
|
+
const cookie = createSessionTokenCookie('session-avatar-upload', authConfig, {
|
|
11084
|
+
userId: 'u1',
|
|
11085
|
+
role: 'member',
|
|
11086
|
+
workspaceId: 'workspace-1'
|
|
11087
|
+
});
|
|
11088
|
+
const match = matchRoute('POST', '/api/taskforce/auth/profile/avatar/upload', routesWithStorage);
|
|
11089
|
+
const req = createMockReq('POST', '/api/taskforce/auth/profile/avatar/upload', Buffer.from([1, 2, 3, 4]), {
|
|
11090
|
+
cookie,
|
|
11091
|
+
'content-type': 'image/png',
|
|
11092
|
+
'x-taskforce-avatar-draft-id': 'avatar-draft-1'
|
|
11093
|
+
});
|
|
11094
|
+
const res = createMockRes();
|
|
11095
|
+
await match?.handler(req, res, {});
|
|
11096
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
11097
|
+
expect(payload.success).toBe(true);
|
|
11098
|
+
expect(payload.draftId).toBe('avatar-draft-1');
|
|
11099
|
+
const r2Call = fetchMock.mock.calls[0];
|
|
11100
|
+
expect(String(r2Call?.[0])).toContain('https://example.r2.cloudflarestorage.com/taskforce-docs/taskforce/users/u1/profile/avatar/avatar-draft-1-headshot.png');
|
|
11101
|
+
expect(r2Call?.[1]).toEqual(expect.objectContaining({
|
|
11102
|
+
method: 'PUT',
|
|
11103
|
+
body: expect.any(Uint8Array)
|
|
11104
|
+
}));
|
|
11105
|
+
expect((r2Call?.[1]?.headers)['content-type']).toBe('image/png');
|
|
11106
|
+
vi.unstubAllGlobals();
|
|
11107
|
+
});
|
|
9726
11108
|
it('POST /api/taskforce/auth/profile/avatar/finalize should validate and finalize the uploaded draft', async () => {
|
|
9727
11109
|
const authConfig = {
|
|
9728
11110
|
runtimeMode: 'cloud',
|
|
@@ -9847,6 +11229,56 @@ describe('Server Routes', () => {
|
|
|
9847
11229
|
allowed: true
|
|
9848
11230
|
});
|
|
9849
11231
|
});
|
|
11232
|
+
it('GET /api/taskforce/account/team-management-access should resolve against the authenticated session user and workspace', async () => {
|
|
11233
|
+
const authConfig = {
|
|
11234
|
+
runtimeMode: 'cloud',
|
|
11235
|
+
enabled: true,
|
|
11236
|
+
requiredForApi: true,
|
|
11237
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
11238
|
+
defaultRole: 'member',
|
|
11239
|
+
defaultWorkspaceId: 'default',
|
|
11240
|
+
sessionCookieName: 'taskforce_session',
|
|
11241
|
+
sessionSecret: 'test-secret',
|
|
11242
|
+
sessionTtlSeconds: 3600
|
|
11243
|
+
};
|
|
11244
|
+
routes = createRoutes(core, { authConfig });
|
|
11245
|
+
core.listUserWorkspaces.mockReturnValueOnce([
|
|
11246
|
+
{ id: 'workspace-auth-existing-home', slug: 'home', name: 'Home', description: null, role: 'owner', status: 'active', betaAccess: true },
|
|
11247
|
+
{ id: 'workspace-invited', slug: 'invited', name: 'Invited', description: null, role: 'member', status: 'active', betaAccess: true }
|
|
11248
|
+
]);
|
|
11249
|
+
core.resolveUserAccess.mockImplementation((userId, workspaceId, role) => ({
|
|
11250
|
+
userId,
|
|
11251
|
+
workspaceId,
|
|
11252
|
+
role: userId === 'joined-member' && workspaceId === 'workspace-invited' ? 'member' : role,
|
|
11253
|
+
disabled: false,
|
|
11254
|
+
betaAccess: true
|
|
11255
|
+
}));
|
|
11256
|
+
const cookie = createSessionTokenCookie('session-joined-member', authConfig, {
|
|
11257
|
+
userId: 'joined-member',
|
|
11258
|
+
role: 'member',
|
|
11259
|
+
workspaceId: 'workspace-invited'
|
|
11260
|
+
});
|
|
11261
|
+
const match = matchRoute('GET', '/api/taskforce/account/team-management-access', routes);
|
|
11262
|
+
const req = createMockReq('GET', '/api/taskforce/account/team-management-access', undefined, {
|
|
11263
|
+
cookie,
|
|
11264
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
11265
|
+
'x-taskforce-user-id': 'workspace-auth-existing-home-owner',
|
|
11266
|
+
'x-taskforce-workspace-id': 'workspace-auth-existing-home',
|
|
11267
|
+
'x-taskforce-role': 'owner'
|
|
11268
|
+
});
|
|
11269
|
+
const res = createMockRes();
|
|
11270
|
+
await match?.handler(req, res, {});
|
|
11271
|
+
expect(core.resolveUserAccess).toHaveBeenCalledWith('joined-member', 'workspace-invited', 'member');
|
|
11272
|
+
expect(core.getAccountEntitlementsPayload).toHaveBeenCalledWith({ userId: 'joined-member', workspaceId: 'workspace-invited' });
|
|
11273
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
11274
|
+
expect(payload).toMatchObject({
|
|
11275
|
+
workspaceId: 'workspace-invited',
|
|
11276
|
+
role: 'member',
|
|
11277
|
+
canManageWorkspace: false,
|
|
11278
|
+
teamPlanMode: 'team',
|
|
11279
|
+
allowed: false
|
|
11280
|
+
});
|
|
11281
|
+
});
|
|
9850
11282
|
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 () => {
|
|
9851
11283
|
core.resolveUserAccess.mockReturnValueOnce(null);
|
|
9852
11284
|
const match = matchRoute('GET', '/api/taskforce/account/team-management-access', routes);
|
|
@@ -9923,6 +11355,40 @@ describe('Server Routes', () => {
|
|
|
9923
11355
|
planSelectionRequired: false
|
|
9924
11356
|
});
|
|
9925
11357
|
});
|
|
11358
|
+
it('GET /api/taskforce/account/ai-profile-seat-usage should return cloud-authoritative ai seat usage', async () => {
|
|
11359
|
+
const authConfig = {
|
|
11360
|
+
runtimeMode: 'cloud',
|
|
11361
|
+
enabled: true,
|
|
11362
|
+
requiredForApi: true,
|
|
11363
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
11364
|
+
defaultRole: 'member',
|
|
11365
|
+
defaultWorkspaceId: 'default',
|
|
11366
|
+
sessionCookieName: 'taskforce_session',
|
|
11367
|
+
sessionSecret: 'test-secret',
|
|
11368
|
+
sessionTtlSeconds: 3600
|
|
11369
|
+
};
|
|
11370
|
+
routes = createRoutes(core, { authConfig });
|
|
11371
|
+
core.countAiProfileUsage.mockReturnValueOnce({ used: 0, limit: 20, remaining: 20 });
|
|
11372
|
+
const match = matchRoute('GET', '/api/taskforce/account/ai-profile-seat-usage', routes);
|
|
11373
|
+
const cookie = createSessionTokenCookie('session-ai-seat-usage', authConfig, {
|
|
11374
|
+
userId: 'u1',
|
|
11375
|
+
role: 'owner',
|
|
11376
|
+
workspaceId: 'workspace-1'
|
|
11377
|
+
});
|
|
11378
|
+
const req = createMockReq('GET', '/api/taskforce/account/ai-profile-seat-usage?workspaceId=workspace-1', undefined, {
|
|
11379
|
+
cookie,
|
|
11380
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
11381
|
+
'x-taskforce-user-id': 'u1',
|
|
11382
|
+
'x-taskforce-role': 'owner',
|
|
11383
|
+
'x-taskforce-workspace-id': 'workspace-1'
|
|
11384
|
+
});
|
|
11385
|
+
const res = createMockRes();
|
|
11386
|
+
await match?.handler(req, res, {});
|
|
11387
|
+
expect(core.countAiProfileUsage).toHaveBeenCalledWith('workspace-1');
|
|
11388
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
11389
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
11390
|
+
expect(payload.aiProfileSeatUsage).toEqual({ used: 0, limit: 20, remaining: 20 });
|
|
11391
|
+
});
|
|
9926
11392
|
it('GET /api/taskforce/account/access-status should return pending plan-selection gate', async () => {
|
|
9927
11393
|
core.getAccountAccessStatus.mockReturnValueOnce({
|
|
9928
11394
|
gate: 'plan_selection_required',
|
|
@@ -9995,6 +11461,7 @@ describe('Server Routes', () => {
|
|
|
9995
11461
|
error.code = 'ENTITLEMENT_NOT_FOUND';
|
|
9996
11462
|
throw error;
|
|
9997
11463
|
});
|
|
11464
|
+
core.countAiProfileUsage.mockReturnValueOnce({ used: 0, limit: 20, remaining: 20 });
|
|
9998
11465
|
const match = matchRoute('GET', '/api/taskforce/account/profile-summary', routes);
|
|
9999
11466
|
const req = createMockReq('GET', '/api/taskforce/account/profile-summary?workspaceId=workspace-1', undefined, {
|
|
10000
11467
|
'x-taskforce-user-id': 'u1'
|
|
@@ -10015,7 +11482,75 @@ describe('Server Routes', () => {
|
|
|
10015
11482
|
workspaceRole: 'owner',
|
|
10016
11483
|
canManageWorkspace: true,
|
|
10017
11484
|
teamPlanMode: 'personal',
|
|
10018
|
-
teamManagementAllowed: false
|
|
11485
|
+
teamManagementAllowed: false,
|
|
11486
|
+
aiProfileSeatUsage: {
|
|
11487
|
+
used: 0,
|
|
11488
|
+
limit: 20,
|
|
11489
|
+
remaining: 20
|
|
11490
|
+
}
|
|
11491
|
+
});
|
|
11492
|
+
});
|
|
11493
|
+
it('GET /api/taskforce/account/profile-summary should not leak stale billing mirror plan identity when entitlement is missing', async () => {
|
|
11494
|
+
core.resolveUserAccess.mockReturnValueOnce({
|
|
11495
|
+
userId: 'u1',
|
|
11496
|
+
workspaceId: 'workspace-1',
|
|
11497
|
+
role: 'member',
|
|
11498
|
+
disabled: false,
|
|
11499
|
+
betaAccess: true
|
|
11500
|
+
});
|
|
11501
|
+
core.getAccountAccessStatus.mockReturnValueOnce({
|
|
11502
|
+
gate: 'missing_entitlement',
|
|
11503
|
+
hasEntitlement: false,
|
|
11504
|
+
canAccessApp: false,
|
|
11505
|
+
canAccessPlans: true,
|
|
11506
|
+
planSelectionRequired: false,
|
|
11507
|
+
message: 'No entitlement is linked to this account.',
|
|
11508
|
+
entitlement: null
|
|
11509
|
+
});
|
|
11510
|
+
core.countAiProfileUsage.mockReturnValueOnce({ used: 0, limit: 20, remaining: 20 });
|
|
11511
|
+
const baseDb = core.getDatabaseAdapter();
|
|
11512
|
+
core.getDatabaseAdapter = vi.fn().mockReturnValue({
|
|
11513
|
+
...baseDb,
|
|
11514
|
+
prepare: vi.fn((sql) => {
|
|
11515
|
+
if (sql.includes('FROM user_billing')) {
|
|
11516
|
+
return {
|
|
11517
|
+
get: () => ({
|
|
11518
|
+
stripe_status: 'active',
|
|
11519
|
+
stripe_customer_id: 'cus_stale',
|
|
11520
|
+
stripe_subscription_id: 'sub_stale',
|
|
11521
|
+
stripe_price_id: 'price_stale',
|
|
11522
|
+
billing_interval: 'month',
|
|
11523
|
+
entitlement_state: 'active',
|
|
11524
|
+
tier: 'team-5'
|
|
11525
|
+
})
|
|
11526
|
+
};
|
|
11527
|
+
}
|
|
11528
|
+
if (sql.includes('FROM plan_catalog')) {
|
|
11529
|
+
return {
|
|
11530
|
+
get: () => ({ display_name: 'Team 5' })
|
|
11531
|
+
};
|
|
11532
|
+
}
|
|
11533
|
+
return baseDb.prepare(sql);
|
|
11534
|
+
})
|
|
11535
|
+
});
|
|
11536
|
+
const match = matchRoute('GET', '/api/taskforce/account/profile-summary', routes);
|
|
11537
|
+
const req = createMockReq('GET', '/api/taskforce/account/profile-summary?workspaceId=workspace-1', undefined, {
|
|
11538
|
+
'x-taskforce-user-id': 'u1'
|
|
11539
|
+
});
|
|
11540
|
+
const res = createMockRes();
|
|
11541
|
+
await match?.handler(req, res, {});
|
|
11542
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
11543
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
11544
|
+
expect(payload).toMatchObject({
|
|
11545
|
+
planId: null,
|
|
11546
|
+
planVersionId: null,
|
|
11547
|
+
entitlementState: null,
|
|
11548
|
+
gate: 'missing_entitlement',
|
|
11549
|
+
message: 'No entitlement is linked to this account.',
|
|
11550
|
+
planSelectionRequired: false,
|
|
11551
|
+
stripeStatus: 'active',
|
|
11552
|
+
stripeCustomerId: 'cus_stale',
|
|
11553
|
+
stripeSubscriptionId: 'sub_stale'
|
|
10019
11554
|
});
|
|
10020
11555
|
});
|
|
10021
11556
|
it('GET /api/taskforce/account/entitlements should surface checkout-pending gate when billing is not complete', async () => {
|
|
@@ -10087,6 +11622,20 @@ describe('Server Routes', () => {
|
|
|
10087
11622
|
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"code":"PLAN_SELECTION_REQUIRED"'));
|
|
10088
11623
|
expect(core.listTasks).not.toHaveBeenCalled();
|
|
10089
11624
|
});
|
|
11625
|
+
it('GET /api/taskforce/tasks should require authenticated cloud users', async () => {
|
|
11626
|
+
const authConfig = createCloudAuthConfig();
|
|
11627
|
+
routes = createRoutes(core, { authConfig });
|
|
11628
|
+
core.listTasks.mockReturnValue({ tasks: [] });
|
|
11629
|
+
const match = matchRoute('GET', '/api/taskforce/tasks', routes);
|
|
11630
|
+
const req = createMockReq('GET', '/api/taskforce/tasks', undefined, {
|
|
11631
|
+
'x-taskforce-runtime-mode': 'cloud'
|
|
11632
|
+
});
|
|
11633
|
+
const res = createMockRes();
|
|
11634
|
+
await match?.handler(req, res, {});
|
|
11635
|
+
expect(res.writeHead).toHaveBeenCalledWith(401, expect.any(Object));
|
|
11636
|
+
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"Authentication required."'));
|
|
11637
|
+
expect(core.listTasks).not.toHaveBeenCalled();
|
|
11638
|
+
});
|
|
10090
11639
|
it('GET /api/taskforce/tasks should block authenticated users with missing entitlements', async () => {
|
|
10091
11640
|
const authConfig = {
|
|
10092
11641
|
runtimeMode: 'cloud',
|
|
@@ -10194,17 +11743,31 @@ describe('Server Routes', () => {
|
|
|
10194
11743
|
try {
|
|
10195
11744
|
core.getTenantId = vi.fn().mockReturnValue('default');
|
|
10196
11745
|
core.getDatabaseAdapter = vi.fn().mockReturnValue({
|
|
10197
|
-
prepare: vi.fn().
|
|
10198
|
-
|
|
10199
|
-
{
|
|
10200
|
-
|
|
10201
|
-
|
|
10202
|
-
|
|
10203
|
-
|
|
10204
|
-
|
|
10205
|
-
|
|
10206
|
-
|
|
10207
|
-
|
|
11746
|
+
prepare: vi.fn().mockImplementation((sql) => {
|
|
11747
|
+
if (sql.includes('FROM billing_price_mappings')) {
|
|
11748
|
+
return {
|
|
11749
|
+
get: vi.fn().mockReturnValue({
|
|
11750
|
+
count: 1,
|
|
11751
|
+
max_updated_at: '2026-01-01T00:00:00.000Z'
|
|
11752
|
+
}),
|
|
11753
|
+
all: vi.fn().mockReturnValue([
|
|
11754
|
+
{
|
|
11755
|
+
plan_version_id: 'free-v1',
|
|
11756
|
+
billing_interval: 'month',
|
|
11757
|
+
stripe_price_id: 'free:free-v1:month',
|
|
11758
|
+
pricing_type: 'free',
|
|
11759
|
+
unit_amount: 0,
|
|
11760
|
+
currency: null
|
|
11761
|
+
}
|
|
11762
|
+
])
|
|
11763
|
+
};
|
|
11764
|
+
}
|
|
11765
|
+
return {
|
|
11766
|
+
get: vi.fn().mockReturnValue({
|
|
11767
|
+
count: 0,
|
|
11768
|
+
max_updated_at: null
|
|
11769
|
+
})
|
|
11770
|
+
};
|
|
10208
11771
|
})
|
|
10209
11772
|
});
|
|
10210
11773
|
core.listPlanCatalog.mockReturnValueOnce([
|
|
@@ -10224,6 +11787,7 @@ describe('Server Routes', () => {
|
|
|
10224
11787
|
}
|
|
10225
11788
|
}
|
|
10226
11789
|
]);
|
|
11790
|
+
core.listPlanFeatureCatalog = vi.fn().mockReturnValue([]);
|
|
10227
11791
|
routes = createRoutes(core);
|
|
10228
11792
|
const match = matchRoute('GET', '/api/taskforce/public/pricing', routes);
|
|
10229
11793
|
const req = createMockReq('GET', '/api/taskforce/public/pricing', undefined, {
|
|
@@ -10235,7 +11799,15 @@ describe('Server Routes', () => {
|
|
|
10235
11799
|
const payload = JSON.parse(String(res.end.mock.calls[0][0]));
|
|
10236
11800
|
expect(payload.plans).toHaveLength(1);
|
|
10237
11801
|
expect(payload.plans[0].features).toEqual([
|
|
10238
|
-
{
|
|
11802
|
+
{
|
|
11803
|
+
featureKey: 'ai.assistance',
|
|
11804
|
+
access: 'enabled',
|
|
11805
|
+
config: {},
|
|
11806
|
+
label: null,
|
|
11807
|
+
description: null,
|
|
11808
|
+
publicLabel: null,
|
|
11809
|
+
publicDescription: null
|
|
11810
|
+
}
|
|
10239
11811
|
]);
|
|
10240
11812
|
}
|
|
10241
11813
|
finally {
|
|
@@ -10267,6 +11839,7 @@ describe('Server Routes', () => {
|
|
|
10267
11839
|
expect(core.upsertPlanCatalog).toHaveBeenCalledWith({
|
|
10268
11840
|
planId: 'enterprise',
|
|
10269
11841
|
displayName: 'Enterprise',
|
|
11842
|
+
description: undefined,
|
|
10270
11843
|
status: 'active',
|
|
10271
11844
|
initialTrialDays: 0,
|
|
10272
11845
|
initialMode: undefined,
|
|
@@ -10286,15 +11859,51 @@ describe('Server Routes', () => {
|
|
|
10286
11859
|
expect(core.listPlanCatalog).toHaveBeenCalled();
|
|
10287
11860
|
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
10288
11861
|
});
|
|
10289
|
-
it('GET /api/platform/
|
|
10290
|
-
|
|
10291
|
-
|
|
11862
|
+
it('GET /api/platform/onboarding-plan-options should return onboarding plan options for system admin', async () => {
|
|
11863
|
+
core.listOnboardingPlanOptions = vi.fn().mockReturnValue([
|
|
11864
|
+
{
|
|
11865
|
+
planId: 'free',
|
|
11866
|
+
planDisplayName: 'Free',
|
|
11867
|
+
defaultPlanVersionId: 'free-v1',
|
|
11868
|
+
defaultVersionNumber: 1,
|
|
11869
|
+
planStatus: 'active',
|
|
11870
|
+
supportsAutoGrantFree: true,
|
|
11871
|
+
supportsAutoStartTrial: false
|
|
11872
|
+
}
|
|
11873
|
+
]);
|
|
11874
|
+
const match = matchRoute('GET', '/api/platform/onboarding-plan-options', routes);
|
|
11875
|
+
const req = createMockReq('GET', '/api/platform/onboarding-plan-options', undefined, {
|
|
10292
11876
|
'x-taskforce-runtime-mode': 'cloud',
|
|
10293
11877
|
'x-taskforce-system-role': 'system_admin'
|
|
10294
11878
|
});
|
|
10295
11879
|
const res = createMockRes();
|
|
10296
|
-
await match?.handler(req, res, {
|
|
10297
|
-
expect(core.
|
|
11880
|
+
await match?.handler(req, res, {});
|
|
11881
|
+
expect(core.listOnboardingPlanOptions).toHaveBeenCalled();
|
|
11882
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
11883
|
+
expect(JSON.parse(res.end.mock.calls[0]?.[0] || '{}')).toEqual({
|
|
11884
|
+
success: true,
|
|
11885
|
+
options: [
|
|
11886
|
+
{
|
|
11887
|
+
planId: 'free',
|
|
11888
|
+
planDisplayName: 'Free',
|
|
11889
|
+
defaultPlanVersionId: 'free-v1',
|
|
11890
|
+
defaultVersionNumber: 1,
|
|
11891
|
+
planStatus: 'active',
|
|
11892
|
+
supportsAutoGrantFree: true,
|
|
11893
|
+
supportsAutoStartTrial: false
|
|
11894
|
+
}
|
|
11895
|
+
]
|
|
11896
|
+
});
|
|
11897
|
+
});
|
|
11898
|
+
it('GET /api/platform/plans/:planId/versions should list versions', async () => {
|
|
11899
|
+
const match = matchRoute('GET', '/api/platform/plans/collab/versions', routes);
|
|
11900
|
+
const req = createMockReq('GET', '/api/platform/plans/collab/versions', undefined, {
|
|
11901
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
11902
|
+
'x-taskforce-system-role': 'system_admin'
|
|
11903
|
+
});
|
|
11904
|
+
const res = createMockRes();
|
|
11905
|
+
await match?.handler(req, res, { planId: 'collab' });
|
|
11906
|
+
expect(core.listPlanVersions).toHaveBeenCalledWith('collab');
|
|
10298
11907
|
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
10299
11908
|
});
|
|
10300
11909
|
it('POST /api/platform/plans/:planId/versions should forward per-version trial days', async () => {
|
|
@@ -10344,6 +11953,37 @@ describe('Server Routes', () => {
|
|
|
10344
11953
|
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"featureKey":"collaboration.team_management"'));
|
|
10345
11954
|
expect(res.end).not.toHaveBeenCalledWith(expect.stringContaining('"featureKey":"collaboration.invites"'));
|
|
10346
11955
|
});
|
|
11956
|
+
it('PATCH /api/platform/features/:featureKey should update public marketing copy', async () => {
|
|
11957
|
+
core.updatePlanFeatureCatalogMarketingCopy = vi.fn().mockReturnValue({
|
|
11958
|
+
featureKey: 'collaboration.ai_profiles',
|
|
11959
|
+
label: 'AI Profiles',
|
|
11960
|
+
description: 'Allow registered AI collaborator profiles with an optional per-workspace limit.',
|
|
11961
|
+
publicLabel: 'AI teammates',
|
|
11962
|
+
publicDescription: 'Add reusable AI teammates to your workspace.',
|
|
11963
|
+
publicDisplayOrder: 20,
|
|
11964
|
+
allowedAccessModes: ['enabled', 'limited', 'disabled'],
|
|
11965
|
+
configTemplate: { maxAiProfiles: 1 }
|
|
11966
|
+
});
|
|
11967
|
+
const match = matchRoute('PATCH', '/api/platform/features/collaboration.ai_profiles', routes);
|
|
11968
|
+
const req = createMockReq('PATCH', '/api/platform/features/collaboration.ai_profiles', {
|
|
11969
|
+
publicLabel: 'AI teammates',
|
|
11970
|
+
publicDescription: 'Add reusable AI teammates to your workspace.',
|
|
11971
|
+
publicDisplayOrder: 20
|
|
11972
|
+
}, {
|
|
11973
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
11974
|
+
'x-taskforce-system-role': 'system_admin'
|
|
11975
|
+
});
|
|
11976
|
+
const res = createMockRes();
|
|
11977
|
+
await match?.handler(req, res, { featureKey: 'collaboration.ai_profiles' });
|
|
11978
|
+
expect(core.updatePlanFeatureCatalogMarketingCopy).toHaveBeenCalledWith({
|
|
11979
|
+
featureKey: 'collaboration.ai_profiles',
|
|
11980
|
+
publicLabel: 'AI teammates',
|
|
11981
|
+
publicDescription: 'Add reusable AI teammates to your workspace.',
|
|
11982
|
+
publicDisplayOrder: 20
|
|
11983
|
+
});
|
|
11984
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
11985
|
+
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"publicLabel":"AI teammates"'));
|
|
11986
|
+
});
|
|
10347
11987
|
it('PATCH /api/platform/plans/:planId/versions/:versionId/features should return normalized dependency state', async () => {
|
|
10348
11988
|
core.updatePlanVersionFeatures.mockReturnValueOnce({
|
|
10349
11989
|
planVersionId: 'collab-v1',
|
|
@@ -10473,13 +12113,20 @@ describe('Server Routes', () => {
|
|
|
10473
12113
|
updatedAt: '2026-02-01T00:00:00.000Z'
|
|
10474
12114
|
}
|
|
10475
12115
|
]);
|
|
12116
|
+
core.countSystemUsers.mockReturnValueOnce(1);
|
|
10476
12117
|
const match = matchRoute('GET', '/api/taskforce/admin/system-users', routes);
|
|
10477
|
-
const req = createMockReq('GET', '/api/taskforce/admin/system-users', undefined, { 'x-taskforce-system-role': 'system_admin' });
|
|
12118
|
+
const req = createMockReq('GET', '/api/taskforce/admin/system-users?q=u1&sort=oldest&limit=25&offset=50', undefined, { 'x-taskforce-system-role': 'system_admin' });
|
|
10478
12119
|
const res = createMockRes();
|
|
10479
12120
|
await match?.handler(req, res, {});
|
|
10480
|
-
expect(core.
|
|
12121
|
+
expect(core.countSystemUsers).toHaveBeenCalledWith({ query: 'u1' });
|
|
12122
|
+
expect(core.listSystemUsers).toHaveBeenCalledWith({ query: 'u1', sort: 'oldest', limit: 25, offset: 50 });
|
|
10481
12123
|
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
10482
|
-
|
|
12124
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
12125
|
+
expect(payload.users).toHaveLength(1);
|
|
12126
|
+
expect(payload.total).toBe(1);
|
|
12127
|
+
expect(payload.limit).toBe(25);
|
|
12128
|
+
expect(payload.offset).toBe(50);
|
|
12129
|
+
expect(payload.hasMore).toBe(false);
|
|
10483
12130
|
});
|
|
10484
12131
|
it('GET /api/taskforce/admin/system-users should allow authenticated system admin without role header', async () => {
|
|
10485
12132
|
const authConfig = {
|
|
@@ -10505,7 +12152,8 @@ describe('Server Routes', () => {
|
|
|
10505
12152
|
const res = createMockRes();
|
|
10506
12153
|
await match?.handler(req, res, {});
|
|
10507
12154
|
expect(core.getSystemRole).toHaveBeenCalledWith('admin-user');
|
|
10508
|
-
expect(core.
|
|
12155
|
+
expect(core.countSystemUsers).toHaveBeenCalledWith({ query: '' });
|
|
12156
|
+
expect(core.listSystemUsers).toHaveBeenCalledWith({ query: '', sort: 'newest', limit: 50, offset: 0 });
|
|
10509
12157
|
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
10510
12158
|
});
|
|
10511
12159
|
it('GET /api/taskforce/admin/audit-logs should return events for admins', async () => {
|
|
@@ -10767,6 +12415,268 @@ describe('Server Routes', () => {
|
|
|
10767
12415
|
expect(matchRoute('POST', '/api/taskforce/admin/users/invite', routes)).not.toBeNull();
|
|
10768
12416
|
expect(matchRoute('PATCH', '/api/taskforce/admin/users/u1/role', routes)).not.toBeNull();
|
|
10769
12417
|
expect(matchRoute('GET', '/api/taskforce/admin/workspace-audit-logs', routes)).not.toBeNull();
|
|
12418
|
+
expect(matchRoute('POST', '/api/taskforce/workspace/ai-profiles/avatar', routes)).not.toBeNull();
|
|
12419
|
+
expect(matchRoute('POST', '/api/taskforce/workspace/ai-profiles/avatar/upload', routes)).not.toBeNull();
|
|
12420
|
+
expect(matchRoute('POST', '/api/taskforce/workspace/ai-profiles/metadata', routes)).toBeNull();
|
|
12421
|
+
});
|
|
12422
|
+
it('POST /api/taskforce/admin/users/invite should authorize against the target workspace', async () => {
|
|
12423
|
+
const authConfig = createCloudAuthConfig();
|
|
12424
|
+
routes = createRoutes(core, { authConfig });
|
|
12425
|
+
core.listUserWorkspaces.mockReturnValue([
|
|
12426
|
+
{ id: 'workspace-home', slug: 'home', name: 'Home', role: 'owner', status: 'active', betaAccess: true },
|
|
12427
|
+
{ id: 'workspace-shared', slug: 'shared', name: 'Shared', role: 'member', status: 'active', betaAccess: true }
|
|
12428
|
+
]);
|
|
12429
|
+
core.listWorkspaceUsers.mockImplementation((workspaceId) => {
|
|
12430
|
+
if (workspaceId === 'workspace-home') {
|
|
12431
|
+
return [{
|
|
12432
|
+
userId: 'actor-user',
|
|
12433
|
+
email: 'actor@example.com',
|
|
12434
|
+
displayName: 'Actor',
|
|
12435
|
+
role: 'owner',
|
|
12436
|
+
permissionMode: 'read-write',
|
|
12437
|
+
betaAccess: true,
|
|
12438
|
+
status: 'active',
|
|
12439
|
+
disabled: false
|
|
12440
|
+
}];
|
|
12441
|
+
}
|
|
12442
|
+
if (workspaceId === 'workspace-shared') {
|
|
12443
|
+
return [{
|
|
12444
|
+
userId: 'actor-user',
|
|
12445
|
+
email: 'actor@example.com',
|
|
12446
|
+
displayName: 'Actor',
|
|
12447
|
+
role: 'member',
|
|
12448
|
+
permissionMode: 'read-only',
|
|
12449
|
+
betaAccess: true,
|
|
12450
|
+
status: 'active',
|
|
12451
|
+
disabled: false
|
|
12452
|
+
}];
|
|
12453
|
+
}
|
|
12454
|
+
return [];
|
|
12455
|
+
});
|
|
12456
|
+
core.inviteWorkspaceUser.mockReturnValue({
|
|
12457
|
+
userId: 'invited-user',
|
|
12458
|
+
email: 'invited@example.com',
|
|
12459
|
+
role: 'member',
|
|
12460
|
+
status: 'invited',
|
|
12461
|
+
disabled: false
|
|
12462
|
+
});
|
|
12463
|
+
const cookie = createSessionTokenCookie('session-target-invite-deny', authConfig, {
|
|
12464
|
+
userId: 'actor-user',
|
|
12465
|
+
role: 'owner',
|
|
12466
|
+
workspaceId: 'workspace-home'
|
|
12467
|
+
});
|
|
12468
|
+
const match = matchRoute('POST', '/api/taskforce/admin/users/invite', routes);
|
|
12469
|
+
const req = createMockReq('POST', '/api/taskforce/admin/users/invite', {
|
|
12470
|
+
workspaceId: 'workspace-shared',
|
|
12471
|
+
email: 'invited@example.com',
|
|
12472
|
+
role: 'member'
|
|
12473
|
+
}, {
|
|
12474
|
+
cookie,
|
|
12475
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
12476
|
+
'x-taskforce-role': 'owner',
|
|
12477
|
+
'x-taskforce-user-id': 'actor-user',
|
|
12478
|
+
'x-taskforce-workspace-id': 'workspace-home'
|
|
12479
|
+
});
|
|
12480
|
+
const res = createMockRes();
|
|
12481
|
+
await match?.handler(req, res, {});
|
|
12482
|
+
expect(core.inviteWorkspaceUser).not.toHaveBeenCalled();
|
|
12483
|
+
expect(core.evaluateFeatureAccess).not.toHaveBeenCalled();
|
|
12484
|
+
expect(res.writeHead).toHaveBeenCalledWith(403, expect.any(Object));
|
|
12485
|
+
});
|
|
12486
|
+
it('POST /api/taskforce/admin/users/invite should resolve feature access and inviter identity from the authenticated session user', async () => {
|
|
12487
|
+
const authConfig = createCloudAuthConfig();
|
|
12488
|
+
routes = createRoutes(core, { authConfig });
|
|
12489
|
+
core.listWorkspaceUsers.mockImplementation((workspaceId) => {
|
|
12490
|
+
if (workspaceId === 'workspace-shared') {
|
|
12491
|
+
return [{
|
|
12492
|
+
userId: 'joined-admin',
|
|
12493
|
+
email: 'joined-admin@example.com',
|
|
12494
|
+
displayName: 'Joined Admin',
|
|
12495
|
+
role: 'admin',
|
|
12496
|
+
permissionMode: 'read-write',
|
|
12497
|
+
betaAccess: true,
|
|
12498
|
+
status: 'active',
|
|
12499
|
+
disabled: false
|
|
12500
|
+
}];
|
|
12501
|
+
}
|
|
12502
|
+
return [];
|
|
12503
|
+
});
|
|
12504
|
+
core.evaluateFeatureAccess.mockImplementation(({ userId }) => ({
|
|
12505
|
+
allowed: userId === 'joined-admin',
|
|
12506
|
+
reasonCode: userId === 'joined-admin' ? undefined : 'ACCOUNT_ENTITLEMENT_REQUIRED'
|
|
12507
|
+
}));
|
|
12508
|
+
core.inviteWorkspaceUser.mockReturnValue({
|
|
12509
|
+
userId: 'invited-user',
|
|
12510
|
+
email: 'invited@example.com',
|
|
12511
|
+
role: 'member',
|
|
12512
|
+
status: 'invited',
|
|
12513
|
+
disabled: false
|
|
12514
|
+
});
|
|
12515
|
+
core.requestInviteAcceptance.mockReturnValue({ token: 'invite-token-1' });
|
|
12516
|
+
core.getWorkspaceProfile.mockReturnValue({
|
|
12517
|
+
id: 'workspace-shared',
|
|
12518
|
+
name: 'Shared Workspace'
|
|
12519
|
+
});
|
|
12520
|
+
core.resolveUserIdentity.mockReturnValue({
|
|
12521
|
+
userId: 'joined-admin',
|
|
12522
|
+
email: 'joined-admin@example.com',
|
|
12523
|
+
displayName: 'Joined Admin'
|
|
12524
|
+
});
|
|
12525
|
+
const cookie = createSessionTokenCookie('session-target-invite-identity', authConfig, {
|
|
12526
|
+
userId: 'joined-admin',
|
|
12527
|
+
role: 'admin',
|
|
12528
|
+
workspaceId: 'workspace-shared'
|
|
12529
|
+
});
|
|
12530
|
+
const match = matchRoute('POST', '/api/taskforce/admin/users/invite', routes);
|
|
12531
|
+
const req = createMockReq('POST', '/api/taskforce/admin/users/invite', {
|
|
12532
|
+
workspaceId: 'workspace-shared',
|
|
12533
|
+
email: 'invited@example.com',
|
|
12534
|
+
role: 'member'
|
|
12535
|
+
}, {
|
|
12536
|
+
cookie,
|
|
12537
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
12538
|
+
'x-taskforce-role': 'owner',
|
|
12539
|
+
'x-taskforce-user-id': 'workspace-auth-existing-home-owner',
|
|
12540
|
+
'x-taskforce-workspace-id': 'workspace-auth-existing-home'
|
|
12541
|
+
});
|
|
12542
|
+
const res = createMockRes();
|
|
12543
|
+
await match?.handler(req, res, {});
|
|
12544
|
+
expect(core.evaluateFeatureAccess).toHaveBeenCalledWith(expect.objectContaining({
|
|
12545
|
+
userId: 'joined-admin',
|
|
12546
|
+
workspaceId: 'workspace-shared',
|
|
12547
|
+
featureKey: 'collaboration.invites'
|
|
12548
|
+
}));
|
|
12549
|
+
expect(core.resolveUserIdentity).toHaveBeenCalledWith('joined-admin');
|
|
12550
|
+
expect(core.inviteWorkspaceUser).toHaveBeenCalled();
|
|
12551
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
12552
|
+
});
|
|
12553
|
+
it('PATCH /api/taskforce/admin/users/:id/role should reject active-workspace admins who are only target-workspace members', async () => {
|
|
12554
|
+
const authConfig = createCloudAuthConfig();
|
|
12555
|
+
routes = createRoutes(core, { authConfig });
|
|
12556
|
+
core.listUserWorkspaces.mockReturnValue([
|
|
12557
|
+
{ id: 'workspace-home', slug: 'home', name: 'Home', role: 'owner', status: 'active', betaAccess: true },
|
|
12558
|
+
{ id: 'workspace-shared', slug: 'shared', name: 'Shared', role: 'member', status: 'active', betaAccess: true }
|
|
12559
|
+
]);
|
|
12560
|
+
core.listWorkspaceUsers.mockImplementation((workspaceId) => {
|
|
12561
|
+
if (workspaceId === 'workspace-home') {
|
|
12562
|
+
return [{
|
|
12563
|
+
userId: 'actor-user',
|
|
12564
|
+
email: 'actor@example.com',
|
|
12565
|
+
displayName: 'Actor',
|
|
12566
|
+
role: 'owner',
|
|
12567
|
+
permissionMode: 'read-write',
|
|
12568
|
+
betaAccess: true,
|
|
12569
|
+
status: 'active',
|
|
12570
|
+
disabled: false
|
|
12571
|
+
}];
|
|
12572
|
+
}
|
|
12573
|
+
if (workspaceId === 'workspace-shared') {
|
|
12574
|
+
return [{
|
|
12575
|
+
userId: 'actor-user',
|
|
12576
|
+
email: 'actor@example.com',
|
|
12577
|
+
displayName: 'Actor',
|
|
12578
|
+
role: 'member',
|
|
12579
|
+
permissionMode: 'read-only',
|
|
12580
|
+
betaAccess: true,
|
|
12581
|
+
status: 'active',
|
|
12582
|
+
disabled: false
|
|
12583
|
+
}];
|
|
12584
|
+
}
|
|
12585
|
+
return [];
|
|
12586
|
+
});
|
|
12587
|
+
core.updateWorkspaceUserRole.mockReturnValue(true);
|
|
12588
|
+
const cookie = createSessionTokenCookie('session-target-role-deny', authConfig, {
|
|
12589
|
+
userId: 'actor-user',
|
|
12590
|
+
role: 'owner',
|
|
12591
|
+
workspaceId: 'workspace-home'
|
|
12592
|
+
});
|
|
12593
|
+
const match = matchRoute('PATCH', '/api/taskforce/admin/users/target-user/role', routes);
|
|
12594
|
+
const req = createMockReq('PATCH', '/api/taskforce/admin/users/target-user/role', {
|
|
12595
|
+
workspaceId: 'workspace-shared',
|
|
12596
|
+
role: 'admin'
|
|
12597
|
+
}, {
|
|
12598
|
+
cookie,
|
|
12599
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
12600
|
+
'x-taskforce-role': 'owner',
|
|
12601
|
+
'x-taskforce-user-id': 'actor-user',
|
|
12602
|
+
'x-taskforce-workspace-id': 'workspace-home'
|
|
12603
|
+
});
|
|
12604
|
+
const res = createMockRes();
|
|
12605
|
+
await match?.handler(req, res, { id: 'target-user' });
|
|
12606
|
+
expect(core.updateWorkspaceUserRole).not.toHaveBeenCalled();
|
|
12607
|
+
expect(core.evaluateFeatureAccess).not.toHaveBeenCalled();
|
|
12608
|
+
expect(res.writeHead).toHaveBeenCalledWith(403, expect.any(Object));
|
|
12609
|
+
});
|
|
12610
|
+
it('PATCH /api/taskforce/admin/users/:id/role should allow target-workspace admins with a different active workspace', async () => {
|
|
12611
|
+
const authConfig = createCloudAuthConfig();
|
|
12612
|
+
routes = createRoutes(core, { authConfig });
|
|
12613
|
+
core.listUserWorkspaces.mockReturnValue([
|
|
12614
|
+
{ id: 'workspace-home', slug: 'home', name: 'Home', role: 'member', status: 'active', betaAccess: true },
|
|
12615
|
+
{ id: 'workspace-shared', slug: 'shared', name: 'Shared', role: 'admin', status: 'active', betaAccess: true }
|
|
12616
|
+
]);
|
|
12617
|
+
core.listWorkspaceUsers.mockImplementation((workspaceId) => {
|
|
12618
|
+
if (workspaceId === 'workspace-home') {
|
|
12619
|
+
return [{
|
|
12620
|
+
userId: 'actor-user',
|
|
12621
|
+
email: 'actor@example.com',
|
|
12622
|
+
displayName: 'Actor',
|
|
12623
|
+
role: 'member',
|
|
12624
|
+
permissionMode: 'read-write',
|
|
12625
|
+
betaAccess: true,
|
|
12626
|
+
status: 'active',
|
|
12627
|
+
disabled: false
|
|
12628
|
+
}];
|
|
12629
|
+
}
|
|
12630
|
+
if (workspaceId === 'workspace-shared') {
|
|
12631
|
+
return [{
|
|
12632
|
+
userId: 'actor-user',
|
|
12633
|
+
email: 'actor@example.com',
|
|
12634
|
+
displayName: 'Actor',
|
|
12635
|
+
role: 'admin',
|
|
12636
|
+
permissionMode: 'read-write',
|
|
12637
|
+
betaAccess: true,
|
|
12638
|
+
status: 'active',
|
|
12639
|
+
disabled: false
|
|
12640
|
+
}];
|
|
12641
|
+
}
|
|
12642
|
+
return [];
|
|
12643
|
+
});
|
|
12644
|
+
core.updateWorkspaceUserRole.mockReturnValue(true);
|
|
12645
|
+
const cookie = createSessionTokenCookie('session-target-role-allow', authConfig, {
|
|
12646
|
+
userId: 'actor-user',
|
|
12647
|
+
role: 'member',
|
|
12648
|
+
workspaceId: 'workspace-home'
|
|
12649
|
+
});
|
|
12650
|
+
const match = matchRoute('PATCH', '/api/taskforce/admin/users/target-user/role', routes);
|
|
12651
|
+
const req = createMockReq('PATCH', '/api/taskforce/admin/users/target-user/role', {
|
|
12652
|
+
workspaceId: 'workspace-shared',
|
|
12653
|
+
role: 'admin'
|
|
12654
|
+
}, {
|
|
12655
|
+
cookie,
|
|
12656
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
12657
|
+
'x-taskforce-role': 'member',
|
|
12658
|
+
'x-taskforce-user-id': 'actor-user',
|
|
12659
|
+
'x-taskforce-workspace-id': 'workspace-home'
|
|
12660
|
+
});
|
|
12661
|
+
const res = createMockRes();
|
|
12662
|
+
await match?.handler(req, res, { id: 'target-user' });
|
|
12663
|
+
expect(core.evaluateFeatureAccess).toHaveBeenCalledWith({
|
|
12664
|
+
userId: 'actor-user',
|
|
12665
|
+
workspaceId: 'workspace-shared',
|
|
12666
|
+
featureKey: 'collaboration.team_management'
|
|
12667
|
+
});
|
|
12668
|
+
expect(core.updateWorkspaceUserRole).toHaveBeenCalledWith('target-user', 'workspace-shared', 'admin');
|
|
12669
|
+
expect(core.addAdminAuditLog).toHaveBeenCalledWith(expect.objectContaining({
|
|
12670
|
+
action: 'update-workspace-user-role',
|
|
12671
|
+
actorUserId: 'actor-user',
|
|
12672
|
+
actorRole: 'admin',
|
|
12673
|
+
workspaceId: 'workspace-shared',
|
|
12674
|
+
details: expect.objectContaining({
|
|
12675
|
+
workspaceId: 'workspace-shared',
|
|
12676
|
+
targetUserId: 'target-user'
|
|
12677
|
+
})
|
|
12678
|
+
}));
|
|
12679
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
10770
12680
|
});
|
|
10771
12681
|
it('GET /api/taskforce/workspace/assignee-options should reject unauthenticated cloud requests', async () => {
|
|
10772
12682
|
const authConfig = {
|
|
@@ -10861,6 +12771,7 @@ describe('Server Routes', () => {
|
|
|
10861
12771
|
userId: 'member-1',
|
|
10862
12772
|
email: 'member1@example.com',
|
|
10863
12773
|
displayName: 'Member One',
|
|
12774
|
+
avatarUrl: '/api/taskforce/context/users%2Fmember-1%2Fprofile%2Favatar%2Fheadshot.webp',
|
|
10864
12775
|
role: 'member',
|
|
10865
12776
|
permissionMode: 'read-write',
|
|
10866
12777
|
betaAccess: true,
|
|
@@ -10882,11 +12793,14 @@ describe('Server Routes', () => {
|
|
|
10882
12793
|
provider: 'OpenAI',
|
|
10883
12794
|
model: 'gpt-5.4',
|
|
10884
12795
|
surfaceType: 'coding_tool',
|
|
12796
|
+
seatScope: null,
|
|
12797
|
+
archivedAt: null,
|
|
10885
12798
|
createdAt: '2026-03-01T00:00:00.000Z',
|
|
10886
12799
|
updatedAt: '2026-03-01T00:00:00.000Z',
|
|
10887
12800
|
lastActiveAt: null
|
|
10888
12801
|
}
|
|
10889
12802
|
]);
|
|
12803
|
+
core.countAiProfileUsage.mockReturnValue({ used: 1, limit: 3, remaining: 2 });
|
|
10890
12804
|
const match = matchRoute('GET', '/api/taskforce/workspace/assignee-options', routes);
|
|
10891
12805
|
const cookie = createSessionTokenCookie('session-member-assignees', authConfig, {
|
|
10892
12806
|
userId: 'member-user',
|
|
@@ -10903,9 +12817,14 @@ describe('Server Routes', () => {
|
|
|
10903
12817
|
const res = createMockRes();
|
|
10904
12818
|
await match?.handler(req, res, {});
|
|
10905
12819
|
expect(core.listWorkspaceUsers).toHaveBeenCalledWith('workspace-1');
|
|
10906
|
-
expect(core.listAiProfiles).toHaveBeenCalledWith('workspace-1'
|
|
12820
|
+
expect(core.listAiProfiles).toHaveBeenCalledWith('workspace-1', expect.objectContaining({
|
|
12821
|
+
diagnostics: expect.any(Object)
|
|
12822
|
+
}));
|
|
12823
|
+
expect(core.countAiProfileUsage).toHaveBeenCalledWith('workspace-1');
|
|
12824
|
+
expect(res.setHeader).toHaveBeenCalledWith('Server-Timing', expect.stringContaining('assignee-options'));
|
|
10907
12825
|
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
10908
12826
|
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
12827
|
+
expect(payload.aiProfileSeatUsage).toEqual({ used: 1, limit: 3, remaining: 2 });
|
|
10909
12828
|
expect(payload.assignees).toEqual([
|
|
10910
12829
|
{
|
|
10911
12830
|
value: 'member-1',
|
|
@@ -10916,6 +12835,7 @@ describe('Server Routes', () => {
|
|
|
10916
12835
|
userId: 'member-1',
|
|
10917
12836
|
email: 'member1@example.com',
|
|
10918
12837
|
displayName: 'Member One',
|
|
12838
|
+
avatarUrl: '/api/taskforce/context/users%2Fmember-1%2Fprofile%2Favatar%2Fheadshot.webp',
|
|
10919
12839
|
status: 'active',
|
|
10920
12840
|
disabled: false
|
|
10921
12841
|
},
|
|
@@ -10925,19 +12845,104 @@ describe('Server Routes', () => {
|
|
|
10925
12845
|
username: 'codex',
|
|
10926
12846
|
icon: 'Bot',
|
|
10927
12847
|
color: '#ef6c00',
|
|
12848
|
+
avatarUrl: null,
|
|
12849
|
+
avatarSourceUrl: null,
|
|
12850
|
+
avatarRevision: 0,
|
|
12851
|
+
avatarUpdatedAt: null,
|
|
10928
12852
|
kind: 'agent',
|
|
10929
12853
|
description: 'Builds product code',
|
|
10930
12854
|
role: 'Implementer',
|
|
10931
12855
|
provider: 'OpenAI',
|
|
10932
12856
|
model: 'gpt-5.4',
|
|
10933
12857
|
surfaceType: 'coding_tool',
|
|
12858
|
+
seatScope: null,
|
|
12859
|
+
archivedAt: null,
|
|
12860
|
+
createdAt: '2026-03-01T00:00:00.000Z',
|
|
12861
|
+
updatedAt: '2026-03-01T00:00:00.000Z',
|
|
12862
|
+
lastActiveAt: null
|
|
12863
|
+
}
|
|
12864
|
+
]);
|
|
12865
|
+
});
|
|
12866
|
+
it('GET /api/taskforce/workspace/assignee-options should support agent-only roster loads', async () => {
|
|
12867
|
+
const authConfig = {
|
|
12868
|
+
runtimeMode: 'cloud',
|
|
12869
|
+
enabled: true,
|
|
12870
|
+
requiredForApi: true,
|
|
12871
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
12872
|
+
defaultRole: 'member',
|
|
12873
|
+
defaultWorkspaceId: 'default',
|
|
12874
|
+
sessionCookieName: 'taskforce_session',
|
|
12875
|
+
sessionSecret: 'test-secret',
|
|
12876
|
+
sessionTtlSeconds: 3600
|
|
12877
|
+
};
|
|
12878
|
+
routes = createRoutes(core, { authConfig });
|
|
12879
|
+
core.listWorkspaceUsers.mockReturnValue([
|
|
12880
|
+
{
|
|
12881
|
+
userId: 'member-1',
|
|
12882
|
+
email: 'member1@example.com',
|
|
12883
|
+
displayName: 'Member One',
|
|
12884
|
+
avatarUrl: null,
|
|
12885
|
+
role: 'member',
|
|
12886
|
+
permissionMode: 'read-write',
|
|
12887
|
+
betaAccess: true,
|
|
12888
|
+
status: 'active',
|
|
12889
|
+
disabled: false
|
|
12890
|
+
}
|
|
12891
|
+
]);
|
|
12892
|
+
core.listAiProfiles.mockReturnValue([
|
|
12893
|
+
{
|
|
12894
|
+
id: 'ai-profile-1',
|
|
12895
|
+
workspaceId: 'workspace-1',
|
|
12896
|
+
profileToken: 'tfp_codex_sync',
|
|
12897
|
+
name: 'Codex',
|
|
12898
|
+
username: 'codex',
|
|
12899
|
+
icon: 'Bot',
|
|
12900
|
+
color: '#ef6c00',
|
|
12901
|
+
description: 'Builds product code',
|
|
12902
|
+
role: 'Implementer',
|
|
12903
|
+
provider: 'OpenAI',
|
|
12904
|
+
model: 'gpt-5.4',
|
|
12905
|
+
surfaceType: 'coding_tool',
|
|
12906
|
+
seatScope: 'cloud_metered',
|
|
12907
|
+
archivedAt: null,
|
|
10934
12908
|
createdAt: '2026-03-01T00:00:00.000Z',
|
|
10935
12909
|
updatedAt: '2026-03-01T00:00:00.000Z',
|
|
10936
12910
|
lastActiveAt: null
|
|
10937
12911
|
}
|
|
10938
12912
|
]);
|
|
12913
|
+
core.countAiProfileUsage.mockReturnValue({ used: 1, limit: 3, remaining: 2 });
|
|
12914
|
+
const match = matchRoute('GET', '/api/taskforce/workspace/assignee-options', routes);
|
|
12915
|
+
const cookie = createSessionTokenCookie('session-member-agent-roster', authConfig, {
|
|
12916
|
+
userId: 'member-user',
|
|
12917
|
+
role: 'member',
|
|
12918
|
+
workspaceId: 'workspace-1'
|
|
12919
|
+
});
|
|
12920
|
+
const req = createMockReq('GET', '/api/taskforce/workspace/assignee-options?kind=agent', undefined, {
|
|
12921
|
+
cookie,
|
|
12922
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
12923
|
+
'x-taskforce-role': 'member',
|
|
12924
|
+
'x-taskforce-user-id': 'member-user',
|
|
12925
|
+
'x-taskforce-workspace-id': 'workspace-1'
|
|
12926
|
+
});
|
|
12927
|
+
const res = createMockRes();
|
|
12928
|
+
await match?.handler(req, res, {});
|
|
12929
|
+
expect(core.listWorkspaceUsers).not.toHaveBeenCalled();
|
|
12930
|
+
expect(core.listAiProfiles).toHaveBeenCalledWith('workspace-1', expect.objectContaining({
|
|
12931
|
+
diagnostics: expect.any(Object)
|
|
12932
|
+
}));
|
|
12933
|
+
expect(core.countAiProfileUsage).toHaveBeenCalledWith('workspace-1');
|
|
12934
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
12935
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
12936
|
+
expect(payload.aiProfileSeatUsage).toEqual({ used: 1, limit: 3, remaining: 2 });
|
|
12937
|
+
expect(payload.assignees).toEqual([
|
|
12938
|
+
expect.objectContaining({
|
|
12939
|
+
value: 'ai-profile-1',
|
|
12940
|
+
label: 'Codex',
|
|
12941
|
+
kind: 'agent'
|
|
12942
|
+
})
|
|
12943
|
+
]);
|
|
10939
12944
|
});
|
|
10940
|
-
it('GET /api/taskforce/workspace/assignee-options should include ai profile tokens for workspace admins', async () => {
|
|
12945
|
+
it('GET /api/taskforce/workspace/assignee-options should not include ai profile tokens for workspace admins', async () => {
|
|
10941
12946
|
const authConfig = {
|
|
10942
12947
|
runtimeMode: 'cloud',
|
|
10943
12948
|
enabled: true,
|
|
@@ -10970,6 +12975,7 @@ describe('Server Routes', () => {
|
|
|
10970
12975
|
lastActiveAt: null
|
|
10971
12976
|
}
|
|
10972
12977
|
]);
|
|
12978
|
+
core.countAiProfileUsage.mockReturnValue({ used: 1, limit: 3, remaining: 2 });
|
|
10973
12979
|
const match = matchRoute('GET', '/api/taskforce/workspace/assignee-options', routes);
|
|
10974
12980
|
const cookie = createSessionTokenCookie('session-admin-assignees', authConfig, {
|
|
10975
12981
|
userId: 'admin-user',
|
|
@@ -10986,12 +12992,384 @@ describe('Server Routes', () => {
|
|
|
10986
12992
|
const res = createMockRes();
|
|
10987
12993
|
await match?.handler(req, res, {});
|
|
10988
12994
|
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
12995
|
+
expect(payload.aiProfileSeatUsage).toEqual({ used: 1, limit: 3, remaining: 2 });
|
|
10989
12996
|
expect(payload.assignees).toEqual([
|
|
10990
12997
|
expect.objectContaining({
|
|
10991
12998
|
value: 'ai-profile-1',
|
|
10992
|
-
|
|
12999
|
+
label: 'Codex'
|
|
10993
13000
|
})
|
|
10994
13001
|
]);
|
|
13002
|
+
expect(payload.assignees[0]).not.toHaveProperty('profileToken');
|
|
13003
|
+
});
|
|
13004
|
+
it('POST /api/taskforce/workspace/ai-profiles/avatar should reject direct avatar URL writes', async () => {
|
|
13005
|
+
const authConfig = {
|
|
13006
|
+
runtimeMode: 'cloud',
|
|
13007
|
+
enabled: true,
|
|
13008
|
+
requiredForApi: true,
|
|
13009
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
13010
|
+
defaultRole: 'member',
|
|
13011
|
+
defaultWorkspaceId: 'default',
|
|
13012
|
+
sessionCookieName: 'taskforce_session',
|
|
13013
|
+
sessionSecret: 'test-secret',
|
|
13014
|
+
sessionTtlSeconds: 3600
|
|
13015
|
+
};
|
|
13016
|
+
routes = createRoutes(core, { authConfig });
|
|
13017
|
+
const match = matchRoute('POST', '/api/taskforce/workspace/ai-profiles/avatar', routes);
|
|
13018
|
+
const cookie = createSessionTokenCookie('session-admin-avatar-ai-profile-reject', authConfig, {
|
|
13019
|
+
userId: 'admin-user',
|
|
13020
|
+
role: 'admin',
|
|
13021
|
+
workspaceId: 'workspace-1'
|
|
13022
|
+
});
|
|
13023
|
+
const req = createMockReq('POST', '/api/taskforce/workspace/ai-profiles/avatar', {
|
|
13024
|
+
profileId: 'ai-profile-1',
|
|
13025
|
+
avatarUrl: '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Favatar.webp'
|
|
13026
|
+
}, {
|
|
13027
|
+
cookie,
|
|
13028
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
13029
|
+
'x-taskforce-role': 'admin',
|
|
13030
|
+
'x-taskforce-user-id': 'admin-user',
|
|
13031
|
+
'x-taskforce-workspace-id': 'workspace-1'
|
|
13032
|
+
});
|
|
13033
|
+
const res = createMockRes();
|
|
13034
|
+
await match?.handler(req, res, {});
|
|
13035
|
+
expect(core.updateAiProfileAvatar).not.toHaveBeenCalled();
|
|
13036
|
+
expect(res.writeHead).toHaveBeenCalledWith(400, expect.any(Object));
|
|
13037
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
13038
|
+
expect(payload).toEqual({
|
|
13039
|
+
error: 'AI profile avatar updates must use the stable avatar upload endpoint.',
|
|
13040
|
+
code: 'AI_PROFILE_AVATAR_STABLE_UPLOAD_REQUIRED'
|
|
13041
|
+
});
|
|
13042
|
+
});
|
|
13043
|
+
it('POST /api/taskforce/workspace/ai-profiles/avatar should allow clear-only updates and redact profile tokens', async () => {
|
|
13044
|
+
const authConfig = {
|
|
13045
|
+
runtimeMode: 'cloud',
|
|
13046
|
+
enabled: true,
|
|
13047
|
+
requiredForApi: true,
|
|
13048
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
13049
|
+
defaultRole: 'member',
|
|
13050
|
+
defaultWorkspaceId: 'default',
|
|
13051
|
+
sessionCookieName: 'taskforce_session',
|
|
13052
|
+
sessionSecret: 'test-secret',
|
|
13053
|
+
sessionTtlSeconds: 3600
|
|
13054
|
+
};
|
|
13055
|
+
routes = createRoutes(core, { authConfig });
|
|
13056
|
+
core.listAiProfiles.mockReturnValue([
|
|
13057
|
+
{
|
|
13058
|
+
id: 'ai-profile-1',
|
|
13059
|
+
workspaceId: 'workspace-1',
|
|
13060
|
+
avatarUrl: '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fai-profile-1%2Favatar%2Fdisplay.webp',
|
|
13061
|
+
avatarSourceUrl: '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fai-profile-1%2Favatar%2Fsource.webp'
|
|
13062
|
+
}
|
|
13063
|
+
]);
|
|
13064
|
+
core.updateAiProfileAvatar.mockReturnValue({
|
|
13065
|
+
id: 'ai-profile-1',
|
|
13066
|
+
workspaceId: 'workspace-1',
|
|
13067
|
+
profileToken: 'tfp_codex_sync',
|
|
13068
|
+
name: 'Codex',
|
|
13069
|
+
username: 'codex',
|
|
13070
|
+
icon: 'Bot',
|
|
13071
|
+
color: '#ef6c00',
|
|
13072
|
+
avatarUrl: null,
|
|
13073
|
+
avatarSourceUrl: null,
|
|
13074
|
+
description: 'Builds product code',
|
|
13075
|
+
role: 'Implementer',
|
|
13076
|
+
provider: 'OpenAI',
|
|
13077
|
+
model: 'gpt-5.4',
|
|
13078
|
+
surfaceType: 'coding_tool',
|
|
13079
|
+
archivedAt: null,
|
|
13080
|
+
createdAt: '2026-03-01T00:00:00.000Z',
|
|
13081
|
+
updatedAt: '2026-04-19T17:00:00.000Z',
|
|
13082
|
+
lastActiveAt: null
|
|
13083
|
+
});
|
|
13084
|
+
const match = matchRoute('POST', '/api/taskforce/workspace/ai-profiles/avatar', routes);
|
|
13085
|
+
const cookie = createSessionTokenCookie('session-admin-avatar-ai-profile-clear', authConfig, {
|
|
13086
|
+
userId: 'admin-user',
|
|
13087
|
+
role: 'admin',
|
|
13088
|
+
workspaceId: 'workspace-1'
|
|
13089
|
+
});
|
|
13090
|
+
const req = createMockReq('POST', '/api/taskforce/workspace/ai-profiles/avatar', {
|
|
13091
|
+
profileId: 'ai-profile-1',
|
|
13092
|
+
avatarUrl: null,
|
|
13093
|
+
avatarSourceUrl: null
|
|
13094
|
+
}, {
|
|
13095
|
+
cookie,
|
|
13096
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
13097
|
+
'x-taskforce-role': 'admin',
|
|
13098
|
+
'x-taskforce-user-id': 'admin-user',
|
|
13099
|
+
'x-taskforce-workspace-id': 'workspace-1'
|
|
13100
|
+
});
|
|
13101
|
+
const res = createMockRes();
|
|
13102
|
+
await match?.handler(req, res, {});
|
|
13103
|
+
expect(core.updateAiProfileAvatar).toHaveBeenCalledWith({
|
|
13104
|
+
workspaceId: 'workspace-1',
|
|
13105
|
+
profileId: 'ai-profile-1',
|
|
13106
|
+
avatarUrl: null,
|
|
13107
|
+
avatarSourceUrl: null
|
|
13108
|
+
});
|
|
13109
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
13110
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
13111
|
+
expect(payload.success).toBe(true);
|
|
13112
|
+
expect(payload.profile).toEqual(expect.objectContaining({
|
|
13113
|
+
id: 'ai-profile-1',
|
|
13114
|
+
avatarUrl: null,
|
|
13115
|
+
avatarSourceUrl: null
|
|
13116
|
+
}));
|
|
13117
|
+
expect(payload.profile).not.toHaveProperty('profileToken');
|
|
13118
|
+
});
|
|
13119
|
+
it('POST /api/taskforce/workspace/ai-profiles/merge should redact profile tokens from admin responses', async () => {
|
|
13120
|
+
const authConfig = {
|
|
13121
|
+
runtimeMode: 'cloud',
|
|
13122
|
+
enabled: true,
|
|
13123
|
+
requiredForApi: true,
|
|
13124
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
13125
|
+
defaultRole: 'member',
|
|
13126
|
+
defaultWorkspaceId: 'default',
|
|
13127
|
+
sessionCookieName: 'taskforce_session',
|
|
13128
|
+
sessionSecret: 'test-secret',
|
|
13129
|
+
sessionTtlSeconds: 3600
|
|
13130
|
+
};
|
|
13131
|
+
routes = createRoutes(core, { authConfig });
|
|
13132
|
+
core.mergeAiProfiles.mockReturnValue({
|
|
13133
|
+
id: 'ai-profile-keep',
|
|
13134
|
+
workspaceId: 'workspace-1',
|
|
13135
|
+
profileToken: 'tfp_codex_sync',
|
|
13136
|
+
name: 'Codex',
|
|
13137
|
+
username: 'codex',
|
|
13138
|
+
icon: 'Bot',
|
|
13139
|
+
color: '#ef6c00',
|
|
13140
|
+
description: 'Builds product code',
|
|
13141
|
+
role: 'Implementer',
|
|
13142
|
+
provider: 'OpenAI',
|
|
13143
|
+
model: 'gpt-5.4',
|
|
13144
|
+
surfaceType: 'coding_tool',
|
|
13145
|
+
archivedAt: null,
|
|
13146
|
+
createdAt: '2026-03-01T00:00:00.000Z',
|
|
13147
|
+
updatedAt: '2026-04-19T17:00:00.000Z',
|
|
13148
|
+
lastActiveAt: null
|
|
13149
|
+
});
|
|
13150
|
+
const match = matchRoute('POST', '/api/taskforce/workspace/ai-profiles/merge', routes);
|
|
13151
|
+
const cookie = createSessionTokenCookie('session-admin-merge-ai-profile', authConfig, {
|
|
13152
|
+
userId: 'admin-user',
|
|
13153
|
+
role: 'admin',
|
|
13154
|
+
workspaceId: 'workspace-1'
|
|
13155
|
+
});
|
|
13156
|
+
const req = createMockReq('POST', '/api/taskforce/workspace/ai-profiles/merge', {
|
|
13157
|
+
keepId: 'ai-profile-keep',
|
|
13158
|
+
mergeId: 'ai-profile-merge'
|
|
13159
|
+
}, {
|
|
13160
|
+
cookie,
|
|
13161
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
13162
|
+
'x-taskforce-role': 'admin',
|
|
13163
|
+
'x-taskforce-user-id': 'admin-user',
|
|
13164
|
+
'x-taskforce-workspace-id': 'workspace-1'
|
|
13165
|
+
});
|
|
13166
|
+
const res = createMockRes();
|
|
13167
|
+
await match?.handler(req, res, {});
|
|
13168
|
+
expect(core.mergeAiProfiles).toHaveBeenCalledWith({
|
|
13169
|
+
workspaceId: 'workspace-1',
|
|
13170
|
+
keepId: 'ai-profile-keep',
|
|
13171
|
+
mergeId: 'ai-profile-merge'
|
|
13172
|
+
});
|
|
13173
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
13174
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
13175
|
+
expect(payload.profile).toEqual(expect.objectContaining({
|
|
13176
|
+
id: 'ai-profile-keep',
|
|
13177
|
+
name: 'Codex'
|
|
13178
|
+
}));
|
|
13179
|
+
expect(payload.profile).not.toHaveProperty('profileToken');
|
|
13180
|
+
});
|
|
13181
|
+
it('POST /api/taskforce/workspace/ai-profiles/archive should archive active profiles for workspace admins', async () => {
|
|
13182
|
+
const authConfig = {
|
|
13183
|
+
runtimeMode: 'cloud',
|
|
13184
|
+
enabled: true,
|
|
13185
|
+
requiredForApi: true,
|
|
13186
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
13187
|
+
defaultRole: 'member',
|
|
13188
|
+
defaultWorkspaceId: 'default',
|
|
13189
|
+
sessionCookieName: 'taskforce_session',
|
|
13190
|
+
sessionSecret: 'test-secret',
|
|
13191
|
+
sessionTtlSeconds: 3600
|
|
13192
|
+
};
|
|
13193
|
+
routes = createRoutes(core, { authConfig });
|
|
13194
|
+
core.archiveAiProfile.mockReturnValue({
|
|
13195
|
+
id: 'ai-profile-1',
|
|
13196
|
+
workspaceId: 'workspace-1',
|
|
13197
|
+
profileToken: 'tfp_codex_sync',
|
|
13198
|
+
name: 'Codex',
|
|
13199
|
+
username: 'codex',
|
|
13200
|
+
icon: 'Bot',
|
|
13201
|
+
color: '#ef6c00',
|
|
13202
|
+
description: 'Builds product code',
|
|
13203
|
+
role: 'Implementer',
|
|
13204
|
+
provider: 'OpenAI',
|
|
13205
|
+
model: 'gpt-5.4',
|
|
13206
|
+
surfaceType: 'coding_tool',
|
|
13207
|
+
archivedAt: '2026-04-19T17:00:00.000Z',
|
|
13208
|
+
archivedBy: 'admin-user',
|
|
13209
|
+
archivedReason: 'manual_archive',
|
|
13210
|
+
mergedIntoProfileId: null,
|
|
13211
|
+
createdAt: '2026-03-01T00:00:00.000Z',
|
|
13212
|
+
updatedAt: '2026-04-19T17:00:00.000Z',
|
|
13213
|
+
lastActiveAt: null
|
|
13214
|
+
});
|
|
13215
|
+
const match = matchRoute('POST', '/api/taskforce/workspace/ai-profiles/archive', routes);
|
|
13216
|
+
const cookie = createSessionTokenCookie('session-admin-archive-ai-profile', authConfig, {
|
|
13217
|
+
userId: 'admin-user',
|
|
13218
|
+
role: 'admin',
|
|
13219
|
+
workspaceId: 'workspace-1'
|
|
13220
|
+
});
|
|
13221
|
+
const req = createMockReq('POST', '/api/taskforce/workspace/ai-profiles/archive', {
|
|
13222
|
+
profileId: 'ai-profile-1',
|
|
13223
|
+
reason: 'manual_archive'
|
|
13224
|
+
}, {
|
|
13225
|
+
cookie,
|
|
13226
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
13227
|
+
'x-taskforce-role': 'admin',
|
|
13228
|
+
'x-taskforce-user-id': 'admin-user',
|
|
13229
|
+
'x-taskforce-workspace-id': 'workspace-1'
|
|
13230
|
+
});
|
|
13231
|
+
const res = createMockRes();
|
|
13232
|
+
await match?.handler(req, res, {});
|
|
13233
|
+
expect(core.archiveAiProfile).toHaveBeenCalledWith({
|
|
13234
|
+
workspaceId: 'workspace-1',
|
|
13235
|
+
profileId: 'ai-profile-1',
|
|
13236
|
+
archivedBy: 'admin-user',
|
|
13237
|
+
reason: 'manual_archive'
|
|
13238
|
+
});
|
|
13239
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
13240
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
13241
|
+
expect(payload.profile).toEqual(expect.objectContaining({
|
|
13242
|
+
id: 'ai-profile-1',
|
|
13243
|
+
archivedReason: 'manual_archive'
|
|
13244
|
+
}));
|
|
13245
|
+
expect(payload.profile).not.toHaveProperty('profileToken');
|
|
13246
|
+
});
|
|
13247
|
+
it('POST /api/taskforce/workspace/ai-profiles/archive should honor actual owner membership even when the request role is stale', async () => {
|
|
13248
|
+
const authConfig = {
|
|
13249
|
+
runtimeMode: 'cloud',
|
|
13250
|
+
enabled: true,
|
|
13251
|
+
requiredForApi: true,
|
|
13252
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
13253
|
+
defaultRole: 'member',
|
|
13254
|
+
defaultWorkspaceId: 'default',
|
|
13255
|
+
sessionCookieName: 'taskforce_session',
|
|
13256
|
+
sessionSecret: 'test-secret',
|
|
13257
|
+
sessionTtlSeconds: 3600
|
|
13258
|
+
};
|
|
13259
|
+
routes = createRoutes(core, { authConfig });
|
|
13260
|
+
core.listUserWorkspaces.mockReturnValue([
|
|
13261
|
+
{
|
|
13262
|
+
id: 'workspace-1',
|
|
13263
|
+
slug: 'workspace-1',
|
|
13264
|
+
name: 'Workspace 1',
|
|
13265
|
+
description: null,
|
|
13266
|
+
role: 'owner',
|
|
13267
|
+
status: 'active',
|
|
13268
|
+
betaAccess: true
|
|
13269
|
+
}
|
|
13270
|
+
]);
|
|
13271
|
+
core.listWorkspaceUsers.mockReturnValue([
|
|
13272
|
+
{
|
|
13273
|
+
userId: 'owner-user',
|
|
13274
|
+
email: 'owner@example.com',
|
|
13275
|
+
displayName: 'Owner User',
|
|
13276
|
+
role: 'owner',
|
|
13277
|
+
permissionMode: 'read-write',
|
|
13278
|
+
betaAccess: true,
|
|
13279
|
+
status: 'active',
|
|
13280
|
+
disabled: false
|
|
13281
|
+
}
|
|
13282
|
+
]);
|
|
13283
|
+
core.archiveAiProfile.mockReturnValue({
|
|
13284
|
+
id: 'ai-profile-1',
|
|
13285
|
+
workspaceId: 'workspace-1',
|
|
13286
|
+
profileToken: 'tfp_codex_sync',
|
|
13287
|
+
name: 'Codex',
|
|
13288
|
+
username: 'codex',
|
|
13289
|
+
icon: 'Bot',
|
|
13290
|
+
color: '#ef6c00',
|
|
13291
|
+
description: 'Builds product code',
|
|
13292
|
+
role: 'Implementer',
|
|
13293
|
+
provider: 'OpenAI',
|
|
13294
|
+
model: 'gpt-5.4',
|
|
13295
|
+
surfaceType: 'coding_tool',
|
|
13296
|
+
archivedAt: '2026-04-19T17:00:00.000Z',
|
|
13297
|
+
archivedBy: 'owner-user',
|
|
13298
|
+
archivedReason: 'manual_archive',
|
|
13299
|
+
mergedIntoProfileId: null,
|
|
13300
|
+
createdAt: '2026-03-01T00:00:00.000Z',
|
|
13301
|
+
updatedAt: '2026-04-19T17:00:00.000Z',
|
|
13302
|
+
lastActiveAt: null
|
|
13303
|
+
});
|
|
13304
|
+
const match = matchRoute('POST', '/api/taskforce/workspace/ai-profiles/archive', routes);
|
|
13305
|
+
const cookie = createSessionTokenCookie('session-owner-archive', authConfig, {
|
|
13306
|
+
userId: 'owner-user',
|
|
13307
|
+
role: 'member',
|
|
13308
|
+
workspaceId: 'workspace-1'
|
|
13309
|
+
});
|
|
13310
|
+
const req = createMockReq('POST', '/api/taskforce/workspace/ai-profiles/archive', {
|
|
13311
|
+
profileId: 'ai-profile-1'
|
|
13312
|
+
}, {
|
|
13313
|
+
cookie,
|
|
13314
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
13315
|
+
'x-taskforce-role': 'member',
|
|
13316
|
+
'x-taskforce-user-id': 'owner-user',
|
|
13317
|
+
'x-taskforce-workspace-id': 'workspace-1'
|
|
13318
|
+
});
|
|
13319
|
+
const res = createMockRes();
|
|
13320
|
+
await match?.handler(req, res, {});
|
|
13321
|
+
expect(core.archiveAiProfile).toHaveBeenCalledWith(expect.objectContaining({
|
|
13322
|
+
workspaceId: 'workspace-1',
|
|
13323
|
+
profileId: 'ai-profile-1',
|
|
13324
|
+
archivedBy: 'owner-user'
|
|
13325
|
+
}));
|
|
13326
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
13327
|
+
});
|
|
13328
|
+
it('POST /api/taskforce/workspace/ai-profiles/archive should surface open-task conflicts', async () => {
|
|
13329
|
+
const authConfig = {
|
|
13330
|
+
runtimeMode: 'cloud',
|
|
13331
|
+
enabled: true,
|
|
13332
|
+
requiredForApi: true,
|
|
13333
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
13334
|
+
defaultRole: 'member',
|
|
13335
|
+
defaultWorkspaceId: 'default',
|
|
13336
|
+
sessionCookieName: 'taskforce_session',
|
|
13337
|
+
sessionSecret: 'test-secret',
|
|
13338
|
+
sessionTtlSeconds: 3600
|
|
13339
|
+
};
|
|
13340
|
+
routes = createRoutes(core, { authConfig });
|
|
13341
|
+
const error = new Error('This AI profile still owns open tasks. Reassign or close them before removing it from the roster.');
|
|
13342
|
+
error.statusCode = 409;
|
|
13343
|
+
error.details = { workspaceId: 'workspace-1', profileId: 'ai-profile-1', openTaskCount: 2 };
|
|
13344
|
+
core.archiveAiProfile.mockImplementation(() => {
|
|
13345
|
+
throw error;
|
|
13346
|
+
});
|
|
13347
|
+
const match = matchRoute('POST', '/api/taskforce/workspace/ai-profiles/archive', routes);
|
|
13348
|
+
const cookie = createSessionTokenCookie('session-admin-archive-ai-profile-conflict', authConfig, {
|
|
13349
|
+
userId: 'admin-user',
|
|
13350
|
+
role: 'admin',
|
|
13351
|
+
workspaceId: 'workspace-1'
|
|
13352
|
+
});
|
|
13353
|
+
const req = createMockReq('POST', '/api/taskforce/workspace/ai-profiles/archive', {
|
|
13354
|
+
profileId: 'ai-profile-1',
|
|
13355
|
+
reason: 'manual_archive'
|
|
13356
|
+
}, {
|
|
13357
|
+
cookie,
|
|
13358
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
13359
|
+
'x-taskforce-role': 'admin',
|
|
13360
|
+
'x-taskforce-user-id': 'admin-user',
|
|
13361
|
+
'x-taskforce-workspace-id': 'workspace-1'
|
|
13362
|
+
});
|
|
13363
|
+
const res = createMockRes();
|
|
13364
|
+
await match?.handler(req, res, {});
|
|
13365
|
+
expect(res.writeHead).toHaveBeenCalledWith(409, expect.any(Object));
|
|
13366
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
13367
|
+
expect(payload.error).toContain('This AI profile still owns open tasks');
|
|
13368
|
+
expect(payload.details).toEqual({
|
|
13369
|
+
workspaceId: 'workspace-1',
|
|
13370
|
+
profileId: 'ai-profile-1',
|
|
13371
|
+
openTaskCount: 2
|
|
13372
|
+
});
|
|
10995
13373
|
});
|
|
10996
13374
|
it('GET /api/taskforce/admin/workspace-audit-logs should return events for workspace admins', async () => {
|
|
10997
13375
|
const authConfig = {
|
|
@@ -11090,13 +13468,13 @@ describe('Server Routes', () => {
|
|
|
11090
13468
|
billing: {
|
|
11091
13469
|
onboardingPolicy: {
|
|
11092
13470
|
mode: 'auto_grant_free',
|
|
11093
|
-
|
|
13471
|
+
planId: 'starter'
|
|
11094
13472
|
}
|
|
11095
13473
|
}
|
|
11096
13474
|
});
|
|
11097
13475
|
core.getOnboardingPolicy.mockReturnValue({
|
|
11098
13476
|
mode: 'auto_grant_free',
|
|
11099
|
-
|
|
13477
|
+
planId: 'starter'
|
|
11100
13478
|
});
|
|
11101
13479
|
const match = matchRoute('GET', '/api/taskforce/admin/system-settings', routes);
|
|
11102
13480
|
const req = createMockReq('GET', '/api/taskforce/admin/system-settings', undefined, { 'x-taskforce-role': 'admin' });
|
|
@@ -11104,9 +13482,9 @@ describe('Server Routes', () => {
|
|
|
11104
13482
|
await match?.handler(req, res, {});
|
|
11105
13483
|
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
11106
13484
|
expect(payload.status.onboardingPolicyMode).toBe('auto_grant_free');
|
|
11107
|
-
expect(payload.status.
|
|
13485
|
+
expect(payload.status.onboardingPlanId).toBe('starter');
|
|
11108
13486
|
expect(payload.settings.onboardingPolicyMode).toBe('auto_grant_free');
|
|
11109
|
-
expect(payload.settings.
|
|
13487
|
+
expect(payload.settings.onboardingPlanId).toBe('starter');
|
|
11110
13488
|
});
|
|
11111
13489
|
it('POST /api/taskforce/admin/system-settings should persist onboarding policy fields', async () => {
|
|
11112
13490
|
const match = matchRoute('POST', '/api/taskforce/admin/system-settings', routes);
|
|
@@ -11118,13 +13496,13 @@ describe('Server Routes', () => {
|
|
|
11118
13496
|
authRateLimitMaxRequests: 10,
|
|
11119
13497
|
authRateLimitWindowMs: 900000,
|
|
11120
13498
|
onboardingPolicyMode: 'auto_grant_free',
|
|
11121
|
-
|
|
13499
|
+
onboardingPlanId: 'starter'
|
|
11122
13500
|
}, { 'x-taskforce-role': 'admin' });
|
|
11123
13501
|
const res = createMockRes();
|
|
11124
13502
|
await match?.handler(req, res, {});
|
|
11125
13503
|
expect(core.validateOnboardingPolicy).toHaveBeenCalledWith({
|
|
11126
13504
|
mode: 'auto_grant_free',
|
|
11127
|
-
|
|
13505
|
+
planId: 'starter'
|
|
11128
13506
|
});
|
|
11129
13507
|
expect(core.updateGlobalSettings).toHaveBeenCalledWith({
|
|
11130
13508
|
auth: {
|
|
@@ -11138,7 +13516,7 @@ describe('Server Routes', () => {
|
|
|
11138
13516
|
billing: {
|
|
11139
13517
|
onboardingPolicy: {
|
|
11140
13518
|
mode: 'auto_grant_free',
|
|
11141
|
-
|
|
13519
|
+
planId: 'starter'
|
|
11142
13520
|
}
|
|
11143
13521
|
}
|
|
11144
13522
|
});
|
|
@@ -11146,7 +13524,7 @@ describe('Server Routes', () => {
|
|
|
11146
13524
|
it('POST /api/taskforce/admin/system-settings should preserve onboarding policy when onboarding fields are omitted', async () => {
|
|
11147
13525
|
core.getOnboardingPolicy.mockReturnValue({
|
|
11148
13526
|
mode: 'auto_grant_free',
|
|
11149
|
-
|
|
13527
|
+
planId: 'starter'
|
|
11150
13528
|
});
|
|
11151
13529
|
const match = matchRoute('POST', '/api/taskforce/admin/system-settings', routes);
|
|
11152
13530
|
const req = createMockReq('POST', '/api/taskforce/admin/system-settings', {
|
|
@@ -11161,7 +13539,7 @@ describe('Server Routes', () => {
|
|
|
11161
13539
|
await match?.handler(req, res, {});
|
|
11162
13540
|
expect(core.validateOnboardingPolicy).toHaveBeenCalledWith({
|
|
11163
13541
|
mode: 'auto_grant_free',
|
|
11164
|
-
|
|
13542
|
+
planId: 'starter'
|
|
11165
13543
|
});
|
|
11166
13544
|
expect(core.updateGlobalSettings).toHaveBeenCalledWith({
|
|
11167
13545
|
auth: {
|
|
@@ -11175,7 +13553,7 @@ describe('Server Routes', () => {
|
|
|
11175
13553
|
billing: {
|
|
11176
13554
|
onboardingPolicy: {
|
|
11177
13555
|
mode: 'auto_grant_free',
|
|
11178
|
-
|
|
13556
|
+
planId: 'starter'
|
|
11179
13557
|
}
|
|
11180
13558
|
}
|
|
11181
13559
|
});
|
|
@@ -11184,7 +13562,11 @@ describe('Server Routes', () => {
|
|
|
11184
13562
|
core.getGlobalSettings.mockReturnValue({
|
|
11185
13563
|
site: {
|
|
11186
13564
|
showRunTaskforceLocally: false,
|
|
11187
|
-
pricingPageEnabled: false
|
|
13565
|
+
pricingPageEnabled: false,
|
|
13566
|
+
animatedBackgroundEnabled: true,
|
|
13567
|
+
siteTheme: 'light',
|
|
13568
|
+
pricingPageTitle: 'Choose Your Plan',
|
|
13569
|
+
pricingPageDescription: 'Compare plans and pick the right one.'
|
|
11188
13570
|
}
|
|
11189
13571
|
});
|
|
11190
13572
|
const match = matchRoute('GET', '/api/taskforce/admin/site-settings', routes);
|
|
@@ -11194,14 +13576,26 @@ describe('Server Routes', () => {
|
|
|
11194
13576
|
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
11195
13577
|
expect(payload.status.showRunTaskforceLocally).toBe(false);
|
|
11196
13578
|
expect(payload.status.pricingPageEnabled).toBe(false);
|
|
13579
|
+
expect(payload.status.animatedBackgroundEnabled).toBe(true);
|
|
13580
|
+
expect(payload.status.siteTheme).toBe('light');
|
|
13581
|
+
expect(payload.status.pricingPageTitle).toBe('Choose Your Plan');
|
|
13582
|
+
expect(payload.status.pricingPageDescription).toBe('Compare plans and pick the right one.');
|
|
11197
13583
|
expect(payload.settings.showRunTaskforceLocally).toBe(false);
|
|
11198
13584
|
expect(payload.settings.pricingPageEnabled).toBe(false);
|
|
13585
|
+
expect(payload.settings.animatedBackgroundEnabled).toBe(true);
|
|
13586
|
+
expect(payload.settings.siteTheme).toBe('light');
|
|
13587
|
+
expect(payload.settings.pricingPageTitle).toBe('Choose Your Plan');
|
|
13588
|
+
expect(payload.settings.pricingPageDescription).toBe('Compare plans and pick the right one.');
|
|
11199
13589
|
});
|
|
11200
13590
|
it('GET /api/taskforce/admin/site_settings should expose effective website settings for legacy admin bundles', async () => {
|
|
11201
13591
|
core.getGlobalSettings.mockReturnValue({
|
|
11202
13592
|
site: {
|
|
11203
13593
|
showRunTaskforceLocally: false,
|
|
11204
|
-
pricingPageEnabled: false
|
|
13594
|
+
pricingPageEnabled: false,
|
|
13595
|
+
animatedBackgroundEnabled: true,
|
|
13596
|
+
siteTheme: 'light',
|
|
13597
|
+
pricingPageTitle: 'Choose Your Plan',
|
|
13598
|
+
pricingPageDescription: 'Compare plans and pick the right one.'
|
|
11205
13599
|
}
|
|
11206
13600
|
});
|
|
11207
13601
|
const match = matchRoute('GET', '/api/taskforce/admin/site_settings', routes);
|
|
@@ -11211,21 +13605,37 @@ describe('Server Routes', () => {
|
|
|
11211
13605
|
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
11212
13606
|
expect(payload.status.showRunTaskforceLocally).toBe(false);
|
|
11213
13607
|
expect(payload.status.pricingPageEnabled).toBe(false);
|
|
13608
|
+
expect(payload.status.animatedBackgroundEnabled).toBe(true);
|
|
13609
|
+
expect(payload.status.siteTheme).toBe('light');
|
|
13610
|
+
expect(payload.status.pricingPageTitle).toBe('Choose Your Plan');
|
|
13611
|
+
expect(payload.status.pricingPageDescription).toBe('Compare plans and pick the right one.');
|
|
11214
13612
|
expect(payload.settings.showRunTaskforceLocally).toBe(false);
|
|
11215
13613
|
expect(payload.settings.pricingPageEnabled).toBe(false);
|
|
13614
|
+
expect(payload.settings.animatedBackgroundEnabled).toBe(true);
|
|
13615
|
+
expect(payload.settings.siteTheme).toBe('light');
|
|
13616
|
+
expect(payload.settings.pricingPageTitle).toBe('Choose Your Plan');
|
|
13617
|
+
expect(payload.settings.pricingPageDescription).toBe('Compare plans and pick the right one.');
|
|
11216
13618
|
});
|
|
11217
13619
|
it('POST /api/taskforce/admin/site-settings should persist website settings', async () => {
|
|
11218
13620
|
const match = matchRoute('POST', '/api/taskforce/admin/site-settings', routes);
|
|
11219
13621
|
const req = createMockReq('POST', '/api/taskforce/admin/site-settings', {
|
|
11220
13622
|
showRunTaskforceLocally: false,
|
|
11221
|
-
pricingPageEnabled: false
|
|
13623
|
+
pricingPageEnabled: false,
|
|
13624
|
+
animatedBackgroundEnabled: true,
|
|
13625
|
+
siteTheme: 'light',
|
|
13626
|
+
pricingPageTitle: 'Choose Your Plan',
|
|
13627
|
+
pricingPageDescription: 'Compare plans and pick the right one.'
|
|
11222
13628
|
}, { 'x-taskforce-role': 'admin' });
|
|
11223
13629
|
const res = createMockRes();
|
|
11224
13630
|
await match?.handler(req, res, {});
|
|
11225
13631
|
expect(core.updateGlobalSettings).toHaveBeenCalledWith({
|
|
11226
13632
|
site: {
|
|
11227
13633
|
showRunTaskforceLocally: false,
|
|
11228
|
-
pricingPageEnabled: false
|
|
13634
|
+
pricingPageEnabled: false,
|
|
13635
|
+
animatedBackgroundEnabled: true,
|
|
13636
|
+
siteTheme: 'light',
|
|
13637
|
+
pricingPageTitle: 'Choose Your Plan',
|
|
13638
|
+
pricingPageDescription: 'Compare plans and pick the right one.'
|
|
11229
13639
|
}
|
|
11230
13640
|
});
|
|
11231
13641
|
});
|
|
@@ -11233,14 +13643,22 @@ describe('Server Routes', () => {
|
|
|
11233
13643
|
const match = matchRoute('POST', '/api/taskforce/admin/site_settings', routes);
|
|
11234
13644
|
const req = createMockReq('POST', '/api/taskforce/admin/site_settings', {
|
|
11235
13645
|
showRunTaskforceLocally: false,
|
|
11236
|
-
pricingPageEnabled: false
|
|
13646
|
+
pricingPageEnabled: false,
|
|
13647
|
+
animatedBackgroundEnabled: true,
|
|
13648
|
+
siteTheme: 'light',
|
|
13649
|
+
pricingPageTitle: 'Choose Your Plan',
|
|
13650
|
+
pricingPageDescription: 'Compare plans and pick the right one.'
|
|
11237
13651
|
}, { 'x-taskforce-role': 'admin' });
|
|
11238
13652
|
const res = createMockRes();
|
|
11239
13653
|
await match?.handler(req, res, {});
|
|
11240
13654
|
expect(core.updateGlobalSettings).toHaveBeenCalledWith({
|
|
11241
13655
|
site: {
|
|
11242
13656
|
showRunTaskforceLocally: false,
|
|
11243
|
-
pricingPageEnabled: false
|
|
13657
|
+
pricingPageEnabled: false,
|
|
13658
|
+
animatedBackgroundEnabled: true,
|
|
13659
|
+
siteTheme: 'light',
|
|
13660
|
+
pricingPageTitle: 'Choose Your Plan',
|
|
13661
|
+
pricingPageDescription: 'Compare plans and pick the right one.'
|
|
11244
13662
|
}
|
|
11245
13663
|
});
|
|
11246
13664
|
});
|
|
@@ -11248,7 +13666,11 @@ describe('Server Routes', () => {
|
|
|
11248
13666
|
core.getGlobalSettings.mockReturnValue({
|
|
11249
13667
|
site: {
|
|
11250
13668
|
showRunTaskforceLocally: false,
|
|
11251
|
-
pricingPageEnabled: false
|
|
13669
|
+
pricingPageEnabled: false,
|
|
13670
|
+
animatedBackgroundEnabled: true,
|
|
13671
|
+
siteTheme: 'light',
|
|
13672
|
+
pricingPageTitle: 'Choose Your Plan',
|
|
13673
|
+
pricingPageDescription: 'Compare plans and pick the right one.'
|
|
11252
13674
|
}
|
|
11253
13675
|
});
|
|
11254
13676
|
const match = matchRoute('GET', '/api/taskforce/public/site-config', routes);
|
|
@@ -11263,7 +13685,11 @@ describe('Server Routes', () => {
|
|
|
11263
13685
|
success: true,
|
|
11264
13686
|
config: {
|
|
11265
13687
|
showRunTaskforceLocally: false,
|
|
11266
|
-
pricingPageEnabled: false
|
|
13688
|
+
pricingPageEnabled: false,
|
|
13689
|
+
animatedBackgroundEnabled: true,
|
|
13690
|
+
siteTheme: 'light',
|
|
13691
|
+
pricingPageTitle: 'Choose Your Plan',
|
|
13692
|
+
pricingPageDescription: 'Compare plans and pick the right one.'
|
|
11267
13693
|
}
|
|
11268
13694
|
});
|
|
11269
13695
|
});
|
|
@@ -11271,7 +13697,11 @@ describe('Server Routes', () => {
|
|
|
11271
13697
|
core.getGlobalSettings.mockReturnValue({
|
|
11272
13698
|
site: {
|
|
11273
13699
|
showRunTaskforceLocally: false,
|
|
11274
|
-
pricingPageEnabled: false
|
|
13700
|
+
pricingPageEnabled: false,
|
|
13701
|
+
animatedBackgroundEnabled: true,
|
|
13702
|
+
siteTheme: 'light',
|
|
13703
|
+
pricingPageTitle: 'Choose Your Plan',
|
|
13704
|
+
pricingPageDescription: 'Compare plans and pick the right one.'
|
|
11275
13705
|
}
|
|
11276
13706
|
});
|
|
11277
13707
|
const match = matchRoute('GET', '/api/taskforce/public/site-config-script', routes);
|
|
@@ -11284,17 +13714,20 @@ describe('Server Routes', () => {
|
|
|
11284
13714
|
}));
|
|
11285
13715
|
expect(res.end.mock.calls[0][0]).toContain('window.__TASKFORCE_SITE_CONFIG__ = ');
|
|
11286
13716
|
expect(res.end.mock.calls[0][0]).toContain('"pricingPageEnabled":false');
|
|
13717
|
+
expect(res.end.mock.calls[0][0]).toContain('"animatedBackgroundEnabled":true');
|
|
13718
|
+
expect(res.end.mock.calls[0][0]).toContain('"siteTheme":"light"');
|
|
13719
|
+
expect(res.end.mock.calls[0][0]).toContain('"pricingPageTitle":"Choose Your Plan"');
|
|
11287
13720
|
});
|
|
11288
13721
|
it('POST /api/taskforce/admin/system-settings should reject invalid onboarding settings', async () => {
|
|
11289
13722
|
core.validateOnboardingPolicy.mockReturnValueOnce({
|
|
11290
13723
|
valid: false,
|
|
11291
|
-
code: '
|
|
11292
|
-
message: 'An onboarding plan
|
|
13724
|
+
code: 'ONBOARDING_PLAN_REQUIRED',
|
|
13725
|
+
message: 'An onboarding plan is required for the selected onboarding mode.'
|
|
11293
13726
|
});
|
|
11294
13727
|
const match = matchRoute('POST', '/api/taskforce/admin/system-settings', routes);
|
|
11295
13728
|
const req = createMockReq('POST', '/api/taskforce/admin/system-settings', {
|
|
11296
13729
|
onboardingPolicyMode: 'auto_grant_free',
|
|
11297
|
-
|
|
13730
|
+
onboardingPlanId: null
|
|
11298
13731
|
}, { 'x-taskforce-role': 'admin' });
|
|
11299
13732
|
const res = createMockRes();
|
|
11300
13733
|
await match?.handler(req, res, {});
|
|
@@ -11460,6 +13893,94 @@ describe('Server Routes', () => {
|
|
|
11460
13893
|
]);
|
|
11461
13894
|
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
11462
13895
|
});
|
|
13896
|
+
it('GET /api/taskforce/admin/orphaned-assets should not list AI profile avatar assets still referenced by profiles', async () => {
|
|
13897
|
+
const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
|
|
13898
|
+
const avatarStorageKey = 'workspaces/workspace-1/assets/images/ai-profiles/ai-profile-codex/avatar/asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-cropped.webp';
|
|
13899
|
+
core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
|
|
13900
|
+
core.getTenantId = vi.fn().mockReturnValue('default');
|
|
13901
|
+
core.listAiProfiles.mockReturnValueOnce([
|
|
13902
|
+
{
|
|
13903
|
+
id: 'ai-profile-codex',
|
|
13904
|
+
workspaceId: 'workspace-1',
|
|
13905
|
+
name: 'Codex',
|
|
13906
|
+
avatarUrl: `/api/taskforce/context/${encodeURIComponent(avatarStorageKey)}`,
|
|
13907
|
+
avatarSourceUrl: null,
|
|
13908
|
+
createdAt: '2026-03-18T17:00:00.000Z',
|
|
13909
|
+
updatedAt: '2026-03-18T17:05:00.000Z'
|
|
13910
|
+
}
|
|
13911
|
+
]);
|
|
13912
|
+
store.upsertAsset({
|
|
13913
|
+
assetId: 'asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
|
13914
|
+
workspaceId: 'workspace-1',
|
|
13915
|
+
kind: 'image',
|
|
13916
|
+
originalFilename: 'avatar.webp',
|
|
13917
|
+
mimeType: 'image/webp',
|
|
13918
|
+
storageProvider: 'r2',
|
|
13919
|
+
storageKey: avatarStorageKey,
|
|
13920
|
+
contentSha256: 'sha-avatar',
|
|
13921
|
+
sizeBytes: 42,
|
|
13922
|
+
createdAt: '2026-03-18T17:00:00.000Z',
|
|
13923
|
+
updatedAt: '2026-03-18T17:05:00.000Z'
|
|
13924
|
+
});
|
|
13925
|
+
routes = createRoutes(core);
|
|
13926
|
+
const match = matchRoute('GET', '/api/taskforce/admin/orphaned-assets', routes);
|
|
13927
|
+
const req = createMockReq('GET', '/api/taskforce/admin/orphaned-assets?workspaceId=workspace-1', undefined, {
|
|
13928
|
+
'x-taskforce-role': 'admin',
|
|
13929
|
+
'x-taskforce-workspace-id': 'workspace-1'
|
|
13930
|
+
});
|
|
13931
|
+
const res = createMockRes();
|
|
13932
|
+
await match?.handler(req, res, {});
|
|
13933
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
13934
|
+
expect(payload.success).toBe(true);
|
|
13935
|
+
expect(payload.assets).toEqual([]);
|
|
13936
|
+
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
13937
|
+
});
|
|
13938
|
+
it('POST /api/taskforce/admin/orphaned-assets/delete should reject AI profile avatar assets still referenced by profiles', async () => {
|
|
13939
|
+
const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
|
|
13940
|
+
const avatarStorageKey = 'workspaces/workspace-1/assets/images/ai-profiles/ai-profile-codex/avatar/asset-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-cropped.webp';
|
|
13941
|
+
core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
|
|
13942
|
+
core.getTenantId = vi.fn().mockReturnValue('default');
|
|
13943
|
+
core.listAiProfiles.mockReturnValueOnce([
|
|
13944
|
+
{
|
|
13945
|
+
id: 'ai-profile-codex',
|
|
13946
|
+
workspaceId: 'workspace-1',
|
|
13947
|
+
name: 'Codex',
|
|
13948
|
+
avatarUrl: `/api/taskforce/context/${encodeURIComponent(avatarStorageKey)}`,
|
|
13949
|
+
avatarSourceUrl: null,
|
|
13950
|
+
createdAt: '2026-03-18T17:00:00.000Z',
|
|
13951
|
+
updatedAt: '2026-03-18T17:05:00.000Z'
|
|
13952
|
+
}
|
|
13953
|
+
]);
|
|
13954
|
+
store.upsertAsset({
|
|
13955
|
+
assetId: 'asset-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
|
13956
|
+
workspaceId: 'workspace-1',
|
|
13957
|
+
kind: 'image',
|
|
13958
|
+
originalFilename: 'avatar.webp',
|
|
13959
|
+
mimeType: 'image/webp',
|
|
13960
|
+
storageProvider: 'r2',
|
|
13961
|
+
storageKey: avatarStorageKey,
|
|
13962
|
+
contentSha256: 'sha-avatar',
|
|
13963
|
+
sizeBytes: 42,
|
|
13964
|
+
createdAt: '2026-03-18T17:00:00.000Z',
|
|
13965
|
+
updatedAt: '2026-03-18T17:05:00.000Z'
|
|
13966
|
+
});
|
|
13967
|
+
routes = createRoutes(core);
|
|
13968
|
+
const match = matchRoute('POST', '/api/taskforce/admin/orphaned-assets/delete', routes);
|
|
13969
|
+
const req = createMockReq('POST', '/api/taskforce/admin/orphaned-assets/delete', {
|
|
13970
|
+
assetId: 'asset-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
|
13971
|
+
workspaceId: 'workspace-1'
|
|
13972
|
+
}, {
|
|
13973
|
+
'x-taskforce-role': 'admin',
|
|
13974
|
+
'x-taskforce-workspace-id': 'workspace-1'
|
|
13975
|
+
});
|
|
13976
|
+
const res = createMockRes();
|
|
13977
|
+
await match?.handler(req, res, {});
|
|
13978
|
+
expect(res.writeHead).toHaveBeenCalledWith(409, expect.any(Object));
|
|
13979
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
13980
|
+
expect(payload.error).toMatch(/referenced by an AI profile avatar/i);
|
|
13981
|
+
expect(store.get('asset-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 'workspace-1')?.deletedAt).toBeNull();
|
|
13982
|
+
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
13983
|
+
});
|
|
11463
13984
|
it('POST /api/taskforce/admin/object-storage-settings should persist sanitized values', async () => {
|
|
11464
13985
|
const match = matchRoute('POST', '/api/taskforce/admin/object-storage-settings', routes);
|
|
11465
13986
|
const req = createMockReq('POST', '/api/taskforce/admin/object-storage-settings', {
|