mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
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)
This commit is contained in:
@@ -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
|
||||
|
||||
374
sim/app/api/workflows/[id]/deploy/route.test.ts
Normal file
374
sim/app/api/workflows/[id]/deploy/route.test.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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.`,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -170,8 +170,8 @@ export function ApiKeys({ onOpenChange }: ApiKeysProps) {
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
|
||||
{isLoading ? (
|
||||
|
||||
1
sim/db/migrations/0023_nervous_tyger_tiger.sql
Normal file
1
sim/db/migrations/0023_nervous_tyger_tiger.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "workflow" DROP COLUMN "api_key";
|
||||
1195
sim/db/migrations/meta/0023_snapshot.json
Normal file
1195
sim/db/migrations/meta/0023_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
199
sim/lib/utils.test.ts
Normal file
199
sim/lib/utils.test.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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<string> {
|
||||
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<boolean> {
|
||||
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)}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user