diff --git a/src/tools/args.ts b/src/tools/args.ts new file mode 100644 index 000000000..165f3da0d --- /dev/null +++ b/src/tools/args.ts @@ -0,0 +1,70 @@ +import { z, type ZodString } from "zod"; + +const NO_UNICODE_REGEX = /^[\x20-\x7E]*$/; +export const NO_UNICODE_ERROR = "String cannot contain special characters or Unicode symbols"; + +const ALLOWED_USERNAME_CHARACTERS_REGEX = /^[a-zA-Z0-9._-]+$/; +export const ALLOWED_USERNAME_CHARACTERS_ERROR = + "Username can only contain letters, numbers, dots, hyphens, and underscores"; + +const ALLOWED_REGION_CHARACTERS_REGEX = /^[a-zA-Z0-9_-]+$/; +export const ALLOWED_REGION_CHARACTERS_ERROR = "Region can only contain letters, numbers, hyphens, and underscores"; + +const ALLOWED_CLUSTER_NAME_CHARACTERS_REGEX = /^[a-zA-Z0-9_-]+$/; +export const ALLOWED_CLUSTER_NAME_CHARACTERS_ERROR = + "Cluster names can only contain ASCII letters, numbers, and hyphens."; + +const ALLOWED_PROJECT_NAME_CHARACTERS_REGEX = /^[a-zA-Z0-9\s()@&+:._',-]+$/; +export const ALLOWED_PROJECT_NAME_CHARACTERS_ERROR = + "Project names can't be longer than 64 characters and can only contain letters, numbers, spaces, and the following symbols: ( ) @ & + : . _ - ' ,"; +export const CommonArgs = { + string: (): ZodString => z.string().regex(NO_UNICODE_REGEX, NO_UNICODE_ERROR), + + objectId: (fieldName: string): z.ZodString => + z + .string() + .min(1, `${fieldName} is required`) + .length(24, `${fieldName} must be exactly 24 characters`) + .regex(/^[0-9a-fA-F]+$/, `${fieldName} must contain only hexadecimal characters`), +}; + +export const AtlasArgs = { + projectId: (): z.ZodString => CommonArgs.objectId("projectId"), + + organizationId: (): z.ZodString => CommonArgs.objectId("organizationId"), + + clusterName: (): z.ZodString => + z + .string() + .min(1, "Cluster name is required") + .max(64, "Cluster name must be 64 characters or less") + .regex(ALLOWED_CLUSTER_NAME_CHARACTERS_REGEX, ALLOWED_CLUSTER_NAME_CHARACTERS_ERROR), + + projectName: (): z.ZodString => + z + .string() + .min(1, "Project name is required") + .max(64, "Project name must be 64 characters or less") + .regex(ALLOWED_PROJECT_NAME_CHARACTERS_REGEX, ALLOWED_PROJECT_NAME_CHARACTERS_ERROR), + + username: (): z.ZodString => + z + .string() + .min(1, "Username is required") + .max(100, "Username must be 100 characters or less") + .regex(ALLOWED_USERNAME_CHARACTERS_REGEX, ALLOWED_USERNAME_CHARACTERS_ERROR), + + ipAddress: (): z.ZodString => z.string().ip({ version: "v4" }), + + cidrBlock: (): z.ZodString => z.string().cidr(), + + region: (): z.ZodString => + z + .string() + .min(1, "Region is required") + .max(50, "Region must be 50 characters or less") + .regex(ALLOWED_REGION_CHARACTERS_REGEX, ALLOWED_REGION_CHARACTERS_ERROR), + + password: (): z.ZodString => + z.string().min(1, "Password is required").max(100, "Password must be 100 characters or less"), +}; diff --git a/src/tools/atlas/atlasTool.ts b/src/tools/atlas/atlasTool.ts index 452f2e794..b68eeafde 100644 --- a/src/tools/atlas/atlasTool.ts +++ b/src/tools/atlas/atlasTool.ts @@ -1,6 +1,5 @@ -import type { ToolCategory, TelemetryToolMetadata, ToolArgs } from "../tool.js"; -import { ToolBase } from "../tool.js"; import type { ToolCallback } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { ToolBase, type ToolArgs, type ToolCategory, type TelemetryToolMetadata } from "../tool.js"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { LogId } from "../../common/logger.js"; import { z } from "zod"; diff --git a/src/tools/atlas/connect/connectCluster.ts b/src/tools/atlas/connect/connectCluster.ts index 618f5483b..1baf0c6f7 100644 --- a/src/tools/atlas/connect/connectCluster.ts +++ b/src/tools/atlas/connect/connectCluster.ts @@ -1,13 +1,13 @@ -import { z } from "zod"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { type OperationType, type ToolArgs } from "../../tool.js"; import { AtlasToolBase } from "../atlasTool.js"; -import type { ToolArgs, OperationType } from "../../tool.js"; import { generateSecurePassword } from "../../../helpers/generatePassword.js"; import { LogId } from "../../../common/logger.js"; import { inspectCluster } from "../../../common/atlas/cluster.js"; import { ensureCurrentIpInAccessList } from "../../../common/atlas/accessListUtils.js"; import type { AtlasClusterConnectionInfo } from "../../../common/connectionManager.js"; import { getDefaultRoleFromConfig } from "../../../common/atlas/roles.js"; +import { AtlasArgs } from "../../args.js"; const EXPIRY_MS = 1000 * 60 * 60 * 12; // 12 hours const addedIpAccessListMessage = @@ -20,13 +20,17 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +export const ConnectClusterArgs = { + projectId: AtlasArgs.projectId().describe("Atlas project ID"), + clusterName: AtlasArgs.clusterName().describe("Atlas cluster name"), +}; + export class ConnectClusterTool extends AtlasToolBase { public name = "atlas-connect-cluster"; protected description = "Connect to MongoDB Atlas cluster"; public operationType: OperationType = "connect"; protected argsShape = { - projectId: z.string().describe("Atlas project ID"), - clusterName: z.string().describe("Atlas cluster name"), + ...ConnectClusterArgs, }; private queryConnection( diff --git a/src/tools/atlas/create/createAccessList.ts b/src/tools/atlas/create/createAccessList.ts index c7f5d43d8..0cf3b808e 100644 --- a/src/tools/atlas/create/createAccessList.ts +++ b/src/tools/atlas/create/createAccessList.ts @@ -1,26 +1,27 @@ import { z } from "zod"; +import { type OperationType, type ToolArgs } from "../../tool.js"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { AtlasToolBase } from "../atlasTool.js"; -import type { ToolArgs, OperationType } from "../../tool.js"; import { makeCurrentIpAccessListEntry, DEFAULT_ACCESS_LIST_COMMENT } from "../../../common/atlas/accessListUtils.js"; +import { AtlasArgs, CommonArgs } from "../../args.js"; + +export const CreateAccessListArgs = { + projectId: AtlasArgs.projectId().describe("Atlas project ID"), + ipAddresses: z.array(AtlasArgs.ipAddress()).describe("IP addresses to allow access from").optional(), + cidrBlocks: z.array(AtlasArgs.cidrBlock()).describe("CIDR blocks to allow access from").optional(), + currentIpAddress: z.boolean().describe("Add the current IP address").default(false), + comment: CommonArgs.string() + .describe("Comment for the access list entries") + .default(DEFAULT_ACCESS_LIST_COMMENT) + .optional(), +}; export class CreateAccessListTool extends AtlasToolBase { public name = "atlas-create-access-list"; protected description = "Allow Ip/CIDR ranges to access your MongoDB Atlas clusters."; public operationType: OperationType = "create"; protected argsShape = { - projectId: z.string().describe("Atlas project ID"), - ipAddresses: z - .array(z.string().ip({ version: "v4" })) - .describe("IP addresses to allow access from") - .optional(), - cidrBlocks: z.array(z.string().cidr()).describe("CIDR blocks to allow access from").optional(), - currentIpAddress: z.boolean().describe("Add the current IP address").default(false), - comment: z - .string() - .describe("Comment for the access list entries") - .default(DEFAULT_ACCESS_LIST_COMMENT) - .optional(), + ...CreateAccessListArgs, }; protected async execute({ diff --git a/src/tools/atlas/create/createDBUser.ts b/src/tools/atlas/create/createDBUser.ts index a807c44f5..18d22d358 100644 --- a/src/tools/atlas/create/createDBUser.ts +++ b/src/tools/atlas/create/createDBUser.ts @@ -1,41 +1,45 @@ import { z } from "zod"; +import type { ToolArgs, OperationType } from "../../tool.js"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { AtlasToolBase } from "../atlasTool.js"; -import type { ToolArgs, OperationType } from "../../tool.js"; import type { CloudDatabaseUser, DatabaseUserRole } from "../../../common/atlas/openapi.js"; import { generateSecurePassword } from "../../../helpers/generatePassword.js"; import { ensureCurrentIpInAccessList } from "../../../common/atlas/accessListUtils.js"; +import { AtlasArgs, CommonArgs } from "../../args.js"; + +export const CreateDBUserArgs = { + projectId: AtlasArgs.projectId().describe("Atlas project ID"), + username: AtlasArgs.username().describe("Username for the new user"), + // Models will generate overly simplistic passwords like SecurePassword123 or + // AtlasPassword123, which are easily guessable and exploitable. We're instructing + // the model not to try and generate anything and instead leave the field unset. + password: AtlasArgs.password() + .optional() + .nullable() + .describe( + "Password for the new user. IMPORTANT: If the user hasn't supplied an explicit password, leave it unset and under no circumstances try to generate a random one. A secure password will be generated by the MCP server if necessary." + ), + roles: z + .array( + z.object({ + roleName: CommonArgs.string().describe("Role name"), + databaseName: CommonArgs.string().describe("Database name").default("admin"), + collectionName: CommonArgs.string().describe("Collection name").optional(), + }) + ) + .describe("Roles for the new user"), + clusters: z + .array(AtlasArgs.clusterName()) + .describe("Clusters to assign the user to, leave empty for access to all clusters") + .optional(), +}; export class CreateDBUserTool extends AtlasToolBase { public name = "atlas-create-db-user"; protected description = "Create an MongoDB Atlas database user"; public operationType: OperationType = "create"; protected argsShape = { - projectId: z.string().describe("Atlas project ID"), - username: z.string().describe("Username for the new user"), - // Models will generate overly simplistic passwords like SecurePassword123 or - // AtlasPassword123, which are easily guessable and exploitable. We're instructing - // the model not to try and generate anything and instead leave the field unset. - password: z - .string() - .optional() - .nullable() - .describe( - "Password for the new user. IMPORTANT: If the user hasn't supplied an explicit password, leave it unset and under no circumstances try to generate a random one. A secure password will be generated by the MCP server if necessary." - ), - roles: z - .array( - z.object({ - roleName: z.string().describe("Role name"), - databaseName: z.string().describe("Database name").default("admin"), - collectionName: z.string().describe("Collection name").optional(), - }) - ) - .describe("Roles for the new user"), - clusters: z - .array(z.string()) - .describe("Clusters to assign the user to, leave empty for access to all clusters") - .optional(), + ...CreateDBUserArgs, }; protected async execute({ diff --git a/src/tools/atlas/create/createFreeCluster.ts b/src/tools/atlas/create/createFreeCluster.ts index 5a110d95d..6b1ac98eb 100644 --- a/src/tools/atlas/create/createFreeCluster.ts +++ b/src/tools/atlas/create/createFreeCluster.ts @@ -1,18 +1,18 @@ -import { z } from "zod"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { type ToolArgs, type OperationType } from "../../tool.js"; import { AtlasToolBase } from "../atlasTool.js"; -import type { ToolArgs, OperationType } from "../../tool.js"; import type { ClusterDescription20240805 } from "../../../common/atlas/openapi.js"; import { ensureCurrentIpInAccessList } from "../../../common/atlas/accessListUtils.js"; +import { AtlasArgs } from "../../args.js"; export class CreateFreeClusterTool extends AtlasToolBase { public name = "atlas-create-free-cluster"; protected description = "Create a free MongoDB Atlas cluster"; public operationType: OperationType = "create"; protected argsShape = { - projectId: z.string().describe("Atlas project ID to create the cluster in"), - name: z.string().describe("Name of the cluster"), - region: z.string().describe("Region of the cluster").default("US_EAST_1"), + projectId: AtlasArgs.projectId().describe("Atlas project ID to create the cluster in"), + name: AtlasArgs.clusterName().describe("Name of the cluster"), + region: AtlasArgs.region().describe("Region of the cluster").default("US_EAST_1"), }; protected async execute({ projectId, name, region }: ToolArgs): Promise { diff --git a/src/tools/atlas/create/createProject.ts b/src/tools/atlas/create/createProject.ts index 60753b6b0..b981fd8e8 100644 --- a/src/tools/atlas/create/createProject.ts +++ b/src/tools/atlas/create/createProject.ts @@ -1,16 +1,20 @@ -import { z } from "zod"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { type OperationType, type ToolArgs } from "../../tool.js"; import { AtlasToolBase } from "../atlasTool.js"; -import type { ToolArgs, OperationType } from "../../tool.js"; import type { Group } from "../../../common/atlas/openapi.js"; +import { AtlasArgs } from "../../args.js"; + +export const CreateProjectArgs = { + projectName: AtlasArgs.projectName().optional().describe("Name for the new project"), + organizationId: AtlasArgs.organizationId().optional().describe("Organization ID for the new project"), +}; export class CreateProjectTool extends AtlasToolBase { public name = "atlas-create-project"; protected description = "Create a MongoDB Atlas project"; public operationType: OperationType = "create"; protected argsShape = { - projectName: z.string().optional().describe("Name for the new project"), - organizationId: z.string().optional().describe("Organization ID for the new project"), + ...CreateProjectArgs, }; protected async execute({ projectName, organizationId }: ToolArgs): Promise { diff --git a/src/tools/atlas/read/inspectAccessList.ts b/src/tools/atlas/read/inspectAccessList.ts index 7eedf6ed7..6c8eaed30 100644 --- a/src/tools/atlas/read/inspectAccessList.ts +++ b/src/tools/atlas/read/inspectAccessList.ts @@ -1,15 +1,18 @@ -import { z } from "zod"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { type OperationType, type ToolArgs, formatUntrustedData } from "../../tool.js"; import { AtlasToolBase } from "../atlasTool.js"; -import type { ToolArgs, OperationType } from "../../tool.js"; -import { formatUntrustedData } from "../../tool.js"; +import { AtlasArgs } from "../../args.js"; + +export const InspectAccessListArgs = { + projectId: AtlasArgs.projectId().describe("Atlas project ID"), +}; export class InspectAccessListTool extends AtlasToolBase { public name = "atlas-inspect-access-list"; protected description = "Inspect Ip/CIDR ranges with access to your MongoDB Atlas clusters."; public operationType: OperationType = "read"; protected argsShape = { - projectId: z.string().describe("Atlas project ID"), + ...InspectAccessListArgs, }; protected async execute({ projectId }: ToolArgs): Promise { diff --git a/src/tools/atlas/read/inspectCluster.ts b/src/tools/atlas/read/inspectCluster.ts index feb5f5ac2..56e1e5a8b 100644 --- a/src/tools/atlas/read/inspectCluster.ts +++ b/src/tools/atlas/read/inspectCluster.ts @@ -1,18 +1,21 @@ -import { z } from "zod"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { type OperationType, type ToolArgs, formatUntrustedData } from "../../tool.js"; import { AtlasToolBase } from "../atlasTool.js"; -import type { ToolArgs, OperationType } from "../../tool.js"; -import { formatUntrustedData } from "../../tool.js"; import type { Cluster } from "../../../common/atlas/cluster.js"; import { inspectCluster } from "../../../common/atlas/cluster.js"; +import { AtlasArgs } from "../../args.js"; + +export const InspectClusterArgs = { + projectId: AtlasArgs.projectId().describe("Atlas project ID"), + clusterName: AtlasArgs.clusterName().describe("Atlas cluster name"), +}; export class InspectClusterTool extends AtlasToolBase { public name = "atlas-inspect-cluster"; protected description = "Inspect MongoDB Atlas cluster"; public operationType: OperationType = "read"; protected argsShape = { - projectId: z.string().describe("Atlas project ID"), - clusterName: z.string().describe("Atlas cluster name"), + ...InspectClusterArgs, }; protected async execute({ projectId, clusterName }: ToolArgs): Promise { diff --git a/src/tools/atlas/read/listAlerts.ts b/src/tools/atlas/read/listAlerts.ts index 8ab4666c7..d55a917f8 100644 --- a/src/tools/atlas/read/listAlerts.ts +++ b/src/tools/atlas/read/listAlerts.ts @@ -1,15 +1,18 @@ -import { z } from "zod"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { type OperationType, type ToolArgs, formatUntrustedData } from "../../tool.js"; import { AtlasToolBase } from "../atlasTool.js"; -import type { ToolArgs, OperationType } from "../../tool.js"; -import { formatUntrustedData } from "../../tool.js"; +import { AtlasArgs } from "../../args.js"; + +export const ListAlertsArgs = { + projectId: AtlasArgs.projectId().describe("Atlas project ID to list alerts for"), +}; export class ListAlertsTool extends AtlasToolBase { public name = "atlas-list-alerts"; protected description = "List MongoDB Atlas alerts"; public operationType: OperationType = "read"; protected argsShape = { - projectId: z.string().describe("Atlas project ID to list alerts for"), + ...ListAlertsArgs, }; protected async execute({ projectId }: ToolArgs): Promise { diff --git a/src/tools/atlas/read/listClusters.ts b/src/tools/atlas/read/listClusters.ts index e3894b3f6..5c8e72a30 100644 --- a/src/tools/atlas/read/listClusters.ts +++ b/src/tools/atlas/read/listClusters.ts @@ -1,4 +1,3 @@ -import { z } from "zod"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { AtlasToolBase } from "../atlasTool.js"; import type { ToolArgs, OperationType } from "../../tool.js"; @@ -10,13 +9,18 @@ import type { PaginatedFlexClusters20241113, } from "../../../common/atlas/openapi.js"; import { formatCluster, formatFlexCluster } from "../../../common/atlas/cluster.js"; +import { AtlasArgs } from "../../args.js"; + +export const ListClustersArgs = { + projectId: AtlasArgs.projectId().describe("Atlas project ID to filter clusters").optional(), +}; export class ListClustersTool extends AtlasToolBase { public name = "atlas-list-clusters"; protected description = "List MongoDB Atlas clusters"; public operationType: OperationType = "read"; protected argsShape = { - projectId: z.string().describe("Atlas project ID to filter clusters").optional(), + ...ListClustersArgs, }; protected async execute({ projectId }: ToolArgs): Promise { diff --git a/src/tools/atlas/read/listDBUsers.ts b/src/tools/atlas/read/listDBUsers.ts index 26bb28b93..5ab23250c 100644 --- a/src/tools/atlas/read/listDBUsers.ts +++ b/src/tools/atlas/read/listDBUsers.ts @@ -1,16 +1,20 @@ -import { z } from "zod"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { AtlasToolBase } from "../atlasTool.js"; import type { ToolArgs, OperationType } from "../../tool.js"; import { formatUntrustedData } from "../../tool.js"; import type { DatabaseUserRole, UserScope } from "../../../common/atlas/openapi.js"; +import { AtlasArgs } from "../../args.js"; + +export const ListDBUsersArgs = { + projectId: AtlasArgs.projectId().describe("Atlas project ID to filter DB users"), +}; export class ListDBUsersTool extends AtlasToolBase { public name = "atlas-list-db-users"; protected description = "List MongoDB Atlas database users"; public operationType: OperationType = "read"; protected argsShape = { - projectId: z.string().describe("Atlas project ID to filter DB users"), + ...ListDBUsersArgs, }; protected async execute({ projectId }: ToolArgs): Promise { diff --git a/src/tools/atlas/read/listProjects.ts b/src/tools/atlas/read/listProjects.ts index d5d3931b0..3b7d24939 100644 --- a/src/tools/atlas/read/listProjects.ts +++ b/src/tools/atlas/read/listProjects.ts @@ -2,15 +2,19 @@ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { AtlasToolBase } from "../atlasTool.js"; import type { OperationType } from "../../tool.js"; import { formatUntrustedData } from "../../tool.js"; -import { z } from "zod"; import type { ToolArgs } from "../../tool.js"; +import { AtlasArgs } from "../../args.js"; + +export const ListProjectsArgs = { + orgId: AtlasArgs.organizationId().describe("Atlas organization ID to filter projects").optional(), +}; export class ListProjectsTool extends AtlasToolBase { public name = "atlas-list-projects"; protected description = "List MongoDB Atlas projects"; public operationType: OperationType = "read"; protected argsShape = { - orgId: z.string().describe("Atlas organization ID to filter projects").optional(), + ...ListProjectsArgs, }; protected async execute({ orgId }: ToolArgs): Promise { diff --git a/tests/unit/args.test.ts b/tests/unit/args.test.ts new file mode 100644 index 000000000..d7a5a1eb4 --- /dev/null +++ b/tests/unit/args.test.ts @@ -0,0 +1,397 @@ +import { describe, expect, it } from "vitest"; +import { + AtlasArgs, + CommonArgs, + ALLOWED_PROJECT_NAME_CHARACTERS_ERROR, + ALLOWED_USERNAME_CHARACTERS_ERROR, + ALLOWED_REGION_CHARACTERS_ERROR, + ALLOWED_CLUSTER_NAME_CHARACTERS_ERROR, + NO_UNICODE_ERROR, +} from "../../src/tools/args.js"; + +describe("Tool args", () => { + describe("CommonArgs", () => { + describe("string", () => { + it("should return a ZodString schema", () => { + const schema = CommonArgs.string(); + expect(schema).toBeDefined(); + expect(schema.parse("test")).toBe("test"); + }); + + it("should accept any string value", () => { + const schema = CommonArgs.string(); + expect(schema.parse("hello")).toBe("hello"); + expect(schema.parse("123")).toBe("123"); + expect(schema.parse("test@#$%")).toBe("test@#$%"); + }); + + it("should not allow special characters and unicode symbols", () => { + const schema = CommonArgs.string(); + + // Unicode characters + expect(() => schema.parse("héllo")).toThrow(NO_UNICODE_ERROR); + expect(() => schema.parse("测试")).toThrow(NO_UNICODE_ERROR); + expect(() => schema.parse("café")).toThrow(NO_UNICODE_ERROR); + + // Emojis + expect(() => schema.parse("🚀")).toThrow(NO_UNICODE_ERROR); + expect(() => schema.parse("hello😀")).toThrow(NO_UNICODE_ERROR); + + // Control characters (below ASCII 32) + expect(() => schema.parse("hello\nworld")).toThrow(NO_UNICODE_ERROR); + expect(() => schema.parse("hello\tworld")).toThrow(NO_UNICODE_ERROR); + expect(() => schema.parse("hello\0world")).toThrow(NO_UNICODE_ERROR); + + // Extended ASCII characters (above ASCII 126) + expect(() => schema.parse("hello\x80")).toThrow(NO_UNICODE_ERROR); + expect(() => schema.parse("hello\xFF")).toThrow(NO_UNICODE_ERROR); + }); + + it("should reject non-string values", () => { + const schema = CommonArgs.string(); + expect(() => schema.parse(123)).toThrow(); + expect(() => schema.parse(null)).toThrow(); + expect(() => schema.parse(undefined)).toThrow(); + expect(() => schema.parse({})).toThrow(); + }); + }); + + describe("objectId", () => { + it("should validate 24-character hexadecimal strings", () => { + const schema = CommonArgs.objectId("Test ID"); + const validId = "507f1f77bcf86cd799439011"; + expect(schema.parse(validId)).toBe(validId); + }); + + it("should reject invalid ObjectId formats", () => { + const schema = CommonArgs.objectId("Test ID"); + + // Too short + expect(() => schema.parse("507f1f77bcf86cd79943901")).toThrow(); + + // Too long + expect(() => schema.parse("507f1f77bcf86cd7994390111")).toThrow(); + + // Invalid characters + expect(() => schema.parse("507f1f77bcf86cd79943901g")).toThrow(); + expect(() => schema.parse("507f1f77bcf86cd79943901!")).toThrow(); + + // Empty string + expect(() => schema.parse("")).toThrow(); + }); + + it("should provide custom field name in error messages", () => { + const schema = CommonArgs.objectId("Custom Field"); + expect(() => schema.parse("invalid")).toThrow("Custom Field must be exactly 24 characters"); + }); + + it("should not fail if the value is optional", () => { + const schema = CommonArgs.objectId("Custom Field").optional(); + expect(schema.parse(undefined)).toBeUndefined(); + }); + + it("should not fail if the value is empty", () => { + const schema = CommonArgs.objectId("Custom Field"); + expect(() => schema.parse(undefined)).toThrow("Required"); + }); + }); + }); + + describe("AtlasArgs", () => { + describe("projectId", () => { + it("should validate project IDs", () => { + const schema = AtlasArgs.projectId(); + const validId = "507f1f77bcf86cd799439011"; + expect(schema.parse(validId)).toBe(validId); + }); + + it("should reject invalid project IDs", () => { + const schema = AtlasArgs.projectId(); + expect(() => schema.parse("invalid")).toThrow("projectId must be exactly 24 characters"); + expect(() => schema.parse("507f1f77bc*86cd79943901")).toThrow( + "projectId must contain only hexadecimal characters" + ); + expect(() => schema.parse("")).toThrow("projectId is required"); + expect(() => schema.parse("507f1f77/bcf86cd799439011")).toThrow( + "projectId must contain only hexadecimal characters" + ); + }); + }); + + describe("organizationId", () => { + it("should validate organization IDs", () => { + const schema = AtlasArgs.organizationId(); + const validId = "507f1f77bcf86cd799439011"; + expect(schema.parse(validId)).toBe(validId); + }); + + it("should reject invalid organization IDs", () => { + const schema = AtlasArgs.organizationId(); + expect(() => schema.parse("invalid")).toThrow("organizationId must be exactly 24 characters"); + }); + }); + + describe("clusterName", () => { + it("should validate valid cluster names", () => { + const schema = AtlasArgs.clusterName(); + const validNames = ["my-cluster", "cluster_1", "Cluster123", "test-cluster-2", "my_cluster_name"]; + + validNames.forEach((name) => { + expect(schema.parse(name)).toBe(name); + }); + }); + + it("should reject invalid cluster names", () => { + const schema = AtlasArgs.clusterName(); + + // Empty string + expect(() => schema.parse("")).toThrow("Cluster name is required"); + + // Too long (over 64 characters) + const longName = "a".repeat(65); + expect(() => schema.parse(longName)).toThrow("Cluster name must be 64 characters or less"); + + // Invalid characters + expect(() => schema.parse("cluster@name")).toThrow(ALLOWED_CLUSTER_NAME_CHARACTERS_ERROR); + expect(() => schema.parse("cluster name")).toThrow(ALLOWED_CLUSTER_NAME_CHARACTERS_ERROR); + expect(() => schema.parse("cluster.name")).toThrow(ALLOWED_CLUSTER_NAME_CHARACTERS_ERROR); + expect(() => schema.parse("cluster/name")).toThrow(ALLOWED_CLUSTER_NAME_CHARACTERS_ERROR); + }); + + it("should accept exactly 64 characters", () => { + const schema = AtlasArgs.clusterName(); + const maxLengthName = "a".repeat(64); + expect(schema.parse(maxLengthName)).toBe(maxLengthName); + }); + }); + + describe("username", () => { + it("should validate valid usernames", () => { + const schema = AtlasArgs.username(); + const validUsernames = ["user123", "user_name", "user.name", "user-name", "User123", "test.user_name"]; + + validUsernames.forEach((username) => { + expect(schema.parse(username)).toBe(username); + }); + }); + + it("should reject invalid usernames", () => { + const schema = AtlasArgs.username(); + + // Empty string + expect(() => schema.parse("")).toThrow("Username is required"); + + // Too long (over 100 characters) + const longUsername = "a".repeat(101); + expect(() => schema.parse(longUsername)).toThrow("Username must be 100 characters or less"); + + // Invalid characters + expect(() => schema.parse("user@name")).toThrow(ALLOWED_USERNAME_CHARACTERS_ERROR); + expect(() => schema.parse("user name")).toThrow(ALLOWED_USERNAME_CHARACTERS_ERROR); + }); + + it("should accept exactly 100 characters", () => { + const schema = AtlasArgs.username(); + const maxLengthUsername = "a".repeat(100); + expect(schema.parse(maxLengthUsername)).toBe(maxLengthUsername); + }); + }); + + describe("ipAddress", () => { + it("should validate valid IPv4 addresses", () => { + const schema = AtlasArgs.ipAddress(); + const validIPs = ["192.168.1.1", "10.0.0.1", "172.16.0.1", "127.0.0.1", "0.0.0.0", "255.255.255.255"]; + + validIPs.forEach((ip) => { + expect(schema.parse(ip)).toBe(ip); + }); + }); + + it("should reject invalid IP addresses", () => { + const schema = AtlasArgs.ipAddress(); + + // Invalid formats + expect(() => schema.parse("192.168.1")).toThrow(); + expect(() => schema.parse("192.168.1.1.1")).toThrow(); + expect(() => schema.parse("192.168.1.256")).toThrow(); + expect(() => schema.parse("192.168.1.-1")).toThrow(); + expect(() => schema.parse("not-an-ip")).toThrow(); + + // IPv6 (should be rejected since we only support IPv4) + expect(() => schema.parse("2001:0db8:85a3:0000:0000:8a2e:0370:7334")).toThrow(); + }); + }); + + describe("cidrBlock", () => { + it("should validate valid CIDR blocks", () => { + const schema = AtlasArgs.cidrBlock(); + const validCIDRs = ["192.168.1.0/24", "10.0.0.0/8", "172.16.0.0/12", "0.0.0.0/0", "192.168.1.1/32"]; + + validCIDRs.forEach((cidr) => { + expect(schema.parse(cidr)).toBe(cidr); + }); + }); + + it("should reject invalid CIDR blocks", () => { + const schema = AtlasArgs.cidrBlock(); + + // Invalid formats + expect(() => schema.parse("192.168.1.0")).toThrow("Invalid cidr"); + expect(() => schema.parse("192.168.1.0/")).toThrow("Invalid cidr"); + expect(() => schema.parse("192.168.1.0/33")).toThrow("Invalid cidr"); + expect(() => schema.parse("192.168.1.256/24")).toThrow("Invalid cidr"); + expect(() => schema.parse("not-a-cidr")).toThrow("Invalid cidr"); + }); + }); + + describe("region", () => { + it("should validate valid region names", () => { + const schema = AtlasArgs.region(); + const validRegions = [ + "US_EAST_1", + "us-west-2", + "eu-central-1", + "ap-southeast-1", + "region_123", + "test-region", + ]; + + validRegions.forEach((region) => { + expect(schema.parse(region)).toBe(region); + }); + }); + + it("should accept exactly 50 characters", () => { + const schema = AtlasArgs.region(); + const maxLengthRegion = "a".repeat(50); + expect(schema.parse(maxLengthRegion)).toBe(maxLengthRegion); + }); + + it("should reject invalid region names", () => { + const schema = AtlasArgs.region(); + + // Empty string + expect(() => schema.parse("")).toThrow("Region is required"); + + // Too long (over 50 characters) + const longRegion = "a".repeat(51); + expect(() => schema.parse(longRegion)).toThrow("Region must be 50 characters or less"); + + // Invalid characters + expect(() => schema.parse("US EAST 1")).toThrow(ALLOWED_REGION_CHARACTERS_ERROR); + expect(() => schema.parse("US.EAST.1")).toThrow(ALLOWED_REGION_CHARACTERS_ERROR); + expect(() => schema.parse("US@EAST#1")).toThrow(ALLOWED_REGION_CHARACTERS_ERROR); + }); + }); + + describe("projectName", () => { + it("should validate valid project names", () => { + const schema = AtlasArgs.projectName(); + const validNames = [ + "my-project", + "project_1", + "Project123", + "test-project-2", + "my_project_name", + "project with spaces", + "project(with)parentheses", + "project@with@at", + "project&with&ersand", + "project+with+plus", + "project:with:colon", + "project.with.dots", + "project'with'apostrophe", + "project,with,comma", + "complex project (with) @all &symbols+here:test.name'value,", + ]; + + validNames.forEach((name) => { + expect(schema.parse(name)).toBe(name); + }); + }); + + it("should reject invalid project names", () => { + const schema = AtlasArgs.projectName(); + + // Empty string + expect(() => schema.parse("")).toThrow("Project name is required"); + + // Too long (over 64 characters) + expect(() => schema.parse("a".repeat(65))).toThrow("Project name must be 64 characters or less"); + + // Invalid characters not in the allowed set + expect(() => schema.parse("project#with#hash")).toThrow(ALLOWED_PROJECT_NAME_CHARACTERS_ERROR); + expect(() => schema.parse("project$with$dollar")).toThrow(ALLOWED_PROJECT_NAME_CHARACTERS_ERROR); + expect(() => schema.parse("project!with!exclamation")).toThrow(ALLOWED_PROJECT_NAME_CHARACTERS_ERROR); + expect(() => schema.parse("project[with]brackets")).toThrow(ALLOWED_PROJECT_NAME_CHARACTERS_ERROR); + }); + + it("should accept exactly 64 characters", () => { + const schema = AtlasArgs.projectName(); + const maxLengthName = "a".repeat(64); + expect(schema.parse(maxLengthName)).toBe(maxLengthName); + }); + }); + + describe("password", () => { + it("should validate valid passwords", () => { + const schema = AtlasArgs.password().optional(); + const validPasswords = ["password123", "password_123", "Password123", "test-password-123"]; + validPasswords.forEach((password) => { + expect(schema.parse(password)).toBe(password); + }); + expect(schema.parse(undefined)).toBeUndefined(); + }); + + it("should reject invalid passwords", () => { + const schema = AtlasArgs.password(); + expect(() => schema.parse("")).toThrow("Password is required"); + expect(() => schema.parse("a".repeat(101))).toThrow("Password must be 100 characters or less"); + }); + }); + }); + + describe("Edge Cases and Security", () => { + it("should handle empty strings appropriately", () => { + const schema = CommonArgs.string(); + expect(schema.parse("")).toBe(""); + + // But AtlasArgs validators should reject empty strings + expect(() => AtlasArgs.clusterName().parse("")).toThrow(); + expect(() => AtlasArgs.username().parse("")).toThrow(); + }); + + it("should handle very long strings", () => { + const schema = CommonArgs.string(); + const longString = "a".repeat(10000); + expect(schema.parse(longString)).toBe(longString); + + // But AtlasArgs validators should enforce length limits + expect(() => AtlasArgs.clusterName().parse("a".repeat(65))).toThrow(); + expect(() => AtlasArgs.username().parse("a".repeat(101))).toThrow(); + }); + + it("should handle null and undefined values", () => { + const schema = CommonArgs.string(); + expect(() => schema.parse(null)).toThrow(); + expect(() => schema.parse(undefined)).toThrow(); + }); + }); + + describe("Error Messages", () => { + it("should provide clear error messages for validation failures", () => { + // Test specific error messages + expect(() => AtlasArgs.clusterName().parse("")).toThrow("Cluster name is required"); + expect(() => AtlasArgs.clusterName().parse("a".repeat(65))).toThrow( + "Cluster name must be 64 characters or less" + ); + expect(() => AtlasArgs.clusterName().parse("invalid@name")).toThrow(ALLOWED_CLUSTER_NAME_CHARACTERS_ERROR); + + expect(() => AtlasArgs.username().parse("")).toThrow("Username is required"); + expect(() => AtlasArgs.username().parse("a".repeat(101))).toThrow( + "Username must be 100 characters or less" + ); + expect(() => AtlasArgs.username().parse("invalid name")).toThrow(ALLOWED_USERNAME_CHARACTERS_ERROR); + }); + }); +});