@taskforcehq/taskforce 0.3.304 → 0.3.305
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +140 -247
- package/dist/TaskforceCore.js +165 -70
- package/dist/TaskforceCore.test.js +799 -90
- package/dist/compat/workspaceSyncCompat.js +6 -0
- package/dist/components/features/DocumentIndex.d.ts +1 -1
- package/dist/components/features/DocumentIndex.js +1 -1
- package/dist/components/features/DocumentViewer.d.ts +1 -1
- package/dist/components/features/DocumentViewer.js +32 -9
- package/dist/components/features/DocumentWorkspace.d.ts +5 -22
- package/dist/components/features/DocumentWorkspace.js +12 -155
- package/dist/components/features/DocumentWorkspace.test.js +193 -6
- package/dist/components/features/TaskSettings.js +29 -3
- package/dist/components/features/TaskSettings.test.js +42 -1
- package/dist/components/features/documentWorkspaceModel.d.ts +24 -0
- package/dist/components/features/documentWorkspaceModel.js +57 -0
- package/dist/components/features/useDocumentWorkspaceController.d.ts +34 -0
- package/dist/components/features/useDocumentWorkspaceController.js +113 -0
- package/dist/components/task/TaskForm.d.ts +8 -8
- package/dist/components/task/TaskForm.js +358 -221
- package/dist/components/task/TaskForm.test.js +456 -381
- package/dist/components/task/TaskKanban.js +2 -1
- package/dist/components/views/PlanComparisonPage.js +5 -8
- package/dist/components/views/PlanComparisonPage.test.js +51 -41
- package/dist/components/views/PlansPage.js +126 -84
- package/dist/components/views/PlansPage.test.js +308 -39
- package/dist/components/views/StandaloneLayout.js +28 -22
- package/dist/components/views/panels/FilterToolbar.test.js +6 -4
- package/dist/components/views/standalone/modals/AccountHubModal.d.ts +2 -14
- package/dist/components/views/standalone/modals/AccountHubModal.js +12 -14
- package/dist/components/views/standalone/modals/AccountHubModal.test.js +24 -1
- package/dist/config/envSchema.js +1 -1
- package/dist/core/AiProfileService.d.ts +6 -1
- package/dist/core/AiProfileService.js +161 -16
- package/dist/core/AiProfileService.test.js +3 -1
- package/dist/core/AiProfiles.test.js +200 -0
- package/dist/core/AuthTokenService.test.d.ts +1 -0
- package/dist/core/AuthTokenService.test.js +78 -0
- package/dist/core/EntitlementsPolicy.test.js +75 -13
- package/dist/core/PlanEntitlementService.d.ts +7 -1
- package/dist/core/PlanEntitlementService.js +117 -42
- package/dist/core/PlanVersionPolicy.test.js +19 -26
- package/dist/core/SignupMonetizationSettings.test.js +46 -21
- package/dist/core/SystemAdmin.test.js +212 -56
- package/dist/core/TaskTaxonomyValidation.test.js +53 -0
- package/dist/core/Taskforce.d.ts +3 -0
- package/dist/core/Taskforce.js +50 -10
- package/dist/core/WorkspacePlanMode.test.js +12 -12
- package/dist/core/shared.d.ts +2 -0
- package/dist/core/shared.js +11 -0
- package/dist/hooks/sync/auth.js +3 -1
- package/dist/hooks/sync/transfers.d.ts +2 -1
- package/dist/hooks/sync/transfers.js +8 -4
- package/dist/hooks/useSyncOrchestrator.d.ts +7 -4
- package/dist/hooks/useSyncOrchestrator.js +75 -7
- package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +80 -10
- package/dist/hooks/useTaskMutations.d.ts +2 -1
- package/dist/hooks/useTaskMutations.js +12 -1
- package/dist/hooks/useTaskMutations.test.js +30 -1
- package/dist/hooks/useTaskforce.d.ts +5 -2
- package/dist/hooks/useTaskforce.js +17 -5
- package/dist/hooks/useTaskforce.sync-behavior.test.js +78 -0
- package/dist/hooks/useWorkspaceSyncController.d.ts +3 -1
- package/dist/hooks/useWorkspaceSyncController.js +11 -1
- package/dist/hooks/useWorkspaceSyncController.test.js +1 -0
- package/dist/mcp/adminWorkspaceRegistrar.d.ts +2 -0
- package/dist/mcp/adminWorkspaceRegistrar.js +154 -0
- package/dist/mcp/collaborationRegistrar.d.ts +2 -0
- package/dist/mcp/collaborationRegistrar.js +71 -0
- package/dist/mcp/documentAssetRegistrar.d.ts +2 -0
- package/dist/mcp/documentAssetRegistrar.js +346 -0
- package/dist/mcp/runtime.js +2362 -3530
- package/dist/mcp/runtime.test.js +159 -0
- package/dist/mcp/taskPlanningRegistrar.d.ts +2 -0
- package/dist/mcp/taskPlanningRegistrar.js +418 -0
- package/dist/mcp/toolCatalog.d.ts +35 -0
- package/dist/mcp/toolCatalog.js +3 -0
- package/dist/server/routes/admin.d.ts +1 -5
- package/dist/server/routes/admin.js +138 -61
- package/dist/server/routes/annotatedAttachments.d.ts +1 -1
- package/dist/server/routes/annotatedAttachments.js +1 -1
- package/dist/server/routes/auth.d.ts +1 -4
- package/dist/server/routes/auth.js +23 -63
- package/dist/server/routes/authSupport.d.ts +30 -0
- package/dist/server/routes/authSupport.js +81 -0
- package/dist/server/routes/billing.d.ts +1 -1
- package/dist/server/routes/billing.js +104 -188
- package/dist/server/routes/billing.test.js +214 -86
- package/dist/server/routes/documentReviews.d.ts +1 -1
- package/dist/server/routes/documentReviews.js +1 -1
- package/dist/server/routes/documents.d.ts +4 -2
- package/dist/server/routes/documents.js +73 -7
- package/dist/server/routes/primitives.d.ts +11 -0
- package/dist/server/routes/primitives.js +73 -0
- package/dist/server/routes/resources.d.ts +1 -1
- package/dist/server/routes/resources.js +1 -1
- package/dist/server/routes/shared.d.ts +1 -1
- package/dist/server/routes/sync.d.ts +1 -1
- package/dist/server/routes/sync.js +1 -1
- package/dist/server/routes/tasks.d.ts +1 -1
- package/dist/server/routes/tasks.js +1 -1
- package/dist/server/routes/workspaces.d.ts +1 -1
- package/dist/server/routes/workspaces.js +1 -1
- package/dist/server/routes.d.ts +2 -9
- package/dist/server/routes.js +76 -162
- package/dist/server/routes.test.js +672 -30
- package/dist/sync/contentSyncState.js +16 -7
- package/dist/sync/syncApplyHandlers.test.js +2 -0
- package/dist/sync/syncService.d.ts +1 -0
- package/dist/sync/syncService.js +8 -0
- package/dist/sync/syncService.test.js +14 -0
- package/dist/sync/workspaceSyncModel.d.ts +3 -0
- package/dist/sync/workspaceSyncModel.js +12 -3
- package/dist/sync/workspaceSyncModel.test.js +34 -0
- package/dist/sync/workspaceSyncState.d.ts +2 -0
- package/dist/sync/workspaceSyncState.js +1 -0
- package/dist/ui/assets/{AgentsModule-BLWVbuKP.js → AgentsModule-N2MnQBZk.js} +1 -1
- package/dist/ui/assets/{AnnotatedAttachmentWorkspace-BlS-cqnl.js → AnnotatedAttachmentWorkspace-BG6P_GVW.js} +1 -1
- package/dist/ui/assets/DocumentWorkspace-B3nV_Gc5.css +1 -0
- package/dist/ui/assets/DocumentWorkspace-DRbDMwh8.js +252 -0
- package/dist/ui/assets/{InitiativesModule-BjR6iBf9.js → InitiativesModule-Dhm7M8e7.js} +1 -1
- package/dist/ui/assets/PlansPage-Cq0zXj3I.js +1 -0
- package/dist/ui/assets/TaskSettings-ln0aF7xc.js +9 -0
- package/dist/ui/assets/{WorkflowsModule-DnFqdUME.js → WorkflowsModule-BcS4afRn.js} +1 -1
- package/dist/ui/assets/{index-Ii0B0rTO.css → index-C2-OXZV7.css} +1 -1
- package/dist/ui/assets/index-DcSI5F86.js +6 -0
- package/dist/ui/assets/{vendor-icons-I7ZwG1z_.js → vendor-icons-BSUaRwF8.js} +1 -1
- package/dist/ui/index.html +3 -3
- package/dist/utils/commercialLifecyclePresentation.d.ts +19 -0
- package/dist/utils/commercialLifecyclePresentation.js +196 -0
- package/package.json +8 -1
- package/scripts/check-cloud-mcp.mjs +83 -0
- package/scripts/playwright-auth.sh +5 -1
- package/scripts/run-billing-performance-smoke.sh +24 -0
- package/dist/ui/assets/DocumentWorkspace-BH_6DF6x.js +0 -250
- package/dist/ui/assets/DocumentWorkspace-C_8T8oz-.css +0 -1
- package/dist/ui/assets/PlansPage-CSDQ4sLa.js +0 -1
- package/dist/ui/assets/TaskSettings-CEDy34Jo.js +0 -9
- package/dist/ui/assets/index-DXUdtH1B.js +0 -6
|
@@ -684,6 +684,22 @@ describe('Server Routes', () => {
|
|
|
684
684
|
res.__getBody = () => Buffer.concat(bodyChunks).toString('utf8');
|
|
685
685
|
return res;
|
|
686
686
|
};
|
|
687
|
+
const withDevAuthTokenExposure = async (value, run) => {
|
|
688
|
+
const previous = process.env.TASKFORCE_AUTH_EXPOSE_DEV_TOKENS;
|
|
689
|
+
if (value === undefined)
|
|
690
|
+
delete process.env.TASKFORCE_AUTH_EXPOSE_DEV_TOKENS;
|
|
691
|
+
else
|
|
692
|
+
process.env.TASKFORCE_AUTH_EXPOSE_DEV_TOKENS = value;
|
|
693
|
+
try {
|
|
694
|
+
await run();
|
|
695
|
+
}
|
|
696
|
+
finally {
|
|
697
|
+
if (previous === undefined)
|
|
698
|
+
delete process.env.TASKFORCE_AUTH_EXPOSE_DEV_TOKENS;
|
|
699
|
+
else
|
|
700
|
+
process.env.TASKFORCE_AUTH_EXPOSE_DEV_TOKENS = previous;
|
|
701
|
+
}
|
|
702
|
+
};
|
|
687
703
|
it('GET /api/taskforce/tasks should return tasks', async () => {
|
|
688
704
|
const mockTasks = { tasks: [], config: {} };
|
|
689
705
|
core.listTasks.mockReturnValue(mockTasks);
|
|
@@ -1914,6 +1930,20 @@ describe('Server Routes', () => {
|
|
|
1914
1930
|
});
|
|
1915
1931
|
});
|
|
1916
1932
|
it('POST /api/taskforce/settings/mcp/test-connection should validate the token against workspace and scopes', async () => {
|
|
1933
|
+
const previousCloudEnvironment = process.env.TASKFORCE_CLOUD_ENVIRONMENT;
|
|
1934
|
+
process.env.TASKFORCE_CLOUD_ENVIRONMENT = 'performance';
|
|
1935
|
+
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
|
|
1936
|
+
jsonrpc: '2.0',
|
|
1937
|
+
id: 'mcp-connection-test',
|
|
1938
|
+
result: {
|
|
1939
|
+
protocolVersion: '2025-03-26',
|
|
1940
|
+
serverInfo: { name: 'taskforce-cloud' }
|
|
1941
|
+
}
|
|
1942
|
+
}), {
|
|
1943
|
+
status: 200,
|
|
1944
|
+
headers: { 'Content-Type': 'application/json' }
|
|
1945
|
+
}));
|
|
1946
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
1917
1947
|
core.inspectMcpAccessToken.mockReturnValue({
|
|
1918
1948
|
tokenId: 'mcp-token-1',
|
|
1919
1949
|
tokenPrefix: 'tfmcp_abc123',
|
|
@@ -1940,12 +1970,136 @@ describe('Server Routes', () => {
|
|
|
1940
1970
|
});
|
|
1941
1971
|
const res = createMockRes();
|
|
1942
1972
|
res.req = req;
|
|
1943
|
-
|
|
1973
|
+
try {
|
|
1974
|
+
await match?.handler(req, res, {});
|
|
1975
|
+
}
|
|
1976
|
+
finally {
|
|
1977
|
+
vi.unstubAllGlobals();
|
|
1978
|
+
if (previousCloudEnvironment === undefined)
|
|
1979
|
+
delete process.env.TASKFORCE_CLOUD_ENVIRONMENT;
|
|
1980
|
+
else
|
|
1981
|
+
process.env.TASKFORCE_CLOUD_ENVIRONMENT = previousCloudEnvironment;
|
|
1982
|
+
}
|
|
1944
1983
|
expect(core.inspectMcpAccessToken).toHaveBeenCalledWith('tfmcp_abc123_secret');
|
|
1984
|
+
expect(fetchMock).toHaveBeenCalledWith('https://performance-mcp.taskforcehq.ai/mcp', expect.objectContaining({
|
|
1985
|
+
method: 'POST',
|
|
1986
|
+
headers: expect.objectContaining({
|
|
1987
|
+
Authorization: 'Bearer tfmcp_abc123_secret'
|
|
1988
|
+
})
|
|
1989
|
+
}));
|
|
1945
1990
|
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
1946
1991
|
expect(payload.success).toBe(true);
|
|
1947
1992
|
expect(payload.result.workspaceName).toBe('Workspace One');
|
|
1948
1993
|
expect(payload.result.scopes).toEqual(['tasks:read', 'tasks:write']);
|
|
1994
|
+
expect(payload.result.endpoint).toBe('https://performance-mcp.taskforcehq.ai/mcp');
|
|
1995
|
+
expect(payload.result.serverName).toBe('taskforce-cloud');
|
|
1996
|
+
expect(payload.result.protocolVersion).toBe('2025-03-26');
|
|
1997
|
+
});
|
|
1998
|
+
it('POST /api/taskforce/settings/mcp/test-connection should return a clear error when the dedicated MCP host returns a gateway failure', async () => {
|
|
1999
|
+
const previousCloudEnvironment = process.env.TASKFORCE_CLOUD_ENVIRONMENT;
|
|
2000
|
+
process.env.TASKFORCE_CLOUD_ENVIRONMENT = 'performance';
|
|
2001
|
+
const fetchMock = vi.fn(async () => new Response('Bad gateway', {
|
|
2002
|
+
status: 502,
|
|
2003
|
+
headers: { 'Content-Type': 'text/plain' }
|
|
2004
|
+
}));
|
|
2005
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
2006
|
+
core.inspectMcpAccessToken.mockReturnValue({
|
|
2007
|
+
tokenId: 'mcp-token-1',
|
|
2008
|
+
tokenPrefix: 'tfmcp_abc123',
|
|
2009
|
+
workspaceId: 'workspace-1',
|
|
2010
|
+
userId: 'user-1',
|
|
2011
|
+
name: 'Workspace MCP',
|
|
2012
|
+
scopes: ['tasks:read', 'tasks:write'],
|
|
2013
|
+
status: 'active'
|
|
2014
|
+
});
|
|
2015
|
+
const match = matchRoute('POST', '/api/taskforce/settings/mcp/test-connection', routes);
|
|
2016
|
+
const req = createMockReq('POST', '/api/taskforce/settings/mcp/test-connection', {
|
|
2017
|
+
token: 'tfmcp_abc123_secret',
|
|
2018
|
+
requiredScopes: ['tasks:read', 'tasks:write']
|
|
2019
|
+
}, {
|
|
2020
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
2021
|
+
'x-taskforce-user-id': 'user-1',
|
|
2022
|
+
'x-taskforce-workspace-id': 'workspace-1'
|
|
2023
|
+
});
|
|
2024
|
+
const res = createMockRes();
|
|
2025
|
+
res.req = req;
|
|
2026
|
+
try {
|
|
2027
|
+
await match?.handler(req, res, {});
|
|
2028
|
+
}
|
|
2029
|
+
finally {
|
|
2030
|
+
vi.unstubAllGlobals();
|
|
2031
|
+
if (previousCloudEnvironment === undefined)
|
|
2032
|
+
delete process.env.TASKFORCE_CLOUD_ENVIRONMENT;
|
|
2033
|
+
else
|
|
2034
|
+
process.env.TASKFORCE_CLOUD_ENVIRONMENT = previousCloudEnvironment;
|
|
2035
|
+
}
|
|
2036
|
+
expect(res.writeHead).toHaveBeenCalledWith(502, { 'Content-Type': 'application/json' });
|
|
2037
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
2038
|
+
expect(payload.success).toBe(false);
|
|
2039
|
+
expect(payload.code).toBe('MCP_ENDPOINT_GATEWAY_ERROR');
|
|
2040
|
+
expect(payload.error).toContain('https://performance-mcp.taskforcehq.ai/mcp');
|
|
2041
|
+
expect(payload.details).toEqual(expect.objectContaining({
|
|
2042
|
+
endpoint: 'https://performance-mcp.taskforcehq.ai/mcp',
|
|
2043
|
+
upstreamStatus: 502
|
|
2044
|
+
}));
|
|
2045
|
+
});
|
|
2046
|
+
it('POST /api/taskforce/settings/mcp/test-connection should prefer the dedicated MCP hostname when cloudMcpBaseUrl points at the app host', async () => {
|
|
2047
|
+
const previousCloudEnvironment = process.env.TASKFORCE_CLOUD_ENVIRONMENT;
|
|
2048
|
+
const previousCloudMcpBaseUrl = process.env.VITE_TASKFORCE_CLOUD_MCP_BASE_URL;
|
|
2049
|
+
process.env.TASKFORCE_CLOUD_ENVIRONMENT = 'performance';
|
|
2050
|
+
process.env.VITE_TASKFORCE_CLOUD_MCP_BASE_URL = 'https://performance-app.taskforcehq.ai';
|
|
2051
|
+
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
|
|
2052
|
+
jsonrpc: '2.0',
|
|
2053
|
+
id: 'mcp-connection-test',
|
|
2054
|
+
result: {
|
|
2055
|
+
serverInfo: { name: 'taskforce-cloud' }
|
|
2056
|
+
}
|
|
2057
|
+
}), {
|
|
2058
|
+
status: 200,
|
|
2059
|
+
headers: { 'Content-Type': 'application/json' }
|
|
2060
|
+
}));
|
|
2061
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
2062
|
+
core.inspectMcpAccessToken.mockReturnValue({
|
|
2063
|
+
tokenId: 'mcp-token-1',
|
|
2064
|
+
tokenPrefix: 'tfmcp_abc123',
|
|
2065
|
+
workspaceId: 'workspace-1',
|
|
2066
|
+
userId: 'user-1',
|
|
2067
|
+
name: 'Workspace MCP',
|
|
2068
|
+
scopes: ['tasks:read', 'tasks:write'],
|
|
2069
|
+
status: 'active'
|
|
2070
|
+
});
|
|
2071
|
+
core.getWorkspaceProfile.mockReturnValue({
|
|
2072
|
+
id: 'workspace-1',
|
|
2073
|
+
slug: 'workspace-1',
|
|
2074
|
+
name: 'Workspace One',
|
|
2075
|
+
description: null
|
|
2076
|
+
});
|
|
2077
|
+
const match = matchRoute('POST', '/api/taskforce/settings/mcp/test-connection', routes);
|
|
2078
|
+
const req = createMockReq('POST', '/api/taskforce/settings/mcp/test-connection', {
|
|
2079
|
+
token: 'tfmcp_abc123_secret',
|
|
2080
|
+
requiredScopes: ['tasks:read']
|
|
2081
|
+
}, {
|
|
2082
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
2083
|
+
'x-taskforce-user-id': 'user-1',
|
|
2084
|
+
'x-taskforce-workspace-id': 'workspace-1'
|
|
2085
|
+
});
|
|
2086
|
+
const res = createMockRes();
|
|
2087
|
+
res.req = req;
|
|
2088
|
+
try {
|
|
2089
|
+
await match?.handler(req, res, {});
|
|
2090
|
+
}
|
|
2091
|
+
finally {
|
|
2092
|
+
vi.unstubAllGlobals();
|
|
2093
|
+
if (previousCloudEnvironment === undefined)
|
|
2094
|
+
delete process.env.TASKFORCE_CLOUD_ENVIRONMENT;
|
|
2095
|
+
else
|
|
2096
|
+
process.env.TASKFORCE_CLOUD_ENVIRONMENT = previousCloudEnvironment;
|
|
2097
|
+
if (previousCloudMcpBaseUrl === undefined)
|
|
2098
|
+
delete process.env.VITE_TASKFORCE_CLOUD_MCP_BASE_URL;
|
|
2099
|
+
else
|
|
2100
|
+
process.env.VITE_TASKFORCE_CLOUD_MCP_BASE_URL = previousCloudMcpBaseUrl;
|
|
2101
|
+
}
|
|
2102
|
+
expect(fetchMock).toHaveBeenCalledWith('https://performance-mcp.taskforcehq.ai/mcp', expect.any(Object));
|
|
1949
2103
|
});
|
|
1950
2104
|
it('POST /api/taskforce/settings/mcp/test-connection should report missing scopes', async () => {
|
|
1951
2105
|
core.inspectMcpAccessToken.mockReturnValue({
|
|
@@ -2230,6 +2384,63 @@ describe('Server Routes', () => {
|
|
|
2230
2384
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
2231
2385
|
vi.unstubAllGlobals();
|
|
2232
2386
|
});
|
|
2387
|
+
it('GET /api/taskforce/documents/:assetId/content should fall back to object storage when local canonical metadata points at a missing file', async () => {
|
|
2388
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
2389
|
+
status: 200,
|
|
2390
|
+
ok: true,
|
|
2391
|
+
headers: new Headers({ 'content-type': 'text/markdown' }),
|
|
2392
|
+
arrayBuffer: async () => Buffer.from('# Recovered canonical doc\n').buffer,
|
|
2393
|
+
});
|
|
2394
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
2395
|
+
const routesWithStorage = createRoutes(core, {
|
|
2396
|
+
objectStorage: {
|
|
2397
|
+
provider: 'r2',
|
|
2398
|
+
r2: {
|
|
2399
|
+
accountId: 'acct-1',
|
|
2400
|
+
bucket: 'taskforce-docs',
|
|
2401
|
+
endpoint: 'https://example.r2.cloudflarestorage.com',
|
|
2402
|
+
accessKeyId: 'access-key-1',
|
|
2403
|
+
secretAccessKey: 'secret-key-1',
|
|
2404
|
+
keyPrefix: 'taskforce',
|
|
2405
|
+
signedUploadTtlSeconds: 600,
|
|
2406
|
+
signedDownloadTtlSeconds: 300
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
});
|
|
2410
|
+
const store = new WorkspaceAssetStore(core.getDatabaseAdapter(), core.getTenantId());
|
|
2411
|
+
store.upsertAsset({
|
|
2412
|
+
assetId: 'asset-doc-content-fallback-1',
|
|
2413
|
+
workspaceId: 'default',
|
|
2414
|
+
kind: 'document',
|
|
2415
|
+
logicalName: 'Recovered canonical doc',
|
|
2416
|
+
originalFilename: 'recovered-canonical-doc.md',
|
|
2417
|
+
mimeType: 'text/markdown',
|
|
2418
|
+
storageProvider: 'local',
|
|
2419
|
+
storageKey: 'workspaces/default/assets/documents/asset-doc-content-fallback-1-recovered-canonical-doc.md',
|
|
2420
|
+
contentSha256: 'sha-canonical-doc-fallback-1',
|
|
2421
|
+
sizeBytes: 25,
|
|
2422
|
+
documentId: 'doc-content-fallback-1',
|
|
2423
|
+
version: 1,
|
|
2424
|
+
isLatest: true,
|
|
2425
|
+
scanStatus: 'clean',
|
|
2426
|
+
scanEngine: null,
|
|
2427
|
+
scanDetails: null,
|
|
2428
|
+
deletedAt: null,
|
|
2429
|
+
purgeAfter: null
|
|
2430
|
+
});
|
|
2431
|
+
const req = createMockReq('GET', '/api/taskforce/documents/asset-doc-content-fallback-1/content');
|
|
2432
|
+
const res = createMockRes();
|
|
2433
|
+
await matchRoute('GET', '/api/taskforce/documents/asset-doc-content-fallback-1/content', routesWithStorage)?.handler(req, res, {
|
|
2434
|
+
assetId: 'asset-doc-content-fallback-1'
|
|
2435
|
+
});
|
|
2436
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.objectContaining({
|
|
2437
|
+
'Content-Type': 'text/markdown',
|
|
2438
|
+
'Content-Disposition': 'inline'
|
|
2439
|
+
}));
|
|
2440
|
+
expect(res.end).toHaveBeenCalledWith(expect.any(Buffer));
|
|
2441
|
+
expect(fetchMock).toHaveBeenCalled();
|
|
2442
|
+
vi.unstubAllGlobals();
|
|
2443
|
+
});
|
|
2233
2444
|
it('GET /api/taskforce/documents/:assetId/content should reject non-document assets', async () => {
|
|
2234
2445
|
const routesWithStorage = createRoutes(core, {
|
|
2235
2446
|
objectStorage: {
|
|
@@ -3485,10 +3696,106 @@ describe('Server Routes', () => {
|
|
|
3485
3696
|
const res = createMockRes();
|
|
3486
3697
|
await match?.handler(req, res, { assetId: 'asset-doc-attached-1' });
|
|
3487
3698
|
expect(res.writeHead).toHaveBeenCalledWith(409, { 'Content-Type': 'application/json' });
|
|
3699
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
3700
|
+
expect(payload).toEqual(expect.objectContaining({
|
|
3701
|
+
error: 'Document is still attached to one or more tasks.',
|
|
3702
|
+
activeAttachmentCount: 1,
|
|
3703
|
+
requiresForceDetach: true
|
|
3704
|
+
}));
|
|
3488
3705
|
expect(store.get('asset-doc-attached-1', 'default')?.deletedAt).toBeNull();
|
|
3489
3706
|
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
3490
3707
|
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
3491
3708
|
});
|
|
3709
|
+
it('DELETE /api/taskforce/documents/:assetId should unlink attached canonical documents when forceDetach is true', async () => {
|
|
3710
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-delete-force-attached-'));
|
|
3711
|
+
const basePath = path.join(tmpRoot, '.taskforce');
|
|
3712
|
+
const documentPath = path.join(basePath, 'workspaces', 'default', 'assets', 'documents', 'asset-doc-force-delete-1-notes.md');
|
|
3713
|
+
fs.mkdirSync(path.dirname(documentPath), { recursive: true });
|
|
3714
|
+
fs.writeFileSync(documentPath, '# attached\n', 'utf8');
|
|
3715
|
+
core.getPaths.mockReturnValue({
|
|
3716
|
+
projectRoot: tmpRoot,
|
|
3717
|
+
basePath,
|
|
3718
|
+
});
|
|
3719
|
+
const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
|
|
3720
|
+
core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
|
|
3721
|
+
core.getTenantId = vi.fn().mockReturnValue('default');
|
|
3722
|
+
store.upsertAsset({
|
|
3723
|
+
assetId: 'asset-doc-force-delete-1',
|
|
3724
|
+
workspaceId: 'default',
|
|
3725
|
+
kind: 'document',
|
|
3726
|
+
originalFilename: 'notes.md',
|
|
3727
|
+
mimeType: 'text/markdown',
|
|
3728
|
+
storageProvider: 'local',
|
|
3729
|
+
storageKey: 'workspaces/default/assets/documents/asset-doc-force-delete-1-notes.md',
|
|
3730
|
+
contentSha256: 'sha-doc-force-delete-1',
|
|
3731
|
+
sizeBytes: 16,
|
|
3732
|
+
documentId: 'doc-force-delete-1',
|
|
3733
|
+
version: 1,
|
|
3734
|
+
updatedAt: '2026-03-18T14:00:00.000Z'
|
|
3735
|
+
});
|
|
3736
|
+
store.upsertLink({
|
|
3737
|
+
workspaceId: 'default',
|
|
3738
|
+
assetId: 'asset-doc-force-delete-1',
|
|
3739
|
+
taskId: 'task-keep',
|
|
3740
|
+
role: 'attachment'
|
|
3741
|
+
});
|
|
3742
|
+
core.getTask.mockImplementation((id) => id === 'task-keep'
|
|
3743
|
+
? {
|
|
3744
|
+
id: 'task-keep',
|
|
3745
|
+
title: 'Task Keep',
|
|
3746
|
+
attachments: [{
|
|
3747
|
+
path: '/api/taskforce/documents/asset-doc-force-delete-1/content',
|
|
3748
|
+
fsPath: 'workspaces/default/assets/documents/asset-doc-force-delete-1-notes.md',
|
|
3749
|
+
caption: 'notes.md',
|
|
3750
|
+
assetId: 'asset-doc-force-delete-1'
|
|
3751
|
+
}, {
|
|
3752
|
+
path: '/api/taskforce/context/other-file.md',
|
|
3753
|
+
fsPath: 'workspaces/default/assets/files/other-file.md',
|
|
3754
|
+
caption: 'other-file.md'
|
|
3755
|
+
}]
|
|
3756
|
+
}
|
|
3757
|
+
: null);
|
|
3758
|
+
core.updateTask.mockImplementation((_id, patch) => ({
|
|
3759
|
+
id: 'task-keep',
|
|
3760
|
+
attachments: patch.attachments
|
|
3761
|
+
}));
|
|
3762
|
+
const routesWithDb = createRoutes(core);
|
|
3763
|
+
const match = matchRoute('DELETE', '/api/taskforce/documents/asset-doc-force-delete-1', routesWithDb);
|
|
3764
|
+
const req = createMockReq('DELETE', '/api/taskforce/documents/asset-doc-force-delete-1', {
|
|
3765
|
+
forceDetach: true
|
|
3766
|
+
}, {
|
|
3767
|
+
'x-taskforce-workspace-id': 'default'
|
|
3768
|
+
});
|
|
3769
|
+
const res = createMockRes();
|
|
3770
|
+
await match?.handler(req, res, { assetId: 'asset-doc-force-delete-1' });
|
|
3771
|
+
expect(core.updateTask).toHaveBeenCalledWith('task-keep', {
|
|
3772
|
+
attachments: [{
|
|
3773
|
+
path: '/api/taskforce/context/other-file.md',
|
|
3774
|
+
fsPath: 'workspaces/default/assets/files/other-file.md',
|
|
3775
|
+
caption: 'other-file.md'
|
|
3776
|
+
}]
|
|
3777
|
+
}, 'default', expect.objectContaining({ actorRef: 'user', saveSource: 'manual' }));
|
|
3778
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, { 'Content-Type': 'application/json' });
|
|
3779
|
+
const payload = JSON.parse(res.end.mock.calls[0][0]);
|
|
3780
|
+
expect(payload).toEqual(expect.objectContaining({
|
|
3781
|
+
success: true,
|
|
3782
|
+
softDeleted: true,
|
|
3783
|
+
detachedTaskCount: 1
|
|
3784
|
+
}));
|
|
3785
|
+
expect(store.get('asset-doc-force-delete-1', 'default')).toEqual(expect.objectContaining({
|
|
3786
|
+
deletedAt: expect.any(String),
|
|
3787
|
+
purgeAfter: expect.any(String)
|
|
3788
|
+
}));
|
|
3789
|
+
expect(store.listLinksForAsset('asset-doc-force-delete-1', 'default', { includeDeleted: true })).toEqual([
|
|
3790
|
+
expect.objectContaining({
|
|
3791
|
+
taskId: 'task-keep',
|
|
3792
|
+
role: 'attachment',
|
|
3793
|
+
deletedAt: expect.any(String)
|
|
3794
|
+
})
|
|
3795
|
+
]);
|
|
3796
|
+
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
3797
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
3798
|
+
});
|
|
3492
3799
|
it('PATCH /api/taskforce/documents/:assetId should update the shared display name without changing storage identity', async () => {
|
|
3493
3800
|
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-rename-'));
|
|
3494
3801
|
core.getPaths.mockReturnValue({
|
|
@@ -3726,7 +4033,85 @@ describe('Server Routes', () => {
|
|
|
3726
4033
|
}));
|
|
3727
4034
|
expect(res.setHeader).not.toHaveBeenCalledWith('Set-Cookie', expect.any(String));
|
|
3728
4035
|
expect(res.writeHead).toHaveBeenCalledWith(201, expect.any(Object));
|
|
3729
|
-
|
|
4036
|
+
const payload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
|
|
4037
|
+
expect(payload).toEqual(expect.objectContaining({
|
|
4038
|
+
success: true,
|
|
4039
|
+
verificationRequired: true
|
|
4040
|
+
}));
|
|
4041
|
+
expect(payload.verificationToken).toBeUndefined();
|
|
4042
|
+
});
|
|
4043
|
+
it('POST /api/taskforce/auth/register should not depend on onboarding policy lookups', async () => {
|
|
4044
|
+
const authConfig = {
|
|
4045
|
+
runtimeMode: 'cloud',
|
|
4046
|
+
enabled: true,
|
|
4047
|
+
requiredForApi: true,
|
|
4048
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
4049
|
+
defaultRole: 'member',
|
|
4050
|
+
defaultWorkspaceId: 'default',
|
|
4051
|
+
sessionCookieName: 'taskforce_session',
|
|
4052
|
+
sessionSecret: 'test-secret',
|
|
4053
|
+
sessionTtlSeconds: 3600
|
|
4054
|
+
};
|
|
4055
|
+
core.getOnboardingPolicy.mockImplementationOnce(() => {
|
|
4056
|
+
throw new Error('for SELECT DISTINCT, ORDER BY expressions must appear in select list');
|
|
4057
|
+
});
|
|
4058
|
+
core.getGlobalSettings.mockReturnValue({
|
|
4059
|
+
auth: {
|
|
4060
|
+
allowSelfRegistration: true,
|
|
4061
|
+
requireVerifiedEmail: false
|
|
4062
|
+
}
|
|
4063
|
+
});
|
|
4064
|
+
routes = createRoutes(core, { authConfig });
|
|
4065
|
+
core.registerPasswordUser.mockReturnValueOnce({
|
|
4066
|
+
userId: 'u-new',
|
|
4067
|
+
workspaceId: 'default',
|
|
4068
|
+
role: 'member',
|
|
4069
|
+
planSelectionRequired: false
|
|
4070
|
+
});
|
|
4071
|
+
const match = matchRoute('POST', '/api/taskforce/auth/register', routes);
|
|
4072
|
+
const req = createMockReq('POST', '/api/taskforce/auth/register', {
|
|
4073
|
+
email: 'newuser@example.com',
|
|
4074
|
+
password: 'password123'
|
|
4075
|
+
});
|
|
4076
|
+
const res = createMockRes();
|
|
4077
|
+
await match?.handler(req, res, {});
|
|
4078
|
+
expect(core.registerPasswordUser).toHaveBeenCalledWith(expect.objectContaining({
|
|
4079
|
+
email: 'newuser@example.com'
|
|
4080
|
+
}));
|
|
4081
|
+
expect(res.writeHead).toHaveBeenCalledWith(201, expect.any(Object));
|
|
4082
|
+
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":true'));
|
|
4083
|
+
});
|
|
4084
|
+
it('POST /api/taskforce/auth/register should only expose verificationToken when explicitly enabled', async () => {
|
|
4085
|
+
const authConfig = {
|
|
4086
|
+
runtimeMode: 'cloud',
|
|
4087
|
+
enabled: true,
|
|
4088
|
+
requiredForApi: true,
|
|
4089
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
4090
|
+
defaultRole: 'member',
|
|
4091
|
+
defaultWorkspaceId: 'default',
|
|
4092
|
+
sessionCookieName: 'taskforce_session',
|
|
4093
|
+
sessionSecret: 'test-secret',
|
|
4094
|
+
sessionTtlSeconds: 3600
|
|
4095
|
+
};
|
|
4096
|
+
await withDevAuthTokenExposure('true', async () => {
|
|
4097
|
+
routes = createRoutes(core, { authConfig });
|
|
4098
|
+
core.registerPasswordUser.mockReturnValueOnce({
|
|
4099
|
+
userId: 'user-opt-in',
|
|
4100
|
+
workspaceId: null,
|
|
4101
|
+
role: 'member',
|
|
4102
|
+
planSelectionRequired: false
|
|
4103
|
+
});
|
|
4104
|
+
core.requestEmailVerification.mockReturnValueOnce({ token: 'verify-token-opt-in' });
|
|
4105
|
+
const match = matchRoute('POST', '/api/taskforce/auth/register', routes);
|
|
4106
|
+
const req = createMockReq('POST', '/api/taskforce/auth/register', {
|
|
4107
|
+
email: 'opt-in@example.com',
|
|
4108
|
+
password: 'password123'
|
|
4109
|
+
});
|
|
4110
|
+
const res = createMockRes();
|
|
4111
|
+
await match?.handler(req, res, {});
|
|
4112
|
+
const payload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
|
|
4113
|
+
expect(payload.verificationToken).toBe('verify-token-opt-in');
|
|
4114
|
+
});
|
|
3730
4115
|
});
|
|
3731
4116
|
it('POST /api/taskforce/auth/register should allow first-time registration without workspaceId', async () => {
|
|
3732
4117
|
const authConfig = {
|
|
@@ -3851,35 +4236,37 @@ describe('Server Routes', () => {
|
|
|
3851
4236
|
sessionSecret: 'test-secret',
|
|
3852
4237
|
sessionTtlSeconds: 3600
|
|
3853
4238
|
};
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
4239
|
+
await withDevAuthTokenExposure('true', async () => {
|
|
4240
|
+
routes = createRoutes(core, { authConfig });
|
|
4241
|
+
core.hasWorkspaceAdmin.mockReturnValueOnce(true);
|
|
4242
|
+
core.isEmailVerified.mockReturnValueOnce(false);
|
|
4243
|
+
core.requestEmailVerification.mockReturnValueOnce({ token: 'verify-token-existing' });
|
|
4244
|
+
const error = new Error('Email is already registered');
|
|
4245
|
+
error.statusCode = 409;
|
|
4246
|
+
error.code = 'EMAIL_ALREADY_EXISTS';
|
|
4247
|
+
core.registerPasswordUser.mockImplementationOnce(() => {
|
|
4248
|
+
throw error;
|
|
4249
|
+
});
|
|
4250
|
+
const match = matchRoute('POST', '/api/taskforce/auth/register', routes);
|
|
4251
|
+
const req = createMockReq('POST', '/api/taskforce/auth/register', {
|
|
4252
|
+
email: 'owner@example.com',
|
|
4253
|
+
password: 'password123',
|
|
4254
|
+
workspaceId: 'workspace-1'
|
|
4255
|
+
});
|
|
4256
|
+
const res = createMockRes();
|
|
4257
|
+
await match?.handler(req, res, {});
|
|
4258
|
+
expect(core.isEmailVerified).toHaveBeenCalledWith('owner@example.com');
|
|
4259
|
+
expect(core.requestEmailVerification).toHaveBeenCalledWith({ email: 'owner@example.com' });
|
|
4260
|
+
expect(res.writeHead).toHaveBeenCalledWith(409, expect.any(Object));
|
|
4261
|
+
const responsePayload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
|
|
4262
|
+
expect(responsePayload).toEqual(expect.objectContaining({
|
|
4263
|
+
success: false,
|
|
4264
|
+
code: 'EMAIL_ALREADY_EXISTS_UNVERIFIED',
|
|
4265
|
+
verificationRequired: true,
|
|
4266
|
+
emailSent: true,
|
|
4267
|
+
verificationToken: 'verify-token-existing'
|
|
4268
|
+
}));
|
|
3869
4269
|
});
|
|
3870
|
-
const res = createMockRes();
|
|
3871
|
-
await match?.handler(req, res, {});
|
|
3872
|
-
expect(core.isEmailVerified).toHaveBeenCalledWith('owner@example.com');
|
|
3873
|
-
expect(core.requestEmailVerification).toHaveBeenCalledWith({ email: 'owner@example.com' });
|
|
3874
|
-
expect(res.writeHead).toHaveBeenCalledWith(409, expect.any(Object));
|
|
3875
|
-
const responsePayload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
|
|
3876
|
-
expect(responsePayload).toEqual(expect.objectContaining({
|
|
3877
|
-
success: false,
|
|
3878
|
-
code: 'EMAIL_ALREADY_EXISTS_UNVERIFIED',
|
|
3879
|
-
verificationRequired: true,
|
|
3880
|
-
emailSent: true,
|
|
3881
|
-
verificationToken: 'verify-token-existing'
|
|
3882
|
-
}));
|
|
3883
4270
|
});
|
|
3884
4271
|
it('POST /api/taskforce/auth/register should return conflict when verified email exists', async () => {
|
|
3885
4272
|
const authConfig = {
|
|
@@ -3952,6 +4339,124 @@ describe('Server Routes', () => {
|
|
|
3952
4339
|
code: 'SIGNUP_PLAN_VERSION_NOT_FOUND'
|
|
3953
4340
|
}));
|
|
3954
4341
|
});
|
|
4342
|
+
it('POST /api/taskforce/auth/verify-email/request should report no-send cases without claiming delivery', async () => {
|
|
4343
|
+
const authConfig = {
|
|
4344
|
+
runtimeMode: 'cloud',
|
|
4345
|
+
enabled: true,
|
|
4346
|
+
requiredForApi: true,
|
|
4347
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
4348
|
+
defaultRole: 'member',
|
|
4349
|
+
defaultWorkspaceId: 'default',
|
|
4350
|
+
sessionCookieName: 'taskforce_session',
|
|
4351
|
+
sessionSecret: 'test-secret',
|
|
4352
|
+
sessionTtlSeconds: 3600
|
|
4353
|
+
};
|
|
4354
|
+
routes = createRoutes(core, { authConfig });
|
|
4355
|
+
core.requestEmailVerification.mockReturnValueOnce({});
|
|
4356
|
+
const match = matchRoute('POST', '/api/taskforce/auth/verify-email/request', routes);
|
|
4357
|
+
const req = createMockReq('POST', '/api/taskforce/auth/verify-email/request', { email: 'nobody@example.com' });
|
|
4358
|
+
const res = createMockRes();
|
|
4359
|
+
await match?.handler(req, res, {});
|
|
4360
|
+
const payload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
|
|
4361
|
+
expect(payload).toMatchObject({
|
|
4362
|
+
success: true,
|
|
4363
|
+
emailSent: false,
|
|
4364
|
+
deliveryAttempted: false
|
|
4365
|
+
});
|
|
4366
|
+
expect(payload.emailError).toBeUndefined();
|
|
4367
|
+
});
|
|
4368
|
+
it('POST /api/taskforce/auth/verify-email/request should only expose verificationToken when explicitly enabled', async () => {
|
|
4369
|
+
const authConfig = {
|
|
4370
|
+
runtimeMode: 'cloud',
|
|
4371
|
+
enabled: true,
|
|
4372
|
+
requiredForApi: true,
|
|
4373
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
4374
|
+
defaultRole: 'member',
|
|
4375
|
+
defaultWorkspaceId: 'default',
|
|
4376
|
+
sessionCookieName: 'taskforce_session',
|
|
4377
|
+
sessionSecret: 'test-secret',
|
|
4378
|
+
sessionTtlSeconds: 3600
|
|
4379
|
+
};
|
|
4380
|
+
await withDevAuthTokenExposure(undefined, async () => {
|
|
4381
|
+
routes = createRoutes(core, { authConfig });
|
|
4382
|
+
core.requestEmailVerification.mockReturnValueOnce({ token: 'verify-token-hidden' });
|
|
4383
|
+
const match = matchRoute('POST', '/api/taskforce/auth/verify-email/request', routes);
|
|
4384
|
+
const req = createMockReq('POST', '/api/taskforce/auth/verify-email/request', { email: 'owner@example.com' });
|
|
4385
|
+
const res = createMockRes();
|
|
4386
|
+
await match?.handler(req, res, {});
|
|
4387
|
+
const payload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
|
|
4388
|
+
expect(payload.verificationToken).toBeUndefined();
|
|
4389
|
+
});
|
|
4390
|
+
await withDevAuthTokenExposure('true', async () => {
|
|
4391
|
+
routes = createRoutes(core, { authConfig });
|
|
4392
|
+
core.requestEmailVerification.mockReturnValueOnce({ token: 'verify-token-visible' });
|
|
4393
|
+
const match = matchRoute('POST', '/api/taskforce/auth/verify-email/request', routes);
|
|
4394
|
+
const req = createMockReq('POST', '/api/taskforce/auth/verify-email/request', { email: 'owner@example.com' });
|
|
4395
|
+
const res = createMockRes();
|
|
4396
|
+
await match?.handler(req, res, {});
|
|
4397
|
+
const payload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
|
|
4398
|
+
expect(payload.verificationToken).toBe('verify-token-visible');
|
|
4399
|
+
});
|
|
4400
|
+
});
|
|
4401
|
+
it('POST /api/taskforce/auth/password-reset/request should report no-send cases without claiming delivery', async () => {
|
|
4402
|
+
const authConfig = {
|
|
4403
|
+
runtimeMode: 'cloud',
|
|
4404
|
+
enabled: true,
|
|
4405
|
+
requiredForApi: true,
|
|
4406
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
4407
|
+
defaultRole: 'member',
|
|
4408
|
+
defaultWorkspaceId: 'default',
|
|
4409
|
+
sessionCookieName: 'taskforce_session',
|
|
4410
|
+
sessionSecret: 'test-secret',
|
|
4411
|
+
sessionTtlSeconds: 3600
|
|
4412
|
+
};
|
|
4413
|
+
routes = createRoutes(core, { authConfig });
|
|
4414
|
+
core.requestPasswordReset.mockReturnValueOnce({});
|
|
4415
|
+
const match = matchRoute('POST', '/api/taskforce/auth/password-reset/request', routes);
|
|
4416
|
+
const req = createMockReq('POST', '/api/taskforce/auth/password-reset/request', { email: 'nobody@example.com' });
|
|
4417
|
+
const res = createMockRes();
|
|
4418
|
+
await match?.handler(req, res, {});
|
|
4419
|
+
const payload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
|
|
4420
|
+
expect(payload).toMatchObject({
|
|
4421
|
+
success: true,
|
|
4422
|
+
emailSent: false,
|
|
4423
|
+
deliveryAttempted: false
|
|
4424
|
+
});
|
|
4425
|
+
expect(payload.emailError).toBeUndefined();
|
|
4426
|
+
});
|
|
4427
|
+
it('POST /api/taskforce/auth/password-reset/request should only expose resetToken when explicitly enabled', async () => {
|
|
4428
|
+
const authConfig = {
|
|
4429
|
+
runtimeMode: 'cloud',
|
|
4430
|
+
enabled: true,
|
|
4431
|
+
requiredForApi: true,
|
|
4432
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
4433
|
+
defaultRole: 'member',
|
|
4434
|
+
defaultWorkspaceId: 'default',
|
|
4435
|
+
sessionCookieName: 'taskforce_session',
|
|
4436
|
+
sessionSecret: 'test-secret',
|
|
4437
|
+
sessionTtlSeconds: 3600
|
|
4438
|
+
};
|
|
4439
|
+
await withDevAuthTokenExposure(undefined, async () => {
|
|
4440
|
+
routes = createRoutes(core, { authConfig });
|
|
4441
|
+
core.requestPasswordReset.mockReturnValueOnce({ token: 'reset-token-hidden' });
|
|
4442
|
+
const match = matchRoute('POST', '/api/taskforce/auth/password-reset/request', routes);
|
|
4443
|
+
const req = createMockReq('POST', '/api/taskforce/auth/password-reset/request', { email: 'owner@example.com' });
|
|
4444
|
+
const res = createMockRes();
|
|
4445
|
+
await match?.handler(req, res, {});
|
|
4446
|
+
const payload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
|
|
4447
|
+
expect(payload.resetToken).toBeUndefined();
|
|
4448
|
+
});
|
|
4449
|
+
await withDevAuthTokenExposure('true', async () => {
|
|
4450
|
+
routes = createRoutes(core, { authConfig });
|
|
4451
|
+
core.requestPasswordReset.mockReturnValueOnce({ token: 'reset-token-visible' });
|
|
4452
|
+
const match = matchRoute('POST', '/api/taskforce/auth/password-reset/request', routes);
|
|
4453
|
+
const req = createMockReq('POST', '/api/taskforce/auth/password-reset/request', { email: 'owner@example.com' });
|
|
4454
|
+
const res = createMockRes();
|
|
4455
|
+
await match?.handler(req, res, {});
|
|
4456
|
+
const payload = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
|
|
4457
|
+
expect(payload.resetToken).toBe('reset-token-visible');
|
|
4458
|
+
});
|
|
4459
|
+
});
|
|
3955
4460
|
it('POST /api/taskforce/auth/verify-email/confirm should verify token', async () => {
|
|
3956
4461
|
const authConfig = {
|
|
3957
4462
|
runtimeMode: 'cloud',
|
|
@@ -4407,6 +4912,40 @@ describe('Server Routes', () => {
|
|
|
4407
4912
|
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
4408
4913
|
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":true'));
|
|
4409
4914
|
});
|
|
4915
|
+
it('POST /api/taskforce/auth/login should not depend on onboarding policy lookups', async () => {
|
|
4916
|
+
const authConfig = {
|
|
4917
|
+
runtimeMode: 'cloud',
|
|
4918
|
+
enabled: true,
|
|
4919
|
+
requiredForApi: true,
|
|
4920
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
4921
|
+
defaultRole: 'member',
|
|
4922
|
+
defaultWorkspaceId: 'default',
|
|
4923
|
+
sessionCookieName: 'taskforce_session',
|
|
4924
|
+
sessionSecret: 'test-secret',
|
|
4925
|
+
sessionTtlSeconds: 3600
|
|
4926
|
+
};
|
|
4927
|
+
core.getOnboardingPolicy.mockImplementationOnce(() => {
|
|
4928
|
+
throw new Error('for SELECT DISTINCT, ORDER BY expressions must appear in select list');
|
|
4929
|
+
});
|
|
4930
|
+
routes = createRoutes(core, { authConfig });
|
|
4931
|
+
core.authenticatePasswordUserDetailed.mockReturnValueOnce({
|
|
4932
|
+
ok: false,
|
|
4933
|
+
code: 'INVALID_CREDENTIALS'
|
|
4934
|
+
});
|
|
4935
|
+
const match = matchRoute('POST', '/api/taskforce/auth/login', routes);
|
|
4936
|
+
const req = createMockReq('POST', '/api/taskforce/auth/login', {
|
|
4937
|
+
email: 'user@example.com',
|
|
4938
|
+
password: 'wrongpass'
|
|
4939
|
+
});
|
|
4940
|
+
const res = createMockRes();
|
|
4941
|
+
await match?.handler(req, res, {});
|
|
4942
|
+
expect(res.writeHead).toHaveBeenCalledWith(401, expect.any(Object));
|
|
4943
|
+
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('Invalid email/password.'));
|
|
4944
|
+
expect(core.authenticatePasswordUserDetailed).toHaveBeenCalledWith(expect.objectContaining({
|
|
4945
|
+
email: 'user@example.com',
|
|
4946
|
+
requireVerifiedEmail: true
|
|
4947
|
+
}));
|
|
4948
|
+
});
|
|
4410
4949
|
it('GET /api/taskforce/workspaces should list authenticated user workspaces', async () => {
|
|
4411
4950
|
const authConfig = {
|
|
4412
4951
|
runtimeMode: 'cloud',
|
|
@@ -5726,6 +6265,109 @@ describe('Server Routes', () => {
|
|
|
5726
6265
|
]));
|
|
5727
6266
|
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
5728
6267
|
});
|
|
6268
|
+
it('GET /api/taskforce/sync/workspace/pull should hydrate canonical markdown documents from object storage', async () => {
|
|
6269
|
+
const authConfig = {
|
|
6270
|
+
runtimeMode: 'cloud',
|
|
6271
|
+
enabled: true,
|
|
6272
|
+
requiredForApi: true,
|
|
6273
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
|
|
6274
|
+
defaultRole: 'member',
|
|
6275
|
+
defaultWorkspaceId: 'default',
|
|
6276
|
+
sessionCookieName: 'taskforce_session',
|
|
6277
|
+
sessionSecret: 'test-secret',
|
|
6278
|
+
sessionTtlSeconds: 3600
|
|
6279
|
+
};
|
|
6280
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
6281
|
+
status: 200,
|
|
6282
|
+
ok: true,
|
|
6283
|
+
headers: new Headers({ 'content-type': 'text/markdown' }),
|
|
6284
|
+
arrayBuffer: async () => Uint8Array.from(Buffer.from('# Canonical R2 doc\n')).buffer,
|
|
6285
|
+
});
|
|
6286
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
6287
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-pull-doc-r2-'));
|
|
6288
|
+
const basePath = path.join(tmpRoot, '.taskforce');
|
|
6289
|
+
fs.mkdirSync(basePath, { recursive: true });
|
|
6290
|
+
const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
|
|
6291
|
+
store.upsertAsset({
|
|
6292
|
+
assetId: 'asset-doc-r2-1',
|
|
6293
|
+
workspaceId: 'workspace-1',
|
|
6294
|
+
kind: 'document',
|
|
6295
|
+
originalFilename: 'canonical-r2-doc.md',
|
|
6296
|
+
mimeType: 'text/markdown',
|
|
6297
|
+
storageProvider: 'r2',
|
|
6298
|
+
storageKey: 'workspaces/workspace-1/assets/documents/asset-doc-r2-1-canonical-r2-doc.md',
|
|
6299
|
+
contentSha256: 'sha-doc-r2-1',
|
|
6300
|
+
sizeBytes: 19,
|
|
6301
|
+
documentId: 'doc-r2-1',
|
|
6302
|
+
referenceNumber: 201,
|
|
6303
|
+
version: 2,
|
|
6304
|
+
isLatest: true,
|
|
6305
|
+
createdAt: '2026-03-17T09:00:00.000Z',
|
|
6306
|
+
updatedAt: '2026-03-17T10:00:00.000Z'
|
|
6307
|
+
});
|
|
6308
|
+
store.upsertLink({
|
|
6309
|
+
workspaceId: 'workspace-1',
|
|
6310
|
+
assetId: 'asset-doc-r2-1',
|
|
6311
|
+
taskId: 'task-1',
|
|
6312
|
+
role: 'attachment',
|
|
6313
|
+
createdAt: '2026-03-17T10:00:00.000Z',
|
|
6314
|
+
updatedAt: '2026-03-17T10:00:00.000Z'
|
|
6315
|
+
});
|
|
6316
|
+
core.getPaths.mockReturnValue({
|
|
6317
|
+
projectRoot: tmpRoot,
|
|
6318
|
+
basePath,
|
|
6319
|
+
});
|
|
6320
|
+
core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
|
|
6321
|
+
core.getTenantId = vi.fn().mockReturnValue('default');
|
|
6322
|
+
routes = createRoutes(core, {
|
|
6323
|
+
authConfig,
|
|
6324
|
+
objectStorage: {
|
|
6325
|
+
provider: 'r2',
|
|
6326
|
+
r2: {
|
|
6327
|
+
accountId: 'acct-1',
|
|
6328
|
+
bucket: 'taskforce-docs',
|
|
6329
|
+
endpoint: 'https://example.r2.cloudflarestorage.com',
|
|
6330
|
+
accessKeyId: 'access-key-1',
|
|
6331
|
+
secretAccessKey: 'secret-key-1',
|
|
6332
|
+
keyPrefix: 'taskforce',
|
|
6333
|
+
signedUploadTtlSeconds: 600,
|
|
6334
|
+
signedDownloadTtlSeconds: 300
|
|
6335
|
+
}
|
|
6336
|
+
}
|
|
6337
|
+
});
|
|
6338
|
+
core.listUserWorkspaces.mockReturnValueOnce([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
|
|
6339
|
+
const cookie = createSessionTokenCookie('session-sync-pull-doc-r2', authConfig, {
|
|
6340
|
+
role: 'owner',
|
|
6341
|
+
workspaceId: 'workspace-1',
|
|
6342
|
+
userId: 'user-1'
|
|
6343
|
+
});
|
|
6344
|
+
core.listTasks.mockReturnValueOnce({ tasks: [] });
|
|
6345
|
+
core.listArchive.mockReturnValueOnce({ archived: [] });
|
|
6346
|
+
const match = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
|
|
6347
|
+
const req = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-1&limit=20', undefined, { cookie });
|
|
6348
|
+
const res = createMockRes();
|
|
6349
|
+
await match?.handler(req, res, {});
|
|
6350
|
+
const payloadRaw = res.end.mock.calls[0]?.[0];
|
|
6351
|
+
const payload = JSON.parse(String(payloadRaw || '{}'));
|
|
6352
|
+
expect(payload.changes).toEqual(expect.arrayContaining([
|
|
6353
|
+
expect.objectContaining({
|
|
6354
|
+
op: 'document-upsert',
|
|
6355
|
+
path: 'workspaces/workspace-1/assets/documents/asset-doc-r2-1-canonical-r2-doc.md',
|
|
6356
|
+
content: '# Canonical R2 doc\n',
|
|
6357
|
+
assetId: 'asset-doc-r2-1',
|
|
6358
|
+
documentId: 'doc-r2-1',
|
|
6359
|
+
referenceNumber: 201,
|
|
6360
|
+
version: 2,
|
|
6361
|
+
taskId: 'task-1',
|
|
6362
|
+
originalFilename: 'canonical-r2-doc.md',
|
|
6363
|
+
linkRole: 'attachment'
|
|
6364
|
+
})
|
|
6365
|
+
]));
|
|
6366
|
+
expect(fetchMock).toHaveBeenCalled();
|
|
6367
|
+
vi.unstubAllGlobals();
|
|
6368
|
+
fs.rmSync(dbRoot, { recursive: true, force: true });
|
|
6369
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
6370
|
+
});
|
|
5729
6371
|
it('GET /api/taskforce/sync/workspace/pull should include document review session upserts and deletes', async () => {
|
|
5730
6372
|
const authConfig = {
|
|
5731
6373
|
runtimeMode: 'cloud',
|