@taskforcehq/taskforce 0.3.305 → 0.3.306
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Taskforce.module.css +295 -89
- package/dist/TaskforceCore.js +215 -43
- package/dist/TaskforceCore.test.js +433 -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/context/ContextAttachmentManager.d.ts +14 -0
- package/dist/components/context/ContextAttachmentManager.js +549 -0
- package/dist/components/features/AgentsModule.d.ts +10 -1
- package/dist/components/features/AgentsModule.js +260 -15
- package/dist/components/features/AgentsModule.test.js +255 -5
- package/dist/components/features/DocumentViewer.d.ts +5 -1
- package/dist/components/features/DocumentViewer.js +60 -3
- package/dist/components/features/DocumentWorkspace.js +1 -1
- package/dist/components/features/DocumentWorkspace.test.js +33 -0
- package/dist/components/features/documentWorkspaceModel.d.ts +1 -1
- package/dist/components/task/TaskCard.js +19 -28
- package/dist/components/task/TaskCard.test.js +38 -3
- package/dist/components/task/TaskContextUpload.js +4 -526
- package/dist/components/task/TaskForm.js +85 -90
- 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 -13
- package/dist/components/task/TaskKanban.test.js +36 -0
- package/dist/components/ui/EditableAvatarButton.d.ts +18 -0
- package/dist/components/ui/EditableAvatarButton.js +18 -0
- package/dist/components/views/PlanComparisonPage.d.ts +9 -1
- package/dist/components/views/PlanComparisonPage.js +70 -14
- package/dist/components/views/PlanComparisonPage.test.js +238 -0
- package/dist/components/views/PlansPage.js +127 -36
- package/dist/components/views/PlansPage.test.js +294 -12
- package/dist/components/views/StandaloneLayout.js +283 -533
- package/dist/components/views/panels/PlanningDrawer.d.ts +8 -1
- package/dist/components/views/panels/PlanningDrawer.js +18 -11
- package/dist/components/views/panels/PlanningDrawer.test.d.ts +1 -0
- package/dist/components/views/panels/PlanningDrawer.test.js +141 -0
- package/dist/components/views/planningScope.d.ts +20 -0
- package/dist/components/views/planningScope.js +25 -0
- package/dist/components/views/planningScope.test.d.ts +1 -0
- package/dist/components/views/planningScope.test.js +60 -0
- package/dist/components/views/standalone/modals/AccountHubModal.d.ts +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/core/AiProfileService.d.ts +39 -2
- package/dist/core/AiProfileService.js +639 -73
- package/dist/core/AiProfileService.test.js +19 -1
- package/dist/core/AiProfiles.test.js +566 -7
- package/dist/core/AttachmentLinkService.js +57 -15
- package/dist/core/AuthIdentityService.d.ts +107 -0
- package/dist/core/AuthIdentityService.js +284 -0
- package/dist/core/AuthTokenService.d.ts +4 -0
- package/dist/core/AuthTokenService.js +34 -6
- package/dist/core/AuthTokenService.test.js +3 -3
- package/dist/core/EntitlementsPolicy.test.js +2 -2
- package/dist/core/GlobalSettingsService.js +115 -6
- 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 +52 -0
- 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/TaskAttachmentsCanonical.test.js +51 -1
- package/dist/core/Taskforce.d.ts +225 -14
- package/dist/core/Taskforce.js +765 -192
- package/dist/core/Taskforce.listTasksSlim.test.d.ts +1 -0
- package/dist/core/Taskforce.listTasksSlim.test.js +91 -0
- package/dist/core/UserProfileAvatarDrafts.test.js +30 -3
- package/dist/core/WorkspaceLifecycleService.js +0 -5
- package/dist/core/WorkspacePermissions.test.js +25 -1
- package/dist/core/WorkspacePlanMode.test.js +117 -16
- package/dist/core/shared.d.ts +1 -1
- package/dist/core/shared.js +5 -1
- package/dist/core/types.d.ts +68 -2
- package/dist/hooks/auth/useTaskforceAuthBootstrap.js +26 -1
- package/dist/hooks/sync/bootstrap.js +2 -0
- package/dist/hooks/sync/controlPlane.d.ts +37 -0
- package/dist/hooks/sync/controlPlane.js +78 -0
- package/dist/hooks/sync/controlPlane.test.d.ts +1 -0
- package/dist/hooks/sync/controlPlane.test.js +121 -0
- package/dist/hooks/sync/lifecyclePolicy.d.ts +45 -0
- package/dist/hooks/sync/lifecyclePolicy.js +93 -0
- package/dist/hooks/sync/lifecyclePolicy.test.d.ts +1 -0
- package/dist/hooks/sync/lifecyclePolicy.test.js +124 -0
- package/dist/hooks/sync/orchestratorShared.d.ts +10 -1
- package/dist/hooks/sync/orchestratorShared.js +68 -47
- package/dist/hooks/sync/orchestratorShared.test.js +50 -1
- package/dist/hooks/sync/pullLifecycle.d.ts +31 -0
- package/dist/hooks/sync/pullLifecycle.js +24 -0
- package/dist/hooks/sync/pullLifecycle.test.d.ts +1 -0
- package/dist/hooks/sync/pullLifecycle.test.js +80 -0
- package/dist/hooks/sync/recovery.d.ts +16 -2
- package/dist/hooks/sync/recovery.js +171 -53
- package/dist/hooks/sync/recovery.test.d.ts +1 -0
- package/dist/hooks/sync/recovery.test.js +292 -0
- package/dist/hooks/sync/transfers.d.ts +15 -3
- package/dist/hooks/sync/transfers.js +179 -62
- package/dist/hooks/sync/transfers.test.d.ts +1 -0
- package/dist/hooks/sync/transfers.test.js +396 -0
- package/dist/hooks/sync/useRecentSyncEvents.d.ts +21 -0
- package/dist/hooks/sync/useRecentSyncEvents.js +92 -0
- package/dist/hooks/sync/useRecentSyncEvents.test.d.ts +1 -0
- package/dist/hooks/sync/useRecentSyncEvents.test.js +124 -0
- package/dist/hooks/sync/useSyncStatusActions.d.ts +30 -0
- package/dist/hooks/sync/useSyncStatusActions.js +88 -0
- package/dist/hooks/sync/useSyncStatusActions.test.d.ts +1 -0
- package/dist/hooks/sync/useSyncStatusActions.test.js +133 -0
- package/dist/hooks/sync/useSyncStatusControls.d.ts +25 -0
- package/dist/hooks/sync/useSyncStatusControls.js +60 -0
- package/dist/hooks/sync/useSyncStatusControls.test.d.ts +1 -0
- package/dist/hooks/sync/useSyncStatusControls.test.js +88 -0
- package/dist/hooks/useSyncOrchestrator.aiProfiles.test.js +12 -0
- package/dist/hooks/useSyncOrchestrator.d.ts +9 -2
- package/dist/hooks/useSyncOrchestrator.js +359 -178
- 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 +60 -2
- package/dist/hooks/useTaskforce.js +549 -240
- package/dist/hooks/useTaskforce.runtime-routing.test.d.ts +1 -0
- package/dist/hooks/useTaskforce.runtime-routing.test.js +152 -0
- package/dist/hooks/useTaskforce.sync-behavior.test.js +2380 -212
- package/dist/hooks/useWorkspaceSyncController.d.ts +1 -0
- package/dist/hooks/useWorkspaceSyncController.js +6 -1
- package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.d.ts +16 -0
- package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +316 -35
- package/dist/localization/locales/en-US.d.ts +0 -1
- package/dist/localization/locales/en-US.js +0 -1
- package/dist/localization/locales/es-419.js +0 -1
- package/dist/localization/locales/pt-BR.js +0 -1
- package/dist/mcp/documentAssetRegistrar.js +3 -3
- package/dist/mcp/runtime.d.ts +2 -0
- package/dist/mcp/runtime.js +131 -64
- package/dist/mcp/runtime.test.js +291 -12
- package/dist/migrations/taskSchemaMigrations.d.ts +1 -1
- package/dist/migrations/taskSchemaMigrations.js +171 -61
- package/dist/migrations/taskSchemaMigrations.test.js +15 -0
- package/dist/resources/templates/workflow-sources/workflowDocs.mjs +6 -6
- package/dist/resources/templates/workflows/collaborate.yaml +1 -1
- package/dist/resources/templates/workflows/evaluate.yaml +2 -2
- package/dist/resources/templates/workflows/plan.yaml +1 -1
- package/dist/resources/templates/workflows/review.yaml +2 -2
- package/dist/server/annotatedAttachmentsRoutes.test.js +3 -3
- package/dist/server/auth/providers/apple.d.ts +33 -0
- package/dist/server/auth/providers/apple.js +82 -0
- package/dist/server/auth/providers/appleClientSecret.d.ts +37 -0
- package/dist/server/auth/providers/appleClientSecret.js +65 -0
- package/dist/server/auth/providers/github.d.ts +26 -0
- package/dist/server/auth/providers/github.js +86 -0
- package/dist/server/auth/providers/google.d.ts +21 -0
- package/dist/server/auth/providers/google.js +56 -0
- package/dist/server/auth/providers/types.d.ts +21 -0
- package/dist/server/auth/providers/types.js +1 -0
- package/dist/server/auth.d.ts +2 -0
- package/dist/server/auth.js +6 -3
- package/dist/server/documentReviewRoutes.test.js +36 -3
- package/dist/server/index.cookieProxy.test.js +1 -0
- package/dist/server/index.d.ts +12 -0
- package/dist/server/index.js +120 -9
- package/dist/server/index.rateLimit.test.js +3 -0
- package/dist/server/index.test.js +106 -3
- package/dist/server/routes/admin.d.ts +3 -0
- package/dist/server/routes/admin.js +272 -19
- package/dist/server/routes/auth.d.ts +15 -2
- package/dist/server/routes/auth.js +994 -57
- package/dist/server/routes/authSupport.d.ts +1 -1
- package/dist/server/routes/authSupport.js +1 -1
- package/dist/server/routes/billing.js +361 -35
- package/dist/server/routes/billing.test.js +592 -12
- package/dist/server/routes/documents.d.ts +1 -0
- package/dist/server/routes/documents.js +172 -70
- package/dist/server/routes/shared.d.ts +28 -2
- package/dist/server/routes/shared.js +69 -4
- package/dist/server/routes/sync.integration.test.js +437 -39
- package/dist/server/routes/sync.js +61 -605
- package/dist/server/routes/syncAuxRoutes.d.ts +15 -0
- package/dist/server/routes/syncAuxRoutes.js +91 -0
- package/dist/server/routes/syncAuxRoutes.test.d.ts +1 -0
- package/dist/server/routes/syncAuxRoutes.test.js +158 -0
- package/dist/server/routes/syncPullApplyRoutes.d.ts +36 -0
- package/dist/server/routes/syncPullApplyRoutes.js +204 -0
- package/dist/server/routes/syncPushRoutes.d.ts +24 -0
- package/dist/server/routes/syncPushRoutes.js +212 -0
- package/dist/server/routes/syncRouteGuards.d.ts +36 -0
- package/dist/server/routes/syncRouteGuards.js +67 -0
- package/dist/server/routes/syncRouteGuards.test.d.ts +1 -0
- package/dist/server/routes/syncRouteGuards.test.js +94 -0
- package/dist/server/routes/syncRouteTypes.d.ts +13 -0
- package/dist/server/routes/syncRouteTypes.js +1 -0
- package/dist/server/routes/syncSnapshotRoutes.d.ts +66 -0
- package/dist/server/routes/syncSnapshotRoutes.js +180 -0
- package/dist/server/routes/tasks.js +43 -1
- package/dist/server/routes/workspaces.js +36 -5
- package/dist/server/routes.js +60 -3
- package/dist/server/routes.test.js +1824 -171
- package/dist/services/workflowExportSnapshots.test.js +2 -0
- package/dist/shared/aiProfileSeatScope.d.ts +5 -0
- package/dist/shared/aiProfileSeatScope.js +21 -0
- package/dist/shared/passwordPolicy.d.ts +13 -0
- package/dist/shared/passwordPolicy.js +84 -0
- package/dist/shared/passwordPolicy.test.d.ts +1 -0
- package/dist/shared/passwordPolicy.test.js +34 -0
- package/dist/storage/workspaceAssetStore.d.ts +15 -3
- package/dist/storage/workspaceAssetStore.js +132 -44
- package/dist/storage/workspaceAssetStore.test.js +93 -6
- package/dist/styles/fonts.css +70 -0
- package/dist/sync/collaborationSyncPayload.d.ts +6 -1
- package/dist/sync/collaborationSyncPayload.js +17 -11
- package/dist/sync/collaborationSyncPayload.test.js +13 -1
- package/dist/sync/contentSyncState.d.ts +4 -8
- package/dist/sync/contentSyncState.js +17 -70
- package/dist/sync/syncApplyHandlers.d.ts +7 -0
- package/dist/sync/syncApplyHandlers.js +28 -13
- package/dist/sync/syncApplyHandlers.test.js +70 -3
- package/dist/sync/syncFieldSemantics.d.ts +12 -0
- package/dist/sync/syncFieldSemantics.js +55 -0
- package/dist/sync/syncResourceAdapters.d.ts +44 -0
- package/dist/sync/syncResourceAdapters.js +77 -0
- package/dist/sync/syncService.d.ts +5 -0
- package/dist/sync/syncService.js +52 -0
- package/dist/sync/syncService.test.js +57 -3
- package/dist/sync/taskSyncChangeKey.d.ts +2 -0
- package/dist/sync/taskSyncChangeKey.js +143 -0
- package/dist/sync/taskSyncPayload.js +3 -8
- package/dist/sync/workspacePullFeed.js +4 -3
- package/dist/sync/workspacePullFeed.test.js +43 -0
- package/dist/sync/workspaceRepair.js +5 -2
- package/dist/sync/workspaceSyncModel.d.ts +45 -0
- package/dist/sync/workspaceSyncModel.js +183 -28
- package/dist/sync/workspaceSyncModel.test.js +291 -12
- 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 +17 -0
- package/dist/ui/assets/AgentsModule-DrLawwNl.js +1 -0
- package/dist/ui/assets/{AnnotatedAttachmentWorkspace-BG6P_GVW.js → AnnotatedAttachmentWorkspace-Z9_whf7F.js} +1 -1
- package/dist/ui/assets/CourierPrime-Bold-BuLj_dpw.woff2 +0 -0
- package/dist/ui/assets/CourierPrime-BoldItalic-BrK2pUkZ.woff2 +0 -0
- package/dist/ui/assets/CourierPrime-Italic-DFUZK5Ey.woff2 +0 -0
- package/dist/ui/assets/CourierPrime-Regular-BsKphzVf.woff2 +0 -0
- package/dist/ui/assets/DocumentWorkspace-B03MaSkw.js +252 -0
- package/dist/ui/assets/DocumentWorkspace-Dhdso07p.css +1 -0
- package/dist/ui/assets/{InitiativesModule-Dhm7M8e7.js → InitiativesModule-9E6ParrY.js} +1 -1
- package/dist/ui/assets/Noir_medium-bxQwKGzB.woff2 +0 -0
- package/dist/ui/assets/Noir_regular-ojf2kxlG.woff2 +0 -0
- package/dist/ui/assets/PlansPage-CvfnMiWq.css +1 -0
- package/dist/ui/assets/PlansPage-DRXscYxH.js +1 -0
- package/dist/ui/assets/RussoOne-Regular-Cu_qq_qC.woff2 +0 -0
- package/dist/ui/assets/SpecialElite-Regular-Di6gSvAS.woff2 +0 -0
- package/dist/ui/assets/{TaskSettings-ln0aF7xc.js → TaskSettings-DZX4jk7e.js} +1 -1
- package/dist/ui/assets/{WorkflowsModule-BcS4afRn.js → WorkflowsModule-BA7jcEpH.js} +1 -1
- package/dist/ui/assets/index-9eT8e0Lu.css +1 -0
- package/dist/ui/assets/index-C6k75jr8.js +6 -0
- package/dist/ui/assets/{vendor-icons-BSUaRwF8.js → vendor-icons-DuEd_65c.js} +1 -1
- package/dist/ui/fonts/CourierPrime-Bold.ttf +0 -0
- package/dist/ui/fonts/CourierPrime-Bold.woff2 +0 -0
- package/dist/ui/fonts/CourierPrime-BoldItalic.ttf +0 -0
- package/dist/ui/fonts/CourierPrime-BoldItalic.woff2 +0 -0
- package/dist/ui/fonts/CourierPrime-Italic.ttf +0 -0
- package/dist/ui/fonts/CourierPrime-Italic.woff2 +0 -0
- package/dist/ui/fonts/CourierPrime-Regular.ttf +0 -0
- package/dist/ui/fonts/CourierPrime-Regular.woff2 +0 -0
- package/dist/ui/fonts/Noir_medium.otf +0 -0
- package/dist/ui/fonts/Noir_medium.woff2 +0 -0
- package/dist/ui/fonts/Noir_regular.otf +0 -0
- package/dist/ui/fonts/Noir_regular.woff2 +0 -0
- package/dist/ui/fonts/RussoOne-Regular.woff2 +0 -0
- package/dist/ui/fonts/SpecialElite-Regular.ttf +0 -0
- package/dist/ui/fonts/SpecialElite-Regular.woff2 +0 -0
- package/dist/ui/index.html +3 -3
- package/dist/utils/accountProfileSummaryCache.d.ts +5 -0
- package/dist/utils/assignees.d.ts +2 -0
- package/dist/utils/assignees.js +2 -0
- package/dist/utils/avatarUpload.d.ts +27 -0
- package/dist/utils/avatarUpload.js +132 -0
- package/dist/utils/avatarUpload.test.d.ts +1 -0
- package/dist/utils/avatarUpload.test.js +40 -0
- package/dist/utils/bootstrapTrace.d.ts +7 -0
- package/dist/utils/bootstrapTrace.js +29 -0
- package/dist/utils/commercialLifecyclePresentation.js +3 -0
- package/dist/utils/contextAssetEvents.d.ts +2 -0
- package/dist/utils/syncEventDetails.d.ts +3 -0
- package/dist/utils/syncEventDetails.js +64 -0
- package/dist/utils/syncEventDetails.test.d.ts +1 -0
- package/dist/utils/syncEventDetails.test.js +23 -0
- package/dist/utils/syncEventPresentation.d.ts +15 -0
- package/dist/utils/syncEventPresentation.js +129 -0
- package/dist/utils/syncEventPresentation.test.d.ts +1 -0
- package/dist/utils/syncEventPresentation.test.js +64 -0
- package/dist/utils/syncStatusPresentation.d.ts +72 -0
- package/dist/utils/syncStatusPresentation.js +217 -0
- package/dist/utils/syncStatusPresentation.test.d.ts +1 -0
- package/dist/utils/syncStatusPresentation.test.js +164 -0
- package/dist/utils/taskActivity.d.ts +2 -0
- package/dist/utils/taskActivity.js +228 -22
- package/dist/utils/taskActivity.test.js +119 -8
- package/dist/utils/workspaceSyncPresentation.d.ts +4 -0
- package/dist/utils/workspaceSyncPresentation.js +68 -2
- package/dist/utils/workspaceSyncPresentation.test.js +36 -1
- package/package.json +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/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/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();
|
|
@@ -156,6 +161,9 @@ vi.mock('../core/Taskforce', () => {
|
|
|
156
161
|
listWorkspaceUsers = vi.fn().mockReturnValue([]);
|
|
157
162
|
listAssignableActors = vi.fn();
|
|
158
163
|
listAiProfiles = vi.fn().mockReturnValue([]);
|
|
164
|
+
countAiProfileUsage = vi.fn().mockReturnValue({ used: 0, limit: null, remaining: null });
|
|
165
|
+
mergeAiProfiles = vi.fn();
|
|
166
|
+
archiveAiProfile = vi.fn();
|
|
159
167
|
replaceWorkspaceUsersForSync = vi.fn().mockReturnValue([]);
|
|
160
168
|
listDocumentReviewSessionsForSync = vi.fn().mockReturnValue([]);
|
|
161
169
|
listDocumentReviewCommentsForSync = vi.fn().mockReturnValue([]);
|
|
@@ -235,6 +243,7 @@ vi.mock('../core/Taskforce', () => {
|
|
|
235
243
|
updatedAt: '2026-01-01T00:00:00.000Z'
|
|
236
244
|
}
|
|
237
245
|
]);
|
|
246
|
+
countSystemUsers = vi.fn().mockReturnValue(1);
|
|
238
247
|
getSystemRole = vi.fn().mockReturnValue('user');
|
|
239
248
|
getUserBetaAccess = vi.fn().mockReturnValue(true);
|
|
240
249
|
setSystemRole = vi.fn().mockReturnValue(true);
|
|
@@ -411,7 +420,7 @@ vi.mock('../core/Taskforce', () => {
|
|
|
411
420
|
}
|
|
412
421
|
},
|
|
413
422
|
{
|
|
414
|
-
featureKey: 'workspace.
|
|
423
|
+
featureKey: 'workspace.workspaces',
|
|
415
424
|
label: 'Workspaces',
|
|
416
425
|
description: 'Allow multiple workspaces with a configurable workspace limit.',
|
|
417
426
|
allowedAccessModes: ['enabled', 'disabled'],
|
|
@@ -624,6 +633,12 @@ describe('Server Routes', () => {
|
|
|
624
633
|
if (typeof body === 'string') {
|
|
625
634
|
req.push(body);
|
|
626
635
|
}
|
|
636
|
+
else if (Buffer.isBuffer(body)) {
|
|
637
|
+
req.push(body);
|
|
638
|
+
}
|
|
639
|
+
else if (body instanceof Uint8Array) {
|
|
640
|
+
req.push(Buffer.from(body));
|
|
641
|
+
}
|
|
627
642
|
else {
|
|
628
643
|
req.push(body ? JSON.stringify(body) : null);
|
|
629
644
|
}
|
|
@@ -700,6 +715,17 @@ describe('Server Routes', () => {
|
|
|
700
715
|
process.env.TASKFORCE_AUTH_EXPOSE_DEV_TOKENS = previous;
|
|
701
716
|
}
|
|
702
717
|
};
|
|
718
|
+
const createCloudAuthConfig = () => ({
|
|
719
|
+
runtimeMode: 'cloud',
|
|
720
|
+
enabled: true,
|
|
721
|
+
requiredForApi: true,
|
|
722
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
723
|
+
defaultRole: 'member',
|
|
724
|
+
defaultWorkspaceId: 'default',
|
|
725
|
+
sessionCookieName: 'taskforce_session',
|
|
726
|
+
sessionSecret: 'test-secret',
|
|
727
|
+
sessionTtlSeconds: 3600
|
|
728
|
+
});
|
|
703
729
|
it('GET /api/taskforce/tasks should return tasks', async () => {
|
|
704
730
|
const mockTasks = { tasks: [], config: {} };
|
|
705
731
|
core.listTasks.mockReturnValue(mockTasks);
|
|
@@ -711,6 +737,30 @@ describe('Server Routes', () => {
|
|
|
711
737
|
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
712
738
|
expect(res.end).toHaveBeenCalledWith(JSON.stringify(mockTasks));
|
|
713
739
|
});
|
|
740
|
+
it('GET /api/taskforce/planning/bootstrap should return lightweight planning bootstrap data', async () => {
|
|
741
|
+
const payload = {
|
|
742
|
+
initiatives: [{ id: 'initiative-1', title: 'Launch', description: null, createdAt: '2026-04-22T00:00:00.000Z' }],
|
|
743
|
+
workstreams: [{ id: 'workstream-1', title: 'Billing', description: null, createdAt: '2026-04-22T00:00:00.000Z' }],
|
|
744
|
+
workstreamTaskSummaries: [{
|
|
745
|
+
workstreamId: 'workstream-1',
|
|
746
|
+
taskCount: 2,
|
|
747
|
+
completedTaskCount: 1,
|
|
748
|
+
tasks: [
|
|
749
|
+
{ id: 'task-1', title: 'Draft copy', referenceNumber: 12, status: 'task' },
|
|
750
|
+
{ id: 'task-2', title: 'Review copy', referenceNumber: 13, status: 'done' }
|
|
751
|
+
]
|
|
752
|
+
}]
|
|
753
|
+
};
|
|
754
|
+
core.listPlanningBootstrap.mockReturnValue(payload);
|
|
755
|
+
const match = matchRoute('GET', '/api/taskforce/planning/bootstrap', routes);
|
|
756
|
+
const req = createMockReq('GET', '/api/taskforce/planning/bootstrap');
|
|
757
|
+
const res = createMockRes();
|
|
758
|
+
await match?.handler(req, res, {});
|
|
759
|
+
expect(core.listPlanningBootstrap).toHaveBeenCalledWith('default', expect.any(Object));
|
|
760
|
+
expect(res.setHeader).toHaveBeenCalledWith('Server-Timing', expect.stringContaining('planning-bootstrap'));
|
|
761
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
762
|
+
expect(res.end).toHaveBeenCalledWith(JSON.stringify(payload));
|
|
763
|
+
});
|
|
714
764
|
it('GET /mcp should return MCP JSON error instead of falling through when unauthenticated in cloud runtime', async () => {
|
|
715
765
|
const match = matchRoute('GET', '/mcp', routes);
|
|
716
766
|
const req = createMockReq('GET', '/mcp', undefined, {
|
|
@@ -2157,6 +2207,96 @@ describe('Server Routes', () => {
|
|
|
2157
2207
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
2158
2208
|
vi.unstubAllGlobals();
|
|
2159
2209
|
});
|
|
2210
|
+
it('GET /api/taskforce/context/:filename should proxy R2 user avatars inline instead of redirecting', async () => {
|
|
2211
|
+
const fetchMock = vi.fn().mockResolvedValue(new Response('avatar-bytes', {
|
|
2212
|
+
status: 200,
|
|
2213
|
+
headers: { 'Content-Type': 'image/webp' }
|
|
2214
|
+
}));
|
|
2215
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
2216
|
+
const routesWithStorage = createRoutes(core, {
|
|
2217
|
+
objectStorage: {
|
|
2218
|
+
provider: 'r2',
|
|
2219
|
+
r2: {
|
|
2220
|
+
accountId: 'acct-1',
|
|
2221
|
+
bucket: 'taskforce-docs',
|
|
2222
|
+
endpoint: 'https://example.r2.cloudflarestorage.com',
|
|
2223
|
+
accessKeyId: 'access-key-1',
|
|
2224
|
+
secretAccessKey: 'secret-key-1',
|
|
2225
|
+
keyPrefix: 'taskforce',
|
|
2226
|
+
signedUploadTtlSeconds: 600,
|
|
2227
|
+
signedDownloadTtlSeconds: 300
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
});
|
|
2231
|
+
const encoded = encodeURIComponent('users/user-1/profile/avatar/avatar-draft-1-headshot.webp');
|
|
2232
|
+
const match = matchRoute('GET', `/api/taskforce/context/${encoded}`, routesWithStorage);
|
|
2233
|
+
const req = createMockReq('GET', `/api/taskforce/context/${encoded}`);
|
|
2234
|
+
const res = createMockRes();
|
|
2235
|
+
await match?.handler(req, res, { filename: encoded });
|
|
2236
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.objectContaining({
|
|
2237
|
+
'Content-Type': 'image/webp',
|
|
2238
|
+
'Content-Disposition': 'inline'
|
|
2239
|
+
}));
|
|
2240
|
+
expect(String(res.getHeader('location') || '')).toBe('');
|
|
2241
|
+
expect(res.__getBody()).toBe('avatar-bytes');
|
|
2242
|
+
expect(String(fetchMock.mock.calls[0][0])).toContain('https://example.r2.cloudflarestorage.com/taskforce-docs/taskforce/users/user-1/profile/avatar/avatar-draft-1-headshot.webp');
|
|
2243
|
+
vi.unstubAllGlobals();
|
|
2244
|
+
});
|
|
2245
|
+
it('GET /api/taskforce/context/:filename should proxy missing cloud-owned user avatars in local runtime', async () => {
|
|
2246
|
+
const previousCloudProxyBaseUrl = process.env.TASKFORCE_CLOUD_PROXY_BASE_URL;
|
|
2247
|
+
const previousClientId = process.env.TASKFORCE_CF_ACCESS_CLIENT_ID;
|
|
2248
|
+
const previousClientSecret = process.env.TASKFORCE_CF_ACCESS_CLIENT_SECRET;
|
|
2249
|
+
process.env.TASKFORCE_CLOUD_PROXY_BASE_URL = 'https://performance-app.taskforcehq.ai';
|
|
2250
|
+
process.env.TASKFORCE_CF_ACCESS_CLIENT_ID = 'cf-client-id';
|
|
2251
|
+
process.env.TASKFORCE_CF_ACCESS_CLIENT_SECRET = 'cf-client-secret';
|
|
2252
|
+
const fetchMock = vi.fn().mockResolvedValue(new Response('avatar-bytes', {
|
|
2253
|
+
status: 200,
|
|
2254
|
+
headers: {
|
|
2255
|
+
'Content-Type': 'image/webp',
|
|
2256
|
+
'Cache-Control': 'private, max-age=60'
|
|
2257
|
+
}
|
|
2258
|
+
}));
|
|
2259
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
2260
|
+
try {
|
|
2261
|
+
const relativePath = 'users/user-1/profile/avatar/avatar-draft-1-headshot.webp';
|
|
2262
|
+
const encoded = encodeURIComponent(relativePath);
|
|
2263
|
+
const match = matchRoute('GET', `/api/taskforce/context/${encoded}`, routes);
|
|
2264
|
+
const req = createMockReq('GET', `/api/taskforce/context/${encoded}`, undefined, {
|
|
2265
|
+
host: 'localhost:5174',
|
|
2266
|
+
cookie: 'taskforce_session=session-1',
|
|
2267
|
+
accept: 'image/webp'
|
|
2268
|
+
});
|
|
2269
|
+
const res = createMockRes();
|
|
2270
|
+
await match?.handler(req, res, { filename: encoded });
|
|
2271
|
+
expect(fetchMock).toHaveBeenCalledWith(`https://performance-app.taskforcehq.ai/api/taskforce/context/${encoded}`, expect.objectContaining({
|
|
2272
|
+
method: 'GET',
|
|
2273
|
+
headers: expect.any(Headers)
|
|
2274
|
+
}));
|
|
2275
|
+
const proxiedHeaders = fetchMock.mock.calls[0][1].headers;
|
|
2276
|
+
expect(proxiedHeaders.get('cookie')).toBe('taskforce_session=session-1');
|
|
2277
|
+
expect(proxiedHeaders.get('CF-Access-Client-Id')).toBe('cf-client-id');
|
|
2278
|
+
expect(proxiedHeaders.get('CF-Access-Client-Secret')).toBe('cf-client-secret');
|
|
2279
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.objectContaining({
|
|
2280
|
+
'content-type': 'image/webp'
|
|
2281
|
+
}));
|
|
2282
|
+
expect(res.__getBody()).toBe('avatar-bytes');
|
|
2283
|
+
}
|
|
2284
|
+
finally {
|
|
2285
|
+
vi.unstubAllGlobals();
|
|
2286
|
+
if (previousCloudProxyBaseUrl === undefined)
|
|
2287
|
+
delete process.env.TASKFORCE_CLOUD_PROXY_BASE_URL;
|
|
2288
|
+
else
|
|
2289
|
+
process.env.TASKFORCE_CLOUD_PROXY_BASE_URL = previousCloudProxyBaseUrl;
|
|
2290
|
+
if (previousClientId === undefined)
|
|
2291
|
+
delete process.env.TASKFORCE_CF_ACCESS_CLIENT_ID;
|
|
2292
|
+
else
|
|
2293
|
+
process.env.TASKFORCE_CF_ACCESS_CLIENT_ID = previousClientId;
|
|
2294
|
+
if (previousClientSecret === undefined)
|
|
2295
|
+
delete process.env.TASKFORCE_CF_ACCESS_CLIENT_SECRET;
|
|
2296
|
+
else
|
|
2297
|
+
process.env.TASKFORCE_CF_ACCESS_CLIENT_SECRET = previousClientSecret;
|
|
2298
|
+
}
|
|
2299
|
+
});
|
|
2160
2300
|
it('GET /api/taskforce/context/:filename should proxy inline R2 markdown documents instead of redirecting', async () => {
|
|
2161
2301
|
const fetchMock = vi.fn().mockResolvedValue({
|
|
2162
2302
|
status: 200,
|
|
@@ -3447,6 +3587,53 @@ describe('Server Routes', () => {
|
|
|
3447
3587
|
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
3448
3588
|
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
3449
3589
|
});
|
|
3590
|
+
it('GET /api/taskforce/documents should exclude raw .taskforce/docs markdown files from the document manager', async () => {
|
|
3591
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-exclude-raw-docs-'));
|
|
3592
|
+
const basePath = path.join(tmpRoot, '.taskforce');
|
|
3593
|
+
fs.mkdirSync(path.join(basePath, 'docs'), { recursive: true });
|
|
3594
|
+
fs.writeFileSync(path.join(basePath, 'docs', 'legacy-plan.md'), '# legacy\n', 'utf8');
|
|
3595
|
+
core.getPaths.mockReturnValue({
|
|
3596
|
+
projectRoot: tmpRoot,
|
|
3597
|
+
basePath,
|
|
3598
|
+
});
|
|
3599
|
+
const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
|
|
3600
|
+
core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
|
|
3601
|
+
core.getTenantId = vi.fn().mockReturnValue('default');
|
|
3602
|
+
store.upsertAsset({
|
|
3603
|
+
assetId: 'asset-doc-local-2',
|
|
3604
|
+
workspaceId: 'default',
|
|
3605
|
+
kind: 'document',
|
|
3606
|
+
originalFilename: 'managed-plan.md',
|
|
3607
|
+
mimeType: 'text/markdown',
|
|
3608
|
+
storageProvider: 'local',
|
|
3609
|
+
storageKey: 'workspaces/default/assets/documents/asset-doc-local-2-managed-plan.md',
|
|
3610
|
+
contentSha256: 'sha-doc-local-2',
|
|
3611
|
+
sizeBytes: 99,
|
|
3612
|
+
documentId: 'doc-local-2',
|
|
3613
|
+
version: 1,
|
|
3614
|
+
updatedAt: '2026-04-19T12:00:00.000Z'
|
|
3615
|
+
});
|
|
3616
|
+
const routesWithDb = createRoutes(core);
|
|
3617
|
+
const match = matchRoute('GET', '/api/taskforce/documents', routesWithDb);
|
|
3618
|
+
const req = createMockReq('GET', '/api/taskforce/documents', undefined, {
|
|
3619
|
+
'x-taskforce-workspace-id': 'default',
|
|
3620
|
+
'x-taskforce-runtime-mode': 'local'
|
|
3621
|
+
});
|
|
3622
|
+
const res = createMockRes();
|
|
3623
|
+
await match?.handler(req, res, {});
|
|
3624
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
3625
|
+
expect(Array.isArray(payload.documents)).toBe(true);
|
|
3626
|
+
expect(payload.documents).toHaveLength(1);
|
|
3627
|
+
expect(payload.documents[0]).toEqual(expect.objectContaining({
|
|
3628
|
+
id: 'asset-doc-local-2',
|
|
3629
|
+
assetId: 'asset-doc-local-2',
|
|
3630
|
+
fsPath: 'workspaces/default/assets/documents/asset-doc-local-2-managed-plan.md',
|
|
3631
|
+
attachmentState: 'unattached'
|
|
3632
|
+
}));
|
|
3633
|
+
expect(payload.documents.some((doc) => String(doc.fsPath || '').includes('docs/legacy-plan.md'))).toBe(false);
|
|
3634
|
+
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
3635
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
3636
|
+
});
|
|
3450
3637
|
it('GET /api/taskforce/documents should mark canonical docs without links as unattached', async () => {
|
|
3451
3638
|
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-unattached-local-'));
|
|
3452
3639
|
core.getPaths.mockReturnValue({
|
|
@@ -3543,6 +3730,111 @@ describe('Server Routes', () => {
|
|
|
3543
3730
|
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
3544
3731
|
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
3545
3732
|
});
|
|
3733
|
+
it('GET /api/taskforce/documents should count non-task links in attachment metadata while only surfacing task titles for task links', async () => {
|
|
3734
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-generic-links-'));
|
|
3735
|
+
core.getPaths.mockReturnValue({
|
|
3736
|
+
projectRoot: tmpRoot,
|
|
3737
|
+
basePath: path.join(tmpRoot, '.taskforce'),
|
|
3738
|
+
});
|
|
3739
|
+
const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
|
|
3740
|
+
core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
|
|
3741
|
+
core.getTenantId = vi.fn().mockReturnValue('default');
|
|
3742
|
+
store.upsertAsset({
|
|
3743
|
+
assetId: 'asset-doc-generic-link-1',
|
|
3744
|
+
workspaceId: 'default',
|
|
3745
|
+
kind: 'document',
|
|
3746
|
+
originalFilename: 'shared-notes.md',
|
|
3747
|
+
mimeType: 'text/markdown',
|
|
3748
|
+
storageProvider: 'local',
|
|
3749
|
+
storageKey: 'workspaces/default/assets/documents/asset-doc-generic-link-1-shared-notes.md',
|
|
3750
|
+
contentSha256: 'sha-doc-generic-link-1',
|
|
3751
|
+
sizeBytes: 64,
|
|
3752
|
+
documentId: 'doc-generic-link-1',
|
|
3753
|
+
version: 1,
|
|
3754
|
+
updatedAt: '2026-03-18T13:30:00.000Z'
|
|
3755
|
+
});
|
|
3756
|
+
store.upsertLink({
|
|
3757
|
+
workspaceId: 'default',
|
|
3758
|
+
assetId: 'asset-doc-generic-link-1',
|
|
3759
|
+
targetType: 'initiative',
|
|
3760
|
+
targetId: 'initiative-1',
|
|
3761
|
+
role: 'attachment'
|
|
3762
|
+
});
|
|
3763
|
+
const routesWithDb = createRoutes(core);
|
|
3764
|
+
const match = matchRoute('GET', '/api/taskforce/documents', routesWithDb);
|
|
3765
|
+
const req = createMockReq('GET', '/api/taskforce/documents', undefined, {
|
|
3766
|
+
'x-taskforce-workspace-id': 'default',
|
|
3767
|
+
'x-taskforce-runtime-mode': 'local'
|
|
3768
|
+
});
|
|
3769
|
+
const res = createMockRes();
|
|
3770
|
+
await match?.handler(req, res, {});
|
|
3771
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
3772
|
+
expect(payload.documents).toEqual([
|
|
3773
|
+
expect.objectContaining({
|
|
3774
|
+
id: 'asset-doc-generic-link-1',
|
|
3775
|
+
assetId: 'asset-doc-generic-link-1',
|
|
3776
|
+
attachmentCount: 1,
|
|
3777
|
+
attachmentState: 'attached',
|
|
3778
|
+
taskId: null
|
|
3779
|
+
})
|
|
3780
|
+
]);
|
|
3781
|
+
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
3782
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
3783
|
+
});
|
|
3784
|
+
it('POST /api/taskforce/documents/:assetId/attach should attach a canonical document to a task target', async () => {
|
|
3785
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-attach-task-'));
|
|
3786
|
+
core.getPaths.mockReturnValue({
|
|
3787
|
+
projectRoot: tmpRoot,
|
|
3788
|
+
basePath: path.join(tmpRoot, '.taskforce'),
|
|
3789
|
+
});
|
|
3790
|
+
const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
|
|
3791
|
+
core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
|
|
3792
|
+
core.getTenantId = vi.fn().mockReturnValue('default');
|
|
3793
|
+
store.upsertAsset({
|
|
3794
|
+
assetId: 'asset-doc-attach-route-1',
|
|
3795
|
+
workspaceId: 'default',
|
|
3796
|
+
kind: 'document',
|
|
3797
|
+
logicalName: 'Attach Me',
|
|
3798
|
+
originalFilename: 'attach-me.md',
|
|
3799
|
+
mimeType: 'text/markdown',
|
|
3800
|
+
storageProvider: 'local',
|
|
3801
|
+
storageKey: 'workspaces/default/assets/documents/asset-doc-attach-route-1-attach-me.md',
|
|
3802
|
+
contentSha256: 'sha-doc-attach-route-1',
|
|
3803
|
+
sizeBytes: 28,
|
|
3804
|
+
documentId: 'doc-attach-route-1',
|
|
3805
|
+
version: 1,
|
|
3806
|
+
updatedAt: '2026-03-18T13:30:00.000Z'
|
|
3807
|
+
});
|
|
3808
|
+
core.getTask.mockImplementation((id) => id === 'task-attach-1'
|
|
3809
|
+
? { id: 'task-attach-1', title: 'Attach target', attachments: [] }
|
|
3810
|
+
: null);
|
|
3811
|
+
const routesWithDb = createRoutes(core);
|
|
3812
|
+
const match = matchRoute('POST', '/api/taskforce/documents/asset-doc-attach-route-1/attach', routesWithDb);
|
|
3813
|
+
const req = createMockReq('POST', '/api/taskforce/documents/asset-doc-attach-route-1/attach', {
|
|
3814
|
+
targetType: 'task',
|
|
3815
|
+
targetId: 'task-attach-1',
|
|
3816
|
+
}, {
|
|
3817
|
+
'x-taskforce-workspace-id': 'default'
|
|
3818
|
+
});
|
|
3819
|
+
const res = createMockRes();
|
|
3820
|
+
await match?.handler(req, res, { assetId: 'asset-doc-attach-route-1' });
|
|
3821
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, { 'Content-Type': 'application/json' });
|
|
3822
|
+
expect(core.updateTask).toHaveBeenCalledWith('task-attach-1', expect.objectContaining({
|
|
3823
|
+
attachments: [
|
|
3824
|
+
expect.objectContaining({
|
|
3825
|
+
assetId: 'asset-doc-attach-route-1',
|
|
3826
|
+
taskId: 'task-attach-1',
|
|
3827
|
+
path: '/api/taskforce/documents/asset-doc-attach-route-1/content',
|
|
3828
|
+
})
|
|
3829
|
+
]
|
|
3830
|
+
}), 'default', expect.objectContaining({
|
|
3831
|
+
actorRef: 'user',
|
|
3832
|
+
saveSource: 'manual',
|
|
3833
|
+
}));
|
|
3834
|
+
expect(core.notifyDocumentMetadataMutation).toHaveBeenCalledWith('default', ['workspaces/default/assets/documents/asset-doc-attach-route-1-attach-me.md'], 'upsert');
|
|
3835
|
+
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
3836
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
3837
|
+
});
|
|
3546
3838
|
it('GET /api/taskforce/documents should normalize readable names and attachment metadata for sparse canonical assets', async () => {
|
|
3547
3839
|
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-normalized-cloud-'));
|
|
3548
3840
|
core.getPaths.mockReturnValue({
|
|
@@ -4559,6 +4851,40 @@ describe('Server Routes', () => {
|
|
|
4559
4851
|
expect(payload.inviteeKind).toBe('existing_user');
|
|
4560
4852
|
expect(payload.passwordRequired).toBe(false);
|
|
4561
4853
|
});
|
|
4854
|
+
it('POST /api/taskforce/auth/invite/accept should preserve the created identity in the session cookie', async () => {
|
|
4855
|
+
const authConfig = {
|
|
4856
|
+
runtimeMode: 'cloud',
|
|
4857
|
+
enabled: true,
|
|
4858
|
+
requiredForApi: true,
|
|
4859
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
4860
|
+
defaultRole: 'member',
|
|
4861
|
+
defaultWorkspaceId: 'default',
|
|
4862
|
+
sessionCookieName: 'taskforce_session',
|
|
4863
|
+
sessionSecret: 'test-secret',
|
|
4864
|
+
sessionTtlSeconds: 3600
|
|
4865
|
+
};
|
|
4866
|
+
routes = createRoutes(core, { authConfig });
|
|
4867
|
+
core.acceptInviteWithToken.mockReturnValueOnce({
|
|
4868
|
+
userId: 'u1',
|
|
4869
|
+
email: 'u1@example.com',
|
|
4870
|
+
workspaceId: 'workspace-1',
|
|
4871
|
+
role: 'member',
|
|
4872
|
+
identityId: 'identity-password-u1'
|
|
4873
|
+
});
|
|
4874
|
+
const match = matchRoute('POST', '/api/taskforce/auth/invite/accept', routes);
|
|
4875
|
+
const req = createMockReq('POST', '/api/taskforce/auth/invite/accept', { token: 'accept-token', password: 'password123' }, {
|
|
4876
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
4877
|
+
host: 'localhost'
|
|
4878
|
+
});
|
|
4879
|
+
const res = createMockRes();
|
|
4880
|
+
await match?.handler(req, res, {});
|
|
4881
|
+
const setCookie = res.setHeader.mock.calls.find(([name]) => name === 'Set-Cookie')?.[1];
|
|
4882
|
+
expect(typeof setCookie).toBe('string');
|
|
4883
|
+
const cookieReq = createMockReq('GET', '/api/taskforce/me', undefined, { cookie: setCookie });
|
|
4884
|
+
const access = resolveRequestAccess(cookieReq, authConfig);
|
|
4885
|
+
expect(access.identityId).toBe('identity-password-u1');
|
|
4886
|
+
expect(access.userId).toBe('u1');
|
|
4887
|
+
});
|
|
4562
4888
|
it('POST /api/taskforce/auth/invite/accept should return expired code and audit action', async () => {
|
|
4563
4889
|
const authConfig = {
|
|
4564
4890
|
runtimeMode: 'cloud',
|
|
@@ -4649,7 +4975,8 @@ describe('Server Routes', () => {
|
|
|
4649
4975
|
const cookie = createSessionTokenCookie('session-join', authConfig, {
|
|
4650
4976
|
userId: 'u1',
|
|
4651
4977
|
role: 'member',
|
|
4652
|
-
workspaceId: 'workspace-1'
|
|
4978
|
+
workspaceId: 'workspace-1',
|
|
4979
|
+
identityId: 'identity-u1'
|
|
4653
4980
|
});
|
|
4654
4981
|
const match = matchRoute('POST', '/api/taskforce/auth/invite/join', routes);
|
|
4655
4982
|
const req = createMockReq('POST', '/api/taskforce/auth/invite/join', { token: 'join-token' }, {
|
|
@@ -4663,6 +4990,11 @@ describe('Server Routes', () => {
|
|
|
4663
4990
|
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
4664
4991
|
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
4665
4992
|
expect(payload.workspaceId).toBe('workspace-1');
|
|
4993
|
+
const setCookie = res.setHeader.mock.calls.find(([name]) => name === 'Set-Cookie')?.[1];
|
|
4994
|
+
expect(typeof setCookie).toBe('string');
|
|
4995
|
+
const cookieReq = createMockReq('GET', '/api/taskforce/me', undefined, { cookie: setCookie });
|
|
4996
|
+
const access = resolveRequestAccess(cookieReq, authConfig);
|
|
4997
|
+
expect(access.identityId).toBe('identity-u1');
|
|
4666
4998
|
});
|
|
4667
4999
|
it('POST /api/taskforce/auth/login should auto-verify bootstrap admin and sign in', async () => {
|
|
4668
5000
|
const authConfig = {
|
|
@@ -4991,6 +5323,7 @@ describe('Server Routes', () => {
|
|
|
4991
5323
|
const res = createMockRes();
|
|
4992
5324
|
await match?.handler(req, res, {});
|
|
4993
5325
|
expect(core.listUserWorkspaces).toHaveBeenCalledWith('user-1');
|
|
5326
|
+
expect(res.setHeader).toHaveBeenCalledWith('Server-Timing', expect.stringContaining('workspaces'));
|
|
4994
5327
|
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
4995
5328
|
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"workspace-2"'));
|
|
4996
5329
|
});
|
|
@@ -5884,6 +6217,9 @@ describe('Server Routes', () => {
|
|
|
5884
6217
|
await match?.handler(req, res, {});
|
|
5885
6218
|
const payloadRaw = res.end.mock.calls[0]?.[0];
|
|
5886
6219
|
const payload = JSON.parse(String(payloadRaw || '{}'));
|
|
6220
|
+
expect(payload.knownPaths).toEqual([
|
|
6221
|
+
'workspaces/workspace-local/assets/documents/asset-doc-snapshot-1-sync-notes.md'
|
|
6222
|
+
]);
|
|
5887
6223
|
expect(payload.documents).toEqual(expect.arrayContaining([
|
|
5888
6224
|
expect.objectContaining({
|
|
5889
6225
|
path: 'workspaces/workspace-local/assets/documents/asset-doc-snapshot-1-sync-notes.md',
|
|
@@ -5898,7 +6234,56 @@ describe('Server Routes', () => {
|
|
|
5898
6234
|
expect(String(payload.fingerprint || '')).toContain('asset-doc-snapshot-1');
|
|
5899
6235
|
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
5900
6236
|
});
|
|
5901
|
-
it('GET /api/taskforce/sync/workspace/documents should
|
|
6237
|
+
it('GET /api/taskforce/sync/workspace/documents should retain canonical knownPaths even when content is temporarily unreadable', async () => {
|
|
6238
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-documents-known-paths-'));
|
|
6239
|
+
const projectRoot = fs.realpathSync(tmpRoot);
|
|
6240
|
+
const basePath = path.join(tmpRoot, '.taskforce');
|
|
6241
|
+
const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
|
|
6242
|
+
store.upsertAsset({
|
|
6243
|
+
assetId: 'asset-doc-known-path-1',
|
|
6244
|
+
workspaceId: 'workspace-local',
|
|
6245
|
+
kind: 'document',
|
|
6246
|
+
originalFilename: 'missing-content.md',
|
|
6247
|
+
mimeType: 'text/markdown',
|
|
6248
|
+
storageProvider: 'local',
|
|
6249
|
+
storageKey: 'workspaces/workspace-local/assets/documents/asset-doc-known-path-1-missing-content.md',
|
|
6250
|
+
contentSha256: 'missing-content-hash',
|
|
6251
|
+
sizeBytes: 17,
|
|
6252
|
+
documentId: 'doc-known-path-1',
|
|
6253
|
+
version: 1,
|
|
6254
|
+
isLatest: true,
|
|
6255
|
+
createdAt: '2026-03-17T09:00:00.000Z',
|
|
6256
|
+
updatedAt: '2026-03-17T10:00:00.000Z'
|
|
6257
|
+
});
|
|
6258
|
+
store.upsertLink({
|
|
6259
|
+
workspaceId: 'workspace-local',
|
|
6260
|
+
assetId: 'asset-doc-known-path-1',
|
|
6261
|
+
taskId: 'task-1',
|
|
6262
|
+
role: 'attachment'
|
|
6263
|
+
});
|
|
6264
|
+
core.getPaths.mockReturnValue({
|
|
6265
|
+
projectRoot,
|
|
6266
|
+
basePath,
|
|
6267
|
+
});
|
|
6268
|
+
core.resolvePreferredWorkspaceId.mockReturnValue('workspace-local');
|
|
6269
|
+
core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
|
|
6270
|
+
core.getTenantId = vi.fn().mockReturnValue('default');
|
|
6271
|
+
routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
6272
|
+
const match = matchRoute('GET', '/api/taskforce/sync/workspace/documents', routes);
|
|
6273
|
+
const req = createMockReq('GET', '/api/taskforce/sync/workspace/documents?workspaceId=workspace-local', undefined, {
|
|
6274
|
+
'x-taskforce-workspace-id': 'workspace-local'
|
|
6275
|
+
});
|
|
6276
|
+
const res = createMockRes();
|
|
6277
|
+
await match?.handler(req, res, {});
|
|
6278
|
+
const payloadRaw = res.end.mock.calls[0]?.[0];
|
|
6279
|
+
const payload = JSON.parse(String(payloadRaw || '{}'));
|
|
6280
|
+
expect(payload.documents).toEqual([]);
|
|
6281
|
+
expect(payload.knownPaths).toEqual([
|
|
6282
|
+
'workspaces/workspace-local/assets/documents/asset-doc-known-path-1-missing-content.md'
|
|
6283
|
+
]);
|
|
6284
|
+
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
6285
|
+
});
|
|
6286
|
+
it('GET /api/taskforce/sync/workspace/documents should exclude docs folder markdown files that are not canonical assets', async () => {
|
|
5902
6287
|
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-documents-legacy-ignore-'));
|
|
5903
6288
|
const projectRoot = fs.realpathSync(tmpRoot);
|
|
5904
6289
|
const basePath = path.join(tmpRoot, '.taskforce');
|
|
@@ -5923,12 +6308,7 @@ describe('Server Routes', () => {
|
|
|
5923
6308
|
const payloadRaw = res.end.mock.calls[0]?.[0];
|
|
5924
6309
|
const payload = JSON.parse(String(payloadRaw || '{}'));
|
|
5925
6310
|
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
|
-
]);
|
|
6311
|
+
expect(payload.documents).toEqual([]);
|
|
5932
6312
|
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
5933
6313
|
});
|
|
5934
6314
|
it('GET /api/taskforce/sync/workspace/document-reviews should include document review sessions for the requested workspace', async () => {
|
|
@@ -7086,7 +7466,7 @@ describe('Server Routes', () => {
|
|
|
7086
7466
|
task: expect.objectContaining({ id: 'task-b' })
|
|
7087
7467
|
}));
|
|
7088
7468
|
});
|
|
7089
|
-
it('GET /api/taskforce/sync/workspace/pull should reject
|
|
7469
|
+
it('GET /api/taskforce/sync/workspace/pull should reject an invalid sync cursor instead of replaying from the beginning', async () => {
|
|
7090
7470
|
const authConfig = {
|
|
7091
7471
|
runtimeMode: 'cloud',
|
|
7092
7472
|
enabled: true,
|
|
@@ -7099,45 +7479,149 @@ describe('Server Routes', () => {
|
|
|
7099
7479
|
sessionTtlSeconds: 3600
|
|
7100
7480
|
};
|
|
7101
7481
|
routes = createRoutes(core, { authConfig });
|
|
7102
|
-
|
|
7482
|
+
core.listUserWorkspaces.mockReturnValue([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
|
|
7483
|
+
const cookie = createSessionTokenCookie('session-sync-pull-invalid-cursor', authConfig, {
|
|
7103
7484
|
role: 'owner',
|
|
7104
|
-
workspaceId: 'workspace-
|
|
7485
|
+
workspaceId: 'workspace-1',
|
|
7105
7486
|
userId: 'user-1'
|
|
7106
7487
|
});
|
|
7107
|
-
core.listUserWorkspaces.mockReturnValueOnce([
|
|
7108
|
-
{
|
|
7109
|
-
id: 'workspace-a',
|
|
7110
|
-
slug: 'workspace-a',
|
|
7111
|
-
name: 'Workspace A',
|
|
7112
|
-
description: null,
|
|
7113
|
-
role: 'owner',
|
|
7114
|
-
status: 'active',
|
|
7115
|
-
betaAccess: true
|
|
7116
|
-
}
|
|
7117
|
-
]);
|
|
7118
7488
|
const match = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
|
|
7119
|
-
const req = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-
|
|
7489
|
+
const req = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-1&limit=10&cursor=not-a-real-cursor', undefined, { cookie });
|
|
7120
7490
|
const res = createMockRes();
|
|
7121
7491
|
await match?.handler(req, res, {});
|
|
7122
|
-
expect(
|
|
7123
|
-
expect(res.
|
|
7492
|
+
expect(core.listTasks).not.toHaveBeenCalled();
|
|
7493
|
+
expect(res.writeHead).toHaveBeenCalledWith(400, expect.any(Object));
|
|
7494
|
+
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"code":"INVALID_SYNC_CURSOR"'));
|
|
7124
7495
|
expect(core.addAdminAuditLog).toHaveBeenCalledWith(expect.objectContaining({
|
|
7125
|
-
action: 'sync-workspace-pull-
|
|
7126
|
-
details: { requestedWorkspaceId: 'workspace-denied' }
|
|
7496
|
+
action: 'sync-workspace-pull-invalid-cursor'
|
|
7127
7497
|
}));
|
|
7128
7498
|
});
|
|
7129
|
-
it('
|
|
7130
|
-
|
|
7131
|
-
|
|
7132
|
-
|
|
7133
|
-
|
|
7134
|
-
|
|
7135
|
-
|
|
7136
|
-
|
|
7137
|
-
|
|
7138
|
-
|
|
7139
|
-
|
|
7140
|
-
}
|
|
7499
|
+
it('GET /api/taskforce/sync/workspace/pull should ignore cursors during repair', async () => {
|
|
7500
|
+
const authConfig = {
|
|
7501
|
+
runtimeMode: 'cloud',
|
|
7502
|
+
enabled: true,
|
|
7503
|
+
requiredForApi: true,
|
|
7504
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
7505
|
+
defaultRole: 'member',
|
|
7506
|
+
defaultWorkspaceId: 'default',
|
|
7507
|
+
sessionCookieName: 'taskforce_session',
|
|
7508
|
+
sessionSecret: 'test-secret',
|
|
7509
|
+
sessionTtlSeconds: 3600
|
|
7510
|
+
};
|
|
7511
|
+
routes = createRoutes(core, { authConfig });
|
|
7512
|
+
core.listUserWorkspaces.mockReturnValue([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
|
|
7513
|
+
core.listTasks.mockReturnValue({
|
|
7514
|
+
tasks: [
|
|
7515
|
+
{ id: 'task-repair-1', title: 'Repair me', createdAt: '2026-02-19T16:00:00.000Z', updatedAt: '2026-02-19T16:10:00.000Z' }
|
|
7516
|
+
]
|
|
7517
|
+
});
|
|
7518
|
+
core.listArchive.mockReturnValue({ archived: [] });
|
|
7519
|
+
const cookie = createSessionTokenCookie('session-sync-pull-repair-cursor', authConfig, {
|
|
7520
|
+
role: 'owner',
|
|
7521
|
+
workspaceId: 'workspace-1',
|
|
7522
|
+
userId: 'user-1'
|
|
7523
|
+
});
|
|
7524
|
+
const match = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
|
|
7525
|
+
const req = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-1&limit=10&cursor=not-a-real-cursor&repair=1', undefined, { cookie });
|
|
7526
|
+
const res = createMockRes();
|
|
7527
|
+
await match?.handler(req, res, {});
|
|
7528
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
7529
|
+
expect(core.listTasks).toHaveBeenCalled();
|
|
7530
|
+
const body = JSON.parse(String(res.end.mock.calls[0]?.[0] || '{}'));
|
|
7531
|
+
expect(body.changes).toEqual(expect.arrayContaining([
|
|
7532
|
+
expect.objectContaining({
|
|
7533
|
+
op: 'upsert',
|
|
7534
|
+
task: expect.objectContaining({ id: 'task-repair-1' })
|
|
7535
|
+
})
|
|
7536
|
+
]));
|
|
7537
|
+
});
|
|
7538
|
+
it('GET /api/taskforce/sync/workspace/pull should still return success when success audit throws', async () => {
|
|
7539
|
+
const authConfig = {
|
|
7540
|
+
runtimeMode: 'cloud',
|
|
7541
|
+
enabled: true,
|
|
7542
|
+
requiredForApi: true,
|
|
7543
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
7544
|
+
defaultRole: 'member',
|
|
7545
|
+
defaultWorkspaceId: 'default',
|
|
7546
|
+
sessionCookieName: 'taskforce_session',
|
|
7547
|
+
sessionSecret: 'test-secret',
|
|
7548
|
+
sessionTtlSeconds: 3600
|
|
7549
|
+
};
|
|
7550
|
+
routes = createRoutes(core, { authConfig });
|
|
7551
|
+
core.listUserWorkspaces.mockReturnValueOnce([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
|
|
7552
|
+
const cookie = createSessionTokenCookie('session-sync-pull-audit-throw', authConfig, {
|
|
7553
|
+
role: 'owner',
|
|
7554
|
+
workspaceId: 'workspace-1',
|
|
7555
|
+
userId: 'user-1'
|
|
7556
|
+
});
|
|
7557
|
+
const originalAddAdminAuditLog = core.addAdminAuditLog.getMockImplementation();
|
|
7558
|
+
core.addAdminAuditLog.mockImplementation((entry) => {
|
|
7559
|
+
if (entry?.action === 'sync-workspace-pull-success') {
|
|
7560
|
+
throw new Error('audit unavailable');
|
|
7561
|
+
}
|
|
7562
|
+
return originalAddAdminAuditLog ? originalAddAdminAuditLog(entry) : undefined;
|
|
7563
|
+
});
|
|
7564
|
+
const match = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
|
|
7565
|
+
const req = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-1&limit=10', undefined, { cookie });
|
|
7566
|
+
const res = createMockRes();
|
|
7567
|
+
await match?.handler(req, res, {});
|
|
7568
|
+
expect(res.writeHead).toHaveBeenCalledTimes(1);
|
|
7569
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
7570
|
+
expect(res.end).toHaveBeenCalledTimes(1);
|
|
7571
|
+
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":true'));
|
|
7572
|
+
});
|
|
7573
|
+
it('GET /api/taskforce/sync/workspace/pull should reject unauthorized workspace override', async () => {
|
|
7574
|
+
const authConfig = {
|
|
7575
|
+
runtimeMode: 'cloud',
|
|
7576
|
+
enabled: true,
|
|
7577
|
+
requiredForApi: true,
|
|
7578
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
7579
|
+
defaultRole: 'member',
|
|
7580
|
+
defaultWorkspaceId: 'default',
|
|
7581
|
+
sessionCookieName: 'taskforce_session',
|
|
7582
|
+
sessionSecret: 'test-secret',
|
|
7583
|
+
sessionTtlSeconds: 3600
|
|
7584
|
+
};
|
|
7585
|
+
routes = createRoutes(core, { authConfig });
|
|
7586
|
+
const cookie = createSessionTokenCookie('session-sync-pull-forbidden', authConfig, {
|
|
7587
|
+
role: 'owner',
|
|
7588
|
+
workspaceId: 'workspace-cloud-selected',
|
|
7589
|
+
userId: 'user-1'
|
|
7590
|
+
});
|
|
7591
|
+
core.listUserWorkspaces.mockReturnValueOnce([
|
|
7592
|
+
{
|
|
7593
|
+
id: 'workspace-a',
|
|
7594
|
+
slug: 'workspace-a',
|
|
7595
|
+
name: 'Workspace A',
|
|
7596
|
+
description: null,
|
|
7597
|
+
role: 'owner',
|
|
7598
|
+
status: 'active',
|
|
7599
|
+
betaAccess: true
|
|
7600
|
+
}
|
|
7601
|
+
]);
|
|
7602
|
+
const match = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
|
|
7603
|
+
const req = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-denied', undefined, { cookie });
|
|
7604
|
+
const res = createMockRes();
|
|
7605
|
+
await match?.handler(req, res, {});
|
|
7606
|
+
expect(res.writeHead).toHaveBeenCalledWith(403, expect.any(Object));
|
|
7607
|
+
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('WORKSPACE_ACCESS_DENIED'));
|
|
7608
|
+
expect(core.addAdminAuditLog).toHaveBeenCalledWith(expect.objectContaining({
|
|
7609
|
+
action: 'sync-workspace-pull-forbidden',
|
|
7610
|
+
details: { requestedWorkspaceId: 'workspace-denied' }
|
|
7611
|
+
}));
|
|
7612
|
+
});
|
|
7613
|
+
it('POST /api/taskforce/sync/workspace/apply-local should apply deltas when auth is enabled in local runtime', async () => {
|
|
7614
|
+
core.resolvePreferredWorkspaceId.mockReturnValue('workspace-local');
|
|
7615
|
+
routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
7616
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
|
|
7617
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
|
|
7618
|
+
changes: [
|
|
7619
|
+
{ op: 'upsert', archived: false, task: { id: 'task-1', title: 'Task 1' } },
|
|
7620
|
+
{ op: 'delete', taskId: 'task-2' }
|
|
7621
|
+
]
|
|
7622
|
+
}, {
|
|
7623
|
+
'x-taskforce-workspace-id': 'workspace-local'
|
|
7624
|
+
});
|
|
7141
7625
|
const res = createMockRes();
|
|
7142
7626
|
await match?.handler(req, res, {});
|
|
7143
7627
|
expect(core.deleteTaskForSync).toHaveBeenCalledWith('task-2', 'workspace-local', { persistBackup: false });
|
|
@@ -7147,6 +7631,53 @@ describe('Server Routes', () => {
|
|
|
7147
7631
|
}), 'workspace-local');
|
|
7148
7632
|
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
7149
7633
|
});
|
|
7634
|
+
it('POST /api/taskforce/sync/workspace/apply-local should still return success when success sync telemetry throws after apply', async () => {
|
|
7635
|
+
core.resolvePreferredWorkspaceId.mockReturnValue('workspace-local');
|
|
7636
|
+
routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
7637
|
+
core.recordSyncEvent.mockImplementationOnce(() => {
|
|
7638
|
+
throw new Error('telemetry unavailable');
|
|
7639
|
+
});
|
|
7640
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
|
|
7641
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
|
|
7642
|
+
changes: [
|
|
7643
|
+
{ op: 'upsert', archived: false, task: { id: 'task-1', title: 'Task 1' } }
|
|
7644
|
+
]
|
|
7645
|
+
}, {
|
|
7646
|
+
'x-taskforce-workspace-id': 'workspace-local'
|
|
7647
|
+
});
|
|
7648
|
+
const res = createMockRes();
|
|
7649
|
+
await match?.handler(req, res, {});
|
|
7650
|
+
expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledTimes(1);
|
|
7651
|
+
expect(res.writeHead).toHaveBeenCalledTimes(1);
|
|
7652
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
7653
|
+
expect(res.end).toHaveBeenCalledTimes(1);
|
|
7654
|
+
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":true'));
|
|
7655
|
+
});
|
|
7656
|
+
it('POST /api/taskforce/sync/workspace/apply-local should still return error response when failure-path telemetry throws', async () => {
|
|
7657
|
+
core.resolvePreferredWorkspaceId.mockReturnValue('workspace-local');
|
|
7658
|
+
routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
7659
|
+
core.applyWorkspaceSyncSnapshot.mockImplementationOnce(() => {
|
|
7660
|
+
throw new Error('apply failed');
|
|
7661
|
+
});
|
|
7662
|
+
core.recordSyncEvent.mockImplementationOnce(() => {
|
|
7663
|
+
throw new Error('telemetry unavailable');
|
|
7664
|
+
});
|
|
7665
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
|
|
7666
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
|
|
7667
|
+
changes: [
|
|
7668
|
+
{ op: 'upsert', archived: false, task: { id: 'task-1', title: 'Task 1' } }
|
|
7669
|
+
]
|
|
7670
|
+
}, {
|
|
7671
|
+
'x-taskforce-workspace-id': 'workspace-local'
|
|
7672
|
+
});
|
|
7673
|
+
const res = createMockRes();
|
|
7674
|
+
await match?.handler(req, res, {});
|
|
7675
|
+
expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledTimes(1);
|
|
7676
|
+
expect(res.writeHead).toHaveBeenCalledTimes(1);
|
|
7677
|
+
expect(res.writeHead).toHaveBeenCalledWith(400, expect.any(Object));
|
|
7678
|
+
expect(res.end).toHaveBeenCalledTimes(1);
|
|
7679
|
+
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":false'));
|
|
7680
|
+
});
|
|
7150
7681
|
it('POST /api/taskforce/sync/workspace/apply-local should apply document review session upserts and deletes', async () => {
|
|
7151
7682
|
core.resolvePreferredWorkspaceId.mockReturnValue('workspace-local');
|
|
7152
7683
|
routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
@@ -7891,7 +8422,7 @@ describe('Server Routes', () => {
|
|
|
7891
8422
|
]);
|
|
7892
8423
|
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
7893
8424
|
});
|
|
7894
|
-
it('POST /api/taskforce/sync/workspace/apply-local should
|
|
8425
|
+
it('POST /api/taskforce/sync/workspace/apply-local should adopt cloud document references during normal sync even when content is unchanged', async () => {
|
|
7895
8426
|
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-apply-doc-metadata-'));
|
|
7896
8427
|
const projectRoot = fs.realpathSync(tmpRoot);
|
|
7897
8428
|
const basePath = path.join(tmpRoot, '.taskforce');
|
|
@@ -7945,7 +8476,7 @@ describe('Server Routes', () => {
|
|
|
7945
8476
|
const res = createMockRes();
|
|
7946
8477
|
await match?.handler(req, res, {});
|
|
7947
8478
|
expect(store.get('asset-sync-doc-meta-1', 'workspace-local')).toEqual(expect.objectContaining({
|
|
7948
|
-
referenceNumber:
|
|
8479
|
+
referenceNumber: 42
|
|
7949
8480
|
}));
|
|
7950
8481
|
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
7951
8482
|
});
|
|
@@ -8008,7 +8539,7 @@ describe('Server Routes', () => {
|
|
|
8008
8539
|
}));
|
|
8009
8540
|
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
8010
8541
|
});
|
|
8011
|
-
it('POST /api/taskforce/sync/workspace/apply-local should
|
|
8542
|
+
it('POST /api/taskforce/sync/workspace/apply-local should adopt cloud image references during normal sync even when content is unchanged', async () => {
|
|
8012
8543
|
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-apply-image-metadata-'));
|
|
8013
8544
|
const projectRoot = fs.realpathSync(tmpRoot);
|
|
8014
8545
|
const basePath = path.join(tmpRoot, '.taskforce');
|
|
@@ -8060,7 +8591,7 @@ describe('Server Routes', () => {
|
|
|
8060
8591
|
const res = createMockRes();
|
|
8061
8592
|
await match?.handler(req, res, {});
|
|
8062
8593
|
expect(store.get('asset-sync-image-meta-1', 'workspace-local')).toEqual(expect.objectContaining({
|
|
8063
|
-
referenceNumber:
|
|
8594
|
+
referenceNumber: 42
|
|
8064
8595
|
}));
|
|
8065
8596
|
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
8066
8597
|
});
|
|
@@ -8387,7 +8918,7 @@ describe('Server Routes', () => {
|
|
|
8387
8918
|
action: 'sync-workspace-push-idempotent-replay'
|
|
8388
8919
|
}));
|
|
8389
8920
|
});
|
|
8390
|
-
it('POST /api/taskforce/sync/workspace/push should
|
|
8921
|
+
it('POST /api/taskforce/sync/workspace/push should still return success when idempotency persistence fails after apply', async () => {
|
|
8391
8922
|
const authConfig = {
|
|
8392
8923
|
runtimeMode: 'cloud',
|
|
8393
8924
|
enabled: true,
|
|
@@ -8399,109 +8930,307 @@ describe('Server Routes', () => {
|
|
|
8399
8930
|
sessionSecret: 'test-secret',
|
|
8400
8931
|
sessionTtlSeconds: 3600
|
|
8401
8932
|
};
|
|
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');
|
|
8933
|
+
routes = createRoutes(core, { authConfig });
|
|
8410
8934
|
core.listUserWorkspaces.mockReturnValue([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
|
|
8411
|
-
|
|
8412
|
-
|
|
8413
|
-
|
|
8935
|
+
const cookie = createSessionTokenCookie('session-sync-push-idempotency-write-fail', authConfig, {
|
|
8936
|
+
role: 'owner',
|
|
8937
|
+
workspaceId: 'workspace-1',
|
|
8938
|
+
userId: 'user-1'
|
|
8939
|
+
});
|
|
8940
|
+
core.applyWorkspaceSyncSnapshot.mockReturnValueOnce({
|
|
8941
|
+
total: 1,
|
|
8942
|
+
created: 1,
|
|
8414
8943
|
updated: 0,
|
|
8415
8944
|
archived: 0,
|
|
8416
8945
|
unarchived: 0,
|
|
8417
8946
|
skipped: 0
|
|
8418
8947
|
});
|
|
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'
|
|
8948
|
+
core.writePushIdempotency.mockImplementationOnce(() => {
|
|
8949
|
+
throw new Error('disk full');
|
|
8448
8950
|
});
|
|
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
8951
|
const match = matchRoute('POST', '/api/taskforce/sync/workspace/push', routes);
|
|
8452
8952
|
const req = createMockReq('POST', '/api/taskforce/sync/workspace/push', {
|
|
8453
8953
|
workspaceId: 'workspace-1',
|
|
8954
|
+
idempotencyKey: 'push-idempotency-write-fail-1',
|
|
8454
8955
|
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
|
-
}
|
|
8956
|
+
{ op: 'upsert', task: { id: 'task-1', title: 'Task 1' }, archived: false }
|
|
8478
8957
|
]
|
|
8479
8958
|
}, { cookie });
|
|
8480
8959
|
const res = createMockRes();
|
|
8481
8960
|
await match?.handler(req, res, {});
|
|
8961
|
+
expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledTimes(1);
|
|
8482
8962
|
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
|
|
8963
|
+
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":true'));
|
|
8964
|
+
expect(core.addAdminAuditLog).toHaveBeenCalledWith(expect.objectContaining({
|
|
8965
|
+
action: 'sync-workspace-push-idempotency-write-failed'
|
|
8490
8966
|
}));
|
|
8491
|
-
expect(
|
|
8492
|
-
|
|
8493
|
-
|
|
8494
|
-
storageKey: imagePath
|
|
8967
|
+
expect(core.recordSyncEvent).toHaveBeenCalledWith('workspace-1', expect.objectContaining({
|
|
8968
|
+
eventType: 'push',
|
|
8969
|
+
status: 'success'
|
|
8495
8970
|
}));
|
|
8496
|
-
expect(store.listLinksForTask('task-sync-1', 'workspace-1')).toEqual(expect.arrayContaining([
|
|
8497
|
-
expect.objectContaining({ assetId: 'asset-doc-sync-1', role: 'attachment', deletedAt: null }),
|
|
8498
|
-
expect.objectContaining({ assetId: 'asset-image-sync-1', role: 'image', deletedAt: null })
|
|
8499
|
-
]));
|
|
8500
|
-
vi.unstubAllGlobals();
|
|
8501
|
-
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
8502
|
-
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
8503
8971
|
});
|
|
8504
|
-
it('POST /api/taskforce/sync/workspace/push should
|
|
8972
|
+
it('POST /api/taskforce/sync/workspace/push should still return success when applied-task readback fails after apply', async () => {
|
|
8973
|
+
const authConfig = {
|
|
8974
|
+
runtimeMode: 'cloud',
|
|
8975
|
+
enabled: true,
|
|
8976
|
+
requiredForApi: true,
|
|
8977
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
8978
|
+
defaultRole: 'member',
|
|
8979
|
+
defaultWorkspaceId: 'default',
|
|
8980
|
+
sessionCookieName: 'taskforce_session',
|
|
8981
|
+
sessionSecret: 'test-secret',
|
|
8982
|
+
sessionTtlSeconds: 3600
|
|
8983
|
+
};
|
|
8984
|
+
routes = createRoutes(core, { authConfig });
|
|
8985
|
+
core.listUserWorkspaces.mockReturnValue([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
|
|
8986
|
+
const cookie = createSessionTokenCookie('session-sync-push-upsert-echo-fail', authConfig, {
|
|
8987
|
+
role: 'owner',
|
|
8988
|
+
workspaceId: 'workspace-1',
|
|
8989
|
+
userId: 'user-1'
|
|
8990
|
+
});
|
|
8991
|
+
core.applyWorkspaceSyncSnapshot.mockReturnValueOnce({
|
|
8992
|
+
total: 1,
|
|
8993
|
+
created: 1,
|
|
8994
|
+
updated: 0,
|
|
8995
|
+
archived: 0,
|
|
8996
|
+
unarchived: 0,
|
|
8997
|
+
skipped: 0
|
|
8998
|
+
});
|
|
8999
|
+
core.getTask.mockImplementationOnce(() => {
|
|
9000
|
+
throw new Error('readback unavailable');
|
|
9001
|
+
});
|
|
9002
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/push', routes);
|
|
9003
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/push', {
|
|
9004
|
+
workspaceId: 'workspace-1',
|
|
9005
|
+
idempotencyKey: 'push-upsert-echo-fail-1',
|
|
9006
|
+
changes: [
|
|
9007
|
+
{ op: 'upsert', task: { id: 'task-1', title: 'Task 1' }, archived: false }
|
|
9008
|
+
]
|
|
9009
|
+
}, { cookie });
|
|
9010
|
+
const res = createMockRes();
|
|
9011
|
+
await match?.handler(req, res, {});
|
|
9012
|
+
expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledTimes(1);
|
|
9013
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
9014
|
+
const payload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
|
|
9015
|
+
expect(payload.success).toBe(true);
|
|
9016
|
+
expect(payload.appliedTaskUpserts).toEqual([]);
|
|
9017
|
+
expect(core.addAdminAuditLog).toHaveBeenCalledWith(expect.objectContaining({
|
|
9018
|
+
action: 'sync-workspace-push-upsert-echo-failed'
|
|
9019
|
+
}));
|
|
9020
|
+
expect(core.recordSyncEvent).toHaveBeenCalledWith('workspace-1', expect.objectContaining({
|
|
9021
|
+
eventType: 'push',
|
|
9022
|
+
status: 'success'
|
|
9023
|
+
}));
|
|
9024
|
+
});
|
|
9025
|
+
it('POST /api/taskforce/sync/workspace/push should still return success when success-path audit logging throws after apply', async () => {
|
|
9026
|
+
const authConfig = {
|
|
9027
|
+
runtimeMode: 'cloud',
|
|
9028
|
+
enabled: true,
|
|
9029
|
+
requiredForApi: true,
|
|
9030
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
9031
|
+
defaultRole: 'member',
|
|
9032
|
+
defaultWorkspaceId: 'default',
|
|
9033
|
+
sessionCookieName: 'taskforce_session',
|
|
9034
|
+
sessionSecret: 'test-secret',
|
|
9035
|
+
sessionTtlSeconds: 3600
|
|
9036
|
+
};
|
|
9037
|
+
routes = createRoutes(core, { authConfig });
|
|
9038
|
+
core.listUserWorkspaces.mockReturnValue([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
|
|
9039
|
+
const cookie = createSessionTokenCookie('session-sync-push-success-audit-fail', authConfig, {
|
|
9040
|
+
role: 'owner',
|
|
9041
|
+
workspaceId: 'workspace-1',
|
|
9042
|
+
userId: 'user-1'
|
|
9043
|
+
});
|
|
9044
|
+
core.applyWorkspaceSyncSnapshot.mockReturnValueOnce({
|
|
9045
|
+
total: 1,
|
|
9046
|
+
created: 1,
|
|
9047
|
+
updated: 0,
|
|
9048
|
+
archived: 0,
|
|
9049
|
+
unarchived: 0,
|
|
9050
|
+
skipped: 0
|
|
9051
|
+
});
|
|
9052
|
+
const originalAddAdminAuditLog = core.addAdminAuditLog.getMockImplementation();
|
|
9053
|
+
core.addAdminAuditLog.mockImplementation((entry) => {
|
|
9054
|
+
if (entry?.action === 'sync-workspace-push-success') {
|
|
9055
|
+
throw new Error('audit unavailable');
|
|
9056
|
+
}
|
|
9057
|
+
return originalAddAdminAuditLog ? originalAddAdminAuditLog(entry) : undefined;
|
|
9058
|
+
});
|
|
9059
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/push', routes);
|
|
9060
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/push', {
|
|
9061
|
+
workspaceId: 'workspace-1',
|
|
9062
|
+
idempotencyKey: 'push-success-audit-fail-1',
|
|
9063
|
+
changes: [
|
|
9064
|
+
{ op: 'upsert', task: { id: 'task-1', title: 'Task 1' }, archived: false }
|
|
9065
|
+
]
|
|
9066
|
+
}, { cookie });
|
|
9067
|
+
const res = createMockRes();
|
|
9068
|
+
await match?.handler(req, res, {});
|
|
9069
|
+
expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledTimes(1);
|
|
9070
|
+
expect(res.writeHead).toHaveBeenCalledTimes(1);
|
|
9071
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
9072
|
+
expect(res.end).toHaveBeenCalledTimes(1);
|
|
9073
|
+
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":true'));
|
|
9074
|
+
expect(core.recordSyncEvent).toHaveBeenCalledWith('workspace-1', expect.objectContaining({
|
|
9075
|
+
eventType: 'push',
|
|
9076
|
+
status: 'success'
|
|
9077
|
+
}));
|
|
9078
|
+
});
|
|
9079
|
+
it('POST /api/taskforce/sync/workspace/push should still return error response when failure-path telemetry throws', async () => {
|
|
9080
|
+
const authConfig = {
|
|
9081
|
+
runtimeMode: 'cloud',
|
|
9082
|
+
enabled: true,
|
|
9083
|
+
requiredForApi: true,
|
|
9084
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
9085
|
+
defaultRole: 'member',
|
|
9086
|
+
defaultWorkspaceId: 'default',
|
|
9087
|
+
sessionCookieName: 'taskforce_session',
|
|
9088
|
+
sessionSecret: 'test-secret',
|
|
9089
|
+
sessionTtlSeconds: 3600
|
|
9090
|
+
};
|
|
9091
|
+
routes = createRoutes(core, { authConfig });
|
|
9092
|
+
core.listUserWorkspaces.mockReturnValue([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
|
|
9093
|
+
const cookie = createSessionTokenCookie('session-sync-push-failure-telemetry-throw', authConfig, {
|
|
9094
|
+
role: 'owner',
|
|
9095
|
+
workspaceId: 'workspace-1',
|
|
9096
|
+
userId: 'user-1'
|
|
9097
|
+
});
|
|
9098
|
+
core.applyWorkspaceSyncSnapshot.mockImplementationOnce(() => {
|
|
9099
|
+
throw new Error('apply failed');
|
|
9100
|
+
});
|
|
9101
|
+
core.recordSyncEvent.mockImplementationOnce(() => {
|
|
9102
|
+
throw new Error('telemetry unavailable');
|
|
9103
|
+
});
|
|
9104
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/push', routes);
|
|
9105
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/push', {
|
|
9106
|
+
workspaceId: 'workspace-1',
|
|
9107
|
+
changes: [
|
|
9108
|
+
{ op: 'upsert', task: { id: 'task-1', title: 'Task 1' }, archived: false }
|
|
9109
|
+
]
|
|
9110
|
+
}, { cookie });
|
|
9111
|
+
const res = createMockRes();
|
|
9112
|
+
await match?.handler(req, res, {});
|
|
9113
|
+
expect(core.applyWorkspaceSyncSnapshot).toHaveBeenCalledTimes(1);
|
|
9114
|
+
expect(res.writeHead).toHaveBeenCalledTimes(1);
|
|
9115
|
+
expect(res.writeHead).toHaveBeenCalledWith(400, expect.any(Object));
|
|
9116
|
+
expect(res.end).toHaveBeenCalledTimes(1);
|
|
9117
|
+
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":false'));
|
|
9118
|
+
});
|
|
9119
|
+
it('POST /api/taskforce/sync/workspace/push should persist synced cloud attachments into R2-backed canonical assets', async () => {
|
|
9120
|
+
const authConfig = {
|
|
9121
|
+
runtimeMode: 'cloud',
|
|
9122
|
+
enabled: true,
|
|
9123
|
+
requiredForApi: true,
|
|
9124
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
9125
|
+
defaultRole: 'member',
|
|
9126
|
+
defaultWorkspaceId: 'default',
|
|
9127
|
+
sessionCookieName: 'taskforce_session',
|
|
9128
|
+
sessionSecret: 'test-secret',
|
|
9129
|
+
sessionTtlSeconds: 3600
|
|
9130
|
+
};
|
|
9131
|
+
const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
|
|
9132
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-push-r2-'));
|
|
9133
|
+
core.getPaths.mockReturnValue({
|
|
9134
|
+
projectRoot: tmpRoot,
|
|
9135
|
+
basePath: path.join(tmpRoot, '.taskforce')
|
|
9136
|
+
});
|
|
9137
|
+
core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
|
|
9138
|
+
core.getTenantId = vi.fn().mockReturnValue('default');
|
|
9139
|
+
core.listUserWorkspaces.mockReturnValue([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
|
|
9140
|
+
core.applyWorkspaceSyncSnapshot.mockReturnValue({
|
|
9141
|
+
total: 0,
|
|
9142
|
+
created: 0,
|
|
9143
|
+
updated: 0,
|
|
9144
|
+
archived: 0,
|
|
9145
|
+
unarchived: 0,
|
|
9146
|
+
skipped: 0
|
|
9147
|
+
});
|
|
9148
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
9149
|
+
ok: true,
|
|
9150
|
+
status: 200,
|
|
9151
|
+
statusText: 'OK',
|
|
9152
|
+
headers: new Headers(),
|
|
9153
|
+
text: async () => '',
|
|
9154
|
+
json: async () => ({})
|
|
9155
|
+
});
|
|
9156
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
9157
|
+
routes = createRoutes(core, {
|
|
9158
|
+
authConfig,
|
|
9159
|
+
objectStorage: {
|
|
9160
|
+
provider: 'r2',
|
|
9161
|
+
r2: {
|
|
9162
|
+
accountId: 'acct-1',
|
|
9163
|
+
bucket: 'taskforce-perf-assets',
|
|
9164
|
+
endpoint: 'https://acct-1.r2.cloudflarestorage.com',
|
|
9165
|
+
accessKeyId: 'access-key-1',
|
|
9166
|
+
secretAccessKey: 'secret-key-1',
|
|
9167
|
+
keyPrefix: '',
|
|
9168
|
+
signedUploadTtlSeconds: 600,
|
|
9169
|
+
signedDownloadTtlSeconds: 300
|
|
9170
|
+
}
|
|
9171
|
+
}
|
|
9172
|
+
});
|
|
9173
|
+
const cookie = createSessionTokenCookie('session-sync-push-r2', authConfig, {
|
|
9174
|
+
role: 'owner',
|
|
9175
|
+
workspaceId: 'workspace-1',
|
|
9176
|
+
userId: 'user-1'
|
|
9177
|
+
});
|
|
9178
|
+
const markdownPath = 'workspaces/workspace-1/assets/documents/asset-doc-sync-1-plan.md';
|
|
9179
|
+
const imagePath = 'workspaces/workspace-1/assets/images/asset-image-sync-1-proof.png';
|
|
9180
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/push', routes);
|
|
9181
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/push', {
|
|
9182
|
+
workspaceId: 'workspace-1',
|
|
9183
|
+
changes: [
|
|
9184
|
+
{
|
|
9185
|
+
op: 'document-upsert',
|
|
9186
|
+
path: markdownPath,
|
|
9187
|
+
updatedAt: '2026-03-18T23:20:00.000Z',
|
|
9188
|
+
content: '# Plan',
|
|
9189
|
+
assetId: 'asset-doc-sync-1',
|
|
9190
|
+
documentId: 'doc-sync-1',
|
|
9191
|
+
taskId: 'task-sync-1',
|
|
9192
|
+
originalFilename: 'plan.md',
|
|
9193
|
+
linkRole: 'attachment'
|
|
9194
|
+
},
|
|
9195
|
+
{
|
|
9196
|
+
op: 'asset-upsert',
|
|
9197
|
+
path: imagePath,
|
|
9198
|
+
updatedAt: '2026-03-18T23:20:01.000Z',
|
|
9199
|
+
contentBase64: Buffer.from('png-bytes').toString('base64'),
|
|
9200
|
+
assetId: 'asset-image-sync-1',
|
|
9201
|
+
kind: 'image',
|
|
9202
|
+
mimeType: 'image/png',
|
|
9203
|
+
taskId: 'task-sync-1',
|
|
9204
|
+
originalFilename: 'proof.png',
|
|
9205
|
+
linkRole: 'image'
|
|
9206
|
+
}
|
|
9207
|
+
]
|
|
9208
|
+
}, { cookie });
|
|
9209
|
+
const res = createMockRes();
|
|
9210
|
+
await match?.handler(req, res, {});
|
|
9211
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
9212
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
9213
|
+
const docAsset = store.getByStorageKey(markdownPath, 'workspace-1');
|
|
9214
|
+
const imageAsset = store.getByStorageKey(imagePath, 'workspace-1');
|
|
9215
|
+
expect(docAsset).toEqual(expect.objectContaining({
|
|
9216
|
+
assetId: 'asset-doc-sync-1',
|
|
9217
|
+
storageProvider: 'r2',
|
|
9218
|
+
storageKey: markdownPath
|
|
9219
|
+
}));
|
|
9220
|
+
expect(imageAsset).toEqual(expect.objectContaining({
|
|
9221
|
+
assetId: 'asset-image-sync-1',
|
|
9222
|
+
storageProvider: 'r2',
|
|
9223
|
+
storageKey: imagePath
|
|
9224
|
+
}));
|
|
9225
|
+
expect(store.listLinksForTask('task-sync-1', 'workspace-1')).toEqual(expect.arrayContaining([
|
|
9226
|
+
expect.objectContaining({ assetId: 'asset-doc-sync-1', role: 'attachment', deletedAt: null }),
|
|
9227
|
+
expect.objectContaining({ assetId: 'asset-image-sync-1', role: 'image', deletedAt: null })
|
|
9228
|
+
]));
|
|
9229
|
+
vi.unstubAllGlobals();
|
|
9230
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
9231
|
+
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
9232
|
+
});
|
|
9233
|
+
it('POST /api/taskforce/sync/workspace/push should audit unauthorized access', async () => {
|
|
8505
9234
|
const authConfig = {
|
|
8506
9235
|
runtimeMode: 'cloud',
|
|
8507
9236
|
enabled: true,
|
|
@@ -9417,6 +10146,127 @@ describe('Server Routes', () => {
|
|
|
9417
10146
|
expect(res.setHeader).toHaveBeenCalledWith('Set-Cookie', expect.stringContaining('taskforce_session='));
|
|
9418
10147
|
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
9419
10148
|
});
|
|
10149
|
+
it('POST /api/taskforce/session/workspace should allow a read-only user to switch to another active workspace', async () => {
|
|
10150
|
+
const authConfig = {
|
|
10151
|
+
runtimeMode: 'cloud',
|
|
10152
|
+
enabled: true,
|
|
10153
|
+
requiredForApi: true,
|
|
10154
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
10155
|
+
defaultRole: 'member',
|
|
10156
|
+
defaultWorkspaceId: 'default',
|
|
10157
|
+
sessionCookieName: 'taskforce_session',
|
|
10158
|
+
sessionSecret: 'test-secret',
|
|
10159
|
+
sessionTtlSeconds: 3600
|
|
10160
|
+
};
|
|
10161
|
+
routes = createRoutes(core, { authConfig });
|
|
10162
|
+
const cookie = createSessionTokenCookie('session-read-only', authConfig, {
|
|
10163
|
+
role: 'read-only',
|
|
10164
|
+
workspaceId: 'workspace-shared',
|
|
10165
|
+
userId: 'user-1'
|
|
10166
|
+
});
|
|
10167
|
+
core.listUserWorkspaces.mockReturnValue([
|
|
10168
|
+
{
|
|
10169
|
+
id: 'workspace-shared',
|
|
10170
|
+
slug: 'workspace-shared',
|
|
10171
|
+
name: 'Shared Workspace',
|
|
10172
|
+
description: null,
|
|
10173
|
+
role: 'read-only',
|
|
10174
|
+
status: 'active',
|
|
10175
|
+
betaAccess: true
|
|
10176
|
+
},
|
|
10177
|
+
{
|
|
10178
|
+
id: 'workspace-home',
|
|
10179
|
+
slug: 'workspace-home',
|
|
10180
|
+
name: 'Home Workspace',
|
|
10181
|
+
description: null,
|
|
10182
|
+
role: 'owner',
|
|
10183
|
+
status: 'active',
|
|
10184
|
+
betaAccess: true
|
|
10185
|
+
}
|
|
10186
|
+
]);
|
|
10187
|
+
core.resolveUserAccess.mockReturnValue({
|
|
10188
|
+
userId: 'user-1',
|
|
10189
|
+
workspaceId: 'workspace-home',
|
|
10190
|
+
role: 'owner',
|
|
10191
|
+
permissionMode: 'read-write',
|
|
10192
|
+
disabled: false,
|
|
10193
|
+
betaAccess: true
|
|
10194
|
+
});
|
|
10195
|
+
const match = matchRoute('POST', '/api/taskforce/session/workspace', routes);
|
|
10196
|
+
const req = createMockReq('POST', '/api/taskforce/session/workspace', {
|
|
10197
|
+
workspaceId: 'workspace-home'
|
|
10198
|
+
}, {
|
|
10199
|
+
cookie
|
|
10200
|
+
});
|
|
10201
|
+
const res = createMockRes();
|
|
10202
|
+
res.setHeader = vi.fn();
|
|
10203
|
+
await match?.handler(req, res, {});
|
|
10204
|
+
expect(core.resolveUserAccess).toHaveBeenCalledWith('user-1', 'workspace-home', 'read-only');
|
|
10205
|
+
expect(res.setHeader).toHaveBeenCalledWith('Set-Cookie', expect.stringContaining('taskforce_session='));
|
|
10206
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
10207
|
+
const payload = JSON.parse(String(res.end.mock.calls[0]?.[0] || '{}'));
|
|
10208
|
+
expect(payload).toMatchObject({ success: true, workspaceId: 'workspace-home', role: 'owner' });
|
|
10209
|
+
});
|
|
10210
|
+
it('POST /api/taskforce/session/workspace should allow switching into an active read-only workspace', async () => {
|
|
10211
|
+
const authConfig = {
|
|
10212
|
+
runtimeMode: 'cloud',
|
|
10213
|
+
enabled: true,
|
|
10214
|
+
requiredForApi: true,
|
|
10215
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
10216
|
+
defaultRole: 'member',
|
|
10217
|
+
defaultWorkspaceId: 'default',
|
|
10218
|
+
sessionCookieName: 'taskforce_session',
|
|
10219
|
+
sessionSecret: 'test-secret',
|
|
10220
|
+
sessionTtlSeconds: 3600
|
|
10221
|
+
};
|
|
10222
|
+
routes = createRoutes(core, { authConfig });
|
|
10223
|
+
const cookie = createSessionTokenCookie('session-member', authConfig, {
|
|
10224
|
+
role: 'owner',
|
|
10225
|
+
workspaceId: 'workspace-home',
|
|
10226
|
+
userId: 'user-1'
|
|
10227
|
+
});
|
|
10228
|
+
core.listUserWorkspaces.mockReturnValue([
|
|
10229
|
+
{
|
|
10230
|
+
id: 'workspace-home',
|
|
10231
|
+
slug: 'workspace-home',
|
|
10232
|
+
name: 'Home Workspace',
|
|
10233
|
+
description: null,
|
|
10234
|
+
role: 'owner',
|
|
10235
|
+
status: 'active',
|
|
10236
|
+
betaAccess: true
|
|
10237
|
+
},
|
|
10238
|
+
{
|
|
10239
|
+
id: 'workspace-shared',
|
|
10240
|
+
slug: 'workspace-shared',
|
|
10241
|
+
name: 'Shared Workspace',
|
|
10242
|
+
description: null,
|
|
10243
|
+
role: 'read-only',
|
|
10244
|
+
status: 'active',
|
|
10245
|
+
betaAccess: true
|
|
10246
|
+
}
|
|
10247
|
+
]);
|
|
10248
|
+
core.resolveUserAccess.mockReturnValue({
|
|
10249
|
+
userId: 'user-1',
|
|
10250
|
+
workspaceId: 'workspace-shared',
|
|
10251
|
+
role: 'read-only',
|
|
10252
|
+
permissionMode: 'read-only',
|
|
10253
|
+
disabled: false,
|
|
10254
|
+
betaAccess: true
|
|
10255
|
+
});
|
|
10256
|
+
const match = matchRoute('POST', '/api/taskforce/session/workspace', routes);
|
|
10257
|
+
const req = createMockReq('POST', '/api/taskforce/session/workspace', {
|
|
10258
|
+
workspaceId: 'workspace-shared'
|
|
10259
|
+
}, {
|
|
10260
|
+
cookie
|
|
10261
|
+
});
|
|
10262
|
+
const res = createMockRes();
|
|
10263
|
+
res.setHeader = vi.fn();
|
|
10264
|
+
await match?.handler(req, res, {});
|
|
10265
|
+
expect(res.setHeader).toHaveBeenCalledWith('Set-Cookie', expect.stringContaining('taskforce_session='));
|
|
10266
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
10267
|
+
const payload = JSON.parse(String(res.end.mock.calls[0]?.[0] || '{}'));
|
|
10268
|
+
expect(payload).toMatchObject({ success: true, workspaceId: 'workspace-shared', role: 'read-only' });
|
|
10269
|
+
});
|
|
9420
10270
|
it('POST /api/taskforce/session/workspace should be disabled in local runtime', async () => {
|
|
9421
10271
|
const authConfig = {
|
|
9422
10272
|
runtimeMode: 'local',
|
|
@@ -9513,6 +10363,7 @@ describe('Server Routes', () => {
|
|
|
9513
10363
|
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
9514
10364
|
expect(payload.authenticated).toBe(true);
|
|
9515
10365
|
expect(payload.betaAccess).toBe(false);
|
|
10366
|
+
expect(res.setHeader).toHaveBeenCalledWith('Server-Timing', expect.stringContaining('auth-session'));
|
|
9516
10367
|
expect(core.getUserBetaAccess).toHaveBeenCalledWith('u-no-workspace');
|
|
9517
10368
|
});
|
|
9518
10369
|
it('GET /api/taskforce/auth/session should expose avatarUrl from the resolved profile', async () => {
|
|
@@ -9723,6 +10574,74 @@ describe('Server Routes', () => {
|
|
|
9723
10574
|
mimeType: 'image/png'
|
|
9724
10575
|
}));
|
|
9725
10576
|
});
|
|
10577
|
+
it('POST /api/taskforce/auth/profile/avatar/upload should store the avatar body through object storage', async () => {
|
|
10578
|
+
const authConfig = {
|
|
10579
|
+
runtimeMode: 'cloud',
|
|
10580
|
+
enabled: true,
|
|
10581
|
+
requiredForApi: true,
|
|
10582
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
10583
|
+
defaultRole: 'member',
|
|
10584
|
+
defaultWorkspaceId: 'default',
|
|
10585
|
+
sessionCookieName: 'taskforce_session',
|
|
10586
|
+
sessionSecret: 'test-secret',
|
|
10587
|
+
sessionTtlSeconds: 3600
|
|
10588
|
+
};
|
|
10589
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
10590
|
+
ok: true,
|
|
10591
|
+
status: 200,
|
|
10592
|
+
statusText: 'OK',
|
|
10593
|
+
text: async () => ''
|
|
10594
|
+
});
|
|
10595
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
10596
|
+
const routesWithStorage = createRoutes(core, {
|
|
10597
|
+
authConfig,
|
|
10598
|
+
objectStorage: {
|
|
10599
|
+
provider: 'r2',
|
|
10600
|
+
r2: {
|
|
10601
|
+
accountId: 'acct-1',
|
|
10602
|
+
bucket: 'taskforce-docs',
|
|
10603
|
+
endpoint: 'https://example.r2.cloudflarestorage.com',
|
|
10604
|
+
accessKeyId: 'access-key-1',
|
|
10605
|
+
secretAccessKey: 'secret-key-1',
|
|
10606
|
+
keyPrefix: 'taskforce',
|
|
10607
|
+
signedUploadTtlSeconds: 600,
|
|
10608
|
+
signedDownloadTtlSeconds: 300
|
|
10609
|
+
}
|
|
10610
|
+
}
|
|
10611
|
+
});
|
|
10612
|
+
core.getUserAvatarUploadDraft.mockReturnValueOnce({
|
|
10613
|
+
draftId: 'avatar-draft-1',
|
|
10614
|
+
userId: 'u1',
|
|
10615
|
+
storageKey: 'users/u1/profile/avatar/avatar-draft-1-headshot.png',
|
|
10616
|
+
mimeType: 'image/png',
|
|
10617
|
+
finalizedAt: null,
|
|
10618
|
+
discardedAt: null
|
|
10619
|
+
});
|
|
10620
|
+
const cookie = createSessionTokenCookie('session-avatar-upload', authConfig, {
|
|
10621
|
+
userId: 'u1',
|
|
10622
|
+
role: 'member',
|
|
10623
|
+
workspaceId: 'workspace-1'
|
|
10624
|
+
});
|
|
10625
|
+
const match = matchRoute('POST', '/api/taskforce/auth/profile/avatar/upload', routesWithStorage);
|
|
10626
|
+
const req = createMockReq('POST', '/api/taskforce/auth/profile/avatar/upload', Buffer.from([1, 2, 3, 4]), {
|
|
10627
|
+
cookie,
|
|
10628
|
+
'content-type': 'image/png',
|
|
10629
|
+
'x-taskforce-avatar-draft-id': 'avatar-draft-1'
|
|
10630
|
+
});
|
|
10631
|
+
const res = createMockRes();
|
|
10632
|
+
await match?.handler(req, res, {});
|
|
10633
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
10634
|
+
expect(payload.success).toBe(true);
|
|
10635
|
+
expect(payload.draftId).toBe('avatar-draft-1');
|
|
10636
|
+
const r2Call = fetchMock.mock.calls[0];
|
|
10637
|
+
expect(String(r2Call?.[0])).toContain('https://example.r2.cloudflarestorage.com/taskforce-docs/taskforce/users/u1/profile/avatar/avatar-draft-1-headshot.png');
|
|
10638
|
+
expect(r2Call?.[1]).toEqual(expect.objectContaining({
|
|
10639
|
+
method: 'PUT',
|
|
10640
|
+
body: expect.any(Uint8Array)
|
|
10641
|
+
}));
|
|
10642
|
+
expect((r2Call?.[1]?.headers)['content-type']).toBe('image/png');
|
|
10643
|
+
vi.unstubAllGlobals();
|
|
10644
|
+
});
|
|
9726
10645
|
it('POST /api/taskforce/auth/profile/avatar/finalize should validate and finalize the uploaded draft', async () => {
|
|
9727
10646
|
const authConfig = {
|
|
9728
10647
|
runtimeMode: 'cloud',
|
|
@@ -9847,6 +10766,56 @@ describe('Server Routes', () => {
|
|
|
9847
10766
|
allowed: true
|
|
9848
10767
|
});
|
|
9849
10768
|
});
|
|
10769
|
+
it('GET /api/taskforce/account/team-management-access should resolve against the authenticated session user and workspace', async () => {
|
|
10770
|
+
const authConfig = {
|
|
10771
|
+
runtimeMode: 'cloud',
|
|
10772
|
+
enabled: true,
|
|
10773
|
+
requiredForApi: true,
|
|
10774
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
10775
|
+
defaultRole: 'member',
|
|
10776
|
+
defaultWorkspaceId: 'default',
|
|
10777
|
+
sessionCookieName: 'taskforce_session',
|
|
10778
|
+
sessionSecret: 'test-secret',
|
|
10779
|
+
sessionTtlSeconds: 3600
|
|
10780
|
+
};
|
|
10781
|
+
routes = createRoutes(core, { authConfig });
|
|
10782
|
+
core.listUserWorkspaces.mockReturnValueOnce([
|
|
10783
|
+
{ id: 'workspace-auth-existing-home', slug: 'home', name: 'Home', description: null, role: 'owner', status: 'active', betaAccess: true },
|
|
10784
|
+
{ id: 'workspace-invited', slug: 'invited', name: 'Invited', description: null, role: 'member', status: 'active', betaAccess: true }
|
|
10785
|
+
]);
|
|
10786
|
+
core.resolveUserAccess.mockImplementation((userId, workspaceId, role) => ({
|
|
10787
|
+
userId,
|
|
10788
|
+
workspaceId,
|
|
10789
|
+
role: userId === 'joined-member' && workspaceId === 'workspace-invited' ? 'member' : role,
|
|
10790
|
+
disabled: false,
|
|
10791
|
+
betaAccess: true
|
|
10792
|
+
}));
|
|
10793
|
+
const cookie = createSessionTokenCookie('session-joined-member', authConfig, {
|
|
10794
|
+
userId: 'joined-member',
|
|
10795
|
+
role: 'member',
|
|
10796
|
+
workspaceId: 'workspace-invited'
|
|
10797
|
+
});
|
|
10798
|
+
const match = matchRoute('GET', '/api/taskforce/account/team-management-access', routes);
|
|
10799
|
+
const req = createMockReq('GET', '/api/taskforce/account/team-management-access', undefined, {
|
|
10800
|
+
cookie,
|
|
10801
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
10802
|
+
'x-taskforce-user-id': 'workspace-auth-existing-home-owner',
|
|
10803
|
+
'x-taskforce-workspace-id': 'workspace-auth-existing-home',
|
|
10804
|
+
'x-taskforce-role': 'owner'
|
|
10805
|
+
});
|
|
10806
|
+
const res = createMockRes();
|
|
10807
|
+
await match?.handler(req, res, {});
|
|
10808
|
+
expect(core.resolveUserAccess).toHaveBeenCalledWith('joined-member', 'workspace-invited', 'member');
|
|
10809
|
+
expect(core.getAccountEntitlementsPayload).toHaveBeenCalledWith({ userId: 'joined-member', workspaceId: 'workspace-invited' });
|
|
10810
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
10811
|
+
expect(payload).toMatchObject({
|
|
10812
|
+
workspaceId: 'workspace-invited',
|
|
10813
|
+
role: 'member',
|
|
10814
|
+
canManageWorkspace: false,
|
|
10815
|
+
teamPlanMode: 'team',
|
|
10816
|
+
allowed: false
|
|
10817
|
+
});
|
|
10818
|
+
});
|
|
9850
10819
|
it('GET /api/taskforce/account/team-management-access should fall back to the hinted workspace role when local access cannot resolve the cloud user id', async () => {
|
|
9851
10820
|
core.resolveUserAccess.mockReturnValueOnce(null);
|
|
9852
10821
|
const match = matchRoute('GET', '/api/taskforce/account/team-management-access', routes);
|
|
@@ -9923,6 +10892,40 @@ describe('Server Routes', () => {
|
|
|
9923
10892
|
planSelectionRequired: false
|
|
9924
10893
|
});
|
|
9925
10894
|
});
|
|
10895
|
+
it('GET /api/taskforce/account/ai-profile-seat-usage should return cloud-authoritative ai seat usage', async () => {
|
|
10896
|
+
const authConfig = {
|
|
10897
|
+
runtimeMode: 'cloud',
|
|
10898
|
+
enabled: true,
|
|
10899
|
+
requiredForApi: true,
|
|
10900
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
10901
|
+
defaultRole: 'member',
|
|
10902
|
+
defaultWorkspaceId: 'default',
|
|
10903
|
+
sessionCookieName: 'taskforce_session',
|
|
10904
|
+
sessionSecret: 'test-secret',
|
|
10905
|
+
sessionTtlSeconds: 3600
|
|
10906
|
+
};
|
|
10907
|
+
routes = createRoutes(core, { authConfig });
|
|
10908
|
+
core.countAiProfileUsage.mockReturnValueOnce({ used: 0, limit: 20, remaining: 20 });
|
|
10909
|
+
const match = matchRoute('GET', '/api/taskforce/account/ai-profile-seat-usage', routes);
|
|
10910
|
+
const cookie = createSessionTokenCookie('session-ai-seat-usage', authConfig, {
|
|
10911
|
+
userId: 'u1',
|
|
10912
|
+
role: 'owner',
|
|
10913
|
+
workspaceId: 'workspace-1'
|
|
10914
|
+
});
|
|
10915
|
+
const req = createMockReq('GET', '/api/taskforce/account/ai-profile-seat-usage?workspaceId=workspace-1', undefined, {
|
|
10916
|
+
cookie,
|
|
10917
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
10918
|
+
'x-taskforce-user-id': 'u1',
|
|
10919
|
+
'x-taskforce-role': 'owner',
|
|
10920
|
+
'x-taskforce-workspace-id': 'workspace-1'
|
|
10921
|
+
});
|
|
10922
|
+
const res = createMockRes();
|
|
10923
|
+
await match?.handler(req, res, {});
|
|
10924
|
+
expect(core.countAiProfileUsage).toHaveBeenCalledWith('workspace-1');
|
|
10925
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
10926
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
10927
|
+
expect(payload.aiProfileSeatUsage).toEqual({ used: 0, limit: 20, remaining: 20 });
|
|
10928
|
+
});
|
|
9926
10929
|
it('GET /api/taskforce/account/access-status should return pending plan-selection gate', async () => {
|
|
9927
10930
|
core.getAccountAccessStatus.mockReturnValueOnce({
|
|
9928
10931
|
gate: 'plan_selection_required',
|
|
@@ -9995,6 +10998,78 @@ describe('Server Routes', () => {
|
|
|
9995
10998
|
error.code = 'ENTITLEMENT_NOT_FOUND';
|
|
9996
10999
|
throw error;
|
|
9997
11000
|
});
|
|
11001
|
+
core.countAiProfileUsage.mockReturnValueOnce({ used: 0, limit: 20, remaining: 20 });
|
|
11002
|
+
const match = matchRoute('GET', '/api/taskforce/account/profile-summary', routes);
|
|
11003
|
+
const req = createMockReq('GET', '/api/taskforce/account/profile-summary?workspaceId=workspace-1', undefined, {
|
|
11004
|
+
'x-taskforce-user-id': 'u1'
|
|
11005
|
+
});
|
|
11006
|
+
const res = createMockRes();
|
|
11007
|
+
await match?.handler(req, res, {});
|
|
11008
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
11009
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
11010
|
+
expect(payload).toMatchObject({
|
|
11011
|
+
planId: 'pro',
|
|
11012
|
+
planVersionId: 'pro-v1',
|
|
11013
|
+
entitlementState: 'checkout_pending',
|
|
11014
|
+
gate: 'checkout_pending',
|
|
11015
|
+
message: 'Complete checkout to continue.',
|
|
11016
|
+
billingConflictCode: null,
|
|
11017
|
+
planSelectionRequired: false,
|
|
11018
|
+
workspaceId: 'workspace-1',
|
|
11019
|
+
workspaceRole: 'owner',
|
|
11020
|
+
canManageWorkspace: true,
|
|
11021
|
+
teamPlanMode: 'personal',
|
|
11022
|
+
teamManagementAllowed: false,
|
|
11023
|
+
aiProfileSeatUsage: {
|
|
11024
|
+
used: 0,
|
|
11025
|
+
limit: 20,
|
|
11026
|
+
remaining: 20
|
|
11027
|
+
}
|
|
11028
|
+
});
|
|
11029
|
+
});
|
|
11030
|
+
it('GET /api/taskforce/account/profile-summary should not leak stale billing mirror plan identity when entitlement is missing', async () => {
|
|
11031
|
+
core.resolveUserAccess.mockReturnValueOnce({
|
|
11032
|
+
userId: 'u1',
|
|
11033
|
+
workspaceId: 'workspace-1',
|
|
11034
|
+
role: 'member',
|
|
11035
|
+
disabled: false,
|
|
11036
|
+
betaAccess: true
|
|
11037
|
+
});
|
|
11038
|
+
core.getAccountAccessStatus.mockReturnValueOnce({
|
|
11039
|
+
gate: 'missing_entitlement',
|
|
11040
|
+
hasEntitlement: false,
|
|
11041
|
+
canAccessApp: false,
|
|
11042
|
+
canAccessPlans: true,
|
|
11043
|
+
planSelectionRequired: false,
|
|
11044
|
+
message: 'No entitlement is linked to this account.',
|
|
11045
|
+
entitlement: null
|
|
11046
|
+
});
|
|
11047
|
+
core.countAiProfileUsage.mockReturnValueOnce({ used: 0, limit: 20, remaining: 20 });
|
|
11048
|
+
const baseDb = core.getDatabaseAdapter();
|
|
11049
|
+
core.getDatabaseAdapter = vi.fn().mockReturnValue({
|
|
11050
|
+
...baseDb,
|
|
11051
|
+
prepare: vi.fn((sql) => {
|
|
11052
|
+
if (sql.includes('FROM user_billing')) {
|
|
11053
|
+
return {
|
|
11054
|
+
get: () => ({
|
|
11055
|
+
stripe_status: 'active',
|
|
11056
|
+
stripe_customer_id: 'cus_stale',
|
|
11057
|
+
stripe_subscription_id: 'sub_stale',
|
|
11058
|
+
stripe_price_id: 'price_stale',
|
|
11059
|
+
billing_interval: 'month',
|
|
11060
|
+
entitlement_state: 'active',
|
|
11061
|
+
tier: 'team-5'
|
|
11062
|
+
})
|
|
11063
|
+
};
|
|
11064
|
+
}
|
|
11065
|
+
if (sql.includes('FROM plan_catalog')) {
|
|
11066
|
+
return {
|
|
11067
|
+
get: () => ({ display_name: 'Team 5' })
|
|
11068
|
+
};
|
|
11069
|
+
}
|
|
11070
|
+
return baseDb.prepare(sql);
|
|
11071
|
+
})
|
|
11072
|
+
});
|
|
9998
11073
|
const match = matchRoute('GET', '/api/taskforce/account/profile-summary', routes);
|
|
9999
11074
|
const req = createMockReq('GET', '/api/taskforce/account/profile-summary?workspaceId=workspace-1', undefined, {
|
|
10000
11075
|
'x-taskforce-user-id': 'u1'
|
|
@@ -10004,18 +11079,15 @@ describe('Server Routes', () => {
|
|
|
10004
11079
|
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
10005
11080
|
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
10006
11081
|
expect(payload).toMatchObject({
|
|
10007
|
-
planId:
|
|
10008
|
-
planVersionId:
|
|
10009
|
-
entitlementState:
|
|
10010
|
-
gate: '
|
|
10011
|
-
message: '
|
|
10012
|
-
billingConflictCode: null,
|
|
11082
|
+
planId: null,
|
|
11083
|
+
planVersionId: null,
|
|
11084
|
+
entitlementState: null,
|
|
11085
|
+
gate: 'missing_entitlement',
|
|
11086
|
+
message: 'No entitlement is linked to this account.',
|
|
10013
11087
|
planSelectionRequired: false,
|
|
10014
|
-
|
|
10015
|
-
|
|
10016
|
-
|
|
10017
|
-
teamPlanMode: 'personal',
|
|
10018
|
-
teamManagementAllowed: false
|
|
11088
|
+
stripeStatus: 'active',
|
|
11089
|
+
stripeCustomerId: 'cus_stale',
|
|
11090
|
+
stripeSubscriptionId: 'sub_stale'
|
|
10019
11091
|
});
|
|
10020
11092
|
});
|
|
10021
11093
|
it('GET /api/taskforce/account/entitlements should surface checkout-pending gate when billing is not complete', async () => {
|
|
@@ -10087,6 +11159,20 @@ describe('Server Routes', () => {
|
|
|
10087
11159
|
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"code":"PLAN_SELECTION_REQUIRED"'));
|
|
10088
11160
|
expect(core.listTasks).not.toHaveBeenCalled();
|
|
10089
11161
|
});
|
|
11162
|
+
it('GET /api/taskforce/tasks should require authenticated cloud users', async () => {
|
|
11163
|
+
const authConfig = createCloudAuthConfig();
|
|
11164
|
+
routes = createRoutes(core, { authConfig });
|
|
11165
|
+
core.listTasks.mockReturnValue({ tasks: [] });
|
|
11166
|
+
const match = matchRoute('GET', '/api/taskforce/tasks', routes);
|
|
11167
|
+
const req = createMockReq('GET', '/api/taskforce/tasks', undefined, {
|
|
11168
|
+
'x-taskforce-runtime-mode': 'cloud'
|
|
11169
|
+
});
|
|
11170
|
+
const res = createMockRes();
|
|
11171
|
+
await match?.handler(req, res, {});
|
|
11172
|
+
expect(res.writeHead).toHaveBeenCalledWith(401, expect.any(Object));
|
|
11173
|
+
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"Authentication required."'));
|
|
11174
|
+
expect(core.listTasks).not.toHaveBeenCalled();
|
|
11175
|
+
});
|
|
10090
11176
|
it('GET /api/taskforce/tasks should block authenticated users with missing entitlements', async () => {
|
|
10091
11177
|
const authConfig = {
|
|
10092
11178
|
runtimeMode: 'cloud',
|
|
@@ -10194,17 +11280,31 @@ describe('Server Routes', () => {
|
|
|
10194
11280
|
try {
|
|
10195
11281
|
core.getTenantId = vi.fn().mockReturnValue('default');
|
|
10196
11282
|
core.getDatabaseAdapter = vi.fn().mockReturnValue({
|
|
10197
|
-
prepare: vi.fn().
|
|
10198
|
-
|
|
10199
|
-
{
|
|
10200
|
-
|
|
10201
|
-
|
|
10202
|
-
|
|
10203
|
-
|
|
10204
|
-
|
|
10205
|
-
|
|
10206
|
-
|
|
10207
|
-
|
|
11283
|
+
prepare: vi.fn().mockImplementation((sql) => {
|
|
11284
|
+
if (sql.includes('FROM billing_price_mappings')) {
|
|
11285
|
+
return {
|
|
11286
|
+
get: vi.fn().mockReturnValue({
|
|
11287
|
+
count: 1,
|
|
11288
|
+
max_updated_at: '2026-01-01T00:00:00.000Z'
|
|
11289
|
+
}),
|
|
11290
|
+
all: vi.fn().mockReturnValue([
|
|
11291
|
+
{
|
|
11292
|
+
plan_version_id: 'free-v1',
|
|
11293
|
+
billing_interval: 'month',
|
|
11294
|
+
stripe_price_id: 'free:free-v1:month',
|
|
11295
|
+
pricing_type: 'free',
|
|
11296
|
+
unit_amount: 0,
|
|
11297
|
+
currency: null
|
|
11298
|
+
}
|
|
11299
|
+
])
|
|
11300
|
+
};
|
|
11301
|
+
}
|
|
11302
|
+
return {
|
|
11303
|
+
get: vi.fn().mockReturnValue({
|
|
11304
|
+
count: 0,
|
|
11305
|
+
max_updated_at: null
|
|
11306
|
+
})
|
|
11307
|
+
};
|
|
10208
11308
|
})
|
|
10209
11309
|
});
|
|
10210
11310
|
core.listPlanCatalog.mockReturnValueOnce([
|
|
@@ -10224,6 +11324,7 @@ describe('Server Routes', () => {
|
|
|
10224
11324
|
}
|
|
10225
11325
|
}
|
|
10226
11326
|
]);
|
|
11327
|
+
core.listPlanFeatureCatalog = vi.fn().mockReturnValue([]);
|
|
10227
11328
|
routes = createRoutes(core);
|
|
10228
11329
|
const match = matchRoute('GET', '/api/taskforce/public/pricing', routes);
|
|
10229
11330
|
const req = createMockReq('GET', '/api/taskforce/public/pricing', undefined, {
|
|
@@ -10235,7 +11336,15 @@ describe('Server Routes', () => {
|
|
|
10235
11336
|
const payload = JSON.parse(String(res.end.mock.calls[0][0]));
|
|
10236
11337
|
expect(payload.plans).toHaveLength(1);
|
|
10237
11338
|
expect(payload.plans[0].features).toEqual([
|
|
10238
|
-
{
|
|
11339
|
+
{
|
|
11340
|
+
featureKey: 'ai.assistance',
|
|
11341
|
+
access: 'enabled',
|
|
11342
|
+
config: {},
|
|
11343
|
+
label: null,
|
|
11344
|
+
description: null,
|
|
11345
|
+
publicLabel: null,
|
|
11346
|
+
publicDescription: null
|
|
11347
|
+
}
|
|
10239
11348
|
]);
|
|
10240
11349
|
}
|
|
10241
11350
|
finally {
|
|
@@ -10267,6 +11376,7 @@ describe('Server Routes', () => {
|
|
|
10267
11376
|
expect(core.upsertPlanCatalog).toHaveBeenCalledWith({
|
|
10268
11377
|
planId: 'enterprise',
|
|
10269
11378
|
displayName: 'Enterprise',
|
|
11379
|
+
description: undefined,
|
|
10270
11380
|
status: 'active',
|
|
10271
11381
|
initialTrialDays: 0,
|
|
10272
11382
|
initialMode: undefined,
|
|
@@ -10286,6 +11396,42 @@ describe('Server Routes', () => {
|
|
|
10286
11396
|
expect(core.listPlanCatalog).toHaveBeenCalled();
|
|
10287
11397
|
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
10288
11398
|
});
|
|
11399
|
+
it('GET /api/platform/onboarding-plan-options should return onboarding plan options for system admin', async () => {
|
|
11400
|
+
core.listOnboardingPlanOptions = vi.fn().mockReturnValue([
|
|
11401
|
+
{
|
|
11402
|
+
planId: 'free',
|
|
11403
|
+
planDisplayName: 'Free',
|
|
11404
|
+
defaultPlanVersionId: 'free-v1',
|
|
11405
|
+
defaultVersionNumber: 1,
|
|
11406
|
+
planStatus: 'active',
|
|
11407
|
+
supportsAutoGrantFree: true,
|
|
11408
|
+
supportsAutoStartTrial: false
|
|
11409
|
+
}
|
|
11410
|
+
]);
|
|
11411
|
+
const match = matchRoute('GET', '/api/platform/onboarding-plan-options', routes);
|
|
11412
|
+
const req = createMockReq('GET', '/api/platform/onboarding-plan-options', undefined, {
|
|
11413
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
11414
|
+
'x-taskforce-system-role': 'system_admin'
|
|
11415
|
+
});
|
|
11416
|
+
const res = createMockRes();
|
|
11417
|
+
await match?.handler(req, res, {});
|
|
11418
|
+
expect(core.listOnboardingPlanOptions).toHaveBeenCalled();
|
|
11419
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
11420
|
+
expect(JSON.parse(res.end.mock.calls[0]?.[0] || '{}')).toEqual({
|
|
11421
|
+
success: true,
|
|
11422
|
+
options: [
|
|
11423
|
+
{
|
|
11424
|
+
planId: 'free',
|
|
11425
|
+
planDisplayName: 'Free',
|
|
11426
|
+
defaultPlanVersionId: 'free-v1',
|
|
11427
|
+
defaultVersionNumber: 1,
|
|
11428
|
+
planStatus: 'active',
|
|
11429
|
+
supportsAutoGrantFree: true,
|
|
11430
|
+
supportsAutoStartTrial: false
|
|
11431
|
+
}
|
|
11432
|
+
]
|
|
11433
|
+
});
|
|
11434
|
+
});
|
|
10289
11435
|
it('GET /api/platform/plans/:planId/versions should list versions', async () => {
|
|
10290
11436
|
const match = matchRoute('GET', '/api/platform/plans/collab/versions', routes);
|
|
10291
11437
|
const req = createMockReq('GET', '/api/platform/plans/collab/versions', undefined, {
|
|
@@ -10344,6 +11490,37 @@ describe('Server Routes', () => {
|
|
|
10344
11490
|
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"featureKey":"collaboration.team_management"'));
|
|
10345
11491
|
expect(res.end).not.toHaveBeenCalledWith(expect.stringContaining('"featureKey":"collaboration.invites"'));
|
|
10346
11492
|
});
|
|
11493
|
+
it('PATCH /api/platform/features/:featureKey should update public marketing copy', async () => {
|
|
11494
|
+
core.updatePlanFeatureCatalogMarketingCopy = vi.fn().mockReturnValue({
|
|
11495
|
+
featureKey: 'collaboration.ai_profiles',
|
|
11496
|
+
label: 'AI Profiles',
|
|
11497
|
+
description: 'Allow registered AI collaborator profiles with an optional per-workspace limit.',
|
|
11498
|
+
publicLabel: 'AI teammates',
|
|
11499
|
+
publicDescription: 'Add reusable AI teammates to your workspace.',
|
|
11500
|
+
publicDisplayOrder: 20,
|
|
11501
|
+
allowedAccessModes: ['enabled', 'limited', 'disabled'],
|
|
11502
|
+
configTemplate: { maxAiProfiles: 1 }
|
|
11503
|
+
});
|
|
11504
|
+
const match = matchRoute('PATCH', '/api/platform/features/collaboration.ai_profiles', routes);
|
|
11505
|
+
const req = createMockReq('PATCH', '/api/platform/features/collaboration.ai_profiles', {
|
|
11506
|
+
publicLabel: 'AI teammates',
|
|
11507
|
+
publicDescription: 'Add reusable AI teammates to your workspace.',
|
|
11508
|
+
publicDisplayOrder: 20
|
|
11509
|
+
}, {
|
|
11510
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
11511
|
+
'x-taskforce-system-role': 'system_admin'
|
|
11512
|
+
});
|
|
11513
|
+
const res = createMockRes();
|
|
11514
|
+
await match?.handler(req, res, { featureKey: 'collaboration.ai_profiles' });
|
|
11515
|
+
expect(core.updatePlanFeatureCatalogMarketingCopy).toHaveBeenCalledWith({
|
|
11516
|
+
featureKey: 'collaboration.ai_profiles',
|
|
11517
|
+
publicLabel: 'AI teammates',
|
|
11518
|
+
publicDescription: 'Add reusable AI teammates to your workspace.',
|
|
11519
|
+
publicDisplayOrder: 20
|
|
11520
|
+
});
|
|
11521
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
11522
|
+
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"publicLabel":"AI teammates"'));
|
|
11523
|
+
});
|
|
10347
11524
|
it('PATCH /api/platform/plans/:planId/versions/:versionId/features should return normalized dependency state', async () => {
|
|
10348
11525
|
core.updatePlanVersionFeatures.mockReturnValueOnce({
|
|
10349
11526
|
planVersionId: 'collab-v1',
|
|
@@ -10473,13 +11650,20 @@ describe('Server Routes', () => {
|
|
|
10473
11650
|
updatedAt: '2026-02-01T00:00:00.000Z'
|
|
10474
11651
|
}
|
|
10475
11652
|
]);
|
|
11653
|
+
core.countSystemUsers.mockReturnValueOnce(1);
|
|
10476
11654
|
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' });
|
|
11655
|
+
const req = createMockReq('GET', '/api/taskforce/admin/system-users?q=u1&sort=oldest&limit=25&offset=50', undefined, { 'x-taskforce-system-role': 'system_admin' });
|
|
10478
11656
|
const res = createMockRes();
|
|
10479
11657
|
await match?.handler(req, res, {});
|
|
10480
|
-
expect(core.
|
|
11658
|
+
expect(core.countSystemUsers).toHaveBeenCalledWith({ query: 'u1' });
|
|
11659
|
+
expect(core.listSystemUsers).toHaveBeenCalledWith({ query: 'u1', sort: 'oldest', limit: 25, offset: 50 });
|
|
10481
11660
|
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
10482
|
-
|
|
11661
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
11662
|
+
expect(payload.users).toHaveLength(1);
|
|
11663
|
+
expect(payload.total).toBe(1);
|
|
11664
|
+
expect(payload.limit).toBe(25);
|
|
11665
|
+
expect(payload.offset).toBe(50);
|
|
11666
|
+
expect(payload.hasMore).toBe(false);
|
|
10483
11667
|
});
|
|
10484
11668
|
it('GET /api/taskforce/admin/system-users should allow authenticated system admin without role header', async () => {
|
|
10485
11669
|
const authConfig = {
|
|
@@ -10505,7 +11689,8 @@ describe('Server Routes', () => {
|
|
|
10505
11689
|
const res = createMockRes();
|
|
10506
11690
|
await match?.handler(req, res, {});
|
|
10507
11691
|
expect(core.getSystemRole).toHaveBeenCalledWith('admin-user');
|
|
10508
|
-
expect(core.
|
|
11692
|
+
expect(core.countSystemUsers).toHaveBeenCalledWith({ query: '' });
|
|
11693
|
+
expect(core.listSystemUsers).toHaveBeenCalledWith({ query: '', sort: 'newest', limit: 50, offset: 0 });
|
|
10509
11694
|
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
10510
11695
|
});
|
|
10511
11696
|
it('GET /api/taskforce/admin/audit-logs should return events for admins', async () => {
|
|
@@ -10767,6 +11952,266 @@ describe('Server Routes', () => {
|
|
|
10767
11952
|
expect(matchRoute('POST', '/api/taskforce/admin/users/invite', routes)).not.toBeNull();
|
|
10768
11953
|
expect(matchRoute('PATCH', '/api/taskforce/admin/users/u1/role', routes)).not.toBeNull();
|
|
10769
11954
|
expect(matchRoute('GET', '/api/taskforce/admin/workspace-audit-logs', routes)).not.toBeNull();
|
|
11955
|
+
expect(matchRoute('POST', '/api/taskforce/workspace/ai-profiles/avatar', routes)).not.toBeNull();
|
|
11956
|
+
});
|
|
11957
|
+
it('POST /api/taskforce/admin/users/invite should authorize against the target workspace', async () => {
|
|
11958
|
+
const authConfig = createCloudAuthConfig();
|
|
11959
|
+
routes = createRoutes(core, { authConfig });
|
|
11960
|
+
core.listUserWorkspaces.mockReturnValue([
|
|
11961
|
+
{ id: 'workspace-home', slug: 'home', name: 'Home', role: 'owner', status: 'active', betaAccess: true },
|
|
11962
|
+
{ id: 'workspace-shared', slug: 'shared', name: 'Shared', role: 'member', status: 'active', betaAccess: true }
|
|
11963
|
+
]);
|
|
11964
|
+
core.listWorkspaceUsers.mockImplementation((workspaceId) => {
|
|
11965
|
+
if (workspaceId === 'workspace-home') {
|
|
11966
|
+
return [{
|
|
11967
|
+
userId: 'actor-user',
|
|
11968
|
+
email: 'actor@example.com',
|
|
11969
|
+
displayName: 'Actor',
|
|
11970
|
+
role: 'owner',
|
|
11971
|
+
permissionMode: 'read-write',
|
|
11972
|
+
betaAccess: true,
|
|
11973
|
+
status: 'active',
|
|
11974
|
+
disabled: false
|
|
11975
|
+
}];
|
|
11976
|
+
}
|
|
11977
|
+
if (workspaceId === 'workspace-shared') {
|
|
11978
|
+
return [{
|
|
11979
|
+
userId: 'actor-user',
|
|
11980
|
+
email: 'actor@example.com',
|
|
11981
|
+
displayName: 'Actor',
|
|
11982
|
+
role: 'member',
|
|
11983
|
+
permissionMode: 'read-only',
|
|
11984
|
+
betaAccess: true,
|
|
11985
|
+
status: 'active',
|
|
11986
|
+
disabled: false
|
|
11987
|
+
}];
|
|
11988
|
+
}
|
|
11989
|
+
return [];
|
|
11990
|
+
});
|
|
11991
|
+
core.inviteWorkspaceUser.mockReturnValue({
|
|
11992
|
+
userId: 'invited-user',
|
|
11993
|
+
email: 'invited@example.com',
|
|
11994
|
+
role: 'member',
|
|
11995
|
+
status: 'invited',
|
|
11996
|
+
disabled: false
|
|
11997
|
+
});
|
|
11998
|
+
const cookie = createSessionTokenCookie('session-target-invite-deny', authConfig, {
|
|
11999
|
+
userId: 'actor-user',
|
|
12000
|
+
role: 'owner',
|
|
12001
|
+
workspaceId: 'workspace-home'
|
|
12002
|
+
});
|
|
12003
|
+
const match = matchRoute('POST', '/api/taskforce/admin/users/invite', routes);
|
|
12004
|
+
const req = createMockReq('POST', '/api/taskforce/admin/users/invite', {
|
|
12005
|
+
workspaceId: 'workspace-shared',
|
|
12006
|
+
email: 'invited@example.com',
|
|
12007
|
+
role: 'member'
|
|
12008
|
+
}, {
|
|
12009
|
+
cookie,
|
|
12010
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
12011
|
+
'x-taskforce-role': 'owner',
|
|
12012
|
+
'x-taskforce-user-id': 'actor-user',
|
|
12013
|
+
'x-taskforce-workspace-id': 'workspace-home'
|
|
12014
|
+
});
|
|
12015
|
+
const res = createMockRes();
|
|
12016
|
+
await match?.handler(req, res, {});
|
|
12017
|
+
expect(core.inviteWorkspaceUser).not.toHaveBeenCalled();
|
|
12018
|
+
expect(core.evaluateFeatureAccess).not.toHaveBeenCalled();
|
|
12019
|
+
expect(res.writeHead).toHaveBeenCalledWith(403, expect.any(Object));
|
|
12020
|
+
});
|
|
12021
|
+
it('POST /api/taskforce/admin/users/invite should resolve feature access and inviter identity from the authenticated session user', async () => {
|
|
12022
|
+
const authConfig = createCloudAuthConfig();
|
|
12023
|
+
routes = createRoutes(core, { authConfig });
|
|
12024
|
+
core.listWorkspaceUsers.mockImplementation((workspaceId) => {
|
|
12025
|
+
if (workspaceId === 'workspace-shared') {
|
|
12026
|
+
return [{
|
|
12027
|
+
userId: 'joined-admin',
|
|
12028
|
+
email: 'joined-admin@example.com',
|
|
12029
|
+
displayName: 'Joined Admin',
|
|
12030
|
+
role: 'admin',
|
|
12031
|
+
permissionMode: 'read-write',
|
|
12032
|
+
betaAccess: true,
|
|
12033
|
+
status: 'active',
|
|
12034
|
+
disabled: false
|
|
12035
|
+
}];
|
|
12036
|
+
}
|
|
12037
|
+
return [];
|
|
12038
|
+
});
|
|
12039
|
+
core.evaluateFeatureAccess.mockImplementation(({ userId }) => ({
|
|
12040
|
+
allowed: userId === 'joined-admin',
|
|
12041
|
+
reasonCode: userId === 'joined-admin' ? undefined : 'ACCOUNT_ENTITLEMENT_REQUIRED'
|
|
12042
|
+
}));
|
|
12043
|
+
core.inviteWorkspaceUser.mockReturnValue({
|
|
12044
|
+
userId: 'invited-user',
|
|
12045
|
+
email: 'invited@example.com',
|
|
12046
|
+
role: 'member',
|
|
12047
|
+
status: 'invited',
|
|
12048
|
+
disabled: false
|
|
12049
|
+
});
|
|
12050
|
+
core.requestInviteAcceptance.mockReturnValue({ token: 'invite-token-1' });
|
|
12051
|
+
core.getWorkspaceProfile.mockReturnValue({
|
|
12052
|
+
id: 'workspace-shared',
|
|
12053
|
+
name: 'Shared Workspace'
|
|
12054
|
+
});
|
|
12055
|
+
core.resolveUserIdentity.mockReturnValue({
|
|
12056
|
+
userId: 'joined-admin',
|
|
12057
|
+
email: 'joined-admin@example.com',
|
|
12058
|
+
displayName: 'Joined Admin'
|
|
12059
|
+
});
|
|
12060
|
+
const cookie = createSessionTokenCookie('session-target-invite-identity', authConfig, {
|
|
12061
|
+
userId: 'joined-admin',
|
|
12062
|
+
role: 'admin',
|
|
12063
|
+
workspaceId: 'workspace-shared'
|
|
12064
|
+
});
|
|
12065
|
+
const match = matchRoute('POST', '/api/taskforce/admin/users/invite', routes);
|
|
12066
|
+
const req = createMockReq('POST', '/api/taskforce/admin/users/invite', {
|
|
12067
|
+
workspaceId: 'workspace-shared',
|
|
12068
|
+
email: 'invited@example.com',
|
|
12069
|
+
role: 'member'
|
|
12070
|
+
}, {
|
|
12071
|
+
cookie,
|
|
12072
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
12073
|
+
'x-taskforce-role': 'owner',
|
|
12074
|
+
'x-taskforce-user-id': 'workspace-auth-existing-home-owner',
|
|
12075
|
+
'x-taskforce-workspace-id': 'workspace-auth-existing-home'
|
|
12076
|
+
});
|
|
12077
|
+
const res = createMockRes();
|
|
12078
|
+
await match?.handler(req, res, {});
|
|
12079
|
+
expect(core.evaluateFeatureAccess).toHaveBeenCalledWith(expect.objectContaining({
|
|
12080
|
+
userId: 'joined-admin',
|
|
12081
|
+
workspaceId: 'workspace-shared',
|
|
12082
|
+
featureKey: 'collaboration.invites'
|
|
12083
|
+
}));
|
|
12084
|
+
expect(core.resolveUserIdentity).toHaveBeenCalledWith('joined-admin');
|
|
12085
|
+
expect(core.inviteWorkspaceUser).toHaveBeenCalled();
|
|
12086
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
12087
|
+
});
|
|
12088
|
+
it('PATCH /api/taskforce/admin/users/:id/role should reject active-workspace admins who are only target-workspace members', async () => {
|
|
12089
|
+
const authConfig = createCloudAuthConfig();
|
|
12090
|
+
routes = createRoutes(core, { authConfig });
|
|
12091
|
+
core.listUserWorkspaces.mockReturnValue([
|
|
12092
|
+
{ id: 'workspace-home', slug: 'home', name: 'Home', role: 'owner', status: 'active', betaAccess: true },
|
|
12093
|
+
{ id: 'workspace-shared', slug: 'shared', name: 'Shared', role: 'member', status: 'active', betaAccess: true }
|
|
12094
|
+
]);
|
|
12095
|
+
core.listWorkspaceUsers.mockImplementation((workspaceId) => {
|
|
12096
|
+
if (workspaceId === 'workspace-home') {
|
|
12097
|
+
return [{
|
|
12098
|
+
userId: 'actor-user',
|
|
12099
|
+
email: 'actor@example.com',
|
|
12100
|
+
displayName: 'Actor',
|
|
12101
|
+
role: 'owner',
|
|
12102
|
+
permissionMode: 'read-write',
|
|
12103
|
+
betaAccess: true,
|
|
12104
|
+
status: 'active',
|
|
12105
|
+
disabled: false
|
|
12106
|
+
}];
|
|
12107
|
+
}
|
|
12108
|
+
if (workspaceId === 'workspace-shared') {
|
|
12109
|
+
return [{
|
|
12110
|
+
userId: 'actor-user',
|
|
12111
|
+
email: 'actor@example.com',
|
|
12112
|
+
displayName: 'Actor',
|
|
12113
|
+
role: 'member',
|
|
12114
|
+
permissionMode: 'read-only',
|
|
12115
|
+
betaAccess: true,
|
|
12116
|
+
status: 'active',
|
|
12117
|
+
disabled: false
|
|
12118
|
+
}];
|
|
12119
|
+
}
|
|
12120
|
+
return [];
|
|
12121
|
+
});
|
|
12122
|
+
core.updateWorkspaceUserRole.mockReturnValue(true);
|
|
12123
|
+
const cookie = createSessionTokenCookie('session-target-role-deny', authConfig, {
|
|
12124
|
+
userId: 'actor-user',
|
|
12125
|
+
role: 'owner',
|
|
12126
|
+
workspaceId: 'workspace-home'
|
|
12127
|
+
});
|
|
12128
|
+
const match = matchRoute('PATCH', '/api/taskforce/admin/users/target-user/role', routes);
|
|
12129
|
+
const req = createMockReq('PATCH', '/api/taskforce/admin/users/target-user/role', {
|
|
12130
|
+
workspaceId: 'workspace-shared',
|
|
12131
|
+
role: 'admin'
|
|
12132
|
+
}, {
|
|
12133
|
+
cookie,
|
|
12134
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
12135
|
+
'x-taskforce-role': 'owner',
|
|
12136
|
+
'x-taskforce-user-id': 'actor-user',
|
|
12137
|
+
'x-taskforce-workspace-id': 'workspace-home'
|
|
12138
|
+
});
|
|
12139
|
+
const res = createMockRes();
|
|
12140
|
+
await match?.handler(req, res, { id: 'target-user' });
|
|
12141
|
+
expect(core.updateWorkspaceUserRole).not.toHaveBeenCalled();
|
|
12142
|
+
expect(core.evaluateFeatureAccess).not.toHaveBeenCalled();
|
|
12143
|
+
expect(res.writeHead).toHaveBeenCalledWith(403, expect.any(Object));
|
|
12144
|
+
});
|
|
12145
|
+
it('PATCH /api/taskforce/admin/users/:id/role should allow target-workspace admins with a different active workspace', async () => {
|
|
12146
|
+
const authConfig = createCloudAuthConfig();
|
|
12147
|
+
routes = createRoutes(core, { authConfig });
|
|
12148
|
+
core.listUserWorkspaces.mockReturnValue([
|
|
12149
|
+
{ id: 'workspace-home', slug: 'home', name: 'Home', role: 'member', status: 'active', betaAccess: true },
|
|
12150
|
+
{ id: 'workspace-shared', slug: 'shared', name: 'Shared', role: 'admin', status: 'active', betaAccess: true }
|
|
12151
|
+
]);
|
|
12152
|
+
core.listWorkspaceUsers.mockImplementation((workspaceId) => {
|
|
12153
|
+
if (workspaceId === 'workspace-home') {
|
|
12154
|
+
return [{
|
|
12155
|
+
userId: 'actor-user',
|
|
12156
|
+
email: 'actor@example.com',
|
|
12157
|
+
displayName: 'Actor',
|
|
12158
|
+
role: 'member',
|
|
12159
|
+
permissionMode: 'read-write',
|
|
12160
|
+
betaAccess: true,
|
|
12161
|
+
status: 'active',
|
|
12162
|
+
disabled: false
|
|
12163
|
+
}];
|
|
12164
|
+
}
|
|
12165
|
+
if (workspaceId === 'workspace-shared') {
|
|
12166
|
+
return [{
|
|
12167
|
+
userId: 'actor-user',
|
|
12168
|
+
email: 'actor@example.com',
|
|
12169
|
+
displayName: 'Actor',
|
|
12170
|
+
role: 'admin',
|
|
12171
|
+
permissionMode: 'read-write',
|
|
12172
|
+
betaAccess: true,
|
|
12173
|
+
status: 'active',
|
|
12174
|
+
disabled: false
|
|
12175
|
+
}];
|
|
12176
|
+
}
|
|
12177
|
+
return [];
|
|
12178
|
+
});
|
|
12179
|
+
core.updateWorkspaceUserRole.mockReturnValue(true);
|
|
12180
|
+
const cookie = createSessionTokenCookie('session-target-role-allow', authConfig, {
|
|
12181
|
+
userId: 'actor-user',
|
|
12182
|
+
role: 'member',
|
|
12183
|
+
workspaceId: 'workspace-home'
|
|
12184
|
+
});
|
|
12185
|
+
const match = matchRoute('PATCH', '/api/taskforce/admin/users/target-user/role', routes);
|
|
12186
|
+
const req = createMockReq('PATCH', '/api/taskforce/admin/users/target-user/role', {
|
|
12187
|
+
workspaceId: 'workspace-shared',
|
|
12188
|
+
role: 'admin'
|
|
12189
|
+
}, {
|
|
12190
|
+
cookie,
|
|
12191
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
12192
|
+
'x-taskforce-role': 'member',
|
|
12193
|
+
'x-taskforce-user-id': 'actor-user',
|
|
12194
|
+
'x-taskforce-workspace-id': 'workspace-home'
|
|
12195
|
+
});
|
|
12196
|
+
const res = createMockRes();
|
|
12197
|
+
await match?.handler(req, res, { id: 'target-user' });
|
|
12198
|
+
expect(core.evaluateFeatureAccess).toHaveBeenCalledWith({
|
|
12199
|
+
userId: 'actor-user',
|
|
12200
|
+
workspaceId: 'workspace-shared',
|
|
12201
|
+
featureKey: 'collaboration.team_management'
|
|
12202
|
+
});
|
|
12203
|
+
expect(core.updateWorkspaceUserRole).toHaveBeenCalledWith('target-user', 'workspace-shared', 'admin');
|
|
12204
|
+
expect(core.addAdminAuditLog).toHaveBeenCalledWith(expect.objectContaining({
|
|
12205
|
+
action: 'update-workspace-user-role',
|
|
12206
|
+
actorUserId: 'actor-user',
|
|
12207
|
+
actorRole: 'admin',
|
|
12208
|
+
workspaceId: 'workspace-shared',
|
|
12209
|
+
details: expect.objectContaining({
|
|
12210
|
+
workspaceId: 'workspace-shared',
|
|
12211
|
+
targetUserId: 'target-user'
|
|
12212
|
+
})
|
|
12213
|
+
}));
|
|
12214
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
10770
12215
|
});
|
|
10771
12216
|
it('GET /api/taskforce/workspace/assignee-options should reject unauthenticated cloud requests', async () => {
|
|
10772
12217
|
const authConfig = {
|
|
@@ -10861,6 +12306,7 @@ describe('Server Routes', () => {
|
|
|
10861
12306
|
userId: 'member-1',
|
|
10862
12307
|
email: 'member1@example.com',
|
|
10863
12308
|
displayName: 'Member One',
|
|
12309
|
+
avatarUrl: '/api/taskforce/context/users%2Fmember-1%2Fprofile%2Favatar%2Fheadshot.webp',
|
|
10864
12310
|
role: 'member',
|
|
10865
12311
|
permissionMode: 'read-write',
|
|
10866
12312
|
betaAccess: true,
|
|
@@ -10882,11 +12328,14 @@ describe('Server Routes', () => {
|
|
|
10882
12328
|
provider: 'OpenAI',
|
|
10883
12329
|
model: 'gpt-5.4',
|
|
10884
12330
|
surfaceType: 'coding_tool',
|
|
12331
|
+
seatScope: null,
|
|
12332
|
+
archivedAt: null,
|
|
10885
12333
|
createdAt: '2026-03-01T00:00:00.000Z',
|
|
10886
12334
|
updatedAt: '2026-03-01T00:00:00.000Z',
|
|
10887
12335
|
lastActiveAt: null
|
|
10888
12336
|
}
|
|
10889
12337
|
]);
|
|
12338
|
+
core.countAiProfileUsage.mockReturnValue({ used: 1, limit: 3, remaining: 2 });
|
|
10890
12339
|
const match = matchRoute('GET', '/api/taskforce/workspace/assignee-options', routes);
|
|
10891
12340
|
const cookie = createSessionTokenCookie('session-member-assignees', authConfig, {
|
|
10892
12341
|
userId: 'member-user',
|
|
@@ -10903,9 +12352,14 @@ describe('Server Routes', () => {
|
|
|
10903
12352
|
const res = createMockRes();
|
|
10904
12353
|
await match?.handler(req, res, {});
|
|
10905
12354
|
expect(core.listWorkspaceUsers).toHaveBeenCalledWith('workspace-1');
|
|
10906
|
-
expect(core.listAiProfiles).toHaveBeenCalledWith('workspace-1'
|
|
12355
|
+
expect(core.listAiProfiles).toHaveBeenCalledWith('workspace-1', expect.objectContaining({
|
|
12356
|
+
diagnostics: expect.any(Object)
|
|
12357
|
+
}));
|
|
12358
|
+
expect(core.countAiProfileUsage).toHaveBeenCalledWith('workspace-1');
|
|
12359
|
+
expect(res.setHeader).toHaveBeenCalledWith('Server-Timing', expect.stringContaining('assignee-options'));
|
|
10907
12360
|
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
10908
12361
|
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
12362
|
+
expect(payload.aiProfileSeatUsage).toEqual({ used: 1, limit: 3, remaining: 2 });
|
|
10909
12363
|
expect(payload.assignees).toEqual([
|
|
10910
12364
|
{
|
|
10911
12365
|
value: 'member-1',
|
|
@@ -10916,6 +12370,7 @@ describe('Server Routes', () => {
|
|
|
10916
12370
|
userId: 'member-1',
|
|
10917
12371
|
email: 'member1@example.com',
|
|
10918
12372
|
displayName: 'Member One',
|
|
12373
|
+
avatarUrl: '/api/taskforce/context/users%2Fmember-1%2Fprofile%2Favatar%2Fheadshot.webp',
|
|
10919
12374
|
status: 'active',
|
|
10920
12375
|
disabled: false
|
|
10921
12376
|
},
|
|
@@ -10925,12 +12380,16 @@ describe('Server Routes', () => {
|
|
|
10925
12380
|
username: 'codex',
|
|
10926
12381
|
icon: 'Bot',
|
|
10927
12382
|
color: '#ef6c00',
|
|
12383
|
+
avatarUrl: null,
|
|
12384
|
+
avatarSourceUrl: null,
|
|
10928
12385
|
kind: 'agent',
|
|
10929
12386
|
description: 'Builds product code',
|
|
10930
12387
|
role: 'Implementer',
|
|
10931
12388
|
provider: 'OpenAI',
|
|
10932
12389
|
model: 'gpt-5.4',
|
|
10933
12390
|
surfaceType: 'coding_tool',
|
|
12391
|
+
seatScope: null,
|
|
12392
|
+
archivedAt: null,
|
|
10934
12393
|
createdAt: '2026-03-01T00:00:00.000Z',
|
|
10935
12394
|
updatedAt: '2026-03-01T00:00:00.000Z',
|
|
10936
12395
|
lastActiveAt: null
|
|
@@ -10970,6 +12429,7 @@ describe('Server Routes', () => {
|
|
|
10970
12429
|
lastActiveAt: null
|
|
10971
12430
|
}
|
|
10972
12431
|
]);
|
|
12432
|
+
core.countAiProfileUsage.mockReturnValue({ used: 1, limit: 3, remaining: 2 });
|
|
10973
12433
|
const match = matchRoute('GET', '/api/taskforce/workspace/assignee-options', routes);
|
|
10974
12434
|
const cookie = createSessionTokenCookie('session-admin-assignees', authConfig, {
|
|
10975
12435
|
userId: 'admin-user',
|
|
@@ -10986,6 +12446,7 @@ describe('Server Routes', () => {
|
|
|
10986
12446
|
const res = createMockRes();
|
|
10987
12447
|
await match?.handler(req, res, {});
|
|
10988
12448
|
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
12449
|
+
expect(payload.aiProfileSeatUsage).toEqual({ used: 1, limit: 3, remaining: 2 });
|
|
10989
12450
|
expect(payload.assignees).toEqual([
|
|
10990
12451
|
expect.objectContaining({
|
|
10991
12452
|
value: 'ai-profile-1',
|
|
@@ -10993,6 +12454,198 @@ describe('Server Routes', () => {
|
|
|
10993
12454
|
})
|
|
10994
12455
|
]);
|
|
10995
12456
|
});
|
|
12457
|
+
it('POST /api/taskforce/workspace/ai-profiles/archive should archive active profiles for workspace admins', async () => {
|
|
12458
|
+
const authConfig = {
|
|
12459
|
+
runtimeMode: 'cloud',
|
|
12460
|
+
enabled: true,
|
|
12461
|
+
requiredForApi: true,
|
|
12462
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
12463
|
+
defaultRole: 'member',
|
|
12464
|
+
defaultWorkspaceId: 'default',
|
|
12465
|
+
sessionCookieName: 'taskforce_session',
|
|
12466
|
+
sessionSecret: 'test-secret',
|
|
12467
|
+
sessionTtlSeconds: 3600
|
|
12468
|
+
};
|
|
12469
|
+
routes = createRoutes(core, { authConfig });
|
|
12470
|
+
core.archiveAiProfile.mockReturnValue({
|
|
12471
|
+
id: 'ai-profile-1',
|
|
12472
|
+
workspaceId: 'workspace-1',
|
|
12473
|
+
profileToken: 'tfp_codex_sync',
|
|
12474
|
+
name: 'Codex',
|
|
12475
|
+
username: 'codex',
|
|
12476
|
+
icon: 'Bot',
|
|
12477
|
+
color: '#ef6c00',
|
|
12478
|
+
description: 'Builds product code',
|
|
12479
|
+
role: 'Implementer',
|
|
12480
|
+
provider: 'OpenAI',
|
|
12481
|
+
model: 'gpt-5.4',
|
|
12482
|
+
surfaceType: 'coding_tool',
|
|
12483
|
+
archivedAt: '2026-04-19T17:00:00.000Z',
|
|
12484
|
+
archivedBy: 'admin-user',
|
|
12485
|
+
archivedReason: 'manual_archive',
|
|
12486
|
+
mergedIntoProfileId: null,
|
|
12487
|
+
createdAt: '2026-03-01T00:00:00.000Z',
|
|
12488
|
+
updatedAt: '2026-04-19T17:00:00.000Z',
|
|
12489
|
+
lastActiveAt: null
|
|
12490
|
+
});
|
|
12491
|
+
const match = matchRoute('POST', '/api/taskforce/workspace/ai-profiles/archive', routes);
|
|
12492
|
+
const cookie = createSessionTokenCookie('session-admin-archive-ai-profile', authConfig, {
|
|
12493
|
+
userId: 'admin-user',
|
|
12494
|
+
role: 'admin',
|
|
12495
|
+
workspaceId: 'workspace-1'
|
|
12496
|
+
});
|
|
12497
|
+
const req = createMockReq('POST', '/api/taskforce/workspace/ai-profiles/archive', {
|
|
12498
|
+
profileId: 'ai-profile-1',
|
|
12499
|
+
reason: 'manual_archive'
|
|
12500
|
+
}, {
|
|
12501
|
+
cookie,
|
|
12502
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
12503
|
+
'x-taskforce-role': 'admin',
|
|
12504
|
+
'x-taskforce-user-id': 'admin-user',
|
|
12505
|
+
'x-taskforce-workspace-id': 'workspace-1'
|
|
12506
|
+
});
|
|
12507
|
+
const res = createMockRes();
|
|
12508
|
+
await match?.handler(req, res, {});
|
|
12509
|
+
expect(core.archiveAiProfile).toHaveBeenCalledWith({
|
|
12510
|
+
workspaceId: 'workspace-1',
|
|
12511
|
+
profileId: 'ai-profile-1',
|
|
12512
|
+
archivedBy: 'admin-user',
|
|
12513
|
+
reason: 'manual_archive'
|
|
12514
|
+
});
|
|
12515
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
12516
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
12517
|
+
expect(payload.profile).toEqual(expect.objectContaining({
|
|
12518
|
+
id: 'ai-profile-1',
|
|
12519
|
+
archivedReason: 'manual_archive'
|
|
12520
|
+
}));
|
|
12521
|
+
});
|
|
12522
|
+
it('POST /api/taskforce/workspace/ai-profiles/archive should honor actual owner membership even when the request role is stale', async () => {
|
|
12523
|
+
const authConfig = {
|
|
12524
|
+
runtimeMode: 'cloud',
|
|
12525
|
+
enabled: true,
|
|
12526
|
+
requiredForApi: true,
|
|
12527
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
12528
|
+
defaultRole: 'member',
|
|
12529
|
+
defaultWorkspaceId: 'default',
|
|
12530
|
+
sessionCookieName: 'taskforce_session',
|
|
12531
|
+
sessionSecret: 'test-secret',
|
|
12532
|
+
sessionTtlSeconds: 3600
|
|
12533
|
+
};
|
|
12534
|
+
routes = createRoutes(core, { authConfig });
|
|
12535
|
+
core.listUserWorkspaces.mockReturnValue([
|
|
12536
|
+
{
|
|
12537
|
+
id: 'workspace-1',
|
|
12538
|
+
slug: 'workspace-1',
|
|
12539
|
+
name: 'Workspace 1',
|
|
12540
|
+
description: null,
|
|
12541
|
+
role: 'owner',
|
|
12542
|
+
status: 'active',
|
|
12543
|
+
betaAccess: true
|
|
12544
|
+
}
|
|
12545
|
+
]);
|
|
12546
|
+
core.listWorkspaceUsers.mockReturnValue([
|
|
12547
|
+
{
|
|
12548
|
+
userId: 'owner-user',
|
|
12549
|
+
email: 'owner@example.com',
|
|
12550
|
+
displayName: 'Owner User',
|
|
12551
|
+
role: 'owner',
|
|
12552
|
+
permissionMode: 'read-write',
|
|
12553
|
+
betaAccess: true,
|
|
12554
|
+
status: 'active',
|
|
12555
|
+
disabled: false
|
|
12556
|
+
}
|
|
12557
|
+
]);
|
|
12558
|
+
core.archiveAiProfile.mockReturnValue({
|
|
12559
|
+
id: 'ai-profile-1',
|
|
12560
|
+
workspaceId: 'workspace-1',
|
|
12561
|
+
profileToken: 'tfp_codex_sync',
|
|
12562
|
+
name: 'Codex',
|
|
12563
|
+
username: 'codex',
|
|
12564
|
+
icon: 'Bot',
|
|
12565
|
+
color: '#ef6c00',
|
|
12566
|
+
description: 'Builds product code',
|
|
12567
|
+
role: 'Implementer',
|
|
12568
|
+
provider: 'OpenAI',
|
|
12569
|
+
model: 'gpt-5.4',
|
|
12570
|
+
surfaceType: 'coding_tool',
|
|
12571
|
+
archivedAt: '2026-04-19T17:00:00.000Z',
|
|
12572
|
+
archivedBy: 'owner-user',
|
|
12573
|
+
archivedReason: 'manual_archive',
|
|
12574
|
+
mergedIntoProfileId: null,
|
|
12575
|
+
createdAt: '2026-03-01T00:00:00.000Z',
|
|
12576
|
+
updatedAt: '2026-04-19T17:00:00.000Z',
|
|
12577
|
+
lastActiveAt: null
|
|
12578
|
+
});
|
|
12579
|
+
const match = matchRoute('POST', '/api/taskforce/workspace/ai-profiles/archive', routes);
|
|
12580
|
+
const cookie = createSessionTokenCookie('session-owner-archive', authConfig, {
|
|
12581
|
+
userId: 'owner-user',
|
|
12582
|
+
role: 'member',
|
|
12583
|
+
workspaceId: 'workspace-1'
|
|
12584
|
+
});
|
|
12585
|
+
const req = createMockReq('POST', '/api/taskforce/workspace/ai-profiles/archive', {
|
|
12586
|
+
profileId: 'ai-profile-1'
|
|
12587
|
+
}, {
|
|
12588
|
+
cookie,
|
|
12589
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
12590
|
+
'x-taskforce-role': 'member',
|
|
12591
|
+
'x-taskforce-user-id': 'owner-user',
|
|
12592
|
+
'x-taskforce-workspace-id': 'workspace-1'
|
|
12593
|
+
});
|
|
12594
|
+
const res = createMockRes();
|
|
12595
|
+
await match?.handler(req, res, {});
|
|
12596
|
+
expect(core.archiveAiProfile).toHaveBeenCalledWith(expect.objectContaining({
|
|
12597
|
+
workspaceId: 'workspace-1',
|
|
12598
|
+
profileId: 'ai-profile-1',
|
|
12599
|
+
archivedBy: 'owner-user'
|
|
12600
|
+
}));
|
|
12601
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
12602
|
+
});
|
|
12603
|
+
it('POST /api/taskforce/workspace/ai-profiles/archive should surface open-task conflicts', async () => {
|
|
12604
|
+
const authConfig = {
|
|
12605
|
+
runtimeMode: 'cloud',
|
|
12606
|
+
enabled: true,
|
|
12607
|
+
requiredForApi: true,
|
|
12608
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
12609
|
+
defaultRole: 'member',
|
|
12610
|
+
defaultWorkspaceId: 'default',
|
|
12611
|
+
sessionCookieName: 'taskforce_session',
|
|
12612
|
+
sessionSecret: 'test-secret',
|
|
12613
|
+
sessionTtlSeconds: 3600
|
|
12614
|
+
};
|
|
12615
|
+
routes = createRoutes(core, { authConfig });
|
|
12616
|
+
const error = new Error('This AI profile still owns open tasks. Reassign or close them before removing it from the roster.');
|
|
12617
|
+
error.statusCode = 409;
|
|
12618
|
+
error.details = { workspaceId: 'workspace-1', profileId: 'ai-profile-1', openTaskCount: 2 };
|
|
12619
|
+
core.archiveAiProfile.mockImplementation(() => {
|
|
12620
|
+
throw error;
|
|
12621
|
+
});
|
|
12622
|
+
const match = matchRoute('POST', '/api/taskforce/workspace/ai-profiles/archive', routes);
|
|
12623
|
+
const cookie = createSessionTokenCookie('session-admin-archive-ai-profile-conflict', authConfig, {
|
|
12624
|
+
userId: 'admin-user',
|
|
12625
|
+
role: 'admin',
|
|
12626
|
+
workspaceId: 'workspace-1'
|
|
12627
|
+
});
|
|
12628
|
+
const req = createMockReq('POST', '/api/taskforce/workspace/ai-profiles/archive', {
|
|
12629
|
+
profileId: 'ai-profile-1',
|
|
12630
|
+
reason: 'manual_archive'
|
|
12631
|
+
}, {
|
|
12632
|
+
cookie,
|
|
12633
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
12634
|
+
'x-taskforce-role': 'admin',
|
|
12635
|
+
'x-taskforce-user-id': 'admin-user',
|
|
12636
|
+
'x-taskforce-workspace-id': 'workspace-1'
|
|
12637
|
+
});
|
|
12638
|
+
const res = createMockRes();
|
|
12639
|
+
await match?.handler(req, res, {});
|
|
12640
|
+
expect(res.writeHead).toHaveBeenCalledWith(409, expect.any(Object));
|
|
12641
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
12642
|
+
expect(payload.error).toContain('This AI profile still owns open tasks');
|
|
12643
|
+
expect(payload.details).toEqual({
|
|
12644
|
+
workspaceId: 'workspace-1',
|
|
12645
|
+
profileId: 'ai-profile-1',
|
|
12646
|
+
openTaskCount: 2
|
|
12647
|
+
});
|
|
12648
|
+
});
|
|
10996
12649
|
it('GET /api/taskforce/admin/workspace-audit-logs should return events for workspace admins', async () => {
|
|
10997
12650
|
const authConfig = {
|
|
10998
12651
|
runtimeMode: 'cloud',
|
|
@@ -11090,13 +12743,13 @@ describe('Server Routes', () => {
|
|
|
11090
12743
|
billing: {
|
|
11091
12744
|
onboardingPolicy: {
|
|
11092
12745
|
mode: 'auto_grant_free',
|
|
11093
|
-
|
|
12746
|
+
planId: 'starter'
|
|
11094
12747
|
}
|
|
11095
12748
|
}
|
|
11096
12749
|
});
|
|
11097
12750
|
core.getOnboardingPolicy.mockReturnValue({
|
|
11098
12751
|
mode: 'auto_grant_free',
|
|
11099
|
-
|
|
12752
|
+
planId: 'starter'
|
|
11100
12753
|
});
|
|
11101
12754
|
const match = matchRoute('GET', '/api/taskforce/admin/system-settings', routes);
|
|
11102
12755
|
const req = createMockReq('GET', '/api/taskforce/admin/system-settings', undefined, { 'x-taskforce-role': 'admin' });
|
|
@@ -11104,9 +12757,9 @@ describe('Server Routes', () => {
|
|
|
11104
12757
|
await match?.handler(req, res, {});
|
|
11105
12758
|
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
11106
12759
|
expect(payload.status.onboardingPolicyMode).toBe('auto_grant_free');
|
|
11107
|
-
expect(payload.status.
|
|
12760
|
+
expect(payload.status.onboardingPlanId).toBe('starter');
|
|
11108
12761
|
expect(payload.settings.onboardingPolicyMode).toBe('auto_grant_free');
|
|
11109
|
-
expect(payload.settings.
|
|
12762
|
+
expect(payload.settings.onboardingPlanId).toBe('starter');
|
|
11110
12763
|
});
|
|
11111
12764
|
it('POST /api/taskforce/admin/system-settings should persist onboarding policy fields', async () => {
|
|
11112
12765
|
const match = matchRoute('POST', '/api/taskforce/admin/system-settings', routes);
|
|
@@ -11118,13 +12771,13 @@ describe('Server Routes', () => {
|
|
|
11118
12771
|
authRateLimitMaxRequests: 10,
|
|
11119
12772
|
authRateLimitWindowMs: 900000,
|
|
11120
12773
|
onboardingPolicyMode: 'auto_grant_free',
|
|
11121
|
-
|
|
12774
|
+
onboardingPlanId: 'starter'
|
|
11122
12775
|
}, { 'x-taskforce-role': 'admin' });
|
|
11123
12776
|
const res = createMockRes();
|
|
11124
12777
|
await match?.handler(req, res, {});
|
|
11125
12778
|
expect(core.validateOnboardingPolicy).toHaveBeenCalledWith({
|
|
11126
12779
|
mode: 'auto_grant_free',
|
|
11127
|
-
|
|
12780
|
+
planId: 'starter'
|
|
11128
12781
|
});
|
|
11129
12782
|
expect(core.updateGlobalSettings).toHaveBeenCalledWith({
|
|
11130
12783
|
auth: {
|
|
@@ -11138,7 +12791,7 @@ describe('Server Routes', () => {
|
|
|
11138
12791
|
billing: {
|
|
11139
12792
|
onboardingPolicy: {
|
|
11140
12793
|
mode: 'auto_grant_free',
|
|
11141
|
-
|
|
12794
|
+
planId: 'starter'
|
|
11142
12795
|
}
|
|
11143
12796
|
}
|
|
11144
12797
|
});
|
|
@@ -11146,7 +12799,7 @@ describe('Server Routes', () => {
|
|
|
11146
12799
|
it('POST /api/taskforce/admin/system-settings should preserve onboarding policy when onboarding fields are omitted', async () => {
|
|
11147
12800
|
core.getOnboardingPolicy.mockReturnValue({
|
|
11148
12801
|
mode: 'auto_grant_free',
|
|
11149
|
-
|
|
12802
|
+
planId: 'starter'
|
|
11150
12803
|
});
|
|
11151
12804
|
const match = matchRoute('POST', '/api/taskforce/admin/system-settings', routes);
|
|
11152
12805
|
const req = createMockReq('POST', '/api/taskforce/admin/system-settings', {
|
|
@@ -11161,7 +12814,7 @@ describe('Server Routes', () => {
|
|
|
11161
12814
|
await match?.handler(req, res, {});
|
|
11162
12815
|
expect(core.validateOnboardingPolicy).toHaveBeenCalledWith({
|
|
11163
12816
|
mode: 'auto_grant_free',
|
|
11164
|
-
|
|
12817
|
+
planId: 'starter'
|
|
11165
12818
|
});
|
|
11166
12819
|
expect(core.updateGlobalSettings).toHaveBeenCalledWith({
|
|
11167
12820
|
auth: {
|
|
@@ -11175,7 +12828,7 @@ describe('Server Routes', () => {
|
|
|
11175
12828
|
billing: {
|
|
11176
12829
|
onboardingPolicy: {
|
|
11177
12830
|
mode: 'auto_grant_free',
|
|
11178
|
-
|
|
12831
|
+
planId: 'starter'
|
|
11179
12832
|
}
|
|
11180
12833
|
}
|
|
11181
12834
|
});
|
|
@@ -11288,13 +12941,13 @@ describe('Server Routes', () => {
|
|
|
11288
12941
|
it('POST /api/taskforce/admin/system-settings should reject invalid onboarding settings', async () => {
|
|
11289
12942
|
core.validateOnboardingPolicy.mockReturnValueOnce({
|
|
11290
12943
|
valid: false,
|
|
11291
|
-
code: '
|
|
11292
|
-
message: 'An onboarding plan
|
|
12944
|
+
code: 'ONBOARDING_PLAN_REQUIRED',
|
|
12945
|
+
message: 'An onboarding plan is required for the selected onboarding mode.'
|
|
11293
12946
|
});
|
|
11294
12947
|
const match = matchRoute('POST', '/api/taskforce/admin/system-settings', routes);
|
|
11295
12948
|
const req = createMockReq('POST', '/api/taskforce/admin/system-settings', {
|
|
11296
12949
|
onboardingPolicyMode: 'auto_grant_free',
|
|
11297
|
-
|
|
12950
|
+
onboardingPlanId: null
|
|
11298
12951
|
}, { 'x-taskforce-role': 'admin' });
|
|
11299
12952
|
const res = createMockRes();
|
|
11300
12953
|
await match?.handler(req, res, {});
|