From fe2b64050a9532ff7f55d59c00dd1811973ecc70 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 26 Mar 2025 15:59:54 -0700 Subject: [PATCH] improvement(keys): make keys account-wide instead of issuing a new one every time a workflow is created (#192) * improvement(keys): make keys acconut-wide instead of issuing a new one every time a workflow is created * removed old api key validation & issue logic * standardized api key format & added tests * improvement(api): deprecated /db from /api and moved all routes to their relevant domain location. updated all references to old routes (#191) --- sim/app/api/user/api-keys/route.ts | 6 +- .../api/workflows/[id]/deploy/route.test.ts | 374 ++++++ sim/app/api/workflows/[id]/deploy/route.ts | 72 +- sim/app/api/workflows/middleware.ts | 30 +- .../components/control-bar/control-bar.tsx | 18 +- .../components/api-keys/api-keys.tsx | 4 +- .../migrations/0023_nervous_tyger_tiger.sql | 1 + sim/db/migrations/meta/0023_snapshot.json | 1195 +++++++++++++++++ sim/db/migrations/meta/_journal.json | 9 +- sim/db/schema.ts | 1 - sim/lib/utils.test.ts | 199 +++ sim/lib/utils.ts | 25 +- 12 files changed, 1889 insertions(+), 45 deletions(-) create mode 100644 sim/app/api/workflows/[id]/deploy/route.test.ts create mode 100644 sim/db/migrations/0023_nervous_tyger_tiger.sql create mode 100644 sim/db/migrations/meta/0023_snapshot.json create mode 100644 sim/lib/utils.test.ts diff --git a/sim/app/api/user/api-keys/route.ts b/sim/app/api/user/api-keys/route.ts index f6ef7afb6e..8f27120077 100644 --- a/sim/app/api/user/api-keys/route.ts +++ b/sim/app/api/user/api-keys/route.ts @@ -3,8 +3,9 @@ import { eq } from 'drizzle-orm' import { nanoid } from 'nanoid' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console-logger' -import { apiKey } from '@/db/schema' +import { generateApiKey } from '@/lib/utils' import { db } from '@/db' +import { apiKey } from '@/db/schema' const logger = createLogger('ApiKeysRoute') @@ -61,8 +62,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Invalid request. Name is required.' }, { status: 400 }) } - // Generate an API key - we'll use a prefix to identify this as an API key (sim_) - const keyValue = `sim_${nanoid(32)}` + const keyValue = generateApiKey() // Insert the new API key const [newKey] = await db diff --git a/sim/app/api/workflows/[id]/deploy/route.test.ts b/sim/app/api/workflows/[id]/deploy/route.test.ts new file mode 100644 index 0000000000..a78ddee603 --- /dev/null +++ b/sim/app/api/workflows/[id]/deploy/route.test.ts @@ -0,0 +1,374 @@ +/** + * Integration tests for workflow deployment API route + * + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createMockRequest } from '@/app/api/__test-utils__/utils' + +describe('Workflow Deployment API Route', () => { + beforeEach(() => { + vi.resetModules() + + // Mock utils + vi.doMock('@/lib/utils', () => ({ + generateApiKey: vi.fn().mockReturnValue('sim_testkeygenerated12345'), + })) + + // Mock UUID generation + vi.doMock('uuid', () => ({ + v4: vi.fn().mockReturnValue('mock-uuid-1234'), + })) + + // Mock crypto for request ID + vi.stubGlobal('crypto', { + randomUUID: vi.fn().mockReturnValue('mock-request-id'), + }) + + // Mock logger + vi.doMock('@/lib/logs/console-logger', () => ({ + createLogger: vi.fn().mockReturnValue({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), + })) + + // Mock the middleware to pass validation + vi.doMock('../../middleware', () => ({ + validateWorkflowAccess: vi.fn().mockResolvedValue({ + workflow: { + id: 'workflow-id', + userId: 'user-id', + }, + }), + })) + + // Mock the response utils + vi.doMock('../../utils', () => ({ + createSuccessResponse: vi.fn().mockImplementation((data) => { + return new Response(JSON.stringify(data), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }), + createErrorResponse: vi.fn().mockImplementation((message, status = 500) => { + return new Response(JSON.stringify({ error: message }), { + status, + headers: { 'Content-Type': 'application/json' }, + }) + }), + })) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + /** + * Test GET deployment status + */ + it('should fetch deployment info successfully', async () => { + // Mock the database with proper workflow data + vi.doMock('@/db', () => ({ + db: { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([ + { + isDeployed: false, + deployedAt: null, + userId: 'user-id', + }, + ]), + }), + }), + }), + }, + })) + + // Create a mock request + const req = createMockRequest('GET') + + // Create params similar to what Next.js would provide + const params = Promise.resolve({ id: 'workflow-id' }) + + // Import the handler after mocks are set up + const { GET } = await import('./route') + + // Call the handler + const response = await GET(req, { params }) + + // Check response + expect(response.status).toBe(200) + + // Parse the response body + const data = await response.json() + + // Verify response structure + expect(data).toHaveProperty('isDeployed', false) + expect(data).toHaveProperty('apiKey', null) + expect(data).toHaveProperty('deployedAt', null) + }) + + /** + * Test POST deployment with no existing API key + * This should generate a new API key + */ + it('should create new API key when deploying workflow for user with no API key', async () => { + // Mock DB for this test + const mockInsert = vi.fn().mockReturnValue({ + values: vi.fn().mockReturnValue(undefined), + }) + + const mockUpdate = vi.fn().mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ id: 'workflow-id' }]), + }), + }) + + vi.doMock('@/db', () => ({ + db: { + select: vi + .fn() + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([ + { + userId: 'user-id', + }, + ]), + }), + }), + }) + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), // No existing API key + }), + }), + }), + insert: mockInsert, + update: mockUpdate, + }, + })) + + // Create a mock request + const req = createMockRequest('POST') + + // Create params + const params = Promise.resolve({ id: 'workflow-id' }) + + // Import required modules after mocks are set up + const { POST } = await import('./route') + + // Call the handler + const response = await POST(req, { params }) + + // Check response + expect(response.status).toBe(200) + + // Parse the response body + const data = await response.json() + + // Verify API key was generated + expect(data).toHaveProperty('apiKey', 'sim_testkeygenerated12345') + expect(data).toHaveProperty('isDeployed', true) + expect(data).toHaveProperty('deployedAt') + + // Verify database calls + expect(mockInsert).toHaveBeenCalled() + expect(mockUpdate).toHaveBeenCalled() + }) + + /** + * Test POST deployment with existing API key + * This should use the existing API key + */ + it('should use existing API key when deploying workflow', async () => { + // Mock DB for this test + const mockInsert = vi.fn() + + const mockUpdate = vi.fn().mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ id: 'workflow-id' }]), + }), + }) + + vi.doMock('@/db', () => ({ + db: { + select: vi + .fn() + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([ + { + userId: 'user-id', + }, + ]), + }), + }), + }) + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([ + { + key: 'sim_existingtestapikey12345', + }, + ]), // Existing API key + }), + }), + }), + insert: mockInsert, + update: mockUpdate, + }, + })) + + // Create a mock request + const req = createMockRequest('POST') + + // Create params + const params = Promise.resolve({ id: 'workflow-id' }) + + // Import required modules after mocks are set up + const { POST } = await import('./route') + + // Call the handler + const response = await POST(req, { params }) + + // Check response + expect(response.status).toBe(200) + + // Parse the response body + const data = await response.json() + + // Verify existing API key was used + expect(data).toHaveProperty('apiKey', 'sim_existingtestapikey12345') + expect(data).toHaveProperty('isDeployed', true) + + // Verify database calls - should NOT have inserted a new API key + expect(mockInsert).not.toHaveBeenCalled() + expect(mockUpdate).toHaveBeenCalled() + }) + + /** + * Test DELETE undeployment + */ + it('should undeploy workflow successfully', async () => { + // Mock the DB for this test + const mockUpdate = vi.fn().mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ id: 'workflow-id' }]), + }), + }) + + vi.doMock('@/db', () => ({ + db: { + update: mockUpdate, + }, + })) + + // Create a mock request + const req = createMockRequest('DELETE') + + // Create params + const params = Promise.resolve({ id: 'workflow-id' }) + + // Import the handler after mocks are set up + const { DELETE } = await import('./route') + + // Call the handler + const response = await DELETE(req, { params }) + + // Check response + expect(response.status).toBe(200) + + // Parse the response body + const data = await response.json() + + // Verify response structure + expect(data).toHaveProperty('isDeployed', false) + expect(data).toHaveProperty('deployedAt', null) + expect(data).toHaveProperty('apiKey', null) + + // Verify database calls + expect(mockUpdate).toHaveBeenCalled() + }) + + /** + * Test error handling + */ + it('should handle errors when workflow is not found', async () => { + // Mock middleware to simulate an error + vi.doMock('../../middleware', () => ({ + validateWorkflowAccess: vi.fn().mockResolvedValue({ + error: { + message: 'Workflow not found', + status: 404, + }, + }), + })) + + // Create a mock request + const req = createMockRequest('POST') + + // Create params with an invalid ID + const params = Promise.resolve({ id: 'invalid-id' }) + + // Import the handler after mocks are set up + const { POST } = await import('./route') + + // Call the handler + const response = await POST(req, { params }) + + // Check response + expect(response.status).toBe(404) + + // Parse the response body + const data = await response.json() + + // Verify error message + expect(data).toHaveProperty('error', 'Workflow not found') + }) + + /** + * Test unauthorized access + */ + it('should handle unauthorized access to workflow', async () => { + // Mock middleware to simulate unauthorized access + vi.doMock('../../middleware', () => ({ + validateWorkflowAccess: vi.fn().mockResolvedValue({ + error: { + message: 'Unauthorized access', + status: 403, + }, + }), + })) + + // Create a mock request + const req = createMockRequest('POST') + + // Create params + const params = Promise.resolve({ id: 'workflow-id' }) + + // Import the handler after mocks are set up + const { POST } = await import('./route') + + // Call the handler + const response = await POST(req, { params }) + + // Check response + expect(response.status).toBe(403) + + // Parse the response body + const data = await response.json() + + // Verify error message + expect(data).toHaveProperty('error', 'Unauthorized access') + }) +}) diff --git a/sim/app/api/workflows/[id]/deploy/route.ts b/sim/app/api/workflows/[id]/deploy/route.ts index 296435c1c3..585a0ac0a6 100644 --- a/sim/app/api/workflows/[id]/deploy/route.ts +++ b/sim/app/api/workflows/[id]/deploy/route.ts @@ -2,8 +2,9 @@ import { NextRequest } from 'next/server' import { eq } from 'drizzle-orm' import { v4 as uuidv4 } from 'uuid' import { createLogger } from '@/lib/logs/console-logger' +import { generateApiKey } from '@/lib/utils' import { db } from '@/db' -import { workflow } from '@/db/schema' +import { apiKey, workflow } from '@/db/schema' import { validateWorkflowAccess } from '../../middleware' import { createErrorResponse, createSuccessResponse } from '../../utils' @@ -28,9 +29,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ // Fetch the workflow information including deployment details const result = await db .select({ - apiKey: workflow.apiKey, isDeployed: workflow.isDeployed, deployedAt: workflow.deployedAt, + userId: workflow.userId, }) .from(workflow) .where(eq(workflow.id, id)) @@ -44,18 +45,27 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ const workflowData = result[0] // If the workflow is not deployed, return appropriate response - if (!workflowData.isDeployed || !workflowData.apiKey) { + if (!workflowData.isDeployed) { logger.info(`[${requestId}] Workflow is not deployed: ${id}`) return createSuccessResponse({ isDeployed: false, - apiKey: null, deployedAt: null, + apiKey: null, }) } + // Fetch the user's API key + const userApiKey = await db + .select({ + key: apiKey.key, + }) + .from(apiKey) + .where(eq(apiKey.userId, workflowData.userId)) + .limit(1) + logger.info(`[${requestId}] Successfully retrieved deployment info: ${id}`) return createSuccessResponse({ - apiKey: workflowData.apiKey, + apiKey: userApiKey.length > 0 ? userApiKey[0].key : null, isDeployed: workflowData.isDeployed, deployedAt: workflowData.deployedAt, }) @@ -78,22 +88,61 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return createErrorResponse(validation.error.message, validation.error.status) } - // Generate a new API key - const apiKey = `wf_${uuidv4().replace(/-/g, '')}` + // Get the workflow to find the user + const workflowData = await db + .select({ + userId: workflow.userId, + }) + .from(workflow) + .where(eq(workflow.id, id)) + .limit(1) + + if (workflowData.length === 0) { + logger.warn(`[${requestId}] Workflow not found: ${id}`) + return createErrorResponse('Workflow not found', 404) + } + + const userId = workflowData[0].userId const deployedAt = new Date() - // Update the workflow with the API key and deployment status + // Check if the user already has an API key + const userApiKey = await db + .select({ + key: apiKey.key, + }) + .from(apiKey) + .where(eq(apiKey.userId, userId)) + .limit(1) + + let userKey = null + + // If no API key exists, create one + if (userApiKey.length === 0) { + const newApiKey = generateApiKey() + await db.insert(apiKey).values({ + id: uuidv4(), + userId, + name: 'Default API Key', + key: newApiKey, + createdAt: new Date(), + updatedAt: new Date(), + }) + userKey = newApiKey + } else { + userKey = userApiKey[0].key + } + + // Update the workflow deployment status await db .update(workflow) .set({ - apiKey, isDeployed: true, deployedAt, }) .where(eq(workflow.id, id)) logger.info(`[${requestId}] Workflow deployed successfully: ${id}`) - return createSuccessResponse({ apiKey, isDeployed: true, deployedAt }) + return createSuccessResponse({ apiKey: userKey, isDeployed: true, deployedAt }) } catch (error: any) { logger.error(`[${requestId}] Error deploying workflow: ${id}`, error) return createErrorResponse(error.message || 'Failed to deploy workflow', 500) @@ -116,11 +165,10 @@ export async function DELETE( return createErrorResponse(validation.error.message, validation.error.status) } - // Update the workflow to remove deployment + // Update the workflow to remove deployment status await db .update(workflow) .set({ - apiKey: null, isDeployed: false, deployedAt: null, }) diff --git a/sim/app/api/workflows/middleware.ts b/sim/app/api/workflows/middleware.ts index 9e1fe1c6b0..1498450573 100644 --- a/sim/app/api/workflows/middleware.ts +++ b/sim/app/api/workflows/middleware.ts @@ -1,6 +1,9 @@ import { NextRequest } from 'next/server' +import { eq } from 'drizzle-orm' import { createLogger } from '@/lib/logs/console-logger' import { getWorkflowById } from '@/lib/workflows/utils' +import { db } from '@/db' +import { apiKey } from '@/db/schema' const logger = createLogger('WorkflowMiddleware') @@ -36,18 +39,37 @@ export async function validateWorkflowAccess( } // API key authentication - let apiKey = null + let apiKeyHeader = null for (const [key, value] of request.headers.entries()) { if (key.toLowerCase() === 'x-api-key' && value) { - apiKey = value + apiKeyHeader = value break } } - if (!apiKey || !workflow.apiKey || apiKey !== workflow.apiKey) { + if (!apiKeyHeader) { return { error: { - message: 'Unauthorized', + message: 'Unauthorized: API key required', + status: 401, + }, + } + } + + // Verify API key belongs to the user who owns the workflow + const userApiKeys = await db + .select({ + key: apiKey.key, + }) + .from(apiKey) + .where(eq(apiKey.userId, workflow.userId)) + + const validApiKey = userApiKeys.some((k) => k.key === apiKeyHeader) + + if (!validApiKey) { + return { + error: { + message: 'Unauthorized: Invalid API key', status: 401, }, } diff --git a/sim/app/w/[id]/components/control-bar/control-bar.tsx b/sim/app/w/[id]/components/control-bar/control-bar.tsx index d2ec180223..ddf45b0bbb 100644 --- a/sim/app/w/[id]/components/control-bar/control-bar.tsx +++ b/sim/app/w/[id]/components/control-bar/control-bar.tsx @@ -211,10 +211,10 @@ export function ControlBar() { const handleDeploy = async () => { if (!activeWorkflowId) return - // If already deployed, show the existing deployment info instead of redeploying + // If already deployed, show the API info if (isDeployed) { - // Find existing API notification for this workflow - const apiNotification = workflowNotifications.find( + // Try to find an existing API notification + const apiNotification = notifications.find( (n) => n.type === 'api' && n.workflowId === activeWorkflowId ) @@ -244,11 +244,13 @@ export function ControlBar() { }, { label: 'API Key', - content: apiKey, + content: apiKey || 'No API key found. Visit your account settings to create one.', }, { label: 'Example curl command', - content: `curl -X POST -H "X-API-Key: ${apiKey}" -H "Content-Type: application/json" ${endpoint}`, + content: apiKey + ? `curl -X POST -H "X-API-Key: ${apiKey}" -H "Content-Type: application/json" ${endpoint}` + : `You need an API key to call this endpoint. Visit your account settings to create one.`, }, ], }) @@ -285,11 +287,13 @@ export function ControlBar() { }, { label: 'API Key', - content: apiKey, + content: apiKey || 'No API key found. Visit your account settings to create one.', }, { label: 'Example curl command', - content: `curl -X POST -H "X-API-Key: ${apiKey}" -H "Content-Type: application/json" ${endpoint}`, + content: apiKey + ? `curl -X POST -H "X-API-Key: ${apiKey}" -H "Content-Type: application/json" ${endpoint}` + : `You need an API key to call this endpoint. Visit your account settings to create one.`, }, ], }) diff --git a/sim/app/w/components/sidebar/components/settings-modal/components/api-keys/api-keys.tsx b/sim/app/w/components/sidebar/components/settings-modal/components/api-keys/api-keys.tsx index 24f01ad883..15e01a8b94 100644 --- a/sim/app/w/components/sidebar/components/settings-modal/components/api-keys/api-keys.tsx +++ b/sim/app/w/components/sidebar/components/settings-modal/components/api-keys/api-keys.tsx @@ -170,8 +170,8 @@ export function ApiKeys({ onOpenChange }: ApiKeysProps) {

- API keys allow you to authenticate with the Sim SDK. Keep your API keys secure. They have - access to your account and workflows. + API keys allow you to authenticate and trigger workflows. Keep your API keys secure. They + have access to your account and workflows.

{isLoading ? ( diff --git a/sim/db/migrations/0023_nervous_tyger_tiger.sql b/sim/db/migrations/0023_nervous_tyger_tiger.sql new file mode 100644 index 0000000000..5b959cd3b3 --- /dev/null +++ b/sim/db/migrations/0023_nervous_tyger_tiger.sql @@ -0,0 +1 @@ +ALTER TABLE "workflow" DROP COLUMN "api_key"; \ No newline at end of file diff --git a/sim/db/migrations/meta/0023_snapshot.json b/sim/db/migrations/meta/0023_snapshot.json new file mode 100644 index 0000000000..3c649bcb09 --- /dev/null +++ b/sim/db/migrations/meta/0023_snapshot.json @@ -0,0 +1,1195 @@ +{ + "id": "7b78c285-cc37-4833-86a4-75506efba35a", + "prevId": "bcb7ed22-334b-449b-871e-c64e973289ca", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.marketplace": { + "name": "marketplace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stars": { + "name": "stars", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "marketplace_workflow_id_workflow_id_fk": { + "name": "marketplace_workflow_id_workflow_id_fk", + "tableFrom": "marketplace", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "marketplace_author_id_user_id_fk": { + "name": "marketplace_author_id_user_id_fk", + "tableFrom": "marketplace", + "tableTo": "user", + "columnsFrom": ["author_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.marketplace_star": { + "name": "marketplace_star", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "marketplace_id": { + "name": "marketplace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_marketplace_idx": { + "name": "user_marketplace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "marketplace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "marketplace_star_marketplace_id_marketplace_id_fk": { + "name": "marketplace_star_marketplace_id_marketplace_id_fk", + "tableFrom": "marketplace_star", + "tableTo": "marketplace", + "columnsFrom": ["marketplace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "marketplace_star_user_id_user_id_fk": { + "name": "marketplace_star_user_id_user_id_fk", + "tableFrom": "marketplace_star", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "general": { + "name": "general", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_idx": { + "name": "path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#3972F6'" + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "collaborators": { + "name": "collaborators", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_logs": { + "name": "workflow_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_logs_workflow_id_workflow_id_fk": { + "name": "workflow_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workflow_schedule_workflow_id_unique": { + "name": "workflow_schedule_workflow_id_unique", + "nullsNotDistinct": false, + "columns": ["workflow_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/sim/db/migrations/meta/_journal.json b/sim/db/migrations/meta/_journal.json index b71d93ceff..240ca2eb3b 100644 --- a/sim/db/migrations/meta/_journal.json +++ b/sim/db/migrations/meta/_journal.json @@ -162,6 +162,13 @@ "when": 1742889909342, "tag": "0022_gray_galactus", "breakpoints": true + }, + { + "idx": 23, + "version": "7", + "when": 1743024111706, + "tag": "0023_nervous_tyger_tiger", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/sim/db/schema.ts b/sim/db/schema.ts index b6f37b8add..c0fb291788 100644 --- a/sim/db/schema.ts +++ b/sim/db/schema.ts @@ -73,7 +73,6 @@ export const workflow = pgTable('workflow', { updatedAt: timestamp('updated_at').notNull(), isDeployed: boolean('is_deployed').notNull().default(false), deployedAt: timestamp('deployed_at'), - apiKey: text('api_key'), isPublished: boolean('is_published').notNull().default(false), collaborators: json('collaborators').notNull().default('[]'), runCount: integer('run_count').notNull().default(0), diff --git a/sim/lib/utils.test.ts b/sim/lib/utils.test.ts new file mode 100644 index 0000000000..277626be3e --- /dev/null +++ b/sim/lib/utils.test.ts @@ -0,0 +1,199 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + cn, + convertScheduleOptionsToCron, + decryptSecret, + encryptSecret, + formatDate, + formatDateTime, + formatDuration, + formatTime, + generateApiKey, +} from './utils' + +// Mock crypto module for encryption/decryption tests +vi.mock('crypto', () => ({ + createCipheriv: vi.fn().mockReturnValue({ + update: vi.fn().mockReturnValue('encrypted-data'), + final: vi.fn().mockReturnValue('final-data'), + getAuthTag: vi.fn().mockReturnValue({ + toString: vi.fn().mockReturnValue('auth-tag'), + }), + }), + createDecipheriv: vi.fn().mockReturnValue({ + update: vi.fn().mockReturnValue('decrypted-data'), + final: vi.fn().mockReturnValue('final-data'), + setAuthTag: vi.fn(), + }), + randomBytes: vi.fn().mockReturnValue({ + toString: vi.fn().mockReturnValue('random-iv'), + }), +})) + +// Mock environment variables for encryption key +beforeEach(() => { + process.env.ENCRYPTION_KEY = '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' +}) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('generateApiKey', () => { + it('should generate API key with sim_ prefix', () => { + const key = generateApiKey() + expect(key).toMatch(/^sim_/) + }) + + it('should generate unique API keys for each call', () => { + const key1 = generateApiKey() + const key2 = generateApiKey() + expect(key1).not.toBe(key2) + }) + + it('should generate API keys of correct length', () => { + const key = generateApiKey() + // Expected format: 'sim_' + 32 random characters + expect(key.length).toBe(36) + }) +}) + +describe('cn (class name utility)', () => { + it('should merge class names correctly', () => { + const result = cn('class1', 'class2') + expect(result).toBe('class1 class2') + }) + + it('should handle conditional classes', () => { + const isActive = true + const result = cn('base', isActive && 'active') + expect(result).toBe('base active') + }) + + it('should handle falsy values', () => { + const result = cn('base', false && 'hidden', null, undefined, 0, '') + expect(result).toBe('base') + }) + + it('should handle arrays of class names', () => { + const result = cn('base', ['class1', 'class2']) + expect(result).toContain('base') + expect(result).toContain('class1') + expect(result).toContain('class2') + }) +}) + +describe('encryption and decryption', () => { + it('should encrypt secrets correctly', async () => { + const result = await encryptSecret('my-secret') + expect(result).toHaveProperty('encrypted') + expect(result).toHaveProperty('iv') + expect(result.encrypted).toContain('random-iv') + expect(result.encrypted).toContain('encrypted-data') + expect(result.encrypted).toContain('final-data') + expect(result.encrypted).toContain('auth-tag') + }) + + it('should decrypt secrets correctly', async () => { + const result = await decryptSecret('iv:encrypted:authTag') + expect(result).toHaveProperty('decrypted') + expect(result.decrypted).toBe('decrypted-datafinal-data') + }) + + it('should throw error for invalid decrypt format', async () => { + await expect(decryptSecret('invalid-format')).rejects.toThrow('Invalid encrypted value format') + }) +}) + +describe('convertScheduleOptionsToCron', () => { + it('should convert minutes schedule to cron', () => { + const result = convertScheduleOptionsToCron('minutes', { minutesInterval: '5' }) + expect(result).toBe('*/5 * * * *') + }) + + it('should convert hourly schedule to cron', () => { + const result = convertScheduleOptionsToCron('hourly', { hourlyMinute: '30' }) + expect(result).toBe('30 * * * *') + }) + + it('should convert daily schedule to cron', () => { + const result = convertScheduleOptionsToCron('daily', { dailyTime: '15:30' }) + expect(result).toBe('15 30 * * *') + }) + + it('should convert weekly schedule to cron', () => { + const result = convertScheduleOptionsToCron('weekly', { + weeklyDay: 'MON', + weeklyDayTime: '09:30', + }) + expect(result).toBe('09 30 * * 1') + }) + + it('should convert monthly schedule to cron', () => { + const result = convertScheduleOptionsToCron('monthly', { + monthlyDay: '15', + monthlyTime: '12:00', + }) + expect(result).toBe('12 00 15 * *') + }) + + it('should use custom cron expression directly', () => { + const customCron = '*/15 9-17 * * 1-5' + const result = convertScheduleOptionsToCron('custom', { cronExpression: customCron }) + expect(result).toBe(customCron) + }) + + it('should throw error for unsupported schedule type', () => { + expect(() => convertScheduleOptionsToCron('invalid', {})).toThrow('Unsupported schedule type') + }) + + it('should use default values when options are not provided', () => { + const result = convertScheduleOptionsToCron('daily', {}) + expect(result).toBe('00 09 * * *') + }) +}) + +describe('date formatting functions', () => { + it('should format datetime correctly', () => { + const date = new Date('2023-05-15T14:30:00') + const result = formatDateTime(date) + expect(result).toMatch(/May 15, 2023/) + expect(result).toMatch(/2:30 PM|14:30/) + }) + + it('should format date correctly', () => { + const date = new Date('2023-05-15T14:30:00') + const result = formatDate(date) + expect(result).toMatch(/May 15, 2023/) + expect(result).not.toMatch(/2:30|14:30/) + }) + + it('should format time correctly', () => { + const date = new Date('2023-05-15T14:30:00') + const result = formatTime(date) + expect(result).toMatch(/2:30 PM|14:30/) + expect(result).not.toMatch(/2023|May/) + }) +}) + +describe('formatDuration', () => { + it('should format milliseconds correctly', () => { + const result = formatDuration(500) + expect(result).toBe('500ms') + }) + + it('should format seconds correctly', () => { + const result = formatDuration(5000) + expect(result).toBe('5s') + }) + + it('should format minutes and seconds correctly', () => { + const result = formatDuration(125000) // 2m 5s + expect(result).toBe('2m 5s') + }) + + it('should format hours, minutes correctly', () => { + const result = formatDuration(3725000) // 1h 2m 5s + expect(result).toBe('1h 2m') + }) +}) diff --git a/sim/lib/utils.ts b/sim/lib/utils.ts index e5a657a2c7..f4ab0a8e13 100644 --- a/sim/lib/utils.ts +++ b/sim/lib/utils.ts @@ -1,5 +1,6 @@ import { type ClassValue, clsx } from 'clsx' -import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto' +import { createCipheriv, createDecipheriv, randomBytes } from 'crypto' +import { nanoid } from 'nanoid' import { twMerge } from 'tailwind-merge' import { createLogger } from '@/lib/logs/console-logger' @@ -121,20 +122,6 @@ export function convertScheduleOptionsToCron( } } -export async function generateApiKey(): Promise { - const buffer = randomBytes(32) - const hash = createHash('sha256').update(buffer).digest('hex') - return `wf_${hash}` -} - -export async function validateApiKey( - apiKey: string | null, - storedApiKey: string | null -): Promise { - if (!apiKey || !storedApiKey) return false - return apiKey === storedApiKey -} - /** * Format a date into a human-readable format * @param date - The date to format @@ -202,3 +189,11 @@ export function formatDuration(durationMs: number): string { const remainingMinutes = minutes % 60 return `${hours}h ${remainingMinutes}m` } + +/** + * Generates a standardized API key with the 'sim_' prefix + * @returns A new API key string + */ +export function generateApiKey(): string { + return `sim_${nanoid(32)}` +}