first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
## @n8n/api-types
|
||||
|
||||
This package contains types and schema definitions for the n8n internal API, so that these can be shared between the backend and the frontend code.
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import { baseConfig } from '@n8n/eslint-config/base';
|
||||
|
||||
export default defineConfig(baseConfig, {
|
||||
rules: {
|
||||
'unicorn/filename-case': ['error', { case: 'kebabCase' }],
|
||||
|
||||
// TODO: Remove this
|
||||
'@typescript-eslint/naming-convention': 'warn',
|
||||
'@typescript-eslint/no-empty-object-type': 'warn',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = require('../../../jest.config');
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@n8n/api-types",
|
||||
"version": "1.11.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"format": "biome format --write .",
|
||||
"format:check": "biome ci .",
|
||||
"lint": "eslint . --quiet",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"watch": "tsc -p tsconfig.build.json --watch",
|
||||
"test": "jest",
|
||||
"test:unit": "jest",
|
||||
"test:dev": "jest --watch"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"module": "src/index.ts",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@n8n/typescript-config": "workspace:*",
|
||||
"@n8n/config": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
"n8n-workflow": "workspace:*",
|
||||
"xss": "catalog:",
|
||||
"zod": "catalog:",
|
||||
"@n8n/permissions": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ApiKeyScope } from '@n8n/permissions';
|
||||
|
||||
/** Unix timestamp. Seconds since epoch */
|
||||
export type UnixTimestamp = number | null;
|
||||
|
||||
export type ApiKey = {
|
||||
id: string;
|
||||
label: string;
|
||||
apiKey: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** Null if API key never expires */
|
||||
expiresAt: UnixTimestamp | null;
|
||||
scopes: ApiKeyScope[];
|
||||
};
|
||||
|
||||
export type ApiKeyWithRawValue = ApiKey & { rawApiKey: string };
|
||||
|
||||
export type ApiKeyAudience = 'public-api' | 'mcp-server-api';
|
||||
@@ -0,0 +1,667 @@
|
||||
import type { Scope } from '@n8n/permissions';
|
||||
import {
|
||||
CHAT_TOOL_NODE_TYPE,
|
||||
type ChunkType,
|
||||
DATA_TABLE_TOOL_NODE_TYPE,
|
||||
type INode,
|
||||
INodeSchema,
|
||||
WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE,
|
||||
} from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Z } from './zod-class';
|
||||
|
||||
/**
|
||||
* Supported AI model providers
|
||||
*/
|
||||
export const chatHubLLMProviderSchema = z.enum([
|
||||
'openai',
|
||||
'anthropic',
|
||||
'google',
|
||||
'azureOpenAi',
|
||||
'azureEntraId',
|
||||
'ollama',
|
||||
'awsBedrock',
|
||||
'vercelAiGateway',
|
||||
'xAiGrok',
|
||||
'groq',
|
||||
'openRouter',
|
||||
'deepSeek',
|
||||
'cohere',
|
||||
'mistralCloud',
|
||||
]);
|
||||
export type ChatHubLLMProvider = z.infer<typeof chatHubLLMProviderSchema>;
|
||||
|
||||
export interface ChatHubAgentKnowledgeItem {
|
||||
id: string;
|
||||
type: 'embedding';
|
||||
provider: ChatHubLLMProvider;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema for icon or emoji representation
|
||||
*/
|
||||
export const agentIconOrEmojiSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('icon'),
|
||||
value: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('emoji'),
|
||||
value: z.string(),
|
||||
}),
|
||||
]);
|
||||
export type AgentIconOrEmoji = z.infer<typeof agentIconOrEmojiSchema>;
|
||||
|
||||
export const chatHubProviderSchema = z.enum([
|
||||
...chatHubLLMProviderSchema.options,
|
||||
'n8n',
|
||||
'custom-agent',
|
||||
] as const);
|
||||
export type ChatHubProvider = z.infer<typeof chatHubProviderSchema>;
|
||||
|
||||
/**
|
||||
* Map of providers to their credential types
|
||||
* Only LLM providers (openai, anthropic, google) have credentials
|
||||
*/
|
||||
export const PROVIDER_CREDENTIAL_TYPE_MAP: Record<
|
||||
Exclude<ChatHubProvider, 'n8n' | 'custom-agent'>,
|
||||
string
|
||||
> = {
|
||||
openai: 'openAiApi',
|
||||
anthropic: 'anthropicApi',
|
||||
google: 'googlePalmApi',
|
||||
ollama: 'ollamaApi',
|
||||
azureOpenAi: 'azureOpenAiApi',
|
||||
azureEntraId: 'azureEntraCognitiveServicesOAuth2Api',
|
||||
awsBedrock: 'aws',
|
||||
vercelAiGateway: 'vercelAiGatewayApi',
|
||||
xAiGrok: 'xAiApi',
|
||||
groq: 'groqApi',
|
||||
openRouter: 'openRouterApi',
|
||||
deepSeek: 'deepSeekApi',
|
||||
cohere: 'cohereApi',
|
||||
mistralCloud: 'mistralCloudApi',
|
||||
};
|
||||
|
||||
/**
|
||||
* Chat Hub conversation model configuration
|
||||
*/
|
||||
const openAIModelSchema = z.object({
|
||||
provider: z.literal('openai'),
|
||||
model: z.string(),
|
||||
});
|
||||
|
||||
const anthropicModelSchema = z.object({
|
||||
provider: z.literal('anthropic'),
|
||||
model: z.string(),
|
||||
});
|
||||
|
||||
const googleModelSchema = z.object({
|
||||
provider: z.literal('google'),
|
||||
model: z.string(),
|
||||
});
|
||||
|
||||
const azureOpenAIModelSchema = z.object({
|
||||
provider: z.literal('azureOpenAi'),
|
||||
model: z.string(),
|
||||
});
|
||||
|
||||
const azureEntraIdModelSchema = z.object({
|
||||
provider: z.literal('azureEntraId'),
|
||||
model: z.string(),
|
||||
});
|
||||
|
||||
const ollamaModelSchema = z.object({
|
||||
provider: z.literal('ollama'),
|
||||
model: z.string(),
|
||||
});
|
||||
|
||||
const awsBedrockModelSchema = z.object({
|
||||
provider: z.literal('awsBedrock'),
|
||||
model: z.string(),
|
||||
});
|
||||
|
||||
const vercelAiGatewaySchema = z.object({
|
||||
provider: z.literal('vercelAiGateway'),
|
||||
model: z.string(),
|
||||
});
|
||||
|
||||
const xAiGrokModelSchema = z.object({
|
||||
provider: z.literal('xAiGrok'),
|
||||
model: z.string(),
|
||||
});
|
||||
|
||||
const groqModelSchema = z.object({
|
||||
provider: z.literal('groq'),
|
||||
model: z.string(),
|
||||
});
|
||||
|
||||
const openRouterModelSchema = z.object({
|
||||
provider: z.literal('openRouter'),
|
||||
model: z.string(),
|
||||
});
|
||||
|
||||
const deepSeekModelSchema = z.object({
|
||||
provider: z.literal('deepSeek'),
|
||||
model: z.string(),
|
||||
});
|
||||
|
||||
const cohereModelSchema = z.object({
|
||||
provider: z.literal('cohere'),
|
||||
model: z.string(),
|
||||
});
|
||||
|
||||
const mistralCloudModelSchema = z.object({
|
||||
provider: z.literal('mistralCloud'),
|
||||
model: z.string(),
|
||||
});
|
||||
|
||||
const n8nModelSchema = z.object({
|
||||
provider: z.literal('n8n'),
|
||||
workflowId: z.string(),
|
||||
});
|
||||
|
||||
const chatAgentSchema = z.object({
|
||||
provider: z.literal('custom-agent'),
|
||||
agentId: z.string(),
|
||||
});
|
||||
|
||||
export const chatHubConversationModelSchema = z.discriminatedUnion('provider', [
|
||||
openAIModelSchema,
|
||||
anthropicModelSchema,
|
||||
googleModelSchema,
|
||||
azureOpenAIModelSchema,
|
||||
azureEntraIdModelSchema,
|
||||
ollamaModelSchema,
|
||||
awsBedrockModelSchema,
|
||||
vercelAiGatewaySchema,
|
||||
xAiGrokModelSchema,
|
||||
groqModelSchema,
|
||||
openRouterModelSchema,
|
||||
deepSeekModelSchema,
|
||||
cohereModelSchema,
|
||||
mistralCloudModelSchema,
|
||||
n8nModelSchema,
|
||||
chatAgentSchema,
|
||||
]);
|
||||
|
||||
export type ChatHubOpenAIModel = z.infer<typeof openAIModelSchema>;
|
||||
export type ChatHubAnthropicModel = z.infer<typeof anthropicModelSchema>;
|
||||
export type ChatHubGoogleModel = z.infer<typeof googleModelSchema>;
|
||||
export type ChatHubAzureOpenAIModel = z.infer<typeof azureOpenAIModelSchema>;
|
||||
export type ChatHubAzureEntraIdModel = z.infer<typeof azureEntraIdModelSchema>;
|
||||
export type ChatHubOllamaModel = z.infer<typeof ollamaModelSchema>;
|
||||
export type ChatHubAwsBedrockModel = z.infer<typeof awsBedrockModelSchema>;
|
||||
export type ChatHubVercelAiGatewayModel = z.infer<typeof vercelAiGatewaySchema>;
|
||||
export type ChatHubXAiGrokModel = z.infer<typeof xAiGrokModelSchema>;
|
||||
export type ChatHubGroqModel = z.infer<typeof groqModelSchema>;
|
||||
export type ChatHubOpenRouterModel = z.infer<typeof openRouterModelSchema>;
|
||||
export type ChatHubDeepSeekModel = z.infer<typeof deepSeekModelSchema>;
|
||||
export type ChatHubCohereModel = z.infer<typeof cohereModelSchema>;
|
||||
export type ChatHubMistralCloudModel = z.infer<typeof mistralCloudModelSchema>;
|
||||
export type ChatHubBaseLLMModel =
|
||||
| ChatHubOpenAIModel
|
||||
| ChatHubAnthropicModel
|
||||
| ChatHubGoogleModel
|
||||
| ChatHubAzureOpenAIModel
|
||||
| ChatHubAzureEntraIdModel
|
||||
| ChatHubOllamaModel
|
||||
| ChatHubAwsBedrockModel
|
||||
| ChatHubVercelAiGatewayModel
|
||||
| ChatHubXAiGrokModel
|
||||
| ChatHubGroqModel
|
||||
| ChatHubOpenRouterModel
|
||||
| ChatHubDeepSeekModel
|
||||
| ChatHubCohereModel
|
||||
| ChatHubMistralCloudModel;
|
||||
|
||||
export type ChatHubN8nModel = z.infer<typeof n8nModelSchema>;
|
||||
export type ChatHubCustomAgentModel = z.infer<typeof chatAgentSchema>;
|
||||
export type ChatHubConversationModel = z.infer<typeof chatHubConversationModelSchema>;
|
||||
|
||||
/**
|
||||
* Request schema for fetching available chat models
|
||||
* Maps provider names to credential IDs (null if no credential available)
|
||||
*/
|
||||
export const chatModelsRequestSchema = z.object({
|
||||
credentials: z.record(chatHubProviderSchema, z.string().nullable()),
|
||||
});
|
||||
|
||||
export type ChatModelsRequest = z.infer<typeof chatModelsRequestSchema>;
|
||||
|
||||
export interface ChatModelMetadataDto {
|
||||
allowFileUploads: boolean;
|
||||
allowedFilesMimeTypes: string;
|
||||
priority?: number; // Order on the model picker list, higher means first, default 0
|
||||
capabilities: {
|
||||
functionCalling: boolean;
|
||||
};
|
||||
available: boolean;
|
||||
scopes?: Scope[];
|
||||
}
|
||||
|
||||
export interface ChatModelDto {
|
||||
model: ChatHubConversationModel;
|
||||
name: string;
|
||||
description: string | null;
|
||||
icon: AgentIconOrEmoji | null;
|
||||
updatedAt: string | null;
|
||||
createdAt: string | null;
|
||||
metadata: ChatModelMetadataDto;
|
||||
groupName: string | null;
|
||||
groupIcon: AgentIconOrEmoji | null;
|
||||
suggestedPrompts?: Array<{ text: string; icon?: AgentIconOrEmoji }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response type for fetching available chat models
|
||||
*/
|
||||
export type ChatModelsResponse = Record<
|
||||
ChatHubProvider,
|
||||
{
|
||||
models: ChatModelDto[];
|
||||
error?: string;
|
||||
}
|
||||
>;
|
||||
|
||||
export const emptyChatModelsResponse: ChatModelsResponse = {
|
||||
openai: { models: [] },
|
||||
anthropic: { models: [] },
|
||||
google: { models: [] },
|
||||
azureOpenAi: { models: [] },
|
||||
azureEntraId: { models: [] },
|
||||
ollama: { models: [] },
|
||||
awsBedrock: { models: [] },
|
||||
vercelAiGateway: { models: [] },
|
||||
xAiGrok: { models: [] },
|
||||
groq: { models: [] },
|
||||
openRouter: { models: [] },
|
||||
deepSeek: { models: [] },
|
||||
cohere: { models: [] },
|
||||
mistralCloud: { models: [] },
|
||||
n8n: { models: [] },
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'custom-agent': { models: [] },
|
||||
};
|
||||
|
||||
/**
|
||||
* Chat attachment schema for incoming requests.
|
||||
* Requires base64 data and fileName.
|
||||
* MimeType, fileType, fileExtension, and fileSize are populated server-side.
|
||||
*/
|
||||
export const chatAttachmentSchema = z.object({
|
||||
data: z.string(),
|
||||
mimeType: z.string(),
|
||||
fileName: z.string(),
|
||||
});
|
||||
|
||||
export const isValidTimeZone = (tz: string): boolean => {
|
||||
try {
|
||||
// Throws if invalid timezone
|
||||
new Intl.DateTimeFormat('en-US', { timeZone: tz });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const StrictTimeZoneSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(50)
|
||||
.regex(/^[A-Za-z0-9_/+-]+$/)
|
||||
.refine(isValidTimeZone, {
|
||||
message: 'Unknown or invalid time zone',
|
||||
});
|
||||
|
||||
export const TimeZoneSchema = StrictTimeZoneSchema.optional().catch(undefined);
|
||||
|
||||
export type ChatAttachment = z.infer<typeof chatAttachmentSchema>;
|
||||
|
||||
export class ChatHubSendMessageRequest extends Z.class({
|
||||
messageId: z.string().uuid(),
|
||||
sessionId: z.string().uuid(),
|
||||
message: z.string(),
|
||||
model: chatHubConversationModelSchema,
|
||||
previousMessageId: z.string().uuid().nullable(),
|
||||
credentials: z.record(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
}),
|
||||
),
|
||||
attachments: z.array(chatAttachmentSchema),
|
||||
agentName: z.string().optional(),
|
||||
timeZone: TimeZoneSchema,
|
||||
}) {}
|
||||
|
||||
export class ChatHubRegenerateMessageRequest extends Z.class({
|
||||
model: chatHubConversationModelSchema,
|
||||
credentials: z.record(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
}),
|
||||
),
|
||||
timeZone: TimeZoneSchema,
|
||||
}) {}
|
||||
|
||||
export class ChatHubEditMessageRequest extends Z.class({
|
||||
message: z.string(),
|
||||
messageId: z.string().uuid(),
|
||||
model: chatHubConversationModelSchema,
|
||||
credentials: z.record(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
}),
|
||||
),
|
||||
newAttachments: z.array(chatAttachmentSchema),
|
||||
keepAttachmentIndices: z.array(z.number()),
|
||||
timeZone: TimeZoneSchema,
|
||||
}) {}
|
||||
|
||||
export class ChatHubUpdateConversationRequest extends Z.class({
|
||||
title: z.string().optional(),
|
||||
credentialId: z.string().max(36).optional(),
|
||||
agent: z
|
||||
.object({
|
||||
model: chatHubConversationModelSchema,
|
||||
name: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
toolIds: z.array(z.string().uuid()).optional(),
|
||||
}) {}
|
||||
|
||||
export type ChatHubMessageType = 'human' | 'ai' | 'system' | 'tool' | 'generic';
|
||||
export type ChatHubMessageStatus = 'success' | 'error' | 'running' | 'cancelled' | 'waiting';
|
||||
|
||||
export type ChatSessionId = string; // UUID
|
||||
export type ChatMessageId = string; // UUID
|
||||
|
||||
export interface ChatHubSessionDto {
|
||||
id: ChatSessionId;
|
||||
title: string;
|
||||
ownerId: string;
|
||||
lastMessageAt: string | null;
|
||||
credentialId: string | null;
|
||||
provider: ChatHubProvider | null;
|
||||
model: string | null;
|
||||
workflowId: string | null;
|
||||
agentId: string | null;
|
||||
agentName: string;
|
||||
agentIcon: AgentIconOrEmoji | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
toolIds: string[];
|
||||
}
|
||||
|
||||
export type ChatMessageContentChunk =
|
||||
| { type: 'text'; content: string }
|
||||
| { type: 'hidden'; content: string }
|
||||
| {
|
||||
type: 'artifact-create';
|
||||
content: string;
|
||||
command: ChatArtifactCreateCommand;
|
||||
isIncomplete: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'artifact-edit';
|
||||
content: string;
|
||||
command: ChatArtifactEditCommand;
|
||||
isIncomplete: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'with-buttons';
|
||||
content: string;
|
||||
buttons: ChatHubMessageButton[];
|
||||
blockUserInput: boolean;
|
||||
};
|
||||
|
||||
export interface ChatHubMessageDto {
|
||||
id: ChatMessageId;
|
||||
sessionId: ChatSessionId;
|
||||
type: ChatHubMessageType;
|
||||
name: string;
|
||||
content: ChatMessageContentChunk[];
|
||||
provider: ChatHubProvider | null;
|
||||
model: string | null;
|
||||
workflowId: string | null;
|
||||
agentId: string | null;
|
||||
executionId: number | null;
|
||||
status: ChatHubMessageStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
previousMessageId: ChatMessageId | null;
|
||||
retryOfMessageId: ChatMessageId | null;
|
||||
revisionOfMessageId: ChatMessageId | null;
|
||||
|
||||
attachments: Array<{ fileName?: string; mimeType?: string }>;
|
||||
}
|
||||
|
||||
export class ChatHubConversationsRequest extends Z.class({
|
||||
limit: z.coerce.number().int().min(1).max(100),
|
||||
cursor: z.string().uuid().optional(),
|
||||
}) {}
|
||||
|
||||
export interface ChatHubConversationsResponse {
|
||||
data: ChatHubSessionDto[];
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface ChatHubConversationDto {
|
||||
messages: Record<ChatMessageId, ChatHubMessageDto>;
|
||||
}
|
||||
|
||||
export interface ChatHubConversationResponse {
|
||||
session: ChatHubSessionDto;
|
||||
conversation: ChatHubConversationDto;
|
||||
}
|
||||
|
||||
export const suggestedPromptSchema = z.object({
|
||||
text: z.string().min(1).max(256),
|
||||
icon: agentIconOrEmojiSchema.optional(),
|
||||
});
|
||||
|
||||
export const suggestedPromptsSchema = z.array(suggestedPromptSchema).max(6);
|
||||
|
||||
export type SuggestedPrompt = z.infer<typeof suggestedPromptSchema>;
|
||||
|
||||
export interface ChatHubAgentDto {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
icon: AgentIconOrEmoji | null;
|
||||
suggestedPrompts: SuggestedPrompt[];
|
||||
systemPrompt: string;
|
||||
ownerId: string;
|
||||
credentialId: string | null;
|
||||
provider: ChatHubLLMProvider;
|
||||
model: string;
|
||||
files: ChatHubAgentKnowledgeItem[];
|
||||
toolIds: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export class ChatHubCreateAgentRequest extends Z.class({
|
||||
name: z.string().min(1).max(128),
|
||||
description: z.string().max(512).optional(),
|
||||
icon: agentIconOrEmojiSchema,
|
||||
suggestedPrompts: suggestedPromptsSchema.optional(),
|
||||
systemPrompt: z.string().min(1),
|
||||
credentialId: z.string(),
|
||||
provider: chatHubLLMProviderSchema,
|
||||
model: z.string().max(64),
|
||||
toolIds: z.array(z.string().uuid()),
|
||||
}) {}
|
||||
|
||||
export class ChatHubUpdateAgentRequest extends Z.class({
|
||||
name: z.string().min(1).max(128).optional(),
|
||||
description: z.string().max(512).optional(),
|
||||
icon: agentIconOrEmojiSchema.optional(),
|
||||
suggestedPrompts: suggestedPromptsSchema.optional(),
|
||||
systemPrompt: z.string().min(1).optional(),
|
||||
credentialId: z.string().optional(),
|
||||
provider: chatHubLLMProviderSchema.optional(),
|
||||
model: z.string().max(64).optional(),
|
||||
toolIds: z.array(z.string().uuid()).optional(),
|
||||
}) {}
|
||||
|
||||
export interface MessageChunk {
|
||||
type: ChunkType;
|
||||
content?: string;
|
||||
metadata: {
|
||||
timestamp: number;
|
||||
messageId: ChatMessageId;
|
||||
previousMessageId: ChatMessageId | null;
|
||||
retryOfMessageId: ChatMessageId | null;
|
||||
executionId: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
const chatProviderSettingsSchema = z.object({
|
||||
provider: chatHubLLMProviderSchema,
|
||||
enabled: z.boolean().optional(),
|
||||
credentialId: z.string().nullable(),
|
||||
// Empty list = all models allowed
|
||||
allowedModels: z.array(
|
||||
z.object({
|
||||
displayName: z.string(),
|
||||
model: z.string(),
|
||||
isManual: z.boolean().optional(),
|
||||
}),
|
||||
),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string().nullable(),
|
||||
});
|
||||
|
||||
export type ChatProviderSettingsDto = z.infer<typeof chatProviderSettingsSchema>;
|
||||
|
||||
export class UpdateChatSettingsRequest extends Z.class({
|
||||
payload: chatProviderSettingsSchema,
|
||||
}) {}
|
||||
|
||||
export interface ChatHubModuleSettings {
|
||||
enabled: boolean;
|
||||
providers: Record<ChatHubLLMProvider, ChatProviderSettingsDto>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response returned immediately when sending a message via WebSocket streaming.
|
||||
* Message IDs are not included as they come via WebSocket events (chatHubStreamBegin).
|
||||
*/
|
||||
export interface ChatSendMessageResponse {
|
||||
/** Status indicating streaming has started */
|
||||
status: 'streaming';
|
||||
}
|
||||
|
||||
/**
|
||||
* Request query parameters for reconnecting to a chat stream
|
||||
*/
|
||||
export class ChatReconnectRequest extends Z.class({
|
||||
lastSequence: z.coerce.number().int().min(0).optional(),
|
||||
}) {}
|
||||
|
||||
/**
|
||||
* Response containing pending chunks for reconnection replay
|
||||
*/
|
||||
export interface ChatReconnectResponse {
|
||||
/** Whether there is an active stream for this session */
|
||||
hasActiveStream: boolean;
|
||||
/** Current message ID being streamed, if any */
|
||||
currentMessageId: ChatMessageId | null;
|
||||
/** Pending chunks that were missed during disconnection */
|
||||
pendingChunks: Array<{
|
||||
sequenceNumber: number;
|
||||
content: string;
|
||||
}>;
|
||||
/** Last sequence number received by client (for gap detection) */
|
||||
lastSequenceNumber: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Artifact generated during chat interaction
|
||||
*/
|
||||
export interface ChatArtifact {
|
||||
title: string;
|
||||
/**
|
||||
* Document type (html, md, csv, js etc.)
|
||||
*/
|
||||
type: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ChatArtifactCreateCommand {
|
||||
title: string;
|
||||
type: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ChatArtifactEditCommand {
|
||||
title: string;
|
||||
oldString: string;
|
||||
newString: string;
|
||||
replaceAll: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Button shown in a chat message
|
||||
*/
|
||||
export const chatHubMessageButtonSchema = z.object({
|
||||
text: z.string(),
|
||||
link: z.string(),
|
||||
type: z.enum(['primary', 'secondary']),
|
||||
});
|
||||
|
||||
export type ChatHubMessageButton = z.infer<typeof chatHubMessageButtonSchema>;
|
||||
|
||||
/**
|
||||
* Structured message with buttons, sent from
|
||||
* Chat node in "Send and Wait for Response" mode for HITL approvals
|
||||
*/
|
||||
export const chatHubMessageWithButtonsSchema = z.object({
|
||||
type: z.literal('with-buttons'),
|
||||
text: z.string(),
|
||||
blockUserInput: z.boolean(),
|
||||
buttons: z.array(chatHubMessageButtonSchema).min(1),
|
||||
});
|
||||
|
||||
export type ChatHubMessageWithButtons = z.infer<typeof chatHubMessageWithButtonsSchema>;
|
||||
|
||||
/**
|
||||
* DTO for a configured chat hub tool
|
||||
*/
|
||||
export interface ChatHubToolDto {
|
||||
definition: INode;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/** Tool types blocked for ALL users in the Chat Hub. */
|
||||
export const ALWAYS_BLOCKED_CHAT_HUB_TOOL_TYPES: string[] = [CHAT_TOOL_NODE_TYPE];
|
||||
|
||||
/** Additional tool types blocked for chat-only users (global:chatUser). */
|
||||
export const CHAT_USER_BLOCKED_CHAT_HUB_TOOL_TYPES: string[] = [
|
||||
WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE,
|
||||
DATA_TABLE_TOOL_NODE_TYPE,
|
||||
];
|
||||
|
||||
/**
|
||||
* Request schema for creating a chat hub tool
|
||||
*/
|
||||
export class ChatHubCreateToolRequest extends Z.class({
|
||||
definition: INodeSchema,
|
||||
}) {}
|
||||
|
||||
/**
|
||||
* Request schema for updating a chat hub tool
|
||||
*/
|
||||
export class ChatHubUpdateToolRequest extends Z.class({
|
||||
definition: INodeSchema.optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
}) {}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
export type CommunityNodeType = {
|
||||
id: number;
|
||||
authorGithubUrl: string;
|
||||
authorName: string;
|
||||
checksum: string;
|
||||
description: string;
|
||||
displayName: string;
|
||||
name: string;
|
||||
numberOfStars: number;
|
||||
numberOfDownloads: number;
|
||||
packageName: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
npmVersion: string;
|
||||
isOfficialNode: boolean;
|
||||
companyName?: string;
|
||||
nodeDescription: INodeTypeDescription;
|
||||
isInstalled: boolean;
|
||||
nodeVersions?: Array<{ npmVersion: string; checksum: string }>;
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Date time in the ISO 8601 format, e.g. 2024-10-31T00:00:00.123Z */
|
||||
export type Iso8601DateTimeString = string;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { AiApplySuggestionRequestDto } from '../ai-apply-suggestion-request.dto';
|
||||
|
||||
describe('AiApplySuggestionRequestDto', () => {
|
||||
it('should validate a valid suggestion application request', () => {
|
||||
const validRequest = {
|
||||
sessionId: 'session-123',
|
||||
suggestionId: 'suggestion-456',
|
||||
};
|
||||
|
||||
const result = AiApplySuggestionRequestDto.safeParse(validRequest);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should fail if sessionId is missing', () => {
|
||||
const invalidRequest = {
|
||||
suggestionId: 'suggestion-456',
|
||||
};
|
||||
|
||||
const result = AiApplySuggestionRequestDto.safeParse(invalidRequest);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.issues[0].path).toEqual(['sessionId']);
|
||||
});
|
||||
|
||||
it('should fail if suggestionId is missing', () => {
|
||||
const invalidRequest = {
|
||||
sessionId: 'session-123',
|
||||
};
|
||||
|
||||
const result = AiApplySuggestionRequestDto.safeParse(invalidRequest);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.issues[0].path).toEqual(['suggestionId']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
import { AiAskRequestDto } from '../ai-ask-request.dto';
|
||||
|
||||
describe('AiAskRequestDto', () => {
|
||||
const validRequest = {
|
||||
question: 'How can I improve this workflow?',
|
||||
context: {
|
||||
schema: [
|
||||
{
|
||||
nodeName: 'TestNode',
|
||||
schema: {
|
||||
type: 'string',
|
||||
key: 'testKey',
|
||||
value: 'testValue',
|
||||
path: '/test/path',
|
||||
},
|
||||
},
|
||||
],
|
||||
inputSchema: {
|
||||
nodeName: 'InputNode',
|
||||
schema: {
|
||||
type: 'object',
|
||||
key: 'inputKey',
|
||||
value: [
|
||||
{
|
||||
type: 'string',
|
||||
key: 'nestedKey',
|
||||
value: 'nestedValue',
|
||||
path: '/nested/path',
|
||||
},
|
||||
],
|
||||
path: '/input/path',
|
||||
},
|
||||
},
|
||||
pushRef: 'push-123',
|
||||
ndvPushRef: 'ndv-push-456',
|
||||
},
|
||||
forNode: 'TestWorkflowNode',
|
||||
};
|
||||
|
||||
it('should validate a valid AI ask request', () => {
|
||||
const result = AiAskRequestDto.safeParse(validRequest);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should fail if question is missing', () => {
|
||||
const invalidRequest = {
|
||||
...validRequest,
|
||||
question: undefined,
|
||||
};
|
||||
|
||||
const result = AiAskRequestDto.safeParse(invalidRequest);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.issues[0].path).toEqual(['question']);
|
||||
});
|
||||
|
||||
it('should fail if context is invalid', () => {
|
||||
const invalidRequest = {
|
||||
...validRequest,
|
||||
context: {
|
||||
...validRequest.context,
|
||||
schema: [
|
||||
{
|
||||
nodeName: 'TestNode',
|
||||
schema: {
|
||||
type: 'invalid-type', // Invalid type
|
||||
value: 'testValue',
|
||||
path: '/test/path',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = AiAskRequestDto.safeParse(invalidRequest);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should fail if forNode is missing', () => {
|
||||
const invalidRequest = {
|
||||
...validRequest,
|
||||
forNode: undefined,
|
||||
};
|
||||
|
||||
const result = AiAskRequestDto.safeParse(invalidRequest);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.issues[0].path).toEqual(['forNode']);
|
||||
});
|
||||
|
||||
it('should validate all possible schema types', () => {
|
||||
const allTypesRequest = {
|
||||
question: 'Test all possible types',
|
||||
context: {
|
||||
schema: [
|
||||
{
|
||||
nodeName: 'AllTypesNode',
|
||||
schema: {
|
||||
type: 'object',
|
||||
key: 'typesRoot',
|
||||
value: [
|
||||
{ type: 'string', key: 'stringType', value: 'string', path: '/types/string' },
|
||||
{ type: 'number', key: 'numberType', value: 'number', path: '/types/number' },
|
||||
{ type: 'boolean', key: 'booleanType', value: 'boolean', path: '/types/boolean' },
|
||||
{ type: 'bigint', key: 'bigintType', value: 'bigint', path: '/types/bigint' },
|
||||
{ type: 'symbol', key: 'symbolType', value: 'symbol', path: '/types/symbol' },
|
||||
{ type: 'array', key: 'arrayType', value: [], path: '/types/array' },
|
||||
{ type: 'object', key: 'objectType', value: [], path: '/types/object' },
|
||||
{
|
||||
type: 'function',
|
||||
key: 'functionType',
|
||||
value: 'function',
|
||||
path: '/types/function',
|
||||
},
|
||||
{ type: 'null', key: 'nullType', value: 'null', path: '/types/null' },
|
||||
{
|
||||
type: 'undefined',
|
||||
key: 'undefinedType',
|
||||
value: 'undefined',
|
||||
path: '/types/undefined',
|
||||
},
|
||||
],
|
||||
path: '/types/root',
|
||||
},
|
||||
},
|
||||
],
|
||||
inputSchema: {
|
||||
nodeName: 'InputNode',
|
||||
schema: {
|
||||
type: 'object',
|
||||
key: 'simpleInput',
|
||||
value: [
|
||||
{
|
||||
type: 'string',
|
||||
key: 'simpleKey',
|
||||
value: 'simpleValue',
|
||||
path: '/simple/path',
|
||||
},
|
||||
],
|
||||
path: '/simple/input/path',
|
||||
},
|
||||
},
|
||||
pushRef: 'push-types-123',
|
||||
ndvPushRef: 'ndv-push-types-456',
|
||||
},
|
||||
forNode: 'TypeCheckNode',
|
||||
};
|
||||
|
||||
const result = AiAskRequestDto.safeParse(allTypesRequest);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should fail with invalid type', () => {
|
||||
const invalidTypeRequest = {
|
||||
question: 'Test invalid type',
|
||||
context: {
|
||||
schema: [
|
||||
{
|
||||
nodeName: 'InvalidTypeNode',
|
||||
schema: {
|
||||
type: 'invalid-type', // This should fail
|
||||
key: 'invalidKey',
|
||||
value: 'invalidValue',
|
||||
path: '/invalid/path',
|
||||
},
|
||||
},
|
||||
],
|
||||
inputSchema: {
|
||||
nodeName: 'InputNode',
|
||||
schema: {
|
||||
type: 'object',
|
||||
key: 'simpleInput',
|
||||
value: [
|
||||
{
|
||||
type: 'string',
|
||||
key: 'simpleKey',
|
||||
value: 'simpleValue',
|
||||
path: '/simple/path',
|
||||
},
|
||||
],
|
||||
path: '/simple/input/path',
|
||||
},
|
||||
},
|
||||
pushRef: 'push-invalid-123',
|
||||
ndvPushRef: 'ndv-push-invalid-456',
|
||||
},
|
||||
forNode: 'InvalidTypeNode',
|
||||
};
|
||||
|
||||
const result = AiAskRequestDto.safeParse(invalidTypeRequest);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should validate multiple schema entries', () => {
|
||||
const multiSchemaRequest = {
|
||||
question: 'Multiple schema test',
|
||||
context: {
|
||||
schema: [
|
||||
{
|
||||
nodeName: 'FirstNode',
|
||||
schema: {
|
||||
type: 'string',
|
||||
key: 'firstKey',
|
||||
value: 'firstValue',
|
||||
path: '/first/path',
|
||||
},
|
||||
},
|
||||
{
|
||||
nodeName: 'SecondNode',
|
||||
schema: {
|
||||
type: 'object',
|
||||
key: 'secondKey',
|
||||
value: [
|
||||
{
|
||||
type: 'number',
|
||||
key: 'nestedKey',
|
||||
value: 'nestedValue',
|
||||
path: '/second/nested/path',
|
||||
},
|
||||
],
|
||||
path: '/second/path',
|
||||
},
|
||||
},
|
||||
],
|
||||
inputSchema: {
|
||||
nodeName: 'InputNode',
|
||||
schema: {
|
||||
type: 'object',
|
||||
key: 'simpleInput',
|
||||
value: [
|
||||
{
|
||||
type: 'string',
|
||||
key: 'simpleKey',
|
||||
value: 'simpleValue',
|
||||
path: '/simple/path',
|
||||
},
|
||||
],
|
||||
path: '/simple/input/path',
|
||||
},
|
||||
},
|
||||
pushRef: 'push-multi-123',
|
||||
ndvPushRef: 'ndv-push-multi-456',
|
||||
},
|
||||
forNode: 'MultiSchemaNode',
|
||||
};
|
||||
|
||||
const result = AiAskRequestDto.safeParse(multiSchemaRequest);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,337 @@
|
||||
import { AiBuilderChatRequestDto } from '../ai-build-request.dto';
|
||||
|
||||
describe('AiBuilderChatRequestDto', () => {
|
||||
const validBasePayload = {
|
||||
payload: {
|
||||
id: '12345',
|
||||
role: 'user' as const,
|
||||
type: 'message' as const,
|
||||
text: 'Build me a workflow',
|
||||
workflowContext: {
|
||||
currentWorkflow: {
|
||||
nodes: [],
|
||||
connections: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('expressionValues validation', () => {
|
||||
it('should validate when expressionValues is an empty object', () => {
|
||||
const validRequest = {
|
||||
...validBasePayload,
|
||||
payload: {
|
||||
...validBasePayload.payload,
|
||||
workflowContext: {
|
||||
...validBasePayload.payload.workflowContext,
|
||||
expressionValues: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = AiBuilderChatRequestDto.safeParse(validRequest);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate when expressionValues contains valid expression data', () => {
|
||||
const validRequest = {
|
||||
...validBasePayload,
|
||||
payload: {
|
||||
...validBasePayload.payload,
|
||||
workflowContext: {
|
||||
...validBasePayload.payload.workflowContext,
|
||||
expressionValues: {
|
||||
node1: [
|
||||
{
|
||||
expression: '{{ $json.field }}',
|
||||
resolvedValue: 'test value',
|
||||
nodeType: 'n8n-nodes-base.set',
|
||||
},
|
||||
],
|
||||
node2: [
|
||||
{
|
||||
expression: '{{ $now }}',
|
||||
resolvedValue: '2024-01-01',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = AiBuilderChatRequestDto.safeParse(validRequest);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should fail when expressionValues has items but all expressions are empty', () => {
|
||||
const invalidRequest = {
|
||||
...validBasePayload,
|
||||
payload: {
|
||||
...validBasePayload.payload,
|
||||
workflowContext: {
|
||||
...validBasePayload.payload.workflowContext,
|
||||
expressionValues: {
|
||||
node1: [
|
||||
{
|
||||
expression: '',
|
||||
resolvedValue: 'test value',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = AiBuilderChatRequestDto.safeParse(invalidRequest);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should validate when expressionValues is not provided (optional field)', () => {
|
||||
const validRequest = {
|
||||
...validBasePayload,
|
||||
};
|
||||
|
||||
const result = AiBuilderChatRequestDto.safeParse(validRequest);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('currentWorkflow validation', () => {
|
||||
it('should validate when currentWorkflow has both nodes and connections', () => {
|
||||
const validRequest = {
|
||||
...validBasePayload,
|
||||
};
|
||||
|
||||
const result = AiBuilderChatRequestDto.safeParse(validRequest);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate when currentWorkflow has only nodes', () => {
|
||||
const validRequest = {
|
||||
...validBasePayload,
|
||||
payload: {
|
||||
...validBasePayload.payload,
|
||||
workflowContext: {
|
||||
currentWorkflow: {
|
||||
nodes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = AiBuilderChatRequestDto.safeParse(validRequest);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate when currentWorkflow has only connections', () => {
|
||||
const validRequest = {
|
||||
...validBasePayload,
|
||||
payload: {
|
||||
...validBasePayload.payload,
|
||||
workflowContext: {
|
||||
currentWorkflow: {
|
||||
connections: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = AiBuilderChatRequestDto.safeParse(validRequest);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should fail when currentWorkflow has neither nodes nor connections', () => {
|
||||
const invalidRequest = {
|
||||
...validBasePayload,
|
||||
payload: {
|
||||
...validBasePayload.payload,
|
||||
workflowContext: {
|
||||
currentWorkflow: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = AiBuilderChatRequestDto.safeParse(invalidRequest);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('executionData validation', () => {
|
||||
it('should validate when executionData has runData', () => {
|
||||
const validRequest = {
|
||||
...validBasePayload,
|
||||
payload: {
|
||||
...validBasePayload.payload,
|
||||
workflowContext: {
|
||||
...validBasePayload.payload.workflowContext,
|
||||
executionData: {
|
||||
runData: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = AiBuilderChatRequestDto.safeParse(validRequest);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate when executionData has error', () => {
|
||||
const validRequest = {
|
||||
...validBasePayload,
|
||||
payload: {
|
||||
...validBasePayload.payload,
|
||||
workflowContext: {
|
||||
...validBasePayload.payload.workflowContext,
|
||||
executionData: {
|
||||
error: new Error('test error'),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = AiBuilderChatRequestDto.safeParse(validRequest);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should fail when executionData has neither runData nor error', () => {
|
||||
const invalidRequest = {
|
||||
...validBasePayload,
|
||||
payload: {
|
||||
...validBasePayload.payload,
|
||||
workflowContext: {
|
||||
...validBasePayload.payload.workflowContext,
|
||||
executionData: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = AiBuilderChatRequestDto.safeParse(invalidRequest);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('executionSchema validation', () => {
|
||||
it('should validate when executionSchema has valid items', () => {
|
||||
const validRequest = {
|
||||
...validBasePayload,
|
||||
payload: {
|
||||
...validBasePayload.payload,
|
||||
workflowContext: {
|
||||
...validBasePayload.payload.workflowContext,
|
||||
executionSchema: [
|
||||
{
|
||||
nodeName: 'node1',
|
||||
schema: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = AiBuilderChatRequestDto.safeParse(validRequest);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should fail when executionSchema has items without nodeName or schema', () => {
|
||||
const invalidRequest = {
|
||||
...validBasePayload,
|
||||
payload: {
|
||||
...validBasePayload.payload,
|
||||
workflowContext: {
|
||||
...validBasePayload.payload.workflowContext,
|
||||
executionSchema: [
|
||||
{
|
||||
nodeName: '',
|
||||
schema: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = AiBuilderChatRequestDto.safeParse(invalidRequest);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('basic payload validation', () => {
|
||||
it('should validate a complete valid request', () => {
|
||||
const result = AiBuilderChatRequestDto.safeParse(validBasePayload);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should fail when role is not "user"', () => {
|
||||
const invalidRequest = {
|
||||
...validBasePayload,
|
||||
payload: {
|
||||
...validBasePayload.payload,
|
||||
role: 'assistant',
|
||||
},
|
||||
};
|
||||
|
||||
const result = AiBuilderChatRequestDto.safeParse(invalidRequest);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should fail when type is not "message"', () => {
|
||||
const invalidRequest = {
|
||||
...validBasePayload,
|
||||
payload: {
|
||||
...validBasePayload.payload,
|
||||
type: 'system',
|
||||
},
|
||||
};
|
||||
|
||||
const result = AiBuilderChatRequestDto.safeParse(invalidRequest);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should fail when text is missing', () => {
|
||||
const invalidRequest = {
|
||||
...validBasePayload,
|
||||
payload: {
|
||||
id: '12345',
|
||||
role: 'user' as const,
|
||||
type: 'message' as const,
|
||||
workflowContext: validBasePayload.payload.workflowContext,
|
||||
},
|
||||
};
|
||||
|
||||
const result = AiBuilderChatRequestDto.safeParse(invalidRequest);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should fail when id is missing', () => {
|
||||
const invalidRequest = {
|
||||
...validBasePayload,
|
||||
payload: {
|
||||
role: 'user' as const,
|
||||
type: 'message' as const,
|
||||
text: 'text',
|
||||
workflowContext: validBasePayload.payload.workflowContext,
|
||||
},
|
||||
};
|
||||
|
||||
const result = AiBuilderChatRequestDto.safeParse(invalidRequest);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { AiChatRequestDto } from '../ai-chat-request.dto';
|
||||
|
||||
describe('AiChatRequestDto', () => {
|
||||
it('should validate a request with a payload and session ID', () => {
|
||||
const validRequest = {
|
||||
payload: { someKey: 'someValue' },
|
||||
sessionId: 'session-123',
|
||||
};
|
||||
|
||||
const result = AiChatRequestDto.safeParse(validRequest);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate a request with only a payload', () => {
|
||||
const validRequest = {
|
||||
payload: { complexObject: { nested: 'value' } },
|
||||
};
|
||||
|
||||
const result = AiChatRequestDto.safeParse(validRequest);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should fail if payload is missing', () => {
|
||||
const invalidRequest = {
|
||||
sessionId: 'session-123',
|
||||
};
|
||||
|
||||
const result = AiChatRequestDto.safeParse(invalidRequest);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { nanoId } from 'minifaker';
|
||||
|
||||
import { AiFreeCreditsRequestDto } from '../ai-free-credits-request.dto';
|
||||
import 'minifaker/locales/en';
|
||||
|
||||
describe('AiChatRequestDto', () => {
|
||||
it('should succeed if projectId is a valid nanoid', () => {
|
||||
const validRequest = {
|
||||
projectId: nanoId.nanoid(),
|
||||
};
|
||||
|
||||
const result = AiFreeCreditsRequestDto.safeParse(validRequest);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should succeed if no projectId is sent', () => {
|
||||
const result = AiFreeCreditsRequestDto.safeParse({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should fail is projectId invalid value', () => {
|
||||
const validRequest = {
|
||||
projectId: '',
|
||||
};
|
||||
|
||||
const result = AiFreeCreditsRequestDto.safeParse(validRequest);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class AiApplySuggestionRequestDto extends Z.class({
|
||||
sessionId: z.string(),
|
||||
suggestionId: z.string(),
|
||||
}) {}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { AiAssistantSDK, SchemaType } from '@n8n_io/ai-assistant-sdk';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
// Note: This is copied from the sdk, since this type is not exported
|
||||
type Schema = {
|
||||
type: SchemaType;
|
||||
key?: string;
|
||||
value: string | Schema[];
|
||||
path: string;
|
||||
};
|
||||
|
||||
// Create a lazy validator to handle the recursive type
|
||||
const schemaValidator: z.ZodType<Schema> = z.lazy(() =>
|
||||
z.object({
|
||||
type: z.enum([
|
||||
'string',
|
||||
'number',
|
||||
'boolean',
|
||||
'bigint',
|
||||
'symbol',
|
||||
'array',
|
||||
'object',
|
||||
'function',
|
||||
'null',
|
||||
'undefined',
|
||||
]),
|
||||
key: z.string().optional(),
|
||||
value: z.union([z.string(), z.lazy(() => schemaValidator.array())]),
|
||||
path: z.string(),
|
||||
}),
|
||||
);
|
||||
|
||||
export class AiAskRequestDto
|
||||
extends Z.class({
|
||||
question: z.string(),
|
||||
context: z.object({
|
||||
schema: z.array(
|
||||
z.object({
|
||||
nodeName: z.string(),
|
||||
schema: schemaValidator,
|
||||
}),
|
||||
),
|
||||
inputSchema: z.object({
|
||||
nodeName: z.string(),
|
||||
schema: schemaValidator,
|
||||
}),
|
||||
pushRef: z.string(),
|
||||
ndvPushRef: z.string(),
|
||||
}),
|
||||
forNode: z.string(),
|
||||
})
|
||||
implements AiAssistantSDK.AskAiRequestPayload {}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { IRunExecutionData, IWorkflowBase, NodeExecutionSchema } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export interface ExpressionValue {
|
||||
expression: string;
|
||||
resolvedValue: unknown;
|
||||
nodeType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context for a node selected/focused by the user.
|
||||
* Used for focused nodes feature - allows user to select specific nodes
|
||||
* for the AI to prioritize in its responses.
|
||||
*/
|
||||
export interface SelectedNodeContext {
|
||||
/** Node display name - use to look up full node in currentWorkflow.nodes */
|
||||
name: string;
|
||||
/** Configuration issues/validation errors on the node */
|
||||
issues?: Record<string, string[]>;
|
||||
/** Names of nodes that connect INTO this node */
|
||||
incomingConnections: string[];
|
||||
/** Names of nodes that this node connects TO */
|
||||
outgoingConnections: string[];
|
||||
}
|
||||
|
||||
export class AiBuilderChatRequestDto extends Z.class({
|
||||
payload: z.object({
|
||||
id: z.string(),
|
||||
role: z.literal('user'),
|
||||
type: z.literal('message'),
|
||||
text: z.string(),
|
||||
versionId: z.string().optional(),
|
||||
workflowContext: z.object({
|
||||
currentWorkflow: z
|
||||
.custom<Partial<IWorkflowBase>>((val: Partial<IWorkflowBase>) => {
|
||||
if (!val.nodes && !val.connections) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return val;
|
||||
})
|
||||
.optional(),
|
||||
|
||||
executionData: z
|
||||
.custom<IRunExecutionData['resultData']>((val: IRunExecutionData['resultData']) => {
|
||||
if (!val.runData && !val.error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return val;
|
||||
})
|
||||
.optional(),
|
||||
|
||||
executionSchema: z
|
||||
.custom<NodeExecutionSchema[]>((val: NodeExecutionSchema[]) => {
|
||||
// Check if the array is empty or if all items have nodeName and schema properties
|
||||
if (!Array.isArray(val) || val.every((item) => !item.nodeName || !item.schema)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return val;
|
||||
})
|
||||
.optional(),
|
||||
|
||||
expressionValues: z
|
||||
.custom<Record<string, ExpressionValue[]>>((val: Record<string, ExpressionValue[]>) => {
|
||||
const keys = Object.keys(val);
|
||||
// Check if the array is empty or if all items have nodeName and schema properties
|
||||
if (keys.length > 0 && keys.every((key) => val[key].every((v) => !v.expression))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return val;
|
||||
})
|
||||
.optional(),
|
||||
valuesExcluded: z.boolean().optional(),
|
||||
pinnedNodes: z.array(z.string()).optional(),
|
||||
|
||||
selectedNodes: z
|
||||
.custom<SelectedNodeContext[]>((val: SelectedNodeContext[]) => {
|
||||
if (!Array.isArray(val)) {
|
||||
return false;
|
||||
}
|
||||
if (val.length === 0) {
|
||||
return val;
|
||||
}
|
||||
if (
|
||||
val.every(
|
||||
(item) =>
|
||||
typeof item.name === 'string' &&
|
||||
Array.isArray(item.incomingConnections) &&
|
||||
Array.isArray(item.outgoingConnections),
|
||||
)
|
||||
) {
|
||||
return val;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
featureFlags: z
|
||||
.object({
|
||||
templateExamples: z.boolean().optional(),
|
||||
codeBuilder: z.boolean().optional(),
|
||||
pinData: z.boolean().optional(),
|
||||
planMode: z.boolean().optional(),
|
||||
mergeAskBuild: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
mode: z.enum(['build', 'plan']).optional(),
|
||||
resumeData: z.union([z.record(z.unknown()), z.array(z.unknown())]).optional(),
|
||||
}),
|
||||
}) {}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { AiAssistantSDK } from '@n8n_io/ai-assistant-sdk';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class AiChatRequestDto
|
||||
extends Z.class({
|
||||
payload: z.object({}).passthrough(), // Allow any object shape
|
||||
sessionId: z.string().optional(),
|
||||
})
|
||||
implements AiAssistantSDK.ChatRequestPayload {}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class AiClearSessionRequestDto extends Z.class({
|
||||
workflowId: z.string(),
|
||||
}) {}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class AiFreeCreditsRequestDto extends Z.class({
|
||||
projectId: z.string().min(1).optional(),
|
||||
}) {}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class AiSessionRetrievalRequestDto extends Z.class({
|
||||
workflowId: z.string().optional(),
|
||||
codeBuilder: z.boolean().optional(),
|
||||
}) {}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class AiTruncateMessagesRequestDto extends Z.class({
|
||||
workflowId: z.string(),
|
||||
messageId: z.string(),
|
||||
codeBuilder: z.boolean().optional(),
|
||||
}) {}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class AiUsageSettingsRequestDto extends Z.class({
|
||||
allowSendingParameterValues: z.boolean(),
|
||||
}) {}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { CreateApiKeyRequestDto } from '../create-api-key-request.dto';
|
||||
|
||||
describe('CreateApiKeyRequestDto', () => {
|
||||
describe('Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'expiresAt in the future',
|
||||
expiresAt: Date.now() / 1000 + 1000,
|
||||
scopes: ['user:create'],
|
||||
},
|
||||
{
|
||||
name: 'expiresAt null',
|
||||
expiresAt: null,
|
||||
scopes: ['user:create'],
|
||||
},
|
||||
])('should succeed validation for $name', ({ expiresAt, scopes }) => {
|
||||
const result = CreateApiKeyRequestDto.safeParse({ label: 'valid', expiresAt, scopes });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'expiresAt in the past',
|
||||
expiresAt: Date.now() / 1000 - 1000,
|
||||
scopes: ['user:create'],
|
||||
expectedErrorPath: ['expiresAt'],
|
||||
},
|
||||
{
|
||||
name: 'expiresAt with string',
|
||||
expiresAt: 'invalid',
|
||||
scopes: ['user:create'],
|
||||
expectedErrorPath: ['expiresAt'],
|
||||
},
|
||||
{
|
||||
name: 'expiresAt with []',
|
||||
expiresAt: [],
|
||||
scopes: ['user:create'],
|
||||
expectedErrorPath: ['expiresAt'],
|
||||
},
|
||||
{
|
||||
name: 'expiresAt with {}',
|
||||
expiresAt: {},
|
||||
scopes: ['user:create'],
|
||||
expectedErrorPath: ['expiresAt'],
|
||||
},
|
||||
])('should fail validation for $name', ({ expiresAt, expectedErrorPath }) => {
|
||||
const result = CreateApiKeyRequestDto.safeParse({
|
||||
label: 'valid',
|
||||
expiresAt,
|
||||
scopes: ['user:create'],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (expectedErrorPath) {
|
||||
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { UpdateApiKeyRequestDto } from '../update-api-key-request.dto';
|
||||
|
||||
describe('UpdateApiKeyRequestDto', () => {
|
||||
describe('Valid requests', () => {
|
||||
test('should allow valid label', () => {
|
||||
const result = UpdateApiKeyRequestDto.safeParse({
|
||||
label: 'valid label',
|
||||
scopes: ['user:create'],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test('should allow valid scope', () => {
|
||||
const result = UpdateApiKeyRequestDto.safeParse({
|
||||
label: 'valid label',
|
||||
scopes: ['user:create'],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'empty label',
|
||||
label: '',
|
||||
expectedErrorPath: ['label'],
|
||||
},
|
||||
{
|
||||
name: 'label exceeding 50 characters',
|
||||
label: '2mWMfsrvAmneWluS8IbezaIHZOu2mWMfsrvAmneWluS8IbezaIa',
|
||||
expectedErrorPath: ['label'],
|
||||
},
|
||||
{
|
||||
name: 'label with xss injection',
|
||||
label: '<script>alert("xss");new label</script>',
|
||||
expectedErrorPath: ['label'],
|
||||
},
|
||||
{
|
||||
name: 'scopes with malformed scope',
|
||||
label: 'valid label',
|
||||
scopes: ['user:1'],
|
||||
expectedErrorPath: ['scopes', 0],
|
||||
},
|
||||
{
|
||||
name: 'scopes with empty array',
|
||||
label: 'valid label',
|
||||
scopes: [],
|
||||
expectedErrorPath: ['scopes'],
|
||||
},
|
||||
{
|
||||
name: 'scopes with {}',
|
||||
label: 'valid label',
|
||||
scopes: {},
|
||||
expectedErrorPath: ['scopes'],
|
||||
},
|
||||
])('should fail validation for $name', ({ label, scopes, expectedErrorPath }) => {
|
||||
const result = UpdateApiKeyRequestDto.safeParse({ label, scopes });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (expectedErrorPath) {
|
||||
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { UpdateApiKeyRequestDto } from './update-api-key-request.dto';
|
||||
|
||||
const isTimeNullOrInFuture = (value: number | null) => {
|
||||
if (!value) return true;
|
||||
return value > Date.now() / 1000;
|
||||
};
|
||||
|
||||
export class CreateApiKeyRequestDto extends UpdateApiKeyRequestDto.extend({
|
||||
expiresAt: z
|
||||
.number()
|
||||
.nullable()
|
||||
.refine(isTimeNullOrInFuture, { message: 'Expiration date must be in the future or null' }),
|
||||
}) {}
|
||||
@@ -0,0 +1,16 @@
|
||||
import xss from 'xss';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { scopesSchema } from '../../schemas/scopes.schema';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
const xssCheck = (value: string) =>
|
||||
value ===
|
||||
xss(value, {
|
||||
whiteList: {},
|
||||
});
|
||||
|
||||
export class UpdateApiKeyRequestDto extends Z.class({
|
||||
label: z.string().max(50).min(1).refine(xssCheck),
|
||||
scopes: scopesSchema,
|
||||
}) {}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { LoginRequestDto } from '../login-request.dto';
|
||||
|
||||
describe('LoginRequestDto', () => {
|
||||
describe('Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'complete valid login request',
|
||||
request: {
|
||||
emailOrLdapLoginId: 'test@example.com',
|
||||
password: 'securePassword123',
|
||||
mfaCode: '123456',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'login request without optional MFA',
|
||||
request: {
|
||||
emailOrLdapLoginId: 'test@example.com',
|
||||
password: 'securePassword123',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'login request with both mfaCode and mfaRecoveryCode',
|
||||
request: {
|
||||
emailOrLdapLoginId: 'test@example.com',
|
||||
password: 'securePassword123',
|
||||
mfaCode: '123456',
|
||||
mfaRecoveryCode: 'recovery-code-123',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'login request with only mfaRecoveryCode',
|
||||
request: {
|
||||
emailOrLdapLoginId: 'test@example.com',
|
||||
password: 'securePassword123',
|
||||
mfaRecoveryCode: 'recovery-code-123',
|
||||
},
|
||||
},
|
||||
])('should validate $name', ({ request }) => {
|
||||
const result = LoginRequestDto.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'invalid emailOrLdapLoginId',
|
||||
request: {
|
||||
emailOrLdapLoginId: 0,
|
||||
password: 'securePassword123',
|
||||
},
|
||||
expectedErrorPath: ['emailOrLdapLoginId'],
|
||||
},
|
||||
{
|
||||
name: 'empty password',
|
||||
request: {
|
||||
emailOrLdapLoginId: 'test@example.com',
|
||||
password: '',
|
||||
},
|
||||
expectedErrorPath: ['password'],
|
||||
},
|
||||
{
|
||||
name: 'missing emailOrLdapLoginId',
|
||||
request: {
|
||||
password: 'securePassword123',
|
||||
},
|
||||
expectedErrorPath: ['emailOrLdapLoginId'],
|
||||
},
|
||||
{
|
||||
name: 'missing password',
|
||||
request: {
|
||||
emailOrLdapLoginId: 'test@example.com',
|
||||
},
|
||||
expectedErrorPath: ['password'],
|
||||
},
|
||||
{
|
||||
name: 'emailOrLdapLoginId exceeds max length',
|
||||
request: {
|
||||
emailOrLdapLoginId: 'a'.repeat(256),
|
||||
password: 'securePassword123',
|
||||
},
|
||||
expectedErrorPath: ['emailOrLdapLoginId'],
|
||||
},
|
||||
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
|
||||
const result = LoginRequestDto.safeParse(request);
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (expectedErrorPath) {
|
||||
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { ResolveSignupTokenQueryDto } from '../resolve-signup-token-query.dto';
|
||||
|
||||
describe('ResolveSignupTokenQueryDto', () => {
|
||||
const validUuid = '123e4567-e89b-12d3-a456-426614174000';
|
||||
|
||||
describe('Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'legacy format with both inviterId and inviteeId',
|
||||
request: {
|
||||
inviterId: validUuid,
|
||||
inviteeId: validUuid,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'JWT token format',
|
||||
request: {
|
||||
token:
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpbnZpdGVySWQiOiIxMjNlNDU2Ny1lODliLTEyZDMtYTQ1Ni00MjY2MTQxNzQwMDAiLCJpbnZpdGVlSWQiOiIxMjNlNDU2Ny1lODliLTEyZDMtYTQ1Ni00MjY2MTQxNzQwMDAifQ.test',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'missing inviterId (could be token-based)',
|
||||
request: {
|
||||
inviteeId: validUuid,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'missing inviteeId (could be token-based)',
|
||||
request: {
|
||||
inviterId: validUuid,
|
||||
},
|
||||
},
|
||||
])('should validate $name', ({ request }) => {
|
||||
const result = ResolveSignupTokenQueryDto.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'invalid inviterId UUID',
|
||||
request: {
|
||||
inviterId: 'not-a-valid-uuid',
|
||||
inviteeId: validUuid,
|
||||
},
|
||||
expectedErrorPath: ['inviterId'],
|
||||
},
|
||||
{
|
||||
name: 'invalid inviteeId UUID',
|
||||
request: {
|
||||
inviterId: validUuid,
|
||||
inviteeId: 'not-a-valid-uuid',
|
||||
},
|
||||
expectedErrorPath: ['inviteeId'],
|
||||
},
|
||||
{
|
||||
name: 'UUID with invalid characters',
|
||||
request: {
|
||||
inviterId: '123e4567-e89b-12d3-a456-42661417400G',
|
||||
inviteeId: validUuid,
|
||||
},
|
||||
expectedErrorPath: ['inviterId'],
|
||||
},
|
||||
{
|
||||
name: 'UUID too long',
|
||||
request: {
|
||||
inviterId: '123e4567-e89b-12d3-a456-426614174001234',
|
||||
inviteeId: validUuid,
|
||||
},
|
||||
expectedErrorPath: ['inviterId'],
|
||||
},
|
||||
{
|
||||
name: 'UUID too short',
|
||||
request: {
|
||||
inviterId: '123e4567-e89b-12d3-a456',
|
||||
inviteeId: validUuid,
|
||||
},
|
||||
expectedErrorPath: ['inviterId'],
|
||||
},
|
||||
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
|
||||
const result = ResolveSignupTokenQueryDto.safeParse(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (expectedErrorPath) {
|
||||
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class LoginRequestDto extends Z.class({
|
||||
/*
|
||||
* The LDAP username does not need to be an email, so email validation
|
||||
* is not enforced here. The controller determines whether this is an
|
||||
* email and validates when LDAP is disabled
|
||||
*/
|
||||
emailOrLdapLoginId: z.string().trim().max(255),
|
||||
password: z.string().min(1),
|
||||
mfaCode: z.string().optional(),
|
||||
mfaRecoveryCode: z.string().optional(),
|
||||
}) {}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
// Support both legacy format (inviterId + inviteeId) and new JWT format (token)
|
||||
// All fields are optional at the schema level, but validation ensures either token OR (inviterId AND inviteeId) are provided
|
||||
const resolveSignupTokenShape = {
|
||||
inviterId: z.string().uuid().optional(),
|
||||
inviteeId: z.string().uuid().optional(),
|
||||
token: z.string().optional(),
|
||||
};
|
||||
|
||||
export class ResolveSignupTokenQueryDto extends Z.class(resolveSignupTokenShape) {}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { BinaryDataQueryDto } from '../binary-data-query.dto';
|
||||
|
||||
describe('BinaryDataQueryDto', () => {
|
||||
describe('Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'database mode with view action',
|
||||
request: {
|
||||
id: 'database:some-id',
|
||||
action: 'view',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'filesystem mode with view action',
|
||||
request: {
|
||||
id: 'filesystem:some-id',
|
||||
action: 'view',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'filesystem-v2 mode with download action',
|
||||
request: {
|
||||
id: 'filesystem-v2:some-id',
|
||||
action: 'download',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 's3 mode with view action and optional fields',
|
||||
request: {
|
||||
id: 's3:some-id',
|
||||
action: 'view',
|
||||
fileName: 'test.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
},
|
||||
},
|
||||
])('should validate $name', ({ request }) => {
|
||||
const result = BinaryDataQueryDto.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'missing mode separator',
|
||||
request: {
|
||||
id: 'filesystemsome-id',
|
||||
action: 'view',
|
||||
},
|
||||
expectedErrorPath: ['id'],
|
||||
},
|
||||
{
|
||||
name: 'invalid mode',
|
||||
request: {
|
||||
id: 'invalid:some-id',
|
||||
action: 'view',
|
||||
},
|
||||
expectedErrorPath: ['id'],
|
||||
},
|
||||
{
|
||||
name: 'invalid action',
|
||||
request: {
|
||||
id: 'filesystem:some-id',
|
||||
action: 'invalid',
|
||||
},
|
||||
expectedErrorPath: ['action'],
|
||||
},
|
||||
{
|
||||
name: 'missing id',
|
||||
request: {
|
||||
action: 'view',
|
||||
},
|
||||
expectedErrorPath: ['id'],
|
||||
},
|
||||
{
|
||||
name: 'missing action',
|
||||
request: {
|
||||
id: 'filesystem:some-id',
|
||||
},
|
||||
expectedErrorPath: ['action'],
|
||||
},
|
||||
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
|
||||
const result = BinaryDataQueryDto.safeParse(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (expectedErrorPath) {
|
||||
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import { BinaryDataSignedQueryDto } from '../binary-data-signed-query.dto';
|
||||
|
||||
describe('BinaryDataSignedQueryDto', () => {
|
||||
describe('Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'valid JWT token',
|
||||
request: {
|
||||
token:
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c',
|
||||
},
|
||||
},
|
||||
])('should validate $name', ({ request }) => {
|
||||
const result = BinaryDataSignedQueryDto.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'missing token',
|
||||
request: {},
|
||||
expectedErrorPath: ['token'],
|
||||
},
|
||||
{
|
||||
name: 'empty token',
|
||||
request: {
|
||||
token: '',
|
||||
},
|
||||
expectedErrorPath: ['token'],
|
||||
},
|
||||
{
|
||||
name: 'non-string token',
|
||||
request: {
|
||||
token: 123,
|
||||
},
|
||||
expectedErrorPath: ['token'],
|
||||
},
|
||||
{
|
||||
name: 'token without three segments',
|
||||
request: {
|
||||
token: 'header.payload',
|
||||
},
|
||||
expectedErrorPath: ['token'],
|
||||
},
|
||||
{
|
||||
name: 'token with invalid characters',
|
||||
request: {
|
||||
token: 'header.payload.sign@ture',
|
||||
},
|
||||
expectedErrorPath: ['token'],
|
||||
},
|
||||
{
|
||||
name: 'token with too many segments',
|
||||
request: {
|
||||
token: 'header.payload.signature.extra',
|
||||
},
|
||||
expectedErrorPath: ['token'],
|
||||
},
|
||||
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
|
||||
const result = BinaryDataSignedQueryDto.safeParse(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (expectedErrorPath) {
|
||||
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class BinaryDataQueryDto extends Z.class({
|
||||
id: z
|
||||
.string()
|
||||
.refine((id) => id.includes(':'), {
|
||||
message: 'Missing binary data mode',
|
||||
})
|
||||
.refine(
|
||||
(id) => {
|
||||
const [mode] = id.split(':');
|
||||
return ['database', 'filesystem', 'filesystem-v2', 's3'].includes(mode);
|
||||
},
|
||||
{
|
||||
message: 'Invalid binary data mode',
|
||||
},
|
||||
),
|
||||
action: z.enum(['view', 'download']),
|
||||
fileName: z.string().optional(),
|
||||
mimeType: z.string().optional(),
|
||||
}) {}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class BinaryDataSignedQueryDto extends Z.class({
|
||||
token: z.string().regex(/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/, {
|
||||
message: 'Token must be a valid JWT format',
|
||||
}),
|
||||
}) {}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
import { CreateCredentialResolverDto } from '../create-credential-resolver.dto';
|
||||
|
||||
describe('CreateCredentialResolverDto', () => {
|
||||
describe('Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'basic valid resolver',
|
||||
data: {
|
||||
name: 'Test Resolver',
|
||||
type: 'credential-resolver.test-1.0',
|
||||
config: { test: 'value' },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'resolver with complex config',
|
||||
data: {
|
||||
name: 'AWS Secrets Manager',
|
||||
type: 'credential-resolver.aws-secrets-1.0',
|
||||
config: {
|
||||
region: 'us-east-1',
|
||||
accessKeyId: 'AKIAIOSFODNN7EXAMPLE',
|
||||
secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'resolver with empty config',
|
||||
data: {
|
||||
name: 'Simple Resolver',
|
||||
type: 'credential-resolver.simple-1.0',
|
||||
config: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'resolver with minimum name length',
|
||||
data: {
|
||||
name: 'A',
|
||||
type: 'type',
|
||||
config: {},
|
||||
},
|
||||
},
|
||||
])('should succeed validation for $name', ({ data }) => {
|
||||
const result = CreateCredentialResolverDto.safeParse(data);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.name).toBe(data.name);
|
||||
expect(result.data.type).toBe(data.type);
|
||||
expect(result.data.config).toEqual(data.config);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'missing name',
|
||||
data: {
|
||||
type: 'credential-resolver.test-1.0',
|
||||
config: {},
|
||||
},
|
||||
expectedErrorPath: ['name'],
|
||||
},
|
||||
{
|
||||
name: 'missing type',
|
||||
data: {
|
||||
name: 'Test Resolver',
|
||||
config: {},
|
||||
},
|
||||
expectedErrorPath: ['type'],
|
||||
},
|
||||
{
|
||||
name: 'missing config',
|
||||
data: {
|
||||
name: 'Test Resolver',
|
||||
type: 'credential-resolver.test-1.0',
|
||||
},
|
||||
expectedErrorPath: ['config'],
|
||||
},
|
||||
{
|
||||
name: 'empty name',
|
||||
data: {
|
||||
name: '',
|
||||
type: 'credential-resolver.test-1.0',
|
||||
config: {},
|
||||
},
|
||||
expectedErrorPath: ['name'],
|
||||
},
|
||||
{
|
||||
name: 'whitespace-only name',
|
||||
data: {
|
||||
name: ' ',
|
||||
type: 'credential-resolver.test-1.0',
|
||||
config: {},
|
||||
},
|
||||
expectedErrorPath: ['name'],
|
||||
},
|
||||
{
|
||||
name: 'name too long (>255 chars)',
|
||||
data: {
|
||||
name: 'a'.repeat(256),
|
||||
type: 'credential-resolver.test-1.0',
|
||||
config: {},
|
||||
},
|
||||
expectedErrorPath: ['name'],
|
||||
},
|
||||
{
|
||||
name: 'empty type',
|
||||
data: {
|
||||
name: 'Test Resolver',
|
||||
type: '',
|
||||
config: {},
|
||||
},
|
||||
expectedErrorPath: ['type'],
|
||||
},
|
||||
{
|
||||
name: 'whitespace-only type',
|
||||
data: {
|
||||
name: 'Test Resolver',
|
||||
type: ' ',
|
||||
config: {},
|
||||
},
|
||||
expectedErrorPath: ['type'],
|
||||
},
|
||||
{
|
||||
name: 'type too long (>255 chars)',
|
||||
data: {
|
||||
name: 'Test Resolver',
|
||||
type: 'a'.repeat(256),
|
||||
config: {},
|
||||
},
|
||||
expectedErrorPath: ['type'],
|
||||
},
|
||||
{
|
||||
name: 'config as string',
|
||||
data: {
|
||||
name: 'Test Resolver',
|
||||
type: 'credential-resolver.test-1.0',
|
||||
config: 'invalid',
|
||||
},
|
||||
expectedErrorPath: ['config'],
|
||||
},
|
||||
{
|
||||
name: 'config as array',
|
||||
data: {
|
||||
name: 'Test Resolver',
|
||||
type: 'credential-resolver.test-1.0',
|
||||
config: [],
|
||||
},
|
||||
expectedErrorPath: ['config'],
|
||||
},
|
||||
{
|
||||
name: 'config as null',
|
||||
data: {
|
||||
name: 'Test Resolver',
|
||||
type: 'credential-resolver.test-1.0',
|
||||
config: null,
|
||||
},
|
||||
expectedErrorPath: ['config'],
|
||||
},
|
||||
])('should fail validation for $name', ({ data, expectedErrorPath }) => {
|
||||
const result = CreateCredentialResolverDto.safeParse(data);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (expectedErrorPath && !result.success) {
|
||||
expect(result.error.issues[0].path).toEqual(expectedErrorPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Trimming', () => {
|
||||
test('should trim name', () => {
|
||||
const result = CreateCredentialResolverDto.safeParse({
|
||||
name: ' Test Resolver ',
|
||||
type: 'credential-resolver.test-1.0',
|
||||
config: {},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.name).toBe('Test Resolver');
|
||||
}
|
||||
});
|
||||
|
||||
test('should trim type', () => {
|
||||
const result = CreateCredentialResolverDto.safeParse({
|
||||
name: 'Test Resolver',
|
||||
type: ' credential-resolver.test-1.0 ',
|
||||
config: {},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.type).toBe('credential-resolver.test-1.0');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
import { UpdateCredentialResolverDto } from '../update-credential-resolver.dto';
|
||||
|
||||
describe('UpdateCredentialResolverDto', () => {
|
||||
describe('Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'update only name',
|
||||
data: {
|
||||
name: 'Updated Resolver',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'update only config',
|
||||
data: {
|
||||
config: { prefix: 'updated-' },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'update both name and config',
|
||||
data: {
|
||||
name: 'Updated Resolver',
|
||||
config: { prefix: 'updated-' },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'update with empty config',
|
||||
data: {
|
||||
name: 'Updated Resolver',
|
||||
config: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'update with complex config',
|
||||
data: {
|
||||
config: {
|
||||
region: 'eu-west-1',
|
||||
timeout: 5000,
|
||||
nested: {
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'empty update (all optional)',
|
||||
data: {},
|
||||
},
|
||||
{
|
||||
name: 'minimum name length',
|
||||
data: {
|
||||
name: 'A',
|
||||
},
|
||||
},
|
||||
])('should succeed validation for $name', ({ data }) => {
|
||||
const result = UpdateCredentialResolverDto.safeParse(data);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
if (data.name !== undefined) {
|
||||
expect(result.data.name).toBe(data.name);
|
||||
}
|
||||
if (data.config !== undefined) {
|
||||
expect(result.data.config).toEqual(data.config);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'empty name',
|
||||
data: {
|
||||
name: '',
|
||||
},
|
||||
expectedErrorPath: ['name'],
|
||||
},
|
||||
{
|
||||
name: 'whitespace-only name',
|
||||
data: {
|
||||
name: ' ',
|
||||
},
|
||||
expectedErrorPath: ['name'],
|
||||
},
|
||||
{
|
||||
name: 'name too long (>255 chars)',
|
||||
data: {
|
||||
name: 'a'.repeat(256),
|
||||
},
|
||||
expectedErrorPath: ['name'],
|
||||
},
|
||||
{
|
||||
name: 'config as string',
|
||||
data: {
|
||||
config: 'invalid',
|
||||
},
|
||||
expectedErrorPath: ['config'],
|
||||
},
|
||||
{
|
||||
name: 'config as array',
|
||||
data: {
|
||||
config: [],
|
||||
},
|
||||
expectedErrorPath: ['config'],
|
||||
},
|
||||
{
|
||||
name: 'config as null',
|
||||
data: {
|
||||
config: null,
|
||||
},
|
||||
expectedErrorPath: ['config'],
|
||||
},
|
||||
{
|
||||
name: 'name as number',
|
||||
data: {
|
||||
name: 123,
|
||||
},
|
||||
expectedErrorPath: ['name'],
|
||||
},
|
||||
{
|
||||
name: 'name as object',
|
||||
data: {
|
||||
name: { value: 'test' },
|
||||
},
|
||||
expectedErrorPath: ['name'],
|
||||
},
|
||||
])('should fail validation for $name', ({ data, expectedErrorPath }) => {
|
||||
const result = UpdateCredentialResolverDto.safeParse(data);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (expectedErrorPath && !result.success) {
|
||||
expect(result.error.issues[0].path).toEqual(expectedErrorPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Trimming', () => {
|
||||
test('should trim name when provided', () => {
|
||||
const result = UpdateCredentialResolverDto.safeParse({
|
||||
name: ' Updated Resolver ',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.name).toBe('Updated Resolver');
|
||||
}
|
||||
});
|
||||
|
||||
test('should not affect config', () => {
|
||||
const result = UpdateCredentialResolverDto.safeParse({
|
||||
config: { key: ' value with spaces ' },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.config).toEqual({ key: ' value with spaces ' });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import {
|
||||
credentialResolverNameSchema,
|
||||
credentialResolverConfigSchema,
|
||||
credentialResolverTypeNameSchema,
|
||||
} from '../../schemas/credential-resolver.schema';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class CreateCredentialResolverDto extends Z.class({
|
||||
name: credentialResolverNameSchema,
|
||||
type: credentialResolverTypeNameSchema,
|
||||
config: credentialResolverConfigSchema,
|
||||
}) {}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
credentialResolverNameSchema,
|
||||
credentialResolverConfigSchema,
|
||||
credentialResolverTypeNameSchema,
|
||||
} from '../../schemas/credential-resolver.schema';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class UpdateCredentialResolverDto extends Z.class({
|
||||
type: credentialResolverTypeNameSchema.optional(),
|
||||
name: credentialResolverNameSchema.optional(),
|
||||
config: credentialResolverConfigSchema.optional(),
|
||||
clearCredentials: z.boolean().optional(),
|
||||
}) {}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { CreateCredentialDto } from '../create-credential.dto';
|
||||
|
||||
describe('CreateCredentialDto', () => {
|
||||
describe('Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'with required fields',
|
||||
request: {
|
||||
name: 'My API Credentials',
|
||||
type: 'apiKey',
|
||||
data: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'with optional projectId',
|
||||
request: {
|
||||
name: 'My API Credentials',
|
||||
type: 'apiKey',
|
||||
data: {
|
||||
apiKey: '123',
|
||||
isAdmin: true,
|
||||
},
|
||||
projectId: 'project123',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'with data object',
|
||||
request: {
|
||||
name: 'My API Credentials',
|
||||
type: 'oauth2',
|
||||
data: {
|
||||
clientId: '123',
|
||||
clientSecret: 'secret',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'longer type',
|
||||
request: {
|
||||
name: 'LinkedIn Community Management OAuth2 API',
|
||||
type: 'linkedInCommunityManagementOAuth2Api',
|
||||
data: {
|
||||
clientId: '123',
|
||||
clientSecret: 'secret',
|
||||
},
|
||||
},
|
||||
},
|
||||
])('should validate $name', ({ request }) => {
|
||||
const result = CreateCredentialDto.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test('should not strip out properties from the data object', () => {
|
||||
const result = CreateCredentialDto.safeParse({
|
||||
name: 'My API Credentials',
|
||||
type: 'apiKey',
|
||||
data: {
|
||||
apiKey: '123',
|
||||
otherProperty: 'otherValue',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual({
|
||||
name: 'My API Credentials',
|
||||
type: 'apiKey',
|
||||
data: {
|
||||
apiKey: '123',
|
||||
otherProperty: 'otherValue',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'missing name',
|
||||
request: {
|
||||
type: 'apiKey',
|
||||
data: {},
|
||||
},
|
||||
expectedErrorPath: ['name'],
|
||||
},
|
||||
{
|
||||
name: 'empty name',
|
||||
request: {
|
||||
name: '',
|
||||
type: 'apiKey',
|
||||
data: {},
|
||||
},
|
||||
expectedErrorPath: ['name'],
|
||||
},
|
||||
{
|
||||
name: 'name too long',
|
||||
request: {
|
||||
name: 'a'.repeat(129),
|
||||
type: 'apiKey',
|
||||
data: {},
|
||||
},
|
||||
expectedErrorPath: ['name'],
|
||||
},
|
||||
{
|
||||
name: 'missing type',
|
||||
request: {
|
||||
name: 'My API Credentials',
|
||||
data: {},
|
||||
},
|
||||
expectedErrorPath: ['type'],
|
||||
},
|
||||
{
|
||||
name: 'empty type',
|
||||
request: {
|
||||
name: 'My API Credentials',
|
||||
type: '',
|
||||
data: {},
|
||||
},
|
||||
expectedErrorPath: ['type'],
|
||||
},
|
||||
{
|
||||
name: 'type too long',
|
||||
request: {
|
||||
name: 'My API Credentials',
|
||||
type: 'a'.repeat(129),
|
||||
data: {},
|
||||
},
|
||||
expectedErrorPath: ['type'],
|
||||
},
|
||||
{
|
||||
name: 'missing data',
|
||||
request: {
|
||||
name: 'My API Credentials',
|
||||
type: 'apiKey',
|
||||
},
|
||||
expectedErrorPath: ['data'],
|
||||
},
|
||||
{
|
||||
name: 'invalid data type',
|
||||
request: {
|
||||
name: 'My API Credentials',
|
||||
type: 'apiKey',
|
||||
data: 'invalid',
|
||||
},
|
||||
expectedErrorPath: ['data'],
|
||||
},
|
||||
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
|
||||
const result = CreateCredentialDto.safeParse(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (expectedErrorPath) {
|
||||
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import { CredentialsGetManyRequestQuery } from '../credentials-get-many-request.dto';
|
||||
|
||||
describe('CredentialsGetManyRequestQuery', () => {
|
||||
describe('should pass validation', () => {
|
||||
it('with empty object', () => {
|
||||
const data = {};
|
||||
|
||||
const result = CredentialsGetManyRequestQuery.safeParse(data);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ field: 'includeScopes', value: 'true' },
|
||||
{ field: 'includeScopes', value: 'false' },
|
||||
{ field: 'includeData', value: 'true' },
|
||||
{ field: 'includeData', value: 'false' },
|
||||
{ field: 'externalSecretsStore', value: 'testProviderKey' },
|
||||
])('with $field set to $value', ({ field, value }) => {
|
||||
const data = { [field]: value };
|
||||
|
||||
const result = CredentialsGetManyRequestQuery.safeParse(data);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('with both parameters set', () => {
|
||||
const data = {
|
||||
includeScopes: 'true',
|
||||
includeData: 'true',
|
||||
};
|
||||
|
||||
const result = CredentialsGetManyRequestQuery.safeParse(data);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('should fail validation', () => {
|
||||
test.each([
|
||||
{ field: 'includeScopes', value: true },
|
||||
{ field: 'includeScopes', value: false },
|
||||
{ field: 'includeScopes', value: 'invalid' },
|
||||
{ field: 'includeData', value: true },
|
||||
{ field: 'includeData', value: false },
|
||||
{ field: 'includeData', value: 'invalid' },
|
||||
{ field: 'externalSecretsStore', value: true },
|
||||
{ field: 'externalSecretsStore', value: false },
|
||||
{ field: 'externalSecretsStore', value: 123 },
|
||||
])('with invalid value $value for $field', ({ field, value }) => {
|
||||
const data = { [field]: value };
|
||||
|
||||
const result = CredentialsGetManyRequestQuery.safeParse(data);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.issues[0].path[0]).toBe(field);
|
||||
});
|
||||
});
|
||||
});
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import { CredentialsGetOneRequestQuery } from '../credentials-get-one-request.dto';
|
||||
|
||||
describe('CredentialsGetManyRequestQuery', () => {
|
||||
describe('should pass validation', () => {
|
||||
it('with empty object', () => {
|
||||
const data = {};
|
||||
|
||||
const result = CredentialsGetOneRequestQuery.safeParse(data);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// defaults to false
|
||||
expect(result.data?.includeData).toBe(false);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ field: 'includeData', value: 'true' },
|
||||
{ field: 'includeData', value: 'false' },
|
||||
])('with $field set to $value', ({ field, value }) => {
|
||||
const data = { [field]: value };
|
||||
|
||||
const result = CredentialsGetOneRequestQuery.safeParse(data);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('with both parameters set', () => {
|
||||
const data = {
|
||||
includeScopes: 'true',
|
||||
includeData: 'true',
|
||||
};
|
||||
|
||||
const result = CredentialsGetOneRequestQuery.safeParse(data);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('should fail validation', () => {
|
||||
test.each([
|
||||
{ field: 'includeData', value: true },
|
||||
{ field: 'includeData', value: false },
|
||||
{ field: 'includeData', value: 'invalid' },
|
||||
])('with invalid value $value for $field', ({ field, value }) => {
|
||||
const data = { [field]: value };
|
||||
|
||||
const result = CredentialsGetOneRequestQuery.safeParse(data);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.issues[0].path[0]).toBe(field);
|
||||
});
|
||||
});
|
||||
});
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { GenerateCredentialNameRequestQuery } from '../generate-credential-name.dto';
|
||||
|
||||
describe('GenerateCredentialNameRequestQuery', () => {
|
||||
describe('should pass validation', () => {
|
||||
it('with empty object', () => {
|
||||
const data = {};
|
||||
|
||||
const result = GenerateCredentialNameRequestQuery.safeParse(data);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.name).toBeUndefined();
|
||||
});
|
||||
|
||||
it('with valid name', () => {
|
||||
const data = { name: 'My Credential' };
|
||||
|
||||
const result = GenerateCredentialNameRequestQuery.safeParse(data);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.name).toBe('My Credential');
|
||||
});
|
||||
});
|
||||
|
||||
describe('should fail validation', () => {
|
||||
test.each([
|
||||
{ field: 'name', value: 123 },
|
||||
{ field: 'name', value: true },
|
||||
{ field: 'name', value: {} },
|
||||
{ field: 'name', value: [] },
|
||||
])('with invalid value $value for $field', ({ field, value }) => {
|
||||
const data = { [field]: value };
|
||||
|
||||
const result = GenerateCredentialNameRequestQuery.safeParse(data);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.issues[0].path[0]).toBe(field);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class CreateCredentialDto extends Z.class({
|
||||
name: z.string().min(1).max(128),
|
||||
type: z.string().min(1).max(128),
|
||||
data: z.record(z.string(), z.unknown()),
|
||||
projectId: z.string().optional(),
|
||||
uiContext: z.string().optional(),
|
||||
isGlobal: z.boolean().optional(),
|
||||
isResolvable: z.boolean().optional(),
|
||||
}) {}
|
||||
@@ -0,0 +1,37 @@
|
||||
import z from 'zod';
|
||||
|
||||
import { booleanFromString } from '../../schemas/boolean-from-string';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class CredentialsGetManyRequestQuery extends Z.class({
|
||||
/**
|
||||
* Adds the `scopes` field to each credential which includes all scopes the
|
||||
* requesting user has in relation to the credential, e.g.
|
||||
* ['credential:read', 'credential:update']
|
||||
*/
|
||||
includeScopes: booleanFromString.optional(),
|
||||
|
||||
/**
|
||||
* Adds the decrypted `data` field to each credential.
|
||||
*
|
||||
* It only does this for credentials for which the user has the
|
||||
* `credential:update` scope.
|
||||
*
|
||||
* This switches `includeScopes` to true to be able to check for the scopes
|
||||
*/
|
||||
includeData: booleanFromString.optional(),
|
||||
|
||||
onlySharedWithMe: booleanFromString.optional(),
|
||||
|
||||
/**
|
||||
* Includes global credentials (credentials available to all users).
|
||||
* Defaults to false.
|
||||
*/
|
||||
includeGlobal: booleanFromString.optional().default('false'),
|
||||
|
||||
/**
|
||||
* Filters credentials to only include those that are using a specific external secrets provider.
|
||||
* The value should be the `providerKey` of the external secrets store.
|
||||
*/
|
||||
externalSecretsStore: z.string().optional(),
|
||||
}) {}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { booleanFromString } from '../../schemas/boolean-from-string';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class CredentialsGetOneRequestQuery extends Z.class({
|
||||
/**
|
||||
* Adds the decrypted `data` field to each credential.
|
||||
*
|
||||
* It only does this for credentials for which the user has the
|
||||
* `credential:update` scope.
|
||||
*/
|
||||
includeData: booleanFromString.optional().default('false'),
|
||||
}) {}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class GenerateCredentialNameRequestQuery extends Z.class({
|
||||
name: z.string().optional(),
|
||||
}) {}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { dataTableCreateColumnSchema } from '../../schemas/data-table.schema';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class AddDataTableColumnDto extends Z.class(dataTableCreateColumnSchema.shape) {}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
dataTableColumnNameSchema,
|
||||
dataTableColumnValueSchema,
|
||||
insertRowReturnType,
|
||||
} from '../../schemas/data-table.schema';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class AddDataTableRowsDto extends Z.class({
|
||||
data: z.array(z.record(dataTableColumnNameSchema, dataTableColumnValueSchema)),
|
||||
returnType: insertRowReturnType.optional().default('count'),
|
||||
}) {}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
dataTableColumnNameSchema,
|
||||
dataTableColumnTypeSchema,
|
||||
} from '../../schemas/data-table.schema';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class CreateDataTableColumnDto extends Z.class({
|
||||
name: dataTableColumnNameSchema,
|
||||
type: dataTableColumnTypeSchema,
|
||||
csvColumnName: z.string().optional(),
|
||||
}) {}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CreateDataTableColumnDto } from './create-data-table-column.dto';
|
||||
import { dataTableNameSchema } from '../../schemas/data-table.schema';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class CreateDataTableDto extends Z.class({
|
||||
name: dataTableNameSchema,
|
||||
columns: z.array(CreateDataTableColumnDto.schema),
|
||||
fileId: z.string().optional(),
|
||||
hasHeaders: z.boolean().optional(),
|
||||
}) {}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { jsonParse } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { dataTableFilterSchema } from '../../schemas/data-table-filter.schema';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
const dataTableFilterQueryValidator = z.string().transform((val, ctx) => {
|
||||
if (!val) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Filter is required for delete operations',
|
||||
path: ['filter'],
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = jsonParse(val);
|
||||
try {
|
||||
// Parse with the schema which applies defaults
|
||||
const result = dataTableFilterSchema.parse(parsed);
|
||||
// Ensure filters array is not empty
|
||||
if (!result.filters || result.filters.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'At least one filter condition is required for delete operations',
|
||||
path: ['filter'],
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Invalid filter fields',
|
||||
path: ['filter'],
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
} catch (e) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Invalid filter format',
|
||||
path: ['filter'],
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
});
|
||||
|
||||
const booleanValidator = z
|
||||
.union([z.string(), z.boolean()])
|
||||
.optional()
|
||||
.transform((val) => {
|
||||
if (typeof val === 'string') {
|
||||
return val === 'true';
|
||||
}
|
||||
return val ?? false;
|
||||
});
|
||||
|
||||
const deleteDataTableRowsShape = {
|
||||
filter: dataTableFilterQueryValidator,
|
||||
returnData: booleanValidator,
|
||||
dryRun: booleanValidator,
|
||||
};
|
||||
|
||||
export class DeleteDataTableRowsDto extends Z.class(deleteDataTableRowsShape) {}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
const booleanValidator = z
|
||||
.union([z.string(), z.boolean()])
|
||||
.optional()
|
||||
.transform((val) => {
|
||||
if (typeof val === 'string') {
|
||||
return val === 'true';
|
||||
}
|
||||
return val ?? true;
|
||||
});
|
||||
|
||||
export class DownloadDataTableCsvQueryDto extends Z.class({
|
||||
includeSystemColumns: booleanValidator,
|
||||
}) {}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { jsonParse } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { dataTableFilterSchema } from '../../schemas/data-table-filter.schema';
|
||||
import { dataTableColumnNameSchema } from '../../schemas/data-table.schema';
|
||||
import { Z } from '../../zod-class';
|
||||
import { paginationSchema, publicApiPaginationSchema } from '../pagination/pagination.dto';
|
||||
|
||||
const filterValidator = z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val, ctx) => {
|
||||
if (!val) return undefined;
|
||||
try {
|
||||
const parsed: unknown = jsonParse(val);
|
||||
try {
|
||||
return dataTableFilterSchema.parse(parsed);
|
||||
} catch (e) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Invalid filter fields',
|
||||
path: ['filter'],
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
} catch (e) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Invalid filter format',
|
||||
path: ['filter'],
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
});
|
||||
|
||||
const sortByValidator = z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val, ctx) => {
|
||||
if (val === undefined) return val;
|
||||
|
||||
if (!val.includes(':')) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Invalid sort format, expected <columnName>:<asc/desc>',
|
||||
path: ['sort'],
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
|
||||
let [column, direction] = val.split(':');
|
||||
|
||||
try {
|
||||
column = dataTableColumnNameSchema.parse(column);
|
||||
} catch (e) {
|
||||
const errorMessage =
|
||||
e instanceof z.ZodError ? e.errors[0]?.message : 'Invalid sort columnName';
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: errorMessage,
|
||||
path: ['sortBy'],
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
|
||||
direction = direction?.toUpperCase();
|
||||
if (direction !== 'ASC' && direction !== 'DESC') {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Invalid sort direction',
|
||||
path: ['sort'],
|
||||
});
|
||||
|
||||
return z.NEVER;
|
||||
}
|
||||
return [column, direction] as const;
|
||||
});
|
||||
|
||||
export class ListDataTableContentQueryDto extends Z.class({
|
||||
take: paginationSchema.take.optional(),
|
||||
skip: paginationSchema.skip.optional(),
|
||||
filter: filterValidator.optional(),
|
||||
sortBy: sortByValidator.optional(),
|
||||
search: z.string().optional(),
|
||||
}) {}
|
||||
|
||||
export class PublicApiListDataTableContentQueryDto extends Z.class({
|
||||
limit: publicApiPaginationSchema.limit,
|
||||
offset: publicApiPaginationSchema.offset,
|
||||
filter: filterValidator.optional(),
|
||||
sortBy: sortByValidator.optional(),
|
||||
search: z.string().optional(),
|
||||
}) {}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { jsonParse } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
import { paginationSchema, publicApiPaginationSchema } from '../pagination/pagination.dto';
|
||||
|
||||
const VALID_SORT_OPTIONS = [
|
||||
'name:asc',
|
||||
'name:desc',
|
||||
'createdAt:asc',
|
||||
'createdAt:desc',
|
||||
'updatedAt:asc',
|
||||
'updatedAt:desc',
|
||||
'size:asc',
|
||||
'size:desc',
|
||||
] as const;
|
||||
|
||||
export type ListDataTableQuerySortOptions = (typeof VALID_SORT_OPTIONS)[number];
|
||||
|
||||
const FILTER_OPTIONS = {
|
||||
id: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
name: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
projectId: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
// todo: can probably include others here as well?
|
||||
};
|
||||
|
||||
// Filter schema - only allow specific properties
|
||||
const filterSchema = z.object(FILTER_OPTIONS).strict();
|
||||
// ---------------------
|
||||
// Parameter Validators
|
||||
// ---------------------
|
||||
|
||||
// Filter parameter validation
|
||||
const filterValidator = z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val, ctx) => {
|
||||
if (!val) return undefined;
|
||||
try {
|
||||
const parsed: unknown = jsonParse(val);
|
||||
try {
|
||||
return filterSchema.parse(parsed);
|
||||
} catch (e) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Invalid filter fields',
|
||||
path: ['filter'],
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
} catch (e) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Invalid filter format',
|
||||
path: ['filter'],
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
});
|
||||
|
||||
// SortBy parameter validation
|
||||
const sortByValidator = z
|
||||
.enum(VALID_SORT_OPTIONS, { message: `sortBy must be one of: ${VALID_SORT_OPTIONS.join(', ')}` })
|
||||
.optional();
|
||||
|
||||
export class ListDataTableQueryDto extends Z.class({
|
||||
...paginationSchema,
|
||||
filter: filterValidator,
|
||||
sortBy: sortByValidator,
|
||||
}) {}
|
||||
|
||||
export class PublicApiListDataTableQueryDto extends Z.class({
|
||||
...publicApiPaginationSchema,
|
||||
filter: filterValidator,
|
||||
sortBy: sortByValidator,
|
||||
}) {}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class MoveDataTableColumnDto extends Z.class({
|
||||
targetIndex: z.number().int().nonnegative(),
|
||||
}) {}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { dataTableColumnNameSchema } from '../../schemas/data-table.schema';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class RenameDataTableColumnDto extends Z.class({
|
||||
name: dataTableColumnNameSchema,
|
||||
}) {}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { dataTableFilterSchema } from '../../schemas/data-table-filter.schema';
|
||||
import {
|
||||
dataTableColumnNameSchema,
|
||||
dataTableColumnValueSchema,
|
||||
} from '../../schemas/data-table.schema';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
const updateFilterSchema = dataTableFilterSchema.refine((filter) => filter.filters.length > 0, {
|
||||
message: 'filter must not be empty',
|
||||
});
|
||||
|
||||
const updateDataTableRowShape = {
|
||||
filter: updateFilterSchema,
|
||||
data: z
|
||||
.record(dataTableColumnNameSchema, dataTableColumnValueSchema)
|
||||
.refine((obj) => Object.keys(obj).length > 0, {
|
||||
message: 'data must not be empty',
|
||||
}),
|
||||
returnData: z.boolean().optional().default(false),
|
||||
dryRun: z.boolean().optional().default(false),
|
||||
};
|
||||
|
||||
export class UpdateDataTableRowDto extends Z.class(updateDataTableRowShape) {}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { dataTableNameSchema } from '../../schemas/data-table.schema';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class UpdateDataTableDto extends Z.class({
|
||||
name: dataTableNameSchema,
|
||||
}) {}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { dataTableFilterSchema } from '../../schemas/data-table-filter.schema';
|
||||
import {
|
||||
dataTableColumnNameSchema,
|
||||
dataTableColumnValueSchema,
|
||||
} from '../../schemas/data-table.schema';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
const upsertFilterSchema = dataTableFilterSchema.refine((filter) => filter.filters.length > 0, {
|
||||
message: 'filter must not be empty',
|
||||
});
|
||||
|
||||
const upsertDataTableRowShape = {
|
||||
filter: upsertFilterSchema,
|
||||
data: z
|
||||
.record(dataTableColumnNameSchema, dataTableColumnValueSchema)
|
||||
.refine((obj) => Object.keys(obj).length > 0, {
|
||||
message: 'data must not be empty',
|
||||
}),
|
||||
returnData: z.boolean().optional().default(false),
|
||||
dryRun: z.boolean().optional().default(false),
|
||||
};
|
||||
|
||||
export class UpsertDataTableRowDto extends Z.class(upsertDataTableRowShape) {}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
import { ActionResultRequestDto } from '../action-result-request.dto';
|
||||
|
||||
describe('ActionResultRequestDto', () => {
|
||||
const baseValidRequest = {
|
||||
path: '/test/path',
|
||||
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
|
||||
handler: 'testHandler',
|
||||
currentNodeParameters: {},
|
||||
};
|
||||
|
||||
describe('Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'minimal valid request',
|
||||
request: baseValidRequest,
|
||||
},
|
||||
{
|
||||
name: 'request with payload',
|
||||
request: {
|
||||
...baseValidRequest,
|
||||
payload: { key: 'value' },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'request with credentials',
|
||||
request: {
|
||||
...baseValidRequest,
|
||||
credentials: { testCredential: { id: 'cred1', name: 'Test Cred' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'request with current node parameters',
|
||||
request: {
|
||||
...baseValidRequest,
|
||||
currentNodeParameters: { param1: 'value1' },
|
||||
},
|
||||
},
|
||||
])('should validate $name', ({ request }) => {
|
||||
const result = ActionResultRequestDto.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'missing path',
|
||||
request: {
|
||||
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
|
||||
handler: 'testHandler',
|
||||
},
|
||||
expectedErrorPath: ['path'],
|
||||
},
|
||||
{
|
||||
name: 'missing handler',
|
||||
request: {
|
||||
path: '/test/path',
|
||||
currentNodeParameters: {},
|
||||
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
|
||||
},
|
||||
expectedErrorPath: ['handler'],
|
||||
},
|
||||
{
|
||||
name: 'invalid node version',
|
||||
request: {
|
||||
...baseValidRequest,
|
||||
nodeTypeAndVersion: { name: 'TestNode', version: 0 },
|
||||
},
|
||||
expectedErrorPath: ['nodeTypeAndVersion', 'version'],
|
||||
},
|
||||
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
|
||||
const result = ActionResultRequestDto.safeParse(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (expectedErrorPath) {
|
||||
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
import { OptionsRequestDto } from '../options-request.dto';
|
||||
|
||||
describe('OptionsRequestDto', () => {
|
||||
const baseValidRequest = {
|
||||
path: '/test/path',
|
||||
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
|
||||
currentNodeParameters: {},
|
||||
};
|
||||
|
||||
describe('Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'minimal valid request',
|
||||
request: baseValidRequest,
|
||||
},
|
||||
{
|
||||
name: 'request with method name',
|
||||
request: {
|
||||
...baseValidRequest,
|
||||
methodName: 'testMethod',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'request with load options',
|
||||
request: {
|
||||
...baseValidRequest,
|
||||
loadOptions: {
|
||||
routing: {
|
||||
operations: { someOperation: 'test' },
|
||||
output: { someOutput: 'test' },
|
||||
request: { someRequest: 'test' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'request with credentials',
|
||||
request: {
|
||||
...baseValidRequest,
|
||||
credentials: { testCredential: { id: 'cred1', name: 'Test Cred' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'request with current node parameters',
|
||||
request: {
|
||||
...baseValidRequest,
|
||||
currentNodeParameters: { param1: 'value1' },
|
||||
},
|
||||
},
|
||||
])('should validate $name', ({ request }) => {
|
||||
const result = OptionsRequestDto.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'missing path',
|
||||
request: {
|
||||
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
|
||||
},
|
||||
expectedErrorPath: ['path'],
|
||||
},
|
||||
{
|
||||
name: 'missing node type and version',
|
||||
request: {
|
||||
path: '/test/path',
|
||||
},
|
||||
expectedErrorPath: ['nodeTypeAndVersion'],
|
||||
},
|
||||
{
|
||||
name: 'invalid node version',
|
||||
request: {
|
||||
...baseValidRequest,
|
||||
nodeTypeAndVersion: { name: 'TestNode', version: 0 },
|
||||
},
|
||||
expectedErrorPath: ['nodeTypeAndVersion', 'version'],
|
||||
},
|
||||
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
|
||||
const result = OptionsRequestDto.safeParse(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (expectedErrorPath) {
|
||||
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
import { ResourceLocatorRequestDto } from '../resource-locator-request.dto';
|
||||
|
||||
describe('ResourceLocatorRequestDto', () => {
|
||||
const baseValidRequest = {
|
||||
path: '/test/path',
|
||||
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
|
||||
methodName: 'testMethod',
|
||||
currentNodeParameters: {},
|
||||
};
|
||||
|
||||
describe('Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'minimal valid request',
|
||||
request: baseValidRequest,
|
||||
},
|
||||
{
|
||||
name: 'request with filter',
|
||||
request: {
|
||||
...baseValidRequest,
|
||||
filter: 'testFilter',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'request with pagination token',
|
||||
request: {
|
||||
...baseValidRequest,
|
||||
paginationToken: 'token123',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'request with credentials',
|
||||
request: {
|
||||
...baseValidRequest,
|
||||
credentials: { testCredential: { id: 'cred1', name: 'Test Cred' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'request with current node parameters',
|
||||
request: {
|
||||
...baseValidRequest,
|
||||
currentNodeParameters: { param1: 'value1' },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'request with a semver node version',
|
||||
request: {
|
||||
...baseValidRequest,
|
||||
nodeTypeAndVersion: { name: 'TestNode', version: 1.1 },
|
||||
},
|
||||
},
|
||||
])('should validate $name', ({ request }) => {
|
||||
const result = ResourceLocatorRequestDto.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'missing path',
|
||||
request: {
|
||||
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
|
||||
methodName: 'testMethod',
|
||||
},
|
||||
expectedErrorPath: ['path'],
|
||||
},
|
||||
{
|
||||
name: 'missing method name',
|
||||
request: {
|
||||
path: '/test/path',
|
||||
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
|
||||
currentNodeParameters: {},
|
||||
},
|
||||
expectedErrorPath: ['methodName'],
|
||||
},
|
||||
{
|
||||
name: 'invalid node version',
|
||||
request: {
|
||||
...baseValidRequest,
|
||||
nodeTypeAndVersion: { name: 'TestNode', version: 0 },
|
||||
},
|
||||
expectedErrorPath: ['nodeTypeAndVersion', 'version'],
|
||||
},
|
||||
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
|
||||
const result = ResourceLocatorRequestDto.safeParse(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (expectedErrorPath) {
|
||||
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
import { ResourceMapperFieldsRequestDto } from '../resource-mapper-fields-request.dto';
|
||||
|
||||
describe('ResourceMapperFieldsRequestDto', () => {
|
||||
const baseValidRequest = {
|
||||
path: '/test/path',
|
||||
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
|
||||
methodName: 'testMethod',
|
||||
currentNodeParameters: {},
|
||||
};
|
||||
|
||||
describe('Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'minimal valid request',
|
||||
request: baseValidRequest,
|
||||
},
|
||||
{
|
||||
name: 'request with credentials',
|
||||
request: {
|
||||
...baseValidRequest,
|
||||
credentials: { testCredential: { id: 'cred1', name: 'Test Cred' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'request with current node parameters',
|
||||
request: {
|
||||
...baseValidRequest,
|
||||
currentNodeParameters: { param1: 'value1' },
|
||||
},
|
||||
},
|
||||
])('should validate $name', ({ request }) => {
|
||||
const result = ResourceMapperFieldsRequestDto.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'missing path',
|
||||
request: {
|
||||
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
|
||||
methodName: 'testMethod',
|
||||
},
|
||||
expectedErrorPath: ['path'],
|
||||
},
|
||||
{
|
||||
name: 'missing method name',
|
||||
request: {
|
||||
path: '/test/path',
|
||||
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
|
||||
currentNodeParameters: {},
|
||||
},
|
||||
expectedErrorPath: ['methodName'],
|
||||
},
|
||||
{
|
||||
name: 'invalid node version',
|
||||
request: {
|
||||
...baseValidRequest,
|
||||
nodeTypeAndVersion: { name: 'TestNode', version: 0 },
|
||||
},
|
||||
expectedErrorPath: ['nodeTypeAndVersion', 'version'],
|
||||
},
|
||||
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
|
||||
const result = ResourceMapperFieldsRequestDto.safeParse(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (expectedErrorPath) {
|
||||
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { IDataObject } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { BaseDynamicParametersRequestDto } from './base-dynamic-parameters-request.dto';
|
||||
|
||||
export class ActionResultRequestDto extends BaseDynamicParametersRequestDto.extend({
|
||||
handler: z.string(),
|
||||
payload: z
|
||||
.union([z.object({}).catchall(z.any()) satisfies z.ZodType<IDataObject>, z.string()])
|
||||
.optional(),
|
||||
}) {}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import type { INodeCredentials, INodeParameters, INodeTypeNameVersion } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { nodeVersionSchema } from '../../schemas/node-version.schema';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class BaseDynamicParametersRequestDto extends Z.class({
|
||||
path: z.string(),
|
||||
nodeTypeAndVersion: z.object({
|
||||
name: z.string(),
|
||||
version: nodeVersionSchema,
|
||||
}) satisfies z.ZodType<INodeTypeNameVersion>,
|
||||
currentNodeParameters: z.record(z.string(), z.any()) satisfies z.ZodType<INodeParameters>,
|
||||
methodName: z.string().optional(),
|
||||
credentials: z.record(z.string(), z.any()).optional() satisfies z.ZodType<
|
||||
INodeCredentials | undefined
|
||||
>,
|
||||
projectId: z.string().optional(),
|
||||
workflowId: z.string().optional(),
|
||||
}) {}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ILoadOptions } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { BaseDynamicParametersRequestDto } from './base-dynamic-parameters-request.dto';
|
||||
|
||||
export class OptionsRequestDto extends BaseDynamicParametersRequestDto.extend({
|
||||
loadOptions: z
|
||||
.object({
|
||||
routing: z
|
||||
.object({
|
||||
operations: z.any().optional(),
|
||||
output: z.any().optional(),
|
||||
request: z.any().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional() as z.ZodType<ILoadOptions | undefined>,
|
||||
}) {}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { BaseDynamicParametersRequestDto } from './base-dynamic-parameters-request.dto';
|
||||
|
||||
export class ResourceLocatorRequestDto extends BaseDynamicParametersRequestDto.extend({
|
||||
methodName: z.string(),
|
||||
filter: z.string().optional(),
|
||||
paginationToken: z.string().optional(),
|
||||
}) {}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { BaseDynamicParametersRequestDto } from './base-dynamic-parameters-request.dto';
|
||||
|
||||
export class ResourceMapperFieldsRequestDto extends BaseDynamicParametersRequestDto.extend({
|
||||
methodName: z.string(),
|
||||
}) {}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { booleanFromString } from '../../schemas/boolean-from-string';
|
||||
|
||||
export const ExecutionRedactionQueryDtoSchema = z
|
||||
.object({
|
||||
redactExecutionData: booleanFromString.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type ExecutionRedactionQueryDto = z.output<typeof ExecutionRedactionQueryDtoSchema>;
|
||||
@@ -0,0 +1,65 @@
|
||||
import { CreateFolderDto } from '../create-folder.dto';
|
||||
|
||||
describe('CreateFolderDto', () => {
|
||||
describe('Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'name without parentId',
|
||||
request: {
|
||||
name: 'test',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'name and parentFolderId',
|
||||
request: {
|
||||
name: 'test',
|
||||
parentFolderId: '2Hw01NJ7biAj_LU6',
|
||||
},
|
||||
},
|
||||
])('should validate $name', ({ request }) => {
|
||||
const result = CreateFolderDto.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'missing name',
|
||||
request: {},
|
||||
expectedErrorPath: ['name'],
|
||||
},
|
||||
{
|
||||
name: 'empty name',
|
||||
request: {
|
||||
name: '',
|
||||
},
|
||||
expectedErrorPath: ['name'],
|
||||
},
|
||||
|
||||
{
|
||||
name: 'parentFolderId and no name',
|
||||
request: {
|
||||
parentFolderId: '',
|
||||
},
|
||||
expectedErrorPath: ['name'],
|
||||
},
|
||||
{
|
||||
name: 'invalid parentFolderId',
|
||||
request: {
|
||||
name: 'test',
|
||||
parentFolderId: 1,
|
||||
},
|
||||
expectedErrorPath: ['parentFolderId'],
|
||||
},
|
||||
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
|
||||
const result = CreateFolderDto.safeParse(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (expectedErrorPath) {
|
||||
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import { ListFolderQueryDto } from '../list-folder-query.dto';
|
||||
|
||||
const DEFAULT_PAGINATION = { skip: 0, take: 10 };
|
||||
|
||||
describe('ListFolderQueryDto', () => {
|
||||
describe('Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'empty object (no filters)',
|
||||
request: {},
|
||||
parsedResult: DEFAULT_PAGINATION,
|
||||
},
|
||||
{
|
||||
name: 'valid filter',
|
||||
request: {
|
||||
filter: '{"name":"test"}',
|
||||
},
|
||||
parsedResult: {
|
||||
...DEFAULT_PAGINATION,
|
||||
filter: { name: 'test' },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'filter with parentFolderId',
|
||||
request: {
|
||||
filter: '{"parentFolderId":"abc123"}',
|
||||
},
|
||||
parsedResult: {
|
||||
...DEFAULT_PAGINATION,
|
||||
filter: { parentFolderId: 'abc123' },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'filter with name and parentFolderId',
|
||||
request: {
|
||||
filter: '{"name":"test","parentFolderId":"abc123"}',
|
||||
},
|
||||
parsedResult: {
|
||||
...DEFAULT_PAGINATION,
|
||||
filter: { parentFolderId: 'abc123', name: 'test' },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'filter with tags array',
|
||||
request: {
|
||||
filter: '{"tags":["important","archived"]}',
|
||||
},
|
||||
parsedResult: {
|
||||
...DEFAULT_PAGINATION,
|
||||
filter: { tags: ['important', 'archived'] },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'filter with empty tags array',
|
||||
request: {
|
||||
filter: '{"tags":[]}',
|
||||
},
|
||||
parsedResult: {
|
||||
...DEFAULT_PAGINATION,
|
||||
filter: { tags: [] },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'filter with all properties',
|
||||
request: {
|
||||
filter: '{"name":"test","parentFolderId":"abc123","tags":["important"]}',
|
||||
},
|
||||
parsedResult: {
|
||||
...DEFAULT_PAGINATION,
|
||||
filter: { tags: ['important'], name: 'test', parentFolderId: 'abc123' },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'valid select',
|
||||
request: {
|
||||
select: '["id","name"]',
|
||||
},
|
||||
parsedResult: {
|
||||
...DEFAULT_PAGINATION,
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'valid sortBy',
|
||||
request: {
|
||||
sortBy: 'name:asc',
|
||||
},
|
||||
parsedResult: {
|
||||
...DEFAULT_PAGINATION,
|
||||
sortBy: 'name:asc',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'valid skip and take',
|
||||
request: {
|
||||
skip: '0',
|
||||
take: '20',
|
||||
},
|
||||
parsedResult: {
|
||||
skip: 0,
|
||||
take: 20,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'full query parameters',
|
||||
request: {
|
||||
filter: '{"name":"test","tags":["important"]}',
|
||||
select: '["id","name","createdAt","tags"]',
|
||||
skip: '0',
|
||||
take: '10',
|
||||
sortBy: 'createdAt:desc',
|
||||
},
|
||||
parsedResult: {
|
||||
filter: { name: 'test', tags: ['important'] },
|
||||
select: { id: true, name: true, createdAt: true, tags: true },
|
||||
skip: 0,
|
||||
take: 10,
|
||||
sortBy: 'createdAt:desc',
|
||||
},
|
||||
},
|
||||
])('should validate $name', ({ request, parsedResult }) => {
|
||||
const result = ListFolderQueryDto.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
if (parsedResult) {
|
||||
expect(result.data).toMatchObject(parsedResult);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'invalid filter format',
|
||||
request: {
|
||||
filter: 'not-json',
|
||||
},
|
||||
expectedErrorPath: ['filter'],
|
||||
},
|
||||
{
|
||||
name: 'filter with invalid field',
|
||||
request: {
|
||||
filter: '{"unknownField":"test"}',
|
||||
},
|
||||
expectedErrorPath: ['filter'],
|
||||
},
|
||||
{
|
||||
name: 'filter with tags not as array',
|
||||
request: {
|
||||
filter: '{"tags":"important"}',
|
||||
},
|
||||
expectedErrorPath: ['filter'],
|
||||
},
|
||||
{
|
||||
name: 'filter with tags array containing non-string values',
|
||||
request: {
|
||||
filter: '{"tags":["important", 123]}',
|
||||
},
|
||||
expectedErrorPath: ['filter'],
|
||||
},
|
||||
{
|
||||
name: 'invalid select format',
|
||||
request: {
|
||||
select: 'id,name', // Not an array
|
||||
},
|
||||
expectedErrorPath: ['select'],
|
||||
},
|
||||
{
|
||||
name: 'select with invalid field',
|
||||
request: {
|
||||
select: '["id","invalidField"]',
|
||||
},
|
||||
expectedErrorPath: ['select'],
|
||||
},
|
||||
{
|
||||
name: 'invalid skip format',
|
||||
request: {
|
||||
skip: 'not-a-number',
|
||||
take: '10',
|
||||
},
|
||||
expectedErrorPath: ['skip'],
|
||||
},
|
||||
{
|
||||
name: 'invalid take format',
|
||||
request: {
|
||||
skip: '0',
|
||||
take: 'not-a-number',
|
||||
},
|
||||
expectedErrorPath: ['take'],
|
||||
},
|
||||
{
|
||||
name: 'invalid sortBy value',
|
||||
request: {
|
||||
sortBy: 'invalid-value',
|
||||
},
|
||||
expectedErrorPath: ['sortBy'],
|
||||
},
|
||||
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
|
||||
const result = ListFolderQueryDto.safeParse(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (expectedErrorPath && !result.success) {
|
||||
if (Array.isArray(expectedErrorPath)) {
|
||||
const errorPaths = result.error.issues[0].path;
|
||||
expect(errorPaths).toContain(expectedErrorPath[0]);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { UpdateFolderDto } from '../update-folder.dto';
|
||||
|
||||
describe('UpdateFolderDto', () => {
|
||||
describe('Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'name',
|
||||
request: {
|
||||
name: 'test',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'tagIds',
|
||||
request: {
|
||||
tagIds: ['1', '2'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'empty tagIds',
|
||||
request: {
|
||||
tagIds: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'string parentFolderId',
|
||||
request: {
|
||||
parentFolderId: 'test',
|
||||
},
|
||||
},
|
||||
])('should validate $name', ({ request }) => {
|
||||
const result = UpdateFolderDto.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'empty name',
|
||||
request: {
|
||||
name: '',
|
||||
},
|
||||
expectedErrorPath: ['name'],
|
||||
},
|
||||
{
|
||||
name: 'non string tagIds',
|
||||
request: {
|
||||
tagIds: [0],
|
||||
},
|
||||
expectedErrorPath: ['tagIds'],
|
||||
},
|
||||
{
|
||||
name: 'non array tagIds',
|
||||
request: {
|
||||
tagIds: 0,
|
||||
},
|
||||
expectedErrorPath: ['tagIds'],
|
||||
},
|
||||
{
|
||||
name: 'non string parentFolderId',
|
||||
request: {
|
||||
parentFolderId: 0,
|
||||
},
|
||||
expectedErrorPath: ['parentFolderId'],
|
||||
},
|
||||
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
|
||||
const result = UpdateFolderDto.safeParse(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (expectedErrorPath) {
|
||||
expect(result.error?.issues[0].path[0]).toEqual(expectedErrorPath[0]);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { folderNameSchema, folderIdSchema } from '../../schemas/folder.schema';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class CreateFolderDto extends Z.class({
|
||||
name: folderNameSchema,
|
||||
parentFolderId: folderIdSchema.optional(),
|
||||
}) {}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { folderIdSchema } from '../../schemas/folder.schema';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class DeleteFolderDto extends Z.class({
|
||||
transferToFolderId: folderIdSchema.optional(),
|
||||
}) {}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { jsonParse } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
const VALID_SELECT_FIELDS = [
|
||||
'id',
|
||||
'name',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'project',
|
||||
'tags',
|
||||
'parentFolder',
|
||||
'workflowCount',
|
||||
'subFolderCount',
|
||||
'path',
|
||||
] as const;
|
||||
|
||||
const VALID_SORT_OPTIONS = [
|
||||
'name:asc',
|
||||
'name:desc',
|
||||
'createdAt:asc',
|
||||
'createdAt:desc',
|
||||
'updatedAt:asc',
|
||||
'updatedAt:desc',
|
||||
] as const;
|
||||
|
||||
// Filter schema - only allow specific properties
|
||||
export const filterSchema = z
|
||||
.object({
|
||||
parentFolderId: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
excludeFolderIdAndDescendants: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// ---------------------
|
||||
// Parameter Validators
|
||||
// ---------------------
|
||||
|
||||
// Filter parameter validation
|
||||
const filterValidator = z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val, ctx) => {
|
||||
if (!val) return undefined;
|
||||
try {
|
||||
const parsed: unknown = jsonParse(val);
|
||||
try {
|
||||
return filterSchema.parse(parsed);
|
||||
} catch (e) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Invalid filter fields',
|
||||
path: ['filter'],
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
} catch (e) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Invalid filter format',
|
||||
path: ['filter'],
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
});
|
||||
|
||||
// Skip parameter validation
|
||||
const skipValidator = z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => (val ? parseInt(val, 10) : 0))
|
||||
.refine((val) => !isNaN(val), {
|
||||
message: 'Skip must be a valid number',
|
||||
});
|
||||
|
||||
// Take parameter validation
|
||||
const takeValidator = z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => (val ? parseInt(val, 10) : 10))
|
||||
.refine((val) => !isNaN(val), {
|
||||
message: 'Take must be a valid number',
|
||||
});
|
||||
|
||||
// Select parameter validation
|
||||
const selectFieldsValidator = z.array(z.enum(VALID_SELECT_FIELDS));
|
||||
const selectValidator = z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val, ctx) => {
|
||||
if (!val) return undefined;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(val);
|
||||
try {
|
||||
const selectFields = selectFieldsValidator.parse(parsed);
|
||||
if (selectFields.length === 0) return undefined;
|
||||
type SelectField = (typeof VALID_SELECT_FIELDS)[number];
|
||||
return selectFields.reduce<Record<SelectField, true>>(
|
||||
(acc, field) => ({ ...acc, [field]: true }),
|
||||
{} as Record<SelectField, true>,
|
||||
);
|
||||
} catch (e) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Invalid select fields. Valid fields are: ${VALID_SELECT_FIELDS.join(', ')}`,
|
||||
path: ['select'],
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
} catch (e) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Invalid select format',
|
||||
path: ['select'],
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
});
|
||||
|
||||
// SortBy parameter validation
|
||||
const sortByValidator = z
|
||||
.enum(VALID_SORT_OPTIONS, { message: `sortBy must be one of: ${VALID_SORT_OPTIONS.join(', ')}` })
|
||||
.optional();
|
||||
|
||||
export class ListFolderQueryDto extends Z.class({
|
||||
filter: filterValidator,
|
||||
skip: skipValidator,
|
||||
take: takeValidator,
|
||||
select: selectValidator,
|
||||
sortBy: sortByValidator,
|
||||
}) {}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { folderIdSchema } from '../../schemas/folder.schema';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class TransferFolderBodyDto extends Z.class({
|
||||
destinationProjectId: z.string(),
|
||||
shareCredentials: z.array(z.string()).optional(),
|
||||
destinationParentFolderId: folderIdSchema,
|
||||
}) {}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { folderNameSchema, folderIdSchema } from '../../schemas/folder.schema';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class UpdateFolderDto extends Z.class({
|
||||
name: folderNameSchema.optional(),
|
||||
tagIds: z.array(z.string().max(24)).optional(),
|
||||
parentFolderId: folderIdSchema.optional(),
|
||||
}) {}
|
||||
@@ -0,0 +1,176 @@
|
||||
export { GetNodeTypesByIdentifierRequestDto } from './node-types/get-node-types-by-identifier.dto';
|
||||
|
||||
export { AiAskRequestDto } from './ai/ai-ask-request.dto';
|
||||
export { AiChatRequestDto } from './ai/ai-chat-request.dto';
|
||||
export { AiBuilderChatRequestDto, type SelectedNodeContext } from './ai/ai-build-request.dto';
|
||||
export { AiApplySuggestionRequestDto } from './ai/ai-apply-suggestion-request.dto';
|
||||
export { AiFreeCreditsRequestDto } from './ai/ai-free-credits-request.dto';
|
||||
export { AiSessionRetrievalRequestDto } from './ai/ai-session-retrieval-request.dto';
|
||||
export { AiUsageSettingsRequestDto } from './ai/ai-usage-settings-request.dto';
|
||||
export { AiTruncateMessagesRequestDto } from './ai/ai-truncate-messages-request.dto';
|
||||
export { AiClearSessionRequestDto } from './ai/ai-clear-session-request.dto';
|
||||
|
||||
export { BinaryDataQueryDto } from './binary-data/binary-data-query.dto';
|
||||
export { BinaryDataSignedQueryDto } from './binary-data/binary-data-signed-query.dto';
|
||||
|
||||
export { LoginRequestDto } from './auth/login-request.dto';
|
||||
export { ResolveSignupTokenQueryDto } from './auth/resolve-signup-token-query.dto';
|
||||
|
||||
export { CreateCredentialResolverDto } from './credential-resolver/create-credential-resolver.dto';
|
||||
export { UpdateCredentialResolverDto } from './credential-resolver/update-credential-resolver.dto';
|
||||
|
||||
export { OptionsRequestDto } from './dynamic-node-parameters/options-request.dto';
|
||||
export { ResourceLocatorRequestDto } from './dynamic-node-parameters/resource-locator-request.dto';
|
||||
export { ResourceMapperFieldsRequestDto } from './dynamic-node-parameters/resource-mapper-fields-request.dto';
|
||||
export { ActionResultRequestDto } from './dynamic-node-parameters/action-result-request.dto';
|
||||
|
||||
export { InviteUsersRequestDto } from './invitation/invite-users-request.dto';
|
||||
export { AcceptInvitationRequestDto } from './invitation/accept-invitation-request.dto';
|
||||
|
||||
export { OwnerSetupRequestDto } from './owner/owner-setup-request.dto';
|
||||
export { DismissBannerRequestDto } from './owner/dismiss-banner-request.dto';
|
||||
|
||||
export { ForgotPasswordRequestDto } from './password-reset/forgot-password-request.dto';
|
||||
export { ResolvePasswordTokenQueryDto } from './password-reset/resolve-password-token-query.dto';
|
||||
export { ChangePasswordRequestDto } from './password-reset/change-password-request.dto';
|
||||
|
||||
export { CreateProjectDto } from './project/create-project.dto';
|
||||
export { UpdateProjectDto, UpdateProjectWithRelationsDto } from './project/update-project.dto';
|
||||
export { DeleteProjectDto } from './project/delete-project.dto';
|
||||
export { AddUsersToProjectDto } from './project/add-users-to-project.dto';
|
||||
export { ChangeUserRoleInProject } from './project/change-user-role-in-project.dto';
|
||||
|
||||
export { SamlAcsDto } from './saml/saml-acs.dto';
|
||||
export { SamlPreferences } from './saml/saml-preferences.dto';
|
||||
export { SamlPreferencesAttributeMapping } from './saml/saml-preferences.dto';
|
||||
export { SamlToggleDto } from './saml/saml-toggle.dto';
|
||||
|
||||
export { PasswordUpdateRequestDto } from './user/password-update-request.dto';
|
||||
export { RoleChangeRequestDto } from './user/role-change-request.dto';
|
||||
export { SettingsUpdateRequestDto } from './user/settings-update-request.dto';
|
||||
export { UserSelfSettingsUpdateRequestDto } from './user/user-self-settings-update-request.dto';
|
||||
export { UserUpdateRequestDto } from './user/user-update-request.dto';
|
||||
|
||||
export { CommunityRegisteredRequestDto } from './license/community-registered-request.dto';
|
||||
|
||||
export {
|
||||
PullWorkFolderRequestDto,
|
||||
AUTO_PUBLISH_MODE,
|
||||
} from './source-control/pull-work-folder-request.dto';
|
||||
export { PushWorkFolderRequestDto } from './source-control/push-work-folder-request.dto';
|
||||
export { type GitCommitInfo } from './source-control/push-work-folder-response.dto';
|
||||
|
||||
export { CreateCredentialDto } from './credentials/create-credential.dto';
|
||||
export { VariableListRequestDto } from './variables/variables-list-request.dto';
|
||||
export {
|
||||
CreateVariableRequestDto,
|
||||
NEW_VARIABLE_KEY_REGEX,
|
||||
} from './variables/create-variable-request.dto';
|
||||
export { UpdateVariableRequestDto } from './variables/update-variable-request.dto';
|
||||
export { CredentialsGetOneRequestQuery } from './credentials/credentials-get-one-request.dto';
|
||||
export { CredentialsGetManyRequestQuery } from './credentials/credentials-get-many-request.dto';
|
||||
export { GenerateCredentialNameRequestQuery } from './credentials/generate-credential-name.dto';
|
||||
|
||||
export { CreateWorkflowDto } from './workflows/create-workflow.dto';
|
||||
export { UpdateWorkflowDto } from './workflows/update-workflow.dto';
|
||||
export { ImportWorkflowFromUrlDto } from './workflows/import-workflow-from-url.dto';
|
||||
export { TransferWorkflowBodyDto } from './workflows/transfer.dto';
|
||||
export { ActivateWorkflowDto } from './workflows/activate-workflow.dto';
|
||||
export { DeactivateWorkflowDto } from './workflows/deactivate-workflow.dto';
|
||||
export { ArchiveWorkflowDto } from './workflows/archive-workflow.dto';
|
||||
|
||||
export { CreateOrUpdateTagRequestDto } from './tag/create-or-update-tag-request.dto';
|
||||
export { RetrieveTagQueryDto } from './tag/retrieve-tag-query.dto';
|
||||
|
||||
export { UpdateApiKeyRequestDto } from './api-keys/update-api-key-request.dto';
|
||||
export { CreateApiKeyRequestDto } from './api-keys/create-api-key-request.dto';
|
||||
|
||||
export { CreateFolderDto } from './folders/create-folder.dto';
|
||||
export { UpdateFolderDto } from './folders/update-folder.dto';
|
||||
export { DeleteFolderDto } from './folders/delete-folder.dto';
|
||||
export { ListFolderQueryDto } from './folders/list-folder-query.dto';
|
||||
export { TransferFolderBodyDto } from './folders/transfer-folder.dto';
|
||||
|
||||
export { ListInsightsWorkflowQueryDto } from './insights/list-workflow-query.dto';
|
||||
export { InsightsDateFilterDto } from './insights/date-filter.dto';
|
||||
|
||||
export { GetDestinationQueryDto } from './log-streaming/get-destination-query.dto';
|
||||
export {
|
||||
CreateDestinationDto,
|
||||
type WebhookDestination,
|
||||
type SentryDestination,
|
||||
type SyslogDestination,
|
||||
} from './log-streaming/create-destination.dto';
|
||||
export { TestDestinationQueryDto } from './log-streaming/test-destination-query.dto';
|
||||
export { DeleteDestinationQueryDto } from './log-streaming/delete-destination-query.dto';
|
||||
|
||||
export { PaginationDto } from './pagination/pagination.dto';
|
||||
export {
|
||||
UsersListFilterDto,
|
||||
type UsersListSortOptions,
|
||||
USERS_LIST_SORT_OPTIONS,
|
||||
} from './user/users-list-filter.dto';
|
||||
|
||||
export { UpdateRoleDto } from './roles/update-role.dto';
|
||||
export { CreateRoleDto } from './roles/create-role.dto';
|
||||
export { RoleListQueryDto } from './roles/role-list-query.dto';
|
||||
export { RoleGetQueryDto } from './roles/role-get-query.dto';
|
||||
export {
|
||||
RoleAssignmentsResponseDto,
|
||||
type RoleProjectAssignment,
|
||||
type RoleAssignmentsResponse,
|
||||
} from './roles/role-assignments-response.dto';
|
||||
export {
|
||||
RoleProjectMembersResponseDto,
|
||||
type RoleProjectMember,
|
||||
type RoleProjectMembersResponse,
|
||||
} from './roles/role-project-members-response.dto';
|
||||
|
||||
export { OidcConfigDto } from './oidc/config.dto';
|
||||
|
||||
export { CreateDataTableDto } from './data-table/create-data-table.dto';
|
||||
export { UpdateDataTableDto } from './data-table/update-data-table.dto';
|
||||
export { UpdateDataTableRowDto } from './data-table/update-data-table-row.dto';
|
||||
export { DeleteDataTableRowsDto } from './data-table/delete-data-table-rows.dto';
|
||||
export { UpsertDataTableRowDto } from './data-table/upsert-data-table-row.dto';
|
||||
export {
|
||||
ListDataTableQueryDto,
|
||||
PublicApiListDataTableQueryDto,
|
||||
} from './data-table/list-data-table-query.dto';
|
||||
export {
|
||||
ListDataTableContentQueryDto,
|
||||
PublicApiListDataTableContentQueryDto,
|
||||
} from './data-table/list-data-table-content-query.dto';
|
||||
export { CreateDataTableColumnDto } from './data-table/create-data-table-column.dto';
|
||||
export { AddDataTableRowsDto } from './data-table/add-data-table-rows.dto';
|
||||
export { AddDataTableColumnDto } from './data-table/add-data-table-column.dto';
|
||||
export { MoveDataTableColumnDto } from './data-table/move-data-table-column.dto';
|
||||
export { RenameDataTableColumnDto } from './data-table/rename-data-table-column.dto';
|
||||
export { DownloadDataTableCsvQueryDto } from './data-table/download-data-table-csv-query.dto';
|
||||
|
||||
export {
|
||||
OAuthClientResponseDto,
|
||||
ListOAuthClientsResponseDto,
|
||||
DeleteOAuthClientResponseDto,
|
||||
} from './oauth/oauth-client.dto';
|
||||
export { ProvisioningConfigDto, ProvisioningConfigPatchDto } from './provisioning/config.dto';
|
||||
|
||||
export {
|
||||
SecuritySettingsDto,
|
||||
UpdateSecuritySettingsDto,
|
||||
} from './security-settings/security-settings.dto';
|
||||
|
||||
export { WorkflowHistoryVersionsByIdsDto } from './workflow-history/workflow-history-versions-by-ids.dto';
|
||||
export { UpdateWorkflowHistoryVersionDto } from './workflow-history/update-workflow-history-version.dto';
|
||||
|
||||
export { CreateSecretsProviderConnectionDto } from './secrets-provider/create-secrets-provider-connection.dto';
|
||||
export { SetSecretsProviderConnectionIsEnabledDto } from './secrets-provider/set-secrets-provider-connection-is-enabled.dto';
|
||||
export { TestSecretsProviderConnectionDto } from './secrets-provider/test-secrets-provider-connection.dto';
|
||||
export { UpdateSecretsProviderConnectionDto } from './secrets-provider/update-secrets-provider-connection.dto';
|
||||
|
||||
export { GetQuickConnectApiKeyDto } from './quick-connect/create-quick-connect-credential.dto';
|
||||
|
||||
export {
|
||||
ExecutionRedactionQueryDtoSchema,
|
||||
type ExecutionRedactionQueryDto,
|
||||
} from './executions/execution-redaction-query.dto';
|
||||
@@ -0,0 +1,148 @@
|
||||
import { InsightsDateFilterDto } from '../date-filter.dto';
|
||||
|
||||
describe('InsightsDateFilterDto', () => {
|
||||
describe('Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'empty object (no filters)',
|
||||
request: {},
|
||||
parsedResult: {},
|
||||
},
|
||||
{
|
||||
name: 'valid startDate and endDate (as strings)',
|
||||
request: {
|
||||
startDate: '2025-01-01',
|
||||
endDate: '2025-01-31',
|
||||
},
|
||||
parsedResult: {
|
||||
startDate: new Date('2025-01-01'),
|
||||
endDate: new Date('2025-01-31'),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'valid startDate and endDate (as ISO strings)',
|
||||
request: {
|
||||
startDate: '2025-01-01T00:00:00Z',
|
||||
endDate: '2025-01-31T23:59:59Z',
|
||||
},
|
||||
parsedResult: {
|
||||
startDate: new Date('2025-01-01T00:00:00Z'),
|
||||
endDate: new Date('2025-01-31T23:59:59Z'),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'valid startDate and endDate (as timestamps)',
|
||||
request: {
|
||||
startDate: new Date('2025-01-01').getTime(),
|
||||
endDate: new Date('2025-01-31').getTime(),
|
||||
},
|
||||
parsedResult: {
|
||||
startDate: new Date('2025-01-01'),
|
||||
endDate: new Date('2025-01-31'),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'valid startDate and endDate (as ISO strings)',
|
||||
request: {
|
||||
startDate: '2025-01-01T00:00:00Z',
|
||||
endDate: '2025-01-31T23:59:59Z',
|
||||
},
|
||||
parsedResult: {
|
||||
startDate: new Date('2025-01-01T00:00:00Z'),
|
||||
endDate: new Date('2025-01-31T23:59:59Z'),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'valid startDate and endDate (as timestamps)',
|
||||
request: {
|
||||
startDate: new Date('2025-01-01').getTime(),
|
||||
endDate: new Date('2025-01-31').getTime(),
|
||||
},
|
||||
parsedResult: {
|
||||
startDate: new Date('2025-01-01'),
|
||||
endDate: new Date('2025-01-31'),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'valid projectId',
|
||||
request: {
|
||||
projectId: '2gQLpmP5V4wOY627',
|
||||
},
|
||||
parsedResult: {
|
||||
projectId: '2gQLpmP5V4wOY627',
|
||||
},
|
||||
},
|
||||
])('should validate $name', ({ request, parsedResult }) => {
|
||||
const result = InsightsDateFilterDto.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
if (parsedResult) {
|
||||
expect(result.data).toMatchObject(parsedResult);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'invalid startDate format',
|
||||
request: {
|
||||
startDate: '2025-13-01', // Invalid month
|
||||
endDate: '2025-13-31', // Invalid month
|
||||
},
|
||||
expectedErrorPaths: ['startDate', 'endDate'],
|
||||
},
|
||||
{
|
||||
name: 'startDate is an invalid timestamp',
|
||||
request: {
|
||||
startDate: NaN,
|
||||
},
|
||||
expectedErrorPaths: ['startDate'],
|
||||
},
|
||||
{
|
||||
name: 'endDate is an invalid timestamp',
|
||||
request: {
|
||||
endDate: NaN,
|
||||
projectId: 'validProjectId',
|
||||
},
|
||||
expectedErrorPaths: ['endDate'],
|
||||
},
|
||||
{
|
||||
name: 'startDate is an invalid ISO string',
|
||||
request: {
|
||||
startDate: 'invalid--date',
|
||||
},
|
||||
expectedErrorPaths: ['startDate'],
|
||||
},
|
||||
{
|
||||
name: 'endDate is an invalid ISO string',
|
||||
request: {
|
||||
startDate: '2025-01-01',
|
||||
endDate: 'not-a-date',
|
||||
},
|
||||
expectedErrorPaths: ['endDate'],
|
||||
},
|
||||
{
|
||||
name: 'invalid projectId value',
|
||||
request: {
|
||||
projectId: 10,
|
||||
},
|
||||
expectedErrorPaths: ['projectId'],
|
||||
},
|
||||
{
|
||||
name: 'all fields invalid',
|
||||
request: {
|
||||
startDate: '2025-13-01', // Invalid month
|
||||
endDate: 'not-a-date',
|
||||
projectId: 10,
|
||||
},
|
||||
expectedErrorPaths: ['startDate', 'endDate', 'projectId'],
|
||||
},
|
||||
])('should fail validation for $name', ({ request, expectedErrorPaths }) => {
|
||||
const result = InsightsDateFilterDto.safeParse(request);
|
||||
const issuesPaths = new Set(result.error?.issues.map((issue) => issue.path[0]));
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(new Set(issuesPaths)).toEqual(new Set(expectedErrorPaths));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import { ListInsightsWorkflowQueryDto } from '../list-workflow-query.dto';
|
||||
|
||||
const DEFAULT_PAGINATION = { skip: 0, take: 10 };
|
||||
|
||||
describe('ListInsightsWorkflowQueryDto', () => {
|
||||
describe('Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'empty object (no filters)',
|
||||
request: {},
|
||||
parsedResult: DEFAULT_PAGINATION,
|
||||
},
|
||||
{
|
||||
name: 'valid sortBy',
|
||||
request: {
|
||||
sortBy: 'total:asc',
|
||||
},
|
||||
parsedResult: {
|
||||
...DEFAULT_PAGINATION,
|
||||
sortBy: 'total:asc',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'valid sortBy workflowName:asc',
|
||||
request: {
|
||||
sortBy: 'workflowName:asc',
|
||||
},
|
||||
parsedResult: {
|
||||
...DEFAULT_PAGINATION,
|
||||
sortBy: 'workflowName:asc',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'valid sortBy workflowName:desc',
|
||||
request: {
|
||||
sortBy: 'workflowName:desc',
|
||||
},
|
||||
parsedResult: {
|
||||
...DEFAULT_PAGINATION,
|
||||
sortBy: 'workflowName:desc',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'valid skip and take',
|
||||
request: {
|
||||
skip: '0',
|
||||
take: '20',
|
||||
},
|
||||
parsedResult: {
|
||||
skip: 0,
|
||||
take: 20,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'full query parameters',
|
||||
request: {
|
||||
skip: '0',
|
||||
take: '10',
|
||||
sortBy: 'total:desc',
|
||||
},
|
||||
parsedResult: {
|
||||
skip: 0,
|
||||
take: 10,
|
||||
sortBy: 'total:desc',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'limit take to 100',
|
||||
request: {
|
||||
skip: '0',
|
||||
take: '200',
|
||||
sortBy: 'total:asc',
|
||||
},
|
||||
parsedResult: {
|
||||
skip: 0,
|
||||
take: 100,
|
||||
sortBy: 'total:asc',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'valid projectId',
|
||||
request: {
|
||||
projectId: '2gQLpmP5V4wOY627',
|
||||
},
|
||||
parsedResult: {
|
||||
projectId: '2gQLpmP5V4wOY627',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'valid startDate and endDate (as strings)',
|
||||
request: {
|
||||
startDate: '2025-01-01',
|
||||
endDate: '2025-01-31',
|
||||
},
|
||||
parsedResult: {
|
||||
startDate: new Date('2025-01-01'),
|
||||
endDate: new Date('2025-01-31'),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'valid startDate and endDate (as ISO strings)',
|
||||
request: {
|
||||
startDate: '2025-01-01T00:00:00Z',
|
||||
endDate: '2025-01-31T23:59:59Z',
|
||||
},
|
||||
parsedResult: {
|
||||
startDate: new Date('2025-01-01T00:00:00Z'),
|
||||
endDate: new Date('2025-01-31T23:59:59Z'),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'valid startDate and endDate (as timestamps)',
|
||||
request: {
|
||||
startDate: new Date('2025-01-01').getTime(),
|
||||
endDate: new Date('2025-01-31').getTime(),
|
||||
},
|
||||
parsedResult: {
|
||||
startDate: new Date('2025-01-01'),
|
||||
endDate: new Date('2025-01-31'),
|
||||
},
|
||||
},
|
||||
])('should validate $name', ({ request, parsedResult }) => {
|
||||
const result = ListInsightsWorkflowQueryDto.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
if (parsedResult) {
|
||||
expect(result.data).toMatchObject(parsedResult);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'invalid skip format',
|
||||
request: {
|
||||
skip: 'not-a-number',
|
||||
take: '10',
|
||||
},
|
||||
expectedErrorPaths: ['skip'],
|
||||
},
|
||||
{
|
||||
name: 'invalid take format',
|
||||
request: {
|
||||
skip: '0',
|
||||
take: 'not-a-number',
|
||||
},
|
||||
expectedErrorPaths: ['take'],
|
||||
},
|
||||
{
|
||||
name: 'invalid sortBy value',
|
||||
request: {
|
||||
sortBy: 'invalid-value',
|
||||
},
|
||||
expectedErrorPaths: ['sortBy'],
|
||||
},
|
||||
{
|
||||
name: 'invalid projectId value',
|
||||
request: {
|
||||
projectId: 10,
|
||||
},
|
||||
expectedErrorPaths: ['projectId'],
|
||||
},
|
||||
{
|
||||
name: 'invalid startDate format',
|
||||
request: {
|
||||
startDate: '2025-13-01', // Invalid month
|
||||
endDate: '2025-13-31', // Invalid month
|
||||
},
|
||||
expectedErrorPaths: ['startDate', 'endDate'],
|
||||
},
|
||||
{
|
||||
name: 'startDate is an invalid timestamp',
|
||||
request: {
|
||||
startDate: NaN,
|
||||
},
|
||||
expectedErrorPaths: ['startDate'],
|
||||
},
|
||||
{
|
||||
name: 'endDate is an invalid timestamp',
|
||||
request: {
|
||||
endDate: NaN,
|
||||
projectId: 'validProjectId',
|
||||
},
|
||||
expectedErrorPaths: ['endDate'],
|
||||
},
|
||||
{
|
||||
name: 'startDate is an invalid ISO string',
|
||||
request: {
|
||||
startDate: 'invalid--date',
|
||||
},
|
||||
expectedErrorPaths: ['startDate'],
|
||||
},
|
||||
{
|
||||
name: 'endDate is an invalid ISO string',
|
||||
request: {
|
||||
startDate: '2025-01-01',
|
||||
endDate: 'not-a-date',
|
||||
},
|
||||
expectedErrorPaths: ['endDate'],
|
||||
},
|
||||
{
|
||||
name: 'all fields invalid',
|
||||
request: {
|
||||
sortBy: 'invalid-value',
|
||||
startDate: '2025-13-01', // Invalid month
|
||||
endDate: 'not-a-date',
|
||||
projectId: 10,
|
||||
},
|
||||
expectedErrorPaths: ['sortBy', 'startDate', 'endDate', 'projectId'],
|
||||
},
|
||||
])('should fail validation for $name', ({ request, expectedErrorPaths }) => {
|
||||
const result = ListInsightsWorkflowQueryDto.safeParse(request);
|
||||
|
||||
const issuesPaths = new Set(result.error?.issues.map((issue) => issue.path[0]));
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(new Set(issuesPaths)).toEqual(new Set(expectedErrorPaths));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class InsightsDateFilterDto extends Z.class({
|
||||
startDate: z.coerce.date().optional(),
|
||||
endDate: z.coerce.date().optional(),
|
||||
projectId: z.string().optional(),
|
||||
}) {}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
import { createTakeValidator, paginationSchema } from '../pagination/pagination.dto';
|
||||
|
||||
export const MAX_ITEMS_PER_PAGE = 100;
|
||||
|
||||
const VALID_SORT_OPTIONS = [
|
||||
'total:asc',
|
||||
'total:desc',
|
||||
'succeeded:asc',
|
||||
'succeeded:desc',
|
||||
'failed:asc',
|
||||
'failed:desc',
|
||||
'failureRate:asc',
|
||||
'failureRate:desc',
|
||||
'timeSaved:asc',
|
||||
'timeSaved:desc',
|
||||
'runTime:asc',
|
||||
'runTime:desc',
|
||||
'averageRunTime:asc',
|
||||
'averageRunTime:desc',
|
||||
'workflowName:asc',
|
||||
'workflowName:desc',
|
||||
] as const;
|
||||
|
||||
// ---------------------
|
||||
// Parameter Validators
|
||||
// ---------------------
|
||||
|
||||
const sortByValidator = z
|
||||
.enum(VALID_SORT_OPTIONS, { message: `sortBy must be one of: ${VALID_SORT_OPTIONS.join(', ')}` })
|
||||
.optional();
|
||||
|
||||
export class ListInsightsWorkflowQueryDto extends Z.class({
|
||||
...paginationSchema,
|
||||
take: createTakeValidator(MAX_ITEMS_PER_PAGE),
|
||||
startDate: z.coerce.date().optional(),
|
||||
endDate: z.coerce.date().optional(),
|
||||
sortBy: sortByValidator,
|
||||
projectId: z.string().optional(),
|
||||
}) {}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
import { AcceptInvitationRequestDto } from '../accept-invitation-request.dto';
|
||||
|
||||
describe('AcceptInvitationRequestDto', () => {
|
||||
const validUuid = '123e4567-e89b-12d3-a456-426614174000';
|
||||
|
||||
describe('Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'legacy format with inviterId',
|
||||
request: {
|
||||
inviterId: validUuid,
|
||||
inviteeId: validUuid,
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
password: 'SecurePassword123',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'JWT token format',
|
||||
request: {
|
||||
token:
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpbnZpdGVySWQiOiIxMjNlNDU2Ny1lODliLTEyZDMtYTQ1Ni00MjY2MTQxNzQwMDAiLCJpbnZpdGVlSWQiOiIxMjNlNDU2Ny1lODliLTEyZDMtYTQ1Ni00MjY2MTQxNzQwMDAifQ.test',
|
||||
inviteeId: validUuid,
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
password: 'SecurePassword123',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'missing inviterId (could be token-based)',
|
||||
request: {
|
||||
inviteeId: validUuid,
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
password: 'SecurePassword123',
|
||||
},
|
||||
},
|
||||
])('should validate $name', ({ request }) => {
|
||||
const result = AcceptInvitationRequestDto.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'invalid inviterId',
|
||||
request: {
|
||||
inviterId: 'not-a-valid-uuid',
|
||||
inviteeId: validUuid,
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
password: 'SecurePassword123',
|
||||
},
|
||||
expectedErrorPath: ['inviterId'],
|
||||
},
|
||||
{
|
||||
name: 'invalid inviteeId',
|
||||
request: {
|
||||
inviterId: validUuid,
|
||||
inviteeId: 'not-a-valid-uuid',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
password: 'SecurePassword123',
|
||||
},
|
||||
expectedErrorPath: ['inviteeId'],
|
||||
},
|
||||
{
|
||||
name: 'missing first name',
|
||||
request: {
|
||||
inviterId: validUuid,
|
||||
inviteeId: validUuid,
|
||||
firstName: '',
|
||||
lastName: 'Doe',
|
||||
password: 'SecurePassword123',
|
||||
},
|
||||
expectedErrorPath: ['firstName'],
|
||||
},
|
||||
{
|
||||
name: 'missing last name',
|
||||
request: {
|
||||
inviterId: validUuid,
|
||||
inviteeId: validUuid,
|
||||
firstName: 'John',
|
||||
lastName: '',
|
||||
password: 'SecurePassword123',
|
||||
},
|
||||
expectedErrorPath: ['lastName'],
|
||||
},
|
||||
{
|
||||
name: 'password too short',
|
||||
request: {
|
||||
inviterId: validUuid,
|
||||
inviteeId: validUuid,
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
password: 'short',
|
||||
},
|
||||
expectedErrorPath: ['password'],
|
||||
},
|
||||
{
|
||||
name: 'password without number',
|
||||
request: {
|
||||
inviterId: validUuid,
|
||||
inviteeId: validUuid,
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
password: 'NoNumberPassword',
|
||||
},
|
||||
expectedErrorPath: ['password'],
|
||||
},
|
||||
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
|
||||
const result = AcceptInvitationRequestDto.safeParse(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (expectedErrorPath) {
|
||||
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { InviteUsersRequestDto } from '../invite-users-request.dto';
|
||||
|
||||
describe('InviteUsersRequestDto', () => {
|
||||
describe('Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'empty array',
|
||||
request: [],
|
||||
},
|
||||
{
|
||||
name: 'single user invitation with default role',
|
||||
request: [{ email: 'user@example.com' }],
|
||||
},
|
||||
{
|
||||
name: 'multiple user invitations with different roles',
|
||||
request: [
|
||||
{ email: 'user1@example.com', role: 'global:member' },
|
||||
{ email: 'user2@example.com', role: 'global:admin' },
|
||||
{ email: 'user3@example.com', role: 'custom:role' },
|
||||
{ email: 'user4@example.com', role: 'global:chatUser' },
|
||||
],
|
||||
},
|
||||
])('should validate $name', ({ request }) => {
|
||||
const result = InviteUsersRequestDto.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should default role to global:member', () => {
|
||||
const result = InviteUsersRequestDto.safeParse([{ email: 'user@example.com' }]);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.[0].role).toBe('global:member');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'invalid email',
|
||||
request: [{ email: 'invalid-email' }],
|
||||
expectedErrorPath: [0, 'email'],
|
||||
},
|
||||
{
|
||||
name: 'invalid role',
|
||||
request: [
|
||||
{
|
||||
email: 'user@example.com',
|
||||
role: 'global:owner',
|
||||
},
|
||||
],
|
||||
expectedErrorPath: [0, 'role'],
|
||||
},
|
||||
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
|
||||
const result = InviteUsersRequestDto.safeParse(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (expectedErrorPath) {
|
||||
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { passwordSchema } from '../../schemas/password.schema';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
// Support both legacy format (inviterId) and new JWT format (token)
|
||||
// All fields are optional at the schema level, but validation ensures either token OR inviterId is provided
|
||||
export class AcceptInvitationRequestDto extends Z.class({
|
||||
inviterId: z.string().uuid().optional(),
|
||||
inviteeId: z.string().uuid().optional(),
|
||||
token: z.string().optional(),
|
||||
firstName: z.string().min(1, 'First name is required'),
|
||||
lastName: z.string().min(1, 'Last name is required'),
|
||||
password: passwordSchema,
|
||||
}) {}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { assignableGlobalRoleSchema } from '@n8n/permissions';
|
||||
import { z } from 'zod';
|
||||
|
||||
const invitedUserSchema = z.object({
|
||||
email: z.string().email(),
|
||||
role: assignableGlobalRoleSchema.default('global:member'),
|
||||
});
|
||||
|
||||
const invitationsSchema = z.array(invitedUserSchema);
|
||||
|
||||
export class InviteUsersRequestDto extends Array<z.infer<typeof invitedUserSchema>> {
|
||||
static safeParse(data: unknown) {
|
||||
return invitationsSchema.safeParse(data);
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { CommunityRegisteredRequestDto } from '../community-registered-request.dto';
|
||||
|
||||
describe('CommunityRegisteredRequestDto', () => {
|
||||
it('should fail validation for missing email', () => {
|
||||
const invalidRequest = {};
|
||||
|
||||
const result = CommunityRegisteredRequestDto.safeParse(invalidRequest);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.issues[0]).toEqual(
|
||||
expect.objectContaining({ message: 'Required', path: ['email'] }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail validation for an invalid email', () => {
|
||||
const invalidRequest = {
|
||||
email: 'invalid-email',
|
||||
};
|
||||
|
||||
const result = CommunityRegisteredRequestDto.safeParse(invalidRequest);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.issues[0]).toEqual(
|
||||
expect.objectContaining({ message: 'Invalid email', path: ['email'] }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class CommunityRegisteredRequestDto extends Z.class({ email: z.string().email() }) {}
|
||||
+480
@@ -0,0 +1,480 @@
|
||||
import { CreateDestinationDto } from '../create-destination.dto';
|
||||
|
||||
describe('CreateDestinationDto', () => {
|
||||
describe('Webhook Destination - Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'minimal webhook destination',
|
||||
request: {
|
||||
__type: '$$MessageEventBusDestinationWebhook',
|
||||
url: 'https://example.com/webhook',
|
||||
},
|
||||
parsedResult: {
|
||||
__type: '$$MessageEventBusDestinationWebhook',
|
||||
url: 'https://example.com/webhook',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'webhook with all optional fields',
|
||||
request: {
|
||||
__type: '$$MessageEventBusDestinationWebhook',
|
||||
url: 'https://example.com/webhook',
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
label: 'Production Webhook',
|
||||
enabled: true,
|
||||
subscribedEvents: ['n8n.audit', 'n8n.workflow'],
|
||||
anonymizeAuditMessages: false,
|
||||
method: 'POST',
|
||||
authentication: 'none',
|
||||
sendPayload: true,
|
||||
},
|
||||
parsedResult: {
|
||||
__type: '$$MessageEventBusDestinationWebhook',
|
||||
url: 'https://example.com/webhook',
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
label: 'Production Webhook',
|
||||
enabled: true,
|
||||
subscribedEvents: ['n8n.audit', 'n8n.workflow'],
|
||||
anonymizeAuditMessages: false,
|
||||
method: 'POST',
|
||||
authentication: 'none',
|
||||
sendPayload: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'webhook with circuit breaker options',
|
||||
request: {
|
||||
__type: '$$MessageEventBusDestinationWebhook',
|
||||
url: 'https://example.com/webhook',
|
||||
circuitBreaker: {
|
||||
maxFailures: 5,
|
||||
maxDuration: 10000,
|
||||
halfOpenRequests: 3,
|
||||
failureWindow: 60000,
|
||||
maxConcurrentHalfOpenRequests: 2,
|
||||
},
|
||||
},
|
||||
parsedResult: {
|
||||
__type: '$$MessageEventBusDestinationWebhook',
|
||||
url: 'https://example.com/webhook',
|
||||
circuitBreaker: {
|
||||
maxFailures: 5,
|
||||
maxDuration: 10000,
|
||||
halfOpenRequests: 3,
|
||||
failureWindow: 60000,
|
||||
maxConcurrentHalfOpenRequests: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'webhook with nested proxy from fixedCollection',
|
||||
request: {
|
||||
__type: '$$MessageEventBusDestinationWebhook',
|
||||
url: 'https://example.com/webhook',
|
||||
options: {
|
||||
proxy: {
|
||||
proxy: {
|
||||
protocol: 'http',
|
||||
host: '127.0.0.1',
|
||||
port: 3128,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
parsedResult: {
|
||||
__type: '$$MessageEventBusDestinationWebhook',
|
||||
url: 'https://example.com/webhook',
|
||||
options: {
|
||||
proxy: {
|
||||
protocol: 'http',
|
||||
host: '127.0.0.1',
|
||||
port: 3128,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'webhook with nested redirect from fixedCollection',
|
||||
request: {
|
||||
__type: '$$MessageEventBusDestinationWebhook',
|
||||
url: 'https://example.com/webhook',
|
||||
options: {
|
||||
redirect: {
|
||||
redirect: {
|
||||
followRedirects: true,
|
||||
maxRedirects: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
parsedResult: {
|
||||
__type: '$$MessageEventBusDestinationWebhook',
|
||||
url: 'https://example.com/webhook',
|
||||
options: {
|
||||
redirect: {
|
||||
followRedirects: true,
|
||||
maxRedirects: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
])('should validate $name', ({ request, parsedResult }) => {
|
||||
const result = CreateDestinationDto.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toMatchObject(parsedResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sentry Destination - Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'minimal sentry destination',
|
||||
request: {
|
||||
__type: '$$MessageEventBusDestinationSentry',
|
||||
dsn: 'https://public@sentry.io/1',
|
||||
},
|
||||
parsedResult: {
|
||||
__type: '$$MessageEventBusDestinationSentry',
|
||||
dsn: 'https://public@sentry.io/1',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'sentry with all optional fields',
|
||||
request: {
|
||||
__type: '$$MessageEventBusDestinationSentry',
|
||||
dsn: 'https://public@sentry.io/1',
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
label: 'Production Sentry',
|
||||
enabled: true,
|
||||
subscribedEvents: ['n8n.audit'],
|
||||
anonymizeAuditMessages: true,
|
||||
tracesSampleRate: 0.5,
|
||||
sendPayload: false,
|
||||
},
|
||||
parsedResult: {
|
||||
__type: '$$MessageEventBusDestinationSentry',
|
||||
dsn: 'https://public@sentry.io/1',
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
label: 'Production Sentry',
|
||||
enabled: true,
|
||||
subscribedEvents: ['n8n.audit'],
|
||||
anonymizeAuditMessages: true,
|
||||
tracesSampleRate: 0.5,
|
||||
sendPayload: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'sentry with traces sample rate 0',
|
||||
request: {
|
||||
__type: '$$MessageEventBusDestinationSentry',
|
||||
dsn: 'https://public@sentry.io/1',
|
||||
tracesSampleRate: 0,
|
||||
},
|
||||
parsedResult: {
|
||||
__type: '$$MessageEventBusDestinationSentry',
|
||||
dsn: 'https://public@sentry.io/1',
|
||||
tracesSampleRate: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'sentry with traces sample rate 1',
|
||||
request: {
|
||||
__type: '$$MessageEventBusDestinationSentry',
|
||||
dsn: 'https://public@sentry.io/1',
|
||||
tracesSampleRate: 1,
|
||||
},
|
||||
parsedResult: {
|
||||
__type: '$$MessageEventBusDestinationSentry',
|
||||
dsn: 'https://public@sentry.io/1',
|
||||
tracesSampleRate: 1,
|
||||
},
|
||||
},
|
||||
])('should validate $name', ({ request, parsedResult }) => {
|
||||
const result = CreateDestinationDto.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toMatchObject(parsedResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Syslog Destination - Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'minimal syslog destination',
|
||||
request: {
|
||||
__type: '$$MessageEventBusDestinationSyslog',
|
||||
host: '127.0.0.1',
|
||||
},
|
||||
parsedResult: {
|
||||
__type: '$$MessageEventBusDestinationSyslog',
|
||||
host: '127.0.0.1',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'syslog with all optional fields',
|
||||
request: {
|
||||
__type: '$$MessageEventBusDestinationSyslog',
|
||||
host: 'syslog.example.com',
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
label: 'Production Syslog',
|
||||
enabled: true,
|
||||
subscribedEvents: ['n8n.workflow', 'n8n.execution'],
|
||||
anonymizeAuditMessages: false,
|
||||
port: 514,
|
||||
protocol: 'tcp',
|
||||
facility: 16,
|
||||
app_name: 'n8n-production',
|
||||
eol: '\n',
|
||||
},
|
||||
parsedResult: {
|
||||
__type: '$$MessageEventBusDestinationSyslog',
|
||||
host: 'syslog.example.com',
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
label: 'Production Syslog',
|
||||
enabled: true,
|
||||
subscribedEvents: ['n8n.workflow', 'n8n.execution'],
|
||||
anonymizeAuditMessages: false,
|
||||
port: 514,
|
||||
protocol: 'tcp',
|
||||
facility: 16,
|
||||
app_name: 'n8n-production',
|
||||
eol: '\n',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'syslog with UDP protocol',
|
||||
request: {
|
||||
__type: '$$MessageEventBusDestinationSyslog',
|
||||
host: 'localhost',
|
||||
protocol: 'udp',
|
||||
},
|
||||
parsedResult: {
|
||||
__type: '$$MessageEventBusDestinationSyslog',
|
||||
host: 'localhost',
|
||||
protocol: 'udp',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'syslog with TLS protocol',
|
||||
request: {
|
||||
__type: '$$MessageEventBusDestinationSyslog',
|
||||
host: 'secure.syslog.com',
|
||||
protocol: 'tls',
|
||||
port: 6514,
|
||||
},
|
||||
parsedResult: {
|
||||
__type: '$$MessageEventBusDestinationSyslog',
|
||||
host: 'secure.syslog.com',
|
||||
protocol: 'tls',
|
||||
port: 6514,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'syslog with minimum facility (0)',
|
||||
request: {
|
||||
__type: '$$MessageEventBusDestinationSyslog',
|
||||
host: 'localhost',
|
||||
facility: 0,
|
||||
},
|
||||
parsedResult: {
|
||||
__type: '$$MessageEventBusDestinationSyslog',
|
||||
host: 'localhost',
|
||||
facility: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'syslog with maximum facility (23)',
|
||||
request: {
|
||||
__type: '$$MessageEventBusDestinationSyslog',
|
||||
host: 'localhost',
|
||||
facility: 23,
|
||||
},
|
||||
parsedResult: {
|
||||
__type: '$$MessageEventBusDestinationSyslog',
|
||||
host: 'localhost',
|
||||
facility: 23,
|
||||
},
|
||||
},
|
||||
])('should validate $name', ({ request, parsedResult }) => {
|
||||
const result = CreateDestinationDto.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toMatchObject(parsedResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'invalid __type',
|
||||
request: {
|
||||
__type: 'InvalidType',
|
||||
url: 'https://example.com/webhook',
|
||||
},
|
||||
expectedErrorPaths: ['__type'],
|
||||
},
|
||||
{
|
||||
name: 'webhook missing required url',
|
||||
request: {
|
||||
__type: '$$MessageEventBusDestinationWebhook',
|
||||
},
|
||||
expectedErrorPaths: ['url'],
|
||||
},
|
||||
{
|
||||
name: 'webhook with invalid url',
|
||||
request: {
|
||||
__type: '$$MessageEventBusDestinationWebhook',
|
||||
url: 'not-a-url',
|
||||
},
|
||||
expectedErrorPaths: ['url'],
|
||||
},
|
||||
{
|
||||
name: 'webhook with invalid authentication type',
|
||||
request: {
|
||||
__type: '$$MessageEventBusDestinationWebhook',
|
||||
url: 'https://example.com/webhook',
|
||||
authentication: 'invalidAuth',
|
||||
},
|
||||
expectedErrorPaths: ['authentication'],
|
||||
},
|
||||
{
|
||||
name: 'sentry missing required dsn',
|
||||
request: {
|
||||
__type: '$$MessageEventBusDestinationSentry',
|
||||
},
|
||||
expectedErrorPaths: ['dsn'],
|
||||
},
|
||||
{
|
||||
name: 'sentry with invalid dsn',
|
||||
request: {
|
||||
__type: '$$MessageEventBusDestinationSentry',
|
||||
dsn: 'not-a-url',
|
||||
},
|
||||
expectedErrorPaths: ['dsn'],
|
||||
},
|
||||
{
|
||||
name: 'sentry with traces sample rate below 0',
|
||||
request: {
|
||||
__type: '$$MessageEventBusDestinationSentry',
|
||||
dsn: 'https://public@sentry.io/1',
|
||||
tracesSampleRate: -0.1,
|
||||
},
|
||||
expectedErrorPaths: ['tracesSampleRate'],
|
||||
},
|
||||
{
|
||||
name: 'sentry with traces sample rate above 1',
|
||||
request: {
|
||||
__type: '$$MessageEventBusDestinationSentry',
|
||||
dsn: 'https://public@sentry.io/1',
|
||||
tracesSampleRate: 1.1,
|
||||
},
|
||||
expectedErrorPaths: ['tracesSampleRate'],
|
||||
},
|
||||
{
|
||||
name: 'syslog missing required host',
|
||||
request: {
|
||||
__type: '$$MessageEventBusDestinationSyslog',
|
||||
},
|
||||
expectedErrorPaths: ['host'],
|
||||
},
|
||||
{
|
||||
name: 'syslog with empty host',
|
||||
request: {
|
||||
__type: '$$MessageEventBusDestinationSyslog',
|
||||
host: '',
|
||||
},
|
||||
expectedErrorPaths: ['host'],
|
||||
},
|
||||
{
|
||||
name: 'syslog with invalid port (negative)',
|
||||
request: {
|
||||
__type: '$$MessageEventBusDestinationSyslog',
|
||||
host: 'localhost',
|
||||
port: -1,
|
||||
},
|
||||
expectedErrorPaths: ['port'],
|
||||
},
|
||||
{
|
||||
name: 'syslog with invalid port (zero)',
|
||||
request: {
|
||||
__type: '$$MessageEventBusDestinationSyslog',
|
||||
host: 'localhost',
|
||||
port: 0,
|
||||
},
|
||||
expectedErrorPaths: ['port'],
|
||||
},
|
||||
{
|
||||
name: 'syslog with invalid protocol',
|
||||
request: {
|
||||
__type: '$$MessageEventBusDestinationSyslog',
|
||||
host: 'localhost',
|
||||
protocol: 'http',
|
||||
},
|
||||
expectedErrorPaths: ['protocol'],
|
||||
},
|
||||
{
|
||||
name: 'syslog with facility below 0',
|
||||
request: {
|
||||
__type: '$$MessageEventBusDestinationSyslog',
|
||||
host: 'localhost',
|
||||
facility: -1,
|
||||
},
|
||||
expectedErrorPaths: ['facility'],
|
||||
},
|
||||
{
|
||||
name: 'syslog with facility above 23',
|
||||
request: {
|
||||
__type: '$$MessageEventBusDestinationSyslog',
|
||||
host: 'localhost',
|
||||
facility: 24,
|
||||
},
|
||||
expectedErrorPaths: ['facility'],
|
||||
},
|
||||
{
|
||||
name: 'webhook with flat proxy (missing fixedCollection nesting)',
|
||||
request: {
|
||||
__type: '$$MessageEventBusDestinationWebhook',
|
||||
url: 'https://example.com/webhook',
|
||||
options: {
|
||||
proxy: {
|
||||
protocol: 'http',
|
||||
host: '127.0.0.1',
|
||||
port: 3128,
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErrorPaths: ['proxy'],
|
||||
},
|
||||
{
|
||||
name: 'circuit breaker with negative maxFailures',
|
||||
request: {
|
||||
__type: '$$MessageEventBusDestinationWebhook',
|
||||
url: 'https://example.com/webhook',
|
||||
circuitBreaker: {
|
||||
maxFailures: -1,
|
||||
},
|
||||
},
|
||||
expectedErrorPaths: ['circuitBreaker', 'maxFailures'],
|
||||
},
|
||||
{
|
||||
name: 'circuit breaker with zero maxFailures',
|
||||
request: {
|
||||
__type: '$$MessageEventBusDestinationWebhook',
|
||||
url: 'https://example.com/webhook',
|
||||
circuitBreaker: {
|
||||
maxFailures: 0,
|
||||
},
|
||||
},
|
||||
expectedErrorPaths: ['circuitBreaker', 'maxFailures'],
|
||||
},
|
||||
])('should fail validation for $name', ({ request, expectedErrorPaths }) => {
|
||||
const result = CreateDestinationDto.safeParse(request);
|
||||
const issuesPaths = result.error?.issues.map((issue) => issue.path.join('.')) ?? [];
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
// Check that all expected error paths are present
|
||||
for (const expectedPath of expectedErrorPaths) {
|
||||
expect(issuesPaths.some((path) => path.includes(expectedPath))).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
import { DeleteDestinationQueryDto } from '../delete-destination-query.dto';
|
||||
|
||||
describe('DeleteDestinationQueryDto', () => {
|
||||
describe('Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'valid UUID',
|
||||
request: {
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
},
|
||||
parsedResult: {
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'another valid UUID',
|
||||
request: {
|
||||
id: '123e4567-e89b-12d3-a456-426614174000',
|
||||
},
|
||||
parsedResult: {
|
||||
id: '123e4567-e89b-12d3-a456-426614174000',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'non-UUID string (backward compatibility)',
|
||||
request: {
|
||||
id: 'e2e-tls-test',
|
||||
},
|
||||
parsedResult: {
|
||||
id: 'e2e-tls-test',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'short string',
|
||||
request: {
|
||||
id: '550e8400-e29b-41d4',
|
||||
},
|
||||
parsedResult: {
|
||||
id: '550e8400-e29b-41d4',
|
||||
},
|
||||
},
|
||||
])('should validate $name', ({ request, parsedResult }) => {
|
||||
const result = DeleteDestinationQueryDto.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toMatchObject(parsedResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'missing id',
|
||||
request: {},
|
||||
expectedErrorPaths: ['id'],
|
||||
},
|
||||
{
|
||||
name: 'empty string',
|
||||
request: {
|
||||
id: '',
|
||||
},
|
||||
expectedErrorPaths: ['id'],
|
||||
},
|
||||
{
|
||||
name: 'numeric value',
|
||||
request: {
|
||||
id: 123,
|
||||
},
|
||||
expectedErrorPaths: ['id'],
|
||||
},
|
||||
{
|
||||
name: 'null value',
|
||||
request: {
|
||||
id: null,
|
||||
},
|
||||
expectedErrorPaths: ['id'],
|
||||
},
|
||||
])('should fail validation for $name', ({ request, expectedErrorPaths }) => {
|
||||
const result = DeleteDestinationQueryDto.safeParse(request);
|
||||
const issuesPaths = new Set(result.error?.issues.map((issue) => issue.path[0]));
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(new Set(issuesPaths)).toEqual(new Set(expectedErrorPaths));
|
||||
});
|
||||
});
|
||||
});
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import { GetDestinationQueryDto } from '../get-destination-query.dto';
|
||||
|
||||
describe('GetDestinationQueryDto', () => {
|
||||
describe('Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'empty object (no id - get all destinations)',
|
||||
request: {},
|
||||
parsedResult: {},
|
||||
},
|
||||
{
|
||||
name: 'valid UUID',
|
||||
request: {
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
},
|
||||
parsedResult: {
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'another valid UUID',
|
||||
request: {
|
||||
id: '123e4567-e89b-12d3-a456-426614174000',
|
||||
},
|
||||
parsedResult: {
|
||||
id: '123e4567-e89b-12d3-a456-426614174000',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'non-UUID string (backward compatibility)',
|
||||
request: {
|
||||
id: 'e2e-tls-test',
|
||||
},
|
||||
parsedResult: {
|
||||
id: 'e2e-tls-test',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'short string',
|
||||
request: {
|
||||
id: '550e8400-e29b-41d4',
|
||||
},
|
||||
parsedResult: {
|
||||
id: '550e8400-e29b-41d4',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'string without dashes',
|
||||
request: {
|
||||
id: '550e8400e29b41d4a716446655440000',
|
||||
},
|
||||
parsedResult: {
|
||||
id: '550e8400e29b41d4a716446655440000',
|
||||
},
|
||||
},
|
||||
])('should validate $name', ({ request, parsedResult }) => {
|
||||
const result = GetDestinationQueryDto.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
if (parsedResult) {
|
||||
expect(result.data).toMatchObject(parsedResult);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'empty string',
|
||||
request: {
|
||||
id: '',
|
||||
},
|
||||
expectedErrorPaths: ['id'],
|
||||
},
|
||||
{
|
||||
name: 'numeric value',
|
||||
request: {
|
||||
id: 123,
|
||||
},
|
||||
expectedErrorPaths: ['id'],
|
||||
},
|
||||
])('should fail validation for $name', ({ request, expectedErrorPaths }) => {
|
||||
const result = GetDestinationQueryDto.safeParse(request);
|
||||
const issuesPaths = new Set(result.error?.issues.map((issue) => issue.path[0]));
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(new Set(issuesPaths)).toEqual(new Set(expectedErrorPaths));
|
||||
});
|
||||
});
|
||||
});
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
import { TestDestinationQueryDto } from '../test-destination-query.dto';
|
||||
|
||||
describe('TestDestinationQueryDto', () => {
|
||||
describe('Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'valid UUID',
|
||||
request: {
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
},
|
||||
parsedResult: {
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'another valid UUID',
|
||||
request: {
|
||||
id: '123e4567-e89b-12d3-a456-426614174000',
|
||||
},
|
||||
parsedResult: {
|
||||
id: '123e4567-e89b-12d3-a456-426614174000',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'non-UUID string (backward compatibility)',
|
||||
request: {
|
||||
id: 'e2e-tls-test',
|
||||
},
|
||||
parsedResult: {
|
||||
id: 'e2e-tls-test',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'short string',
|
||||
request: {
|
||||
id: '550e8400-e29b-41d4',
|
||||
},
|
||||
parsedResult: {
|
||||
id: '550e8400-e29b-41d4',
|
||||
},
|
||||
},
|
||||
])('should validate $name', ({ request, parsedResult }) => {
|
||||
const result = TestDestinationQueryDto.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toMatchObject(parsedResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'missing id',
|
||||
request: {},
|
||||
expectedErrorPaths: ['id'],
|
||||
},
|
||||
{
|
||||
name: 'empty string',
|
||||
request: {
|
||||
id: '',
|
||||
},
|
||||
expectedErrorPaths: ['id'],
|
||||
},
|
||||
{
|
||||
name: 'numeric value',
|
||||
request: {
|
||||
id: 123,
|
||||
},
|
||||
expectedErrorPaths: ['id'],
|
||||
},
|
||||
{
|
||||
name: 'null value',
|
||||
request: {
|
||||
id: null,
|
||||
},
|
||||
expectedErrorPaths: ['id'],
|
||||
},
|
||||
])('should fail validation for $name', ({ request, expectedErrorPaths }) => {
|
||||
const result = TestDestinationQueryDto.safeParse(request);
|
||||
const issuesPaths = new Set(result.error?.issues.map((issue) => issue.path[0]));
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(new Set(issuesPaths)).toEqual(new Set(expectedErrorPaths));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import {
|
||||
MessageEventBusDestinationSentryOptionsSchema,
|
||||
MessageEventBusDestinationSyslogOptionsSchema,
|
||||
MessageEventBusDestinationWebhookOptionsSchema,
|
||||
} from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Union of all destination types - discriminated union based on __type field
|
||||
export const CreateDestinationDto = z.discriminatedUnion('__type', [
|
||||
MessageEventBusDestinationWebhookOptionsSchema,
|
||||
MessageEventBusDestinationSentryOptionsSchema,
|
||||
MessageEventBusDestinationSyslogOptionsSchema,
|
||||
]);
|
||||
|
||||
// Type exports for use in other files - re-export from workflow package
|
||||
export type {
|
||||
MessageEventBusDestinationWebhookOptions as WebhookDestination,
|
||||
MessageEventBusDestinationSentryOptions as SentryDestination,
|
||||
MessageEventBusDestinationSyslogOptions as SyslogDestination,
|
||||
} from 'n8n-workflow';
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class DeleteDestinationQueryDto extends Z.class({
|
||||
id: z.string().min(1),
|
||||
}) {}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class GetDestinationQueryDto extends Z.class({
|
||||
id: z.string().min(1).optional(),
|
||||
}) {}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class TestDestinationQueryDto extends Z.class({
|
||||
id: z.string().min(1),
|
||||
}) {}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
/**
|
||||
* Schema for node type identifier in the format "name@version"
|
||||
* e.g., "n8n-nodes-base.httpRequest@4.2" or "n8n-nodes-base.if@2"
|
||||
*/
|
||||
const nodeTypeIdentifierSchema = z
|
||||
.string()
|
||||
.regex(
|
||||
/^[\w.-]+@\d+(\.\d+)?(\.\d+)?$/,
|
||||
'Invalid node type identifier format. Expected "name@version"',
|
||||
);
|
||||
|
||||
export class GetNodeTypesByIdentifierRequestDto extends Z.class({
|
||||
/**
|
||||
* Array of node type identifiers in the format "name@version"
|
||||
* e.g., ["n8n-nodes-base.httpRequest@4.2", "n8n-nodes-base.if@2"]
|
||||
*/
|
||||
identifiers: z.array(nodeTypeIdentifierSchema).min(1).max(1000),
|
||||
}) {}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './oauth-client.dto';
|
||||
@@ -0,0 +1,42 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
/**
|
||||
* DTO for OAuth client response (excludes sensitive data like clientSecret)
|
||||
*/
|
||||
export class OAuthClientResponseDto extends Z.class({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
redirectUris: z.array(z.string()),
|
||||
grantTypes: z.array(z.string()),
|
||||
tokenEndpointAuthMethod: z.string(),
|
||||
createdAt: z.string().datetime(), // Using string for date serialization over HTTP
|
||||
updatedAt: z.string().datetime(),
|
||||
}) {}
|
||||
|
||||
/**
|
||||
* DTO for listing OAuth clients response
|
||||
*/
|
||||
export class ListOAuthClientsResponseDto extends Z.class({
|
||||
data: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
redirectUris: z.array(z.string()),
|
||||
grantTypes: z.array(z.string()),
|
||||
tokenEndpointAuthMethod: z.string(),
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
}),
|
||||
),
|
||||
count: z.number(),
|
||||
}) {}
|
||||
|
||||
/**
|
||||
* DTO for deleting an OAuth client response
|
||||
*/
|
||||
export class DeleteOAuthClientResponseDto extends Z.class({
|
||||
success: z.boolean(),
|
||||
message: z.string(),
|
||||
}) {}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class OidcConfigDto extends Z.class({
|
||||
clientId: z.string().min(1),
|
||||
clientSecret: z.string().min(1),
|
||||
discoveryEndpoint: z.string().url(),
|
||||
loginEnabled: z.boolean().optional().default(false),
|
||||
prompt: z
|
||||
.enum(['none', 'login', 'consent', 'select_account', 'create'])
|
||||
.optional()
|
||||
.default('select_account'),
|
||||
authenticationContextClassReference: z.array(z.string()).default([]),
|
||||
}) {}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user