From 0c095267de198f66221279a8c459642c5dac7d4f Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 19 Feb 2026 13:55:30 -0800 Subject: [PATCH] fix(workflows): disallow duplicate workflow names at the same folder level --- .../app/api/auth/oauth/token/route.test.ts | 13 +- .../app/api/chat/[identifier]/route.test.ts | 6 +- apps/sim/app/api/chat/utils.test.ts | 6 +- apps/sim/app/api/files/delete/route.test.ts | 14 +- apps/sim/app/api/files/parse/route.test.ts | 14 +- .../sim/app/api/files/presigned/route.test.ts | 21 +- .../api/files/serve/[...path]/route.test.ts | 56 ++- apps/sim/app/api/files/upload/route.test.ts | 21 +- .../app/api/knowledge/search/route.test.ts | 5 +- .../api/mcp/serve/[serverId]/route.test.ts | 7 +- apps/sim/app/api/schedules/[id]/route.test.ts | 6 +- apps/sim/app/api/schedules/route.test.ts | 6 +- apps/sim/app/api/tools/custom/route.test.ts | 61 ++- .../api/webhooks/trigger/[path]/route.test.ts | 6 +- .../workflows/[id]/chat/status/route.test.ts | 8 +- .../workflows/[id]/form/status/route.test.ts | 8 +- apps/sim/app/api/workflows/[id]/route.test.ts | 359 +++++++++++++++--- apps/sim/app/api/workflows/[id]/route.ts | 41 +- apps/sim/app/api/workflows/route.test.ts | 14 +- .../condition/condition-handler.test.ts | 6 +- apps/sim/lib/core/security/encryption.test.ts | 27 +- apps/sim/lib/core/utils.test.ts | 34 +- apps/sim/socket/index.test.ts | 22 +- packages/testing/src/index.ts | 4 + .../testing/src/mocks/hybrid-auth.mock.ts | 85 +++++ packages/testing/src/mocks/index.ts | 6 +- packages/testing/src/mocks/request.mock.ts | 14 + packages/testing/src/mocks/telemetry.mock.ts | 30 ++ 28 files changed, 639 insertions(+), 261 deletions(-) create mode 100644 packages/testing/src/mocks/hybrid-auth.mock.ts create mode 100644 packages/testing/src/mocks/telemetry.mock.ts diff --git a/apps/sim/app/api/auth/oauth/token/route.test.ts b/apps/sim/app/api/auth/oauth/token/route.test.ts index 325f4d6c2c..3ed0c576ad 100644 --- a/apps/sim/app/api/auth/oauth/token/route.test.ts +++ b/apps/sim/app/api/auth/oauth/token/route.test.ts @@ -3,7 +3,7 @@ * * @vitest-environment node */ -import { createMockLogger, createMockRequest } from '@sim/testing' +import { createMockLogger, createMockRequest, mockHybridAuth } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' describe('OAuth Token API Routes', () => { @@ -12,7 +12,7 @@ describe('OAuth Token API Routes', () => { const mockRefreshTokenIfNeeded = vi.fn() const mockGetOAuthToken = vi.fn() const mockAuthorizeCredentialUse = vi.fn() - const mockCheckSessionOrInternalAuth = vi.fn() + let mockCheckSessionOrInternalAuth: ReturnType const mockLogger = createMockLogger() @@ -41,9 +41,7 @@ describe('OAuth Token API Routes', () => { authorizeCredentialUse: mockAuthorizeCredentialUse, })) - vi.doMock('@/lib/auth/hybrid', () => ({ - checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth, - })) + ;({ mockCheckSessionOrInternalAuth } = mockHybridAuth()) }) afterEach(() => { @@ -73,23 +71,18 @@ describe('OAuth Token API Routes', () => { refreshed: false, }) - // Create mock request const req = createMockRequest('POST', { credentialId: 'credential-id', }) - // Import handler after setting up mocks const { POST } = await import('@/app/api/auth/oauth/token/route') - // Call handler const response = await POST(req) const data = await response.json() - // Verify request was handled correctly expect(response.status).toBe(200) expect(data).toHaveProperty('accessToken', 'fresh-token') - // Verify mocks were called correctly expect(mockAuthorizeCredentialUse).toHaveBeenCalled() expect(mockGetCredential).toHaveBeenCalled() expect(mockRefreshTokenIfNeeded).toHaveBeenCalled() diff --git a/apps/sim/app/api/chat/[identifier]/route.test.ts b/apps/sim/app/api/chat/[identifier]/route.test.ts index 5a753fd4d9..d3a14c5ac3 100644 --- a/apps/sim/app/api/chat/[identifier]/route.test.ts +++ b/apps/sim/app/api/chat/[identifier]/route.test.ts @@ -3,7 +3,7 @@ * * @vitest-environment node */ -import { loggerMock } from '@sim/testing' +import { loggerMock, requestUtilsMock } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' /** @@ -94,9 +94,7 @@ vi.mock('@/lib/core/utils/sse', () => ({ }, })) -vi.mock('@/lib/core/utils/request', () => ({ - generateRequestId: vi.fn().mockReturnValue('test-request-id'), -})) +vi.mock('@/lib/core/utils/request', () => requestUtilsMock) vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: vi.fn().mockResolvedValue({ decrypted: 'test-password' }), diff --git a/apps/sim/app/api/chat/utils.test.ts b/apps/sim/app/api/chat/utils.test.ts index 84c3bb375a..a6b19ad9c9 100644 --- a/apps/sim/app/api/chat/utils.test.ts +++ b/apps/sim/app/api/chat/utils.test.ts @@ -1,4 +1,4 @@ -import { databaseMock, loggerMock } from '@sim/testing' +import { databaseMock, loggerMock, requestUtilsMock } from '@sim/testing' import type { NextResponse } from 'next/server' /** * Tests for chat API utils @@ -37,9 +37,7 @@ vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: mockDecryptSecret, })) -vi.mock('@/lib/core/utils/request', () => ({ - generateRequestId: vi.fn(), -})) +vi.mock('@/lib/core/utils/request', () => requestUtilsMock) vi.mock('@/lib/core/config/feature-flags', () => ({ isDev: true, diff --git a/apps/sim/app/api/files/delete/route.test.ts b/apps/sim/app/api/files/delete/route.test.ts index 0cc9824f71..26fa2d9f09 100644 --- a/apps/sim/app/api/files/delete/route.test.ts +++ b/apps/sim/app/api/files/delete/route.test.ts @@ -2,6 +2,7 @@ import { createMockRequest, mockAuth, mockCryptoUuid, + mockHybridAuth, mockUuid, setupCommonApiMocks, } from '@sim/testing' @@ -28,13 +29,12 @@ function setupFileApiMocks( authMocks.setUnauthenticated() } - vi.doMock('@/lib/auth/hybrid', () => ({ - checkSessionOrInternalAuth: vi.fn().mockResolvedValue({ - success: authenticated, - userId: authenticated ? 'test-user-id' : undefined, - error: authenticated ? undefined : 'Unauthorized', - }), - })) + const { mockCheckSessionOrInternalAuth } = mockHybridAuth() + mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: authenticated, + userId: authenticated ? 'test-user-id' : undefined, + error: authenticated ? undefined : 'Unauthorized', + }) vi.doMock('@/app/api/files/authorization', () => ({ verifyFileAccess: vi.fn().mockResolvedValue(true), diff --git a/apps/sim/app/api/files/parse/route.test.ts b/apps/sim/app/api/files/parse/route.test.ts index bfdc3bbe71..eb69942d38 100644 --- a/apps/sim/app/api/files/parse/route.test.ts +++ b/apps/sim/app/api/files/parse/route.test.ts @@ -8,6 +8,7 @@ import { createMockRequest, mockAuth, mockCryptoUuid, + mockHybridAuth, mockUuid, setupCommonApiMocks, } from '@sim/testing' @@ -34,13 +35,12 @@ function setupFileApiMocks( authMocks.setUnauthenticated() } - vi.doMock('@/lib/auth/hybrid', () => ({ - checkInternalAuth: vi.fn().mockResolvedValue({ - success: authenticated, - userId: authenticated ? 'test-user-id' : undefined, - error: authenticated ? undefined : 'Unauthorized', - }), - })) + const { mockCheckInternalAuth } = mockHybridAuth() + mockCheckInternalAuth.mockResolvedValue({ + success: authenticated, + userId: authenticated ? 'test-user-id' : undefined, + error: authenticated ? undefined : 'Unauthorized', + }) vi.doMock('@/app/api/files/authorization', () => ({ verifyFileAccess: vi.fn().mockResolvedValue(true), diff --git a/apps/sim/app/api/files/presigned/route.test.ts b/apps/sim/app/api/files/presigned/route.test.ts index 0721269382..4089343a9c 100644 --- a/apps/sim/app/api/files/presigned/route.test.ts +++ b/apps/sim/app/api/files/presigned/route.test.ts @@ -1,4 +1,10 @@ -import { mockAuth, mockCryptoUuid, mockUuid, setupCommonApiMocks } from '@sim/testing' +import { + mockAuth, + mockCryptoUuid, + mockHybridAuth, + mockUuid, + setupCommonApiMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -28,13 +34,12 @@ function setupFileApiMocks( authMocks.setUnauthenticated() } - vi.doMock('@/lib/auth/hybrid', () => ({ - checkHybridAuth: vi.fn().mockResolvedValue({ - success: authenticated, - userId: authenticated ? 'test-user-id' : undefined, - error: authenticated ? undefined : 'Unauthorized', - }), - })) + const { mockCheckHybridAuth } = mockHybridAuth() + mockCheckHybridAuth.mockResolvedValue({ + success: authenticated, + userId: authenticated ? 'test-user-id' : undefined, + error: authenticated ? undefined : 'Unauthorized', + }) vi.doMock('@/app/api/files/authorization', () => ({ verifyFileAccess: vi.fn().mockResolvedValue(true), diff --git a/apps/sim/app/api/files/serve/[...path]/route.test.ts b/apps/sim/app/api/files/serve/[...path]/route.test.ts index d09adf048a..d2b3b58a35 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.test.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.test.ts @@ -7,6 +7,7 @@ import { defaultMockUser, mockAuth, mockCryptoUuid, + mockHybridAuth, mockUuid, setupCommonApiMocks, } from '@sim/testing' @@ -54,12 +55,11 @@ describe('File Serve API Route', () => { withUploadUtils: true, }) - vi.doMock('@/lib/auth/hybrid', () => ({ - checkSessionOrInternalAuth: vi.fn().mockResolvedValue({ - success: true, - userId: 'test-user-id', - }), - })) + const { mockCheckSessionOrInternalAuth: serveAuthMock } = mockHybridAuth() + serveAuthMock.mockResolvedValue({ + success: true, + userId: 'test-user-id', + }) vi.doMock('@/app/api/files/authorization', () => ({ verifyFileAccess: vi.fn().mockResolvedValue(true), @@ -164,12 +164,11 @@ describe('File Serve API Route', () => { findLocalFile: vi.fn().mockReturnValue('/test/uploads/nested/path/file.txt'), })) - vi.doMock('@/lib/auth/hybrid', () => ({ - checkSessionOrInternalAuth: vi.fn().mockResolvedValue({ - success: true, - userId: 'test-user-id', - }), - })) + const { mockCheckSessionOrInternalAuth: serveAuthMock } = mockHybridAuth() + serveAuthMock.mockResolvedValue({ + success: true, + userId: 'test-user-id', + }) vi.doMock('@/app/api/files/authorization', () => ({ verifyFileAccess: vi.fn().mockResolvedValue(true), @@ -225,12 +224,11 @@ describe('File Serve API Route', () => { USE_BLOB_STORAGE: false, })) - vi.doMock('@/lib/auth/hybrid', () => ({ - checkSessionOrInternalAuth: vi.fn().mockResolvedValue({ - success: true, - userId: 'test-user-id', - }), - })) + const { mockCheckSessionOrInternalAuth: serveAuthMock } = mockHybridAuth() + serveAuthMock.mockResolvedValue({ + success: true, + userId: 'test-user-id', + }) vi.doMock('@/app/api/files/authorization', () => ({ verifyFileAccess: vi.fn().mockResolvedValue(true), @@ -290,12 +288,11 @@ describe('File Serve API Route', () => { readFile: vi.fn().mockRejectedValue(new Error('ENOENT: no such file or directory')), })) - vi.doMock('@/lib/auth/hybrid', () => ({ - checkSessionOrInternalAuth: vi.fn().mockResolvedValue({ - success: true, - userId: 'test-user-id', - }), - })) + const { mockCheckSessionOrInternalAuth: serveAuthMock } = mockHybridAuth() + serveAuthMock.mockResolvedValue({ + success: true, + userId: 'test-user-id', + }) vi.doMock('@/app/api/files/authorization', () => ({ verifyFileAccess: vi.fn().mockResolvedValue(false), // File not found = no access @@ -349,12 +346,11 @@ describe('File Serve API Route', () => { for (const test of contentTypeTests) { it(`should serve ${test.ext} file with correct content type`, async () => { - vi.doMock('@/lib/auth/hybrid', () => ({ - checkSessionOrInternalAuth: vi.fn().mockResolvedValue({ - success: true, - userId: 'test-user-id', - }), - })) + const { mockCheckSessionOrInternalAuth: ctAuthMock } = mockHybridAuth() + ctAuthMock.mockResolvedValue({ + success: true, + userId: 'test-user-id', + }) vi.doMock('@/app/api/files/authorization', () => ({ verifyFileAccess: vi.fn().mockResolvedValue(true), diff --git a/apps/sim/app/api/files/upload/route.test.ts b/apps/sim/app/api/files/upload/route.test.ts index 25c8f68adf..be6f4f9bf5 100644 --- a/apps/sim/app/api/files/upload/route.test.ts +++ b/apps/sim/app/api/files/upload/route.test.ts @@ -3,7 +3,13 @@ * * @vitest-environment node */ -import { mockAuth, mockCryptoUuid, mockUuid, setupCommonApiMocks } from '@sim/testing' +import { + mockAuth, + mockCryptoUuid, + mockHybridAuth, + mockUuid, + setupCommonApiMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -27,13 +33,12 @@ function setupFileApiMocks( authMocks.setUnauthenticated() } - vi.doMock('@/lib/auth/hybrid', () => ({ - checkHybridAuth: vi.fn().mockResolvedValue({ - success: authenticated, - userId: authenticated ? 'test-user-id' : undefined, - error: authenticated ? undefined : 'Unauthorized', - }), - })) + const { mockCheckHybridAuth } = mockHybridAuth() + mockCheckHybridAuth.mockResolvedValue({ + success: authenticated, + userId: authenticated ? 'test-user-id' : undefined, + error: authenticated ? undefined : 'Unauthorized', + }) vi.doMock('@/app/api/files/authorization', () => ({ verifyFileAccess: vi.fn().mockResolvedValue(true), diff --git a/apps/sim/app/api/knowledge/search/route.test.ts b/apps/sim/app/api/knowledge/search/route.test.ts index b08ab969b4..bf7ae1f72e 100644 --- a/apps/sim/app/api/knowledge/search/route.test.ts +++ b/apps/sim/app/api/knowledge/search/route.test.ts @@ -10,6 +10,7 @@ import { createMockRequest, mockConsoleLogger, mockKnowledgeSchemas, + requestUtilsMock, } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -29,9 +30,7 @@ mockKnowledgeSchemas() vi.mock('@/lib/core/config/env', () => createEnvMock({ OPENAI_API_KEY: 'test-api-key' })) -vi.mock('@/lib/core/utils/request', () => ({ - generateRequestId: vi.fn(() => 'test-request-id'), -})) +vi.mock('@/lib/core/utils/request', () => requestUtilsMock) vi.mock('@/lib/documents/utils', () => ({ retryWithExponentialBackoff: vi.fn().mockImplementation((fn) => fn()), diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts index 95a3f89eda..fc6b5182ed 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts @@ -3,10 +3,11 @@ * * @vitest-environment node */ +import { mockHybridAuth } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const mockCheckHybridAuth = vi.fn() +let mockCheckHybridAuth: ReturnType const mockGetUserEntityPermissions = vi.fn() const mockGenerateInternalToken = vi.fn() const mockDbSelect = vi.fn() @@ -61,9 +62,7 @@ describe('MCP Serve Route', () => { isDeployed: 'isDeployed', }, })) - vi.doMock('@/lib/auth/hybrid', () => ({ - checkHybridAuth: mockCheckHybridAuth, - })) + ;({ mockCheckHybridAuth } = mockHybridAuth()) vi.doMock('@/lib/workspaces/permissions/utils', () => ({ getUserEntityPermissions: mockGetUserEntityPermissions, })) diff --git a/apps/sim/app/api/schedules/[id]/route.test.ts b/apps/sim/app/api/schedules/[id]/route.test.ts index e2377ddc3e..ca0e723be5 100644 --- a/apps/sim/app/api/schedules/[id]/route.test.ts +++ b/apps/sim/app/api/schedules/[id]/route.test.ts @@ -3,7 +3,7 @@ * * @vitest-environment node */ -import { auditMock, databaseMock, loggerMock } from '@sim/testing' +import { auditMock, databaseMock, loggerMock, requestUtilsMock } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -31,9 +31,7 @@ vi.mock('drizzle-orm', () => ({ eq: vi.fn(), })) -vi.mock('@/lib/core/utils/request', () => ({ - generateRequestId: () => 'test-request-id', -})) +vi.mock('@/lib/core/utils/request', () => requestUtilsMock) vi.mock('@sim/logger', () => loggerMock) diff --git a/apps/sim/app/api/schedules/route.test.ts b/apps/sim/app/api/schedules/route.test.ts index 9d1530d501..434bc2fa8d 100644 --- a/apps/sim/app/api/schedules/route.test.ts +++ b/apps/sim/app/api/schedules/route.test.ts @@ -3,7 +3,7 @@ * * @vitest-environment node */ -import { databaseMock, loggerMock } from '@sim/testing' +import { databaseMock, loggerMock, requestUtilsMock } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -43,9 +43,7 @@ vi.mock('drizzle-orm', () => ({ isNull: vi.fn(), })) -vi.mock('@/lib/core/utils/request', () => ({ - generateRequestId: () => 'test-request-id', -})) +vi.mock('@/lib/core/utils/request', () => requestUtilsMock) vi.mock('@sim/logger', () => loggerMock) diff --git a/apps/sim/app/api/tools/custom/route.test.ts b/apps/sim/app/api/tools/custom/route.test.ts index a5db0632a3..15e26ba506 100644 --- a/apps/sim/app/api/tools/custom/route.test.ts +++ b/apps/sim/app/api/tools/custom/route.test.ts @@ -3,7 +3,7 @@ * * @vitest-environment node */ -import { createMockRequest, loggerMock } from '@sim/testing' +import { createMockRequest, loggerMock, mockHybridAuth } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -180,13 +180,12 @@ describe('Custom Tools API Routes', () => { getSession: vi.fn().mockResolvedValue(mockSession), })) - vi.doMock('@/lib/auth/hybrid', () => ({ - checkSessionOrInternalAuth: vi.fn().mockResolvedValue({ - success: true, - userId: 'user-123', - authType: 'session', - }), - })) + const { mockCheckSessionOrInternalAuth: hybridAuthMock } = mockHybridAuth() + hybridAuthMock.mockResolvedValue({ + success: true, + userId: 'user-123', + authType: 'session', + }) vi.doMock('@/lib/workspaces/permissions/utils', () => ({ getUserEntityPermissions: vi.fn().mockResolvedValue('admin'), @@ -261,12 +260,11 @@ describe('Custom Tools API Routes', () => { 'http://localhost:3000/api/tools/custom?workspaceId=workspace-123' ) - vi.doMock('@/lib/auth/hybrid', () => ({ - checkSessionOrInternalAuth: vi.fn().mockResolvedValue({ - success: false, - error: 'Unauthorized', - }), - })) + const { mockCheckSessionOrInternalAuth: unauthMock } = mockHybridAuth() + unauthMock.mockResolvedValue({ + success: false, + error: 'Unauthorized', + }) const { GET } = await import('@/app/api/tools/custom/route') @@ -297,12 +295,11 @@ describe('Custom Tools API Routes', () => { */ describe('POST /api/tools/custom', () => { it('should reject unauthorized requests', async () => { - vi.doMock('@/lib/auth/hybrid', () => ({ - checkSessionOrInternalAuth: vi.fn().mockResolvedValue({ - success: false, - error: 'Unauthorized', - }), - })) + const { mockCheckSessionOrInternalAuth: unauthMock } = mockHybridAuth() + unauthMock.mockResolvedValue({ + success: false, + error: 'Unauthorized', + }) const req = createMockRequest('POST', { tools: [], workspaceId: 'workspace-123' }) @@ -384,13 +381,12 @@ describe('Custom Tools API Routes', () => { }) it('should prevent unauthorized deletion of user-scoped tool', async () => { - vi.doMock('@/lib/auth/hybrid', () => ({ - checkSessionOrInternalAuth: vi.fn().mockResolvedValue({ - success: true, - userId: 'user-456', - authType: 'session', - }), - })) + const { mockCheckSessionOrInternalAuth: diffUserMock } = mockHybridAuth() + diffUserMock.mockResolvedValue({ + success: true, + userId: 'user-456', + authType: 'session', + }) const userScopedTool = { ...sampleTools[0], workspaceId: null, userId: 'user-123' } const mockLimitUserScoped = vi.fn().mockResolvedValue([userScopedTool]) @@ -408,12 +404,11 @@ describe('Custom Tools API Routes', () => { }) it('should reject unauthorized requests', async () => { - vi.doMock('@/lib/auth/hybrid', () => ({ - checkSessionOrInternalAuth: vi.fn().mockResolvedValue({ - success: false, - error: 'Unauthorized', - }), - })) + const { mockCheckSessionOrInternalAuth: unauthMock } = mockHybridAuth() + unauthMock.mockResolvedValue({ + success: false, + error: 'Unauthorized', + }) const req = new NextRequest('http://localhost:3000/api/tools/custom?id=tool-1') diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts index 737e5ac48b..97cabebf61 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts @@ -3,7 +3,7 @@ * * @vitest-environment node */ -import { createMockRequest, loggerMock } from '@sim/testing' +import { createMockRequest, loggerMock, requestUtilsMock } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' /** Mock execution dependencies for webhook tests */ @@ -348,9 +348,7 @@ vi.mock('postgres', () => vi.fn().mockReturnValue({})) vi.mock('@sim/logger', () => loggerMock) -vi.mock('@/lib/core/utils/request', () => ({ - generateRequestId: vi.fn().mockReturnValue('test-request-id'), -})) +vi.mock('@/lib/core/utils/request', () => requestUtilsMock) process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test' diff --git a/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts b/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts index 8e09e10b0f..1d3df876cb 100644 --- a/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts @@ -3,11 +3,11 @@ * * @vitest-environment node */ -import { loggerMock } from '@sim/testing' +import { loggerMock, mockHybridAuth } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const mockCheckSessionOrInternalAuth = vi.fn() +let mockCheckSessionOrInternalAuth: ReturnType const mockAuthorizeWorkflowByWorkspacePermission = vi.fn() const mockDbSelect = vi.fn() const mockDbFrom = vi.fn() @@ -48,9 +48,7 @@ describe('Workflow Chat Status Route', () => { workflowId: 'workflowId', }, })) - vi.doMock('@/lib/auth/hybrid', () => ({ - checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth, - })) + ;({ mockCheckSessionOrInternalAuth } = mockHybridAuth()) vi.doMock('@/lib/workflows/utils', () => ({ authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission, })) diff --git a/apps/sim/app/api/workflows/[id]/form/status/route.test.ts b/apps/sim/app/api/workflows/[id]/form/status/route.test.ts index d4274a6faf..4ab4b2a5dc 100644 --- a/apps/sim/app/api/workflows/[id]/form/status/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/form/status/route.test.ts @@ -3,11 +3,11 @@ * * @vitest-environment node */ -import { loggerMock } from '@sim/testing' +import { loggerMock, mockHybridAuth } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const mockCheckSessionOrInternalAuth = vi.fn() +let mockCheckSessionOrInternalAuth: ReturnType const mockAuthorizeWorkflowByWorkspacePermission = vi.fn() const mockDbSelect = vi.fn() const mockDbFrom = vi.fn() @@ -43,9 +43,7 @@ describe('Workflow Form Status Route', () => { isActive: 'isActive', }, })) - vi.doMock('@/lib/auth/hybrid', () => ({ - checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth, - })) + ;({ mockCheckSessionOrInternalAuth } = mockHybridAuth()) vi.doMock('@/lib/workflows/utils', () => ({ authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission, })) diff --git a/apps/sim/app/api/workflows/[id]/route.test.ts b/apps/sim/app/api/workflows/[id]/route.test.ts index 3595a26850..fba05c92c9 100644 --- a/apps/sim/app/api/workflows/[id]/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/route.test.ts @@ -5,11 +5,19 @@ * @vitest-environment node */ -import { auditMock, loggerMock, setupGlobalFetchMock } from '@sim/testing' +import { + auditMock, + envMock, + loggerMock, + requestUtilsMock, + setupGlobalFetchMock, + telemetryMock, +} from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const mockGetSession = vi.fn() +const mockCheckHybridAuth = vi.fn() +const mockCheckSessionOrInternalAuth = vi.fn() const mockLoadWorkflowFromNormalizedTables = vi.fn() const mockGetWorkflowById = vi.fn() const mockAuthorizeWorkflowByWorkspacePermission = vi.fn() @@ -17,10 +25,34 @@ const mockDbDelete = vi.fn() const mockDbUpdate = vi.fn() const mockDbSelect = vi.fn() +/** + * Helper to set mock auth state consistently across getSession and hybrid auth. + */ +function mockGetSession(session: { user: { id: string } } | null) { + if (session) { + mockCheckHybridAuth.mockResolvedValue({ success: true, userId: session.user.id }) + mockCheckSessionOrInternalAuth.mockResolvedValue({ success: true, userId: session.user.id }) + } else { + mockCheckHybridAuth.mockResolvedValue({ success: false }) + mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false }) + } +} + vi.mock('@/lib/auth', () => ({ - getSession: () => mockGetSession(), + getSession: vi.fn(), })) +vi.mock('@/lib/auth/hybrid', () => ({ + checkHybridAuth: (...args: unknown[]) => mockCheckHybridAuth(...args), + checkSessionOrInternalAuth: (...args: unknown[]) => mockCheckSessionOrInternalAuth(...args), +})) + +vi.mock('@/lib/core/config/env', () => envMock) + +vi.mock('@/lib/core/telemetry', () => telemetryMock) + +vi.mock('@/lib/core/utils/request', () => requestUtilsMock) + vi.mock('@sim/logger', () => loggerMock) vi.mock('@/lib/audit/log', () => auditMock) @@ -30,20 +62,14 @@ vi.mock('@/lib/workflows/persistence/utils', () => ({ mockLoadWorkflowFromNormalizedTables(workflowId), })) -vi.mock('@/lib/workflows/utils', async () => { - const actual = - await vi.importActual('@/lib/workflows/utils') - - return { - ...actual, - getWorkflowById: (workflowId: string) => mockGetWorkflowById(workflowId), - authorizeWorkflowByWorkspacePermission: (params: { - workflowId: string - userId: string - action?: 'read' | 'write' | 'admin' - }) => mockAuthorizeWorkflowByWorkspacePermission(params), - } -}) +vi.mock('@/lib/workflows/utils', () => ({ + getWorkflowById: (workflowId: string) => mockGetWorkflowById(workflowId), + authorizeWorkflowByWorkspacePermission: (params: { + workflowId: string + userId: string + action?: 'read' | 'write' | 'admin' + }) => mockAuthorizeWorkflowByWorkspacePermission(params), +})) vi.mock('@sim/db', () => ({ db: { @@ -73,7 +99,7 @@ describe('Workflow By ID API Route', () => { describe('GET /api/workflows/[id]', () => { it('should return 401 when user is not authenticated', async () => { - mockGetSession.mockResolvedValue(null) + mockGetSession(null) const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123') const params = Promise.resolve({ id: 'workflow-123' }) @@ -86,9 +112,7 @@ describe('Workflow By ID API Route', () => { }) it('should return 404 when workflow does not exist', async () => { - mockGetSession.mockResolvedValue({ - user: { id: 'user-123' }, - }) + mockGetSession({ user: { id: 'user-123' } }) mockGetWorkflowById.mockResolvedValue(null) @@ -118,9 +142,7 @@ describe('Workflow By ID API Route', () => { isFromNormalizedTables: true, } - mockGetSession.mockResolvedValue({ - user: { id: 'user-123' }, - }) + mockGetSession({ user: { id: 'user-123' } }) mockGetWorkflowById.mockResolvedValue(mockWorkflow) mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ @@ -158,9 +180,7 @@ describe('Workflow By ID API Route', () => { isFromNormalizedTables: true, } - mockGetSession.mockResolvedValue({ - user: { id: 'user-123' }, - }) + mockGetSession({ user: { id: 'user-123' } }) mockGetWorkflowById.mockResolvedValue(mockWorkflow) mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ @@ -190,9 +210,7 @@ describe('Workflow By ID API Route', () => { workspaceId: 'workspace-456', } - mockGetSession.mockResolvedValue({ - user: { id: 'user-123' }, - }) + mockGetSession({ user: { id: 'user-123' } }) mockGetWorkflowById.mockResolvedValue(mockWorkflow) mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ @@ -229,9 +247,7 @@ describe('Workflow By ID API Route', () => { isFromNormalizedTables: true, } - mockGetSession.mockResolvedValue({ - user: { id: 'user-123' }, - }) + mockGetSession({ user: { id: 'user-123' } }) mockGetWorkflowById.mockResolvedValue(mockWorkflow) mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ @@ -264,9 +280,7 @@ describe('Workflow By ID API Route', () => { workspaceId: 'workspace-456', } - mockGetSession.mockResolvedValue({ - user: { id: 'user-123' }, - }) + mockGetSession({ user: { id: 'user-123' } }) mockGetWorkflowById.mockResolvedValue(mockWorkflow) mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ @@ -308,9 +322,7 @@ describe('Workflow By ID API Route', () => { workspaceId: 'workspace-456', } - mockGetSession.mockResolvedValue({ - user: { id: 'user-123' }, - }) + mockGetSession({ user: { id: 'user-123' } }) mockGetWorkflowById.mockResolvedValue(mockWorkflow) mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ @@ -353,9 +365,7 @@ describe('Workflow By ID API Route', () => { workspaceId: 'workspace-456', } - mockGetSession.mockResolvedValue({ - user: { id: 'user-123' }, - }) + mockGetSession({ user: { id: 'user-123' } }) mockGetWorkflowById.mockResolvedValue(mockWorkflow) mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ @@ -392,9 +402,7 @@ describe('Workflow By ID API Route', () => { workspaceId: 'workspace-456', } - mockGetSession.mockResolvedValue({ - user: { id: 'user-123' }, - }) + mockGetSession({ user: { id: 'user-123' } }) mockGetWorkflowById.mockResolvedValue(mockWorkflow) mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ @@ -419,6 +427,16 @@ describe('Workflow By ID API Route', () => { }) describe('PUT /api/workflows/[id]', () => { + function mockDuplicateCheck(results: Array<{ id: string }> = []) { + mockDbSelect.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue(results), + }), + }), + }) + } + it('should allow user with write permission to update workflow', async () => { const mockWorkflow = { id: 'workflow-123', @@ -430,9 +448,7 @@ describe('Workflow By ID API Route', () => { const updateData = { name: 'Updated Workflow' } const updatedWorkflow = { ...mockWorkflow, ...updateData, updatedAt: new Date() } - mockGetSession.mockResolvedValue({ - user: { id: 'user-123' }, - }) + mockGetSession({ user: { id: 'user-123' } }) mockGetWorkflowById.mockResolvedValue(mockWorkflow) mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ @@ -442,6 +458,8 @@ describe('Workflow By ID API Route', () => { workspacePermission: 'write', }) + mockDuplicateCheck([]) + mockDbUpdate.mockReturnValue({ set: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ @@ -474,9 +492,7 @@ describe('Workflow By ID API Route', () => { const updateData = { name: 'Updated Workflow' } const updatedWorkflow = { ...mockWorkflow, ...updateData, updatedAt: new Date() } - mockGetSession.mockResolvedValue({ - user: { id: 'user-123' }, - }) + mockGetSession({ user: { id: 'user-123' } }) mockGetWorkflowById.mockResolvedValue(mockWorkflow) mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ @@ -486,6 +502,8 @@ describe('Workflow By ID API Route', () => { workspacePermission: 'write', }) + mockDuplicateCheck([]) + mockDbUpdate.mockReturnValue({ set: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ @@ -517,9 +535,7 @@ describe('Workflow By ID API Route', () => { const updateData = { name: 'Updated Workflow' } - mockGetSession.mockResolvedValue({ - user: { id: 'user-123' }, - }) + mockGetSession({ user: { id: 'user-123' } }) mockGetWorkflowById.mockResolvedValue(mockWorkflow) mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ @@ -551,9 +567,7 @@ describe('Workflow By ID API Route', () => { workspaceId: 'workspace-456', } - mockGetSession.mockResolvedValue({ - user: { id: 'user-123' }, - }) + mockGetSession({ user: { id: 'user-123' } }) mockGetWorkflowById.mockResolvedValue(mockWorkflow) mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ @@ -577,13 +591,238 @@ describe('Workflow By ID API Route', () => { const data = await response.json() expect(data.error).toBe('Invalid request data') }) + + it('should reject rename when duplicate name exists in same folder', async () => { + const mockWorkflow = { + id: 'workflow-123', + userId: 'user-123', + name: 'Original Name', + folderId: 'folder-1', + workspaceId: 'workspace-456', + } + + mockGetSession({ user: { id: 'user-123' } }) + mockGetWorkflowById.mockResolvedValue(mockWorkflow) + mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: true, + status: 200, + workflow: mockWorkflow, + workspacePermission: 'write', + }) + + mockDuplicateCheck([{ id: 'workflow-other' }]) + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { + method: 'PUT', + body: JSON.stringify({ name: 'Duplicate Name' }), + }) + const params = Promise.resolve({ id: 'workflow-123' }) + + const response = await PUT(req, { params }) + + expect(response.status).toBe(409) + const data = await response.json() + expect(data.error).toBe('A workflow named "Duplicate Name" already exists in this folder') + }) + + it('should reject rename when duplicate name exists at root level', async () => { + const mockWorkflow = { + id: 'workflow-123', + userId: 'user-123', + name: 'Original Name', + folderId: null, + workspaceId: 'workspace-456', + } + + mockGetSession({ user: { id: 'user-123' } }) + mockGetWorkflowById.mockResolvedValue(mockWorkflow) + mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: true, + status: 200, + workflow: mockWorkflow, + workspacePermission: 'write', + }) + + mockDuplicateCheck([{ id: 'workflow-other' }]) + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { + method: 'PUT', + body: JSON.stringify({ name: 'Duplicate Name' }), + }) + const params = Promise.resolve({ id: 'workflow-123' }) + + const response = await PUT(req, { params }) + + expect(response.status).toBe(409) + const data = await response.json() + expect(data.error).toBe('A workflow named "Duplicate Name" already exists in this folder') + }) + + it('should allow rename when no duplicate exists in same folder', async () => { + const mockWorkflow = { + id: 'workflow-123', + userId: 'user-123', + name: 'Original Name', + folderId: 'folder-1', + workspaceId: 'workspace-456', + } + + const updatedWorkflow = { ...mockWorkflow, name: 'Unique Name', updatedAt: new Date() } + + mockGetSession({ user: { id: 'user-123' } }) + mockGetWorkflowById.mockResolvedValue(mockWorkflow) + mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: true, + status: 200, + workflow: mockWorkflow, + workspacePermission: 'write', + }) + + mockDuplicateCheck([]) + + mockDbUpdate.mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([updatedWorkflow]), + }), + }), + }) + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { + method: 'PUT', + body: JSON.stringify({ name: 'Unique Name' }), + }) + const params = Promise.resolve({ id: 'workflow-123' }) + + const response = await PUT(req, { params }) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.workflow.name).toBe('Unique Name') + }) + + it('should allow same name in different folders', async () => { + const mockWorkflow = { + id: 'workflow-123', + userId: 'user-123', + name: 'My Workflow', + folderId: 'folder-1', + workspaceId: 'workspace-456', + } + + const updatedWorkflow = { ...mockWorkflow, folderId: 'folder-2', updatedAt: new Date() } + + mockGetSession({ user: { id: 'user-123' } }) + mockGetWorkflowById.mockResolvedValue(mockWorkflow) + mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: true, + status: 200, + workflow: mockWorkflow, + workspacePermission: 'write', + }) + + // No duplicate in target folder + mockDuplicateCheck([]) + + mockDbUpdate.mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([updatedWorkflow]), + }), + }), + }) + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { + method: 'PUT', + body: JSON.stringify({ folderId: 'folder-2' }), + }) + const params = Promise.resolve({ id: 'workflow-123' }) + + const response = await PUT(req, { params }) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.workflow.folderId).toBe('folder-2') + }) + + it('should reject moving to a folder where same name already exists', async () => { + const mockWorkflow = { + id: 'workflow-123', + userId: 'user-123', + name: 'My Workflow', + folderId: 'folder-1', + workspaceId: 'workspace-456', + } + + mockGetSession({ user: { id: 'user-123' } }) + mockGetWorkflowById.mockResolvedValue(mockWorkflow) + mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: true, + status: 200, + workflow: mockWorkflow, + workspacePermission: 'write', + }) + + // Duplicate exists in target folder + mockDuplicateCheck([{ id: 'workflow-other' }]) + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { + method: 'PUT', + body: JSON.stringify({ folderId: 'folder-2' }), + }) + const params = Promise.resolve({ id: 'workflow-123' }) + + const response = await PUT(req, { params }) + + expect(response.status).toBe(409) + const data = await response.json() + expect(data.error).toBe('A workflow named "My Workflow" already exists in this folder') + }) + + it('should skip duplicate check when only updating non-name/non-folder fields', async () => { + const mockWorkflow = { + id: 'workflow-123', + userId: 'user-123', + name: 'Test Workflow', + workspaceId: 'workspace-456', + } + + const updatedWorkflow = { ...mockWorkflow, color: '#FF0000', updatedAt: new Date() } + + mockGetSession({ user: { id: 'user-123' } }) + mockGetWorkflowById.mockResolvedValue(mockWorkflow) + mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: true, + status: 200, + workflow: mockWorkflow, + workspacePermission: 'write', + }) + + mockDbUpdate.mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([updatedWorkflow]), + }), + }), + }) + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { + method: 'PUT', + body: JSON.stringify({ color: '#FF0000' }), + }) + const params = Promise.resolve({ id: 'workflow-123' }) + + const response = await PUT(req, { params }) + + expect(response.status).toBe(200) + // db.select should NOT have been called since no name/folder change + expect(mockDbSelect).not.toHaveBeenCalled() + }) }) describe('Error handling', () => { it.concurrent('should handle database errors gracefully', async () => { - mockGetSession.mockResolvedValue({ - user: { id: 'user-123' }, - }) + mockGetSession({ user: { id: 'user-123' } }) mockGetWorkflowById.mockRejectedValue(new Error('Database connection timeout')) diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index 170dc83faf..e2621d6e75 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { templates, webhook, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' +import { and, eq, isNull, ne } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' @@ -411,6 +411,45 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ if (updates.folderId !== undefined) updateData.folderId = updates.folderId if (updates.sortOrder !== undefined) updateData.sortOrder = updates.sortOrder + if (updates.name !== undefined || updates.folderId !== undefined) { + const targetName = updates.name ?? workflowData.name + const targetFolderId = + updates.folderId !== undefined ? updates.folderId : workflowData.folderId + + if (!workflowData.workspaceId) { + logger.error(`[${requestId}] Workflow ${workflowId} has no workspaceId`) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } + + const conditions = [ + eq(workflow.workspaceId, workflowData.workspaceId), + eq(workflow.name, targetName), + ne(workflow.id, workflowId), + ] + + if (targetFolderId) { + conditions.push(eq(workflow.folderId, targetFolderId)) + } else { + conditions.push(isNull(workflow.folderId)) + } + + const [duplicate] = await db + .select({ id: workflow.id }) + .from(workflow) + .where(and(...conditions)) + .limit(1) + + if (duplicate) { + logger.warn( + `[${requestId}] Duplicate workflow name "${targetName}" in folder ${targetFolderId ?? 'root'}` + ) + return NextResponse.json( + { error: `A workflow named "${targetName}" already exists in this folder` }, + { status: 409 } + ) + } + } + // Update the workflow const [updatedWorkflow] = await db .update(workflow) diff --git a/apps/sim/app/api/workflows/route.test.ts b/apps/sim/app/api/workflows/route.test.ts index ddef020dc6..9920a7b71c 100644 --- a/apps/sim/app/api/workflows/route.test.ts +++ b/apps/sim/app/api/workflows/route.test.ts @@ -1,11 +1,16 @@ /** * @vitest-environment node */ -import { auditMock, createMockRequest, mockConsoleLogger, setupCommonApiMocks } from '@sim/testing' +import { + auditMock, + createMockRequest, + mockConsoleLogger, + mockHybridAuth, + setupCommonApiMocks, +} from '@sim/testing' import { drizzleOrmMock } from '@sim/testing/mocks' import { beforeEach, describe, expect, it, vi } from 'vitest' -const mockCheckSessionOrInternalAuth = vi.fn() const mockGetUserEntityPermissions = vi.fn() const mockDbSelect = vi.fn() const mockDbInsert = vi.fn() @@ -30,6 +35,7 @@ describe('Workflows API Route - POST ordering', () => { randomUUID: vi.fn().mockReturnValue('workflow-new-id'), }) + const { mockCheckSessionOrInternalAuth } = mockHybridAuth() mockCheckSessionOrInternalAuth.mockResolvedValue({ success: true, userId: 'user-123', @@ -45,10 +51,6 @@ describe('Workflows API Route - POST ordering', () => { }, })) - vi.doMock('@/lib/auth/hybrid', () => ({ - checkSessionOrInternalAuth: (...args: unknown[]) => mockCheckSessionOrInternalAuth(...args), - })) - vi.doMock('@/lib/workspaces/permissions/utils', () => ({ getUserEntityPermissions: (...args: unknown[]) => mockGetUserEntityPermissions(...args), workspaceExists: vi.fn(), diff --git a/apps/sim/executor/handlers/condition/condition-handler.test.ts b/apps/sim/executor/handlers/condition/condition-handler.test.ts index 945bdaa2b8..cb64c484b3 100644 --- a/apps/sim/executor/handlers/condition/condition-handler.test.ts +++ b/apps/sim/executor/handlers/condition/condition-handler.test.ts @@ -1,4 +1,4 @@ -import { loggerMock } from '@sim/testing' +import { loggerMock, requestUtilsMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { BlockType } from '@/executor/constants' import { ConditionBlockHandler } from '@/executor/handlers/condition/condition-handler' @@ -7,9 +7,7 @@ import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' vi.mock('@sim/logger', () => loggerMock) -vi.mock('@/lib/core/utils/request', () => ({ - generateRequestId: vi.fn(() => 'test-request-id'), -})) +vi.mock('@/lib/core/utils/request', () => requestUtilsMock) vi.mock('@/tools', () => ({ executeTool: vi.fn(), diff --git a/apps/sim/lib/core/security/encryption.test.ts b/apps/sim/lib/core/security/encryption.test.ts index 35cfb86a42..43a0391684 100644 --- a/apps/sim/lib/core/security/encryption.test.ts +++ b/apps/sim/lib/core/security/encryption.test.ts @@ -1,20 +1,15 @@ -import { loggerMock } from '@sim/testing' +import { createEnvMock, loggerMock } from '@sim/testing' import { afterEach, describe, expect, it, vi } from 'vitest' -const mockEnv = vi.hoisted(() => ({ - ENCRYPTION_KEY: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', -})) - -vi.mock('@/lib/core/config/env', () => ({ - env: mockEnv, - isTruthy: (value: string | boolean | number | undefined) => - typeof value === 'string' ? value.toLowerCase() === 'true' || value === '1' : Boolean(value), - isFalsy: (value: string | boolean | number | undefined) => - typeof value === 'string' ? value.toLowerCase() === 'false' || value === '0' : value === false, -})) +vi.mock('@/lib/core/config/env', () => + createEnvMock({ + ENCRYPTION_KEY: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + }) +) vi.mock('@sim/logger', () => loggerMock) +import { env } from '@/lib/core/config/env' import { decryptSecret, encryptSecret, generatePassword } from './encryption' describe('encryptSecret', () => { @@ -172,21 +167,21 @@ describe('generatePassword', () => { }) describe('encryption key validation', () => { - const originalEnv = { ...mockEnv } + const originalEncryptionKey = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' afterEach(() => { - mockEnv.ENCRYPTION_KEY = originalEnv.ENCRYPTION_KEY + ;(env as Record).ENCRYPTION_KEY = originalEncryptionKey }) it('should throw error when ENCRYPTION_KEY is not set', async () => { - mockEnv.ENCRYPTION_KEY = '' + ;(env as Record).ENCRYPTION_KEY = '' await expect(encryptSecret('test')).rejects.toThrow( 'ENCRYPTION_KEY must be set to a 64-character hex string (32 bytes)' ) }) it('should throw error when ENCRYPTION_KEY is wrong length', async () => { - mockEnv.ENCRYPTION_KEY = '0123456789abcdef' + ;(env as Record).ENCRYPTION_KEY = '0123456789abcdef' await expect(encryptSecret('test')).rejects.toThrow( 'ENCRYPTION_KEY must be set to a 64-character hex string (32 bytes)' ) diff --git a/apps/sim/lib/core/utils.test.ts b/apps/sim/lib/core/utils.test.ts index 154b14efce..6540b5960d 100644 --- a/apps/sim/lib/core/utils.test.ts +++ b/apps/sim/lib/core/utils.test.ts @@ -1,3 +1,4 @@ +import { createEnvMock } from '@sim/testing' import { afterEach, describe, expect, it, vi } from 'vitest' import { getRotatingApiKey } from '@/lib/core/config/api-keys' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' @@ -30,25 +31,20 @@ vi.mock('crypto', () => ({ }), })) -vi.mock('@/lib/core/config/env', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - env: { - ...actual.env, - ENCRYPTION_KEY: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', // fake key for testing - OPENAI_API_KEY_1: 'test-openai-key-1', // fake key for testing - OPENAI_API_KEY_2: 'test-openai-key-2', // fake key for testing - OPENAI_API_KEY_3: 'test-openai-key-3', // fake key for testing - ANTHROPIC_API_KEY_1: 'test-anthropic-key-1', // fake key for testing - ANTHROPIC_API_KEY_2: 'test-anthropic-key-2', // fake key for testing - ANTHROPIC_API_KEY_3: 'test-anthropic-key-3', // fake key for testing - GEMINI_API_KEY_1: 'test-gemini-key-1', // fake key for testing - GEMINI_API_KEY_2: 'test-gemini-key-2', // fake key for testing - GEMINI_API_KEY_3: 'test-gemini-key-3', // fake key for testing - }, - } -}) +vi.mock('@/lib/core/config/env', () => + createEnvMock({ + ENCRYPTION_KEY: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + OPENAI_API_KEY_1: 'test-openai-key-1', + OPENAI_API_KEY_2: 'test-openai-key-2', + OPENAI_API_KEY_3: 'test-openai-key-3', + ANTHROPIC_API_KEY_1: 'test-anthropic-key-1', + ANTHROPIC_API_KEY_2: 'test-anthropic-key-2', + ANTHROPIC_API_KEY_3: 'test-anthropic-key-3', + GEMINI_API_KEY_1: 'test-gemini-key-1', + GEMINI_API_KEY_2: 'test-gemini-key-2', + GEMINI_API_KEY_3: 'test-gemini-key-3', + }) +) afterEach(() => { vi.clearAllMocks() diff --git a/apps/sim/socket/index.test.ts b/apps/sim/socket/index.test.ts index ee54891205..f470763c99 100644 --- a/apps/sim/socket/index.test.ts +++ b/apps/sim/socket/index.test.ts @@ -4,7 +4,7 @@ * @vitest-environment node */ import { createServer, request as httpRequest } from 'http' -import { createMockLogger, databaseMock } from '@sim/testing' +import { createEnvMock, createMockLogger, databaseMock } from '@sim/testing' import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { createSocketIOServer } from '@/socket/config/socket' import { MemoryRoomManager } from '@/socket/rooms' @@ -30,19 +30,13 @@ vi.mock('redis', () => ({ })), })) -// Mock env to not have REDIS_URL (use importOriginal to get helper functions) -vi.mock('@/lib/core/config/env', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - env: { - ...actual.env, - DATABASE_URL: 'postgres://localhost/test', - NODE_ENV: 'test', - REDIS_URL: undefined, - }, - } -}) +vi.mock('@/lib/core/config/env', () => + createEnvMock({ + DATABASE_URL: 'postgres://localhost/test', + NODE_ENV: 'test', + REDIS_URL: undefined, + }) +) vi.mock('@/socket/middleware/auth', () => ({ authenticateSocket: vi.fn((socket, next) => { diff --git a/packages/testing/src/index.ts b/packages/testing/src/index.ts index ce0d00cf0a..48881879ac 100644 --- a/packages/testing/src/index.ts +++ b/packages/testing/src/index.ts @@ -66,6 +66,7 @@ export { loggerMock, type MockAuthResult, type MockFetchResponse, + type MockHybridAuthResult, type MockRedis, type MockUser, mockAuth, @@ -73,10 +74,13 @@ export { mockConsoleLogger, mockCryptoUuid, mockDrizzleOrm, + mockHybridAuth, mockKnowledgeSchemas, mockUuid, + requestUtilsMock, setupCommonApiMocks, setupGlobalFetchMock, setupGlobalStorageMocks, + telemetryMock, } from './mocks' export * from './types' diff --git a/packages/testing/src/mocks/hybrid-auth.mock.ts b/packages/testing/src/mocks/hybrid-auth.mock.ts new file mode 100644 index 0000000000..7ddb2ecb1e --- /dev/null +++ b/packages/testing/src/mocks/hybrid-auth.mock.ts @@ -0,0 +1,85 @@ +/** + * Mock for @/lib/auth/hybrid module. + * Provides controllable mock functions for checkHybridAuth, checkSessionOrInternalAuth, and checkInternalAuth. + */ +import { vi } from 'vitest' +import type { MockUser } from './auth.mock' +import { defaultMockUser } from './auth.mock' + +interface HybridAuthResponse { + success: boolean + userId?: string + userName?: string | null + userEmail?: string | null + authType?: 'session' | 'api_key' | 'internal_jwt' + error?: string +} + +/** + * Result object returned by mockHybridAuth with helper methods + */ +export interface MockHybridAuthResult { + mockCheckHybridAuth: ReturnType + mockCheckSessionOrInternalAuth: ReturnType + mockCheckInternalAuth: ReturnType + setAuthenticated: (user?: MockUser) => void + setUnauthenticated: () => void +} + +/** + * Mock hybrid authentication for API tests. + * Uses vi.doMock to mock the @/lib/auth/hybrid module. + * + * @param user - Optional default user for authenticated state + * @returns Object with mock functions and authentication helpers + * + * @example + * ```ts + * const hybridAuth = mockHybridAuth() + * hybridAuth.setAuthenticated() // All hybrid auth checks succeed + * hybridAuth.setUnauthenticated() // All hybrid auth checks fail + * ``` + */ +export function mockHybridAuth(user: MockUser = defaultMockUser): MockHybridAuthResult { + const mockCheckHybridAuth = vi.fn<() => Promise>() + const mockCheckSessionOrInternalAuth = vi.fn<() => Promise>() + const mockCheckInternalAuth = vi.fn<() => Promise>() + + vi.doMock('@/lib/auth/hybrid', () => ({ + checkHybridAuth: mockCheckHybridAuth, + checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth, + checkInternalAuth: mockCheckInternalAuth, + })) + + const setAuthenticated = (customUser?: MockUser) => { + const u = customUser || user + const response: HybridAuthResponse = { + success: true, + userId: u.id, + userName: u.name ?? null, + userEmail: u.email, + authType: 'session', + } + mockCheckHybridAuth.mockResolvedValue(response) + mockCheckSessionOrInternalAuth.mockResolvedValue(response) + mockCheckInternalAuth.mockResolvedValue(response) + } + + const setUnauthenticated = () => { + const response: HybridAuthResponse = { + success: false, + error: 'Unauthorized', + } + mockCheckHybridAuth.mockResolvedValue(response) + mockCheckSessionOrInternalAuth.mockResolvedValue(response) + mockCheckInternalAuth.mockResolvedValue(response) + } + + return { + mockCheckHybridAuth, + mockCheckSessionOrInternalAuth, + mockCheckInternalAuth, + setAuthenticated, + setUnauthenticated, + } +} diff --git a/packages/testing/src/mocks/index.ts b/packages/testing/src/mocks/index.ts index 1a302f6272..f2c3fc6730 100644 --- a/packages/testing/src/mocks/index.ts +++ b/packages/testing/src/mocks/index.ts @@ -63,12 +63,14 @@ export { mockNextFetchResponse, setupGlobalFetchMock, } from './fetch.mock' +// Hybrid auth mocks +export { type MockHybridAuthResult, mockHybridAuth } from './hybrid-auth.mock' // Logger mocks export { clearLoggerMocks, createMockLogger, getLoggerCalls, loggerMock } from './logger.mock' // Redis mocks export { clearRedisMocks, createMockRedis, type MockRedis } from './redis.mock' // Request mocks -export { createMockFormDataRequest, createMockRequest } from './request.mock' +export { createMockFormDataRequest, createMockRequest, requestUtilsMock } from './request.mock' // Socket mocks export { createMockSocket, @@ -78,5 +80,7 @@ export { } from './socket.mock' // Storage mocks export { clearStorageMocks, createMockStorage, setupGlobalStorageMocks } from './storage.mock' +// Telemetry mocks +export { telemetryMock } from './telemetry.mock' // UUID mocks export { mockCryptoUuid, mockUuid } from './uuid.mock' diff --git a/packages/testing/src/mocks/request.mock.ts b/packages/testing/src/mocks/request.mock.ts index 2e9a86fd6f..5b8610d550 100644 --- a/packages/testing/src/mocks/request.mock.ts +++ b/packages/testing/src/mocks/request.mock.ts @@ -1,6 +1,7 @@ /** * Mock request utilities for API testing */ +import { vi } from 'vitest' /** * Creates a mock NextRequest for API route testing. @@ -57,3 +58,16 @@ export function createMockFormDataRequest( body: formData, }) } + +/** + * Pre-configured mock for @/lib/core/utils/request module. + * + * @example + * ```ts + * vi.mock('@/lib/core/utils/request', () => requestUtilsMock) + * ``` + */ +export const requestUtilsMock = { + generateRequestId: vi.fn(() => 'mock-request-id'), + noop: vi.fn(), +} diff --git a/packages/testing/src/mocks/telemetry.mock.ts b/packages/testing/src/mocks/telemetry.mock.ts new file mode 100644 index 0000000000..a4c30efc24 --- /dev/null +++ b/packages/testing/src/mocks/telemetry.mock.ts @@ -0,0 +1,30 @@ +/** + * Mock for @/lib/core/telemetry module. + * Provides no-op implementations for telemetry functions and PlatformEvents. + */ +import { vi } from 'vitest' + +/** + * Pre-configured telemetry mock for use with vi.mock. + * All PlatformEvents methods are no-op vi.fn() stubs. + * + * @example + * ```ts + * vi.mock('@/lib/core/telemetry', () => telemetryMock) + * ``` + */ +export const telemetryMock = { + PlatformEvents: new Proxy( + {}, + { + get: (_target, prop) => { + if (typeof prop === 'string') { + return vi.fn() + } + return undefined + }, + } + ), + createWorkflowSpans: vi.fn(), + trackPlatformEvent: vi.fn(), +}