diff --git a/CHANGELOG.md b/CHANGELOG.md index 4779cc7..fffb290 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,38 @@ All notable changes to this project will be documented in this file. +## [1.3.4] - 2026-01-31 + +### Added + +- **Test Suite:** Comprehensive Jest test infrastructure with 209 tests + - `jest.config.js`: Jest configuration for TypeScript + - `src/utils/__tests__/security.test.ts`: Security utilities tests (44 tests) + - `src/utils/__tests__/validation.test.ts`: Zod validation tests (34 tests) + - `src/utils/__tests__/pagination.test.ts`: Cursor pagination tests (25 tests) + - `src/utils/__tests__/query-builder.test.ts`: Query builder tests (38 tests) + - `src/tools/__tests__/tools-structure.test.ts`: Tools structure validation (68 tests) + +### Tested + +- **Utilities Coverage:** + - UUID, email, URL validation + - Rate limiting behaviour + - HTML escaping and sanitization + - Pagination defaults and limits + - Cursor encoding/decoding + - SQL query building + +- **Tools Structure:** + - All 164 tools validated for correct structure + - Input schemas have required properties defined + - Unique tool names across all modules + - Handlers are functions + +### Dependencies + +- Added `ts-jest` for TypeScript test support + ## [1.3.3] - 2026-01-31 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 6abc7a5..49103f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co MCP server for direct PostgreSQL access to Outline Wiki database. Follows patterns established by `mcp-desk-crm-sql-v3`. -**Version:** 1.3.2 +**Version:** 1.3.4 **Total Tools:** 164 tools across 33 modules **Production:** hub.descomplicar.pt (via SSH tunnel) diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..13e9319 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,31 @@ +/** + * Jest Configuration + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ + +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: ['**/*.test.ts'], + moduleFileExtensions: ['ts', 'js', 'json'], + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.d.ts', + '!src/index.ts', + '!src/index-http.ts' + ], + coverageDirectory: 'coverage', + coverageReporters: ['text', 'lcov', 'html'], + verbose: true, + testTimeout: 10000, + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1' + }, + transform: { + '^.+\\.ts$': ['ts-jest', { + useESM: false, + tsconfig: 'tsconfig.json' + }] + } +}; diff --git a/package-lock.json b/package-lock.json index 69919e6..653dcb5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mcp-outline-postgresql", - "version": "1.0.0", + "version": "1.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mcp-outline-postgresql", - "version": "1.0.0", + "version": "1.3.3", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", @@ -19,6 +19,7 @@ "@types/node": "^20.10.0", "@types/pg": "^8.10.9", "jest": "^29.7.0", + "ts-jest": "^29.4.6", "ts-node": "^10.9.2", "typescript": "^5.3.2" } @@ -1520,6 +1521,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/bser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", @@ -2458,6 +2472,28 @@ "dev": true, "license": "ISC" }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2780,6 +2816,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -3485,6 +3522,13 @@ "node": ">=8" } }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3640,6 +3684,16 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3662,6 +3716,13 @@ "node": ">= 0.6" } }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -4662,6 +4723,85 @@ "node": ">=0.6" } }, + "node_modules/ts-jest": { + "version": "29.4.6", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", + "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.8", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.7.3", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <6" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", @@ -4759,6 +4899,20 @@ "node": ">=14.17" } }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -4862,6 +5016,13 @@ "node": ">= 8" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index cd619bd..b61b4aa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mcp-outline-postgresql", - "version": "1.3.3", + "version": "1.3.4", "description": "MCP Server for Outline Wiki via PostgreSQL direct access", "main": "dist/index.js", "scripts": { @@ -21,16 +21,17 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", - "pg": "^8.11.3", "dotenv": "^16.3.1", + "pg": "^8.11.3", "zod": "^3.22.4" }, "devDependencies": { + "@types/jest": "^29.5.11", "@types/node": "^20.10.0", "@types/pg": "^8.10.9", - "typescript": "^5.3.2", - "ts-node": "^10.9.2", "jest": "^29.7.0", - "@types/jest": "^29.5.11" + "ts-jest": "^29.4.6", + "ts-node": "^10.9.2", + "typescript": "^5.3.2" } -} \ No newline at end of file +} diff --git a/src/tools/__tests__/tools-structure.test.ts b/src/tools/__tests__/tools-structure.test.ts new file mode 100644 index 0000000..1b2c644 --- /dev/null +++ b/src/tools/__tests__/tools-structure.test.ts @@ -0,0 +1,610 @@ +/** + * Tools Structure Tests + * Validates that all tools have correct structure without DB connection + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ + +import { documentsTools } from '../documents'; +import { collectionsTools } from '../collections'; +import { usersTools } from '../users'; +import { groupsTools } from '../groups'; +import { commentsTools } from '../comments'; +import { sharesTools } from '../shares'; +import { revisionsTools } from '../revisions'; +import { eventsTools } from '../events'; +import { attachmentsTools } from '../attachments'; +import { fileOperationsTools } from '../file-operations'; +import { oauthTools } from '../oauth'; +import { authTools } from '../auth'; +import { starsTools } from '../stars'; +import { pinsTools } from '../pins'; +import { viewsTools } from '../views'; +import { reactionsTools } from '../reactions'; +import { apiKeysTools } from '../api-keys'; +import { webhooksTools } from '../webhooks'; +import { backlinksTools } from '../backlinks'; +import { searchQueriesTools } from '../search-queries'; +import { teamsTools } from '../teams'; +import { integrationsTools } from '../integrations'; +import { notificationsTools } from '../notifications'; +import { subscriptionsTools } from '../subscriptions'; +import { templatesTools } from '../templates'; +import { importsTools } from '../imports-tools'; +import { emojisTools } from '../emojis'; +import { userPermissionsTools } from '../user-permissions'; +import { bulkOperationsTools } from '../bulk-operations'; +import { advancedSearchTools } from '../advanced-search'; +import { analyticsTools } from '../analytics'; +import { exportImportTools } from '../export-import'; +import { deskSyncTools } from '../desk-sync'; +import { BaseTool } from '../../types/tools'; + +// Helper to validate tool structure +function validateTool(tool: BaseTool): void { + // Name should be snake_case (tools use names like list_documents, not outline_list_documents) + expect(tool.name).toMatch(/^[a-z][a-z0-9_]*$/); + + // Description should exist and be non-empty + expect(tool.description).toBeDefined(); + expect(tool.description.length).toBeGreaterThan(10); + + // Input schema should have correct structure + expect(tool.inputSchema).toBeDefined(); + expect(tool.inputSchema.type).toBe('object'); + expect(tool.inputSchema.properties).toBeDefined(); + expect(typeof tool.inputSchema.properties).toBe('object'); + + // Handler should be a function + expect(typeof tool.handler).toBe('function'); +} + +// Helper to validate required properties in schema +function validateRequiredProps(tool: BaseTool): void { + if (tool.inputSchema.required) { + expect(Array.isArray(tool.inputSchema.required)).toBe(true); + // All required fields should exist in properties + for (const req of tool.inputSchema.required) { + expect(tool.inputSchema.properties).toHaveProperty(req); + } + } +} + +describe('Tools Structure Validation', () => { + describe('Documents Tools', () => { + it('should export correct number of tools', () => { + expect(documentsTools.length).toBeGreaterThanOrEqual(15); + }); + + it('should have valid structure for all tools', () => { + for (const tool of documentsTools) { + validateTool(tool); + validateRequiredProps(tool); + } + }); + + it('should include core document operations', () => { + const names = documentsTools.map(t => t.name); + expect(names).toContain('list_documents'); + expect(names).toContain('get_document'); + expect(names).toContain('create_document'); + expect(names).toContain('update_document'); + expect(names).toContain('delete_document'); + expect(names).toContain('search_documents'); + }); + }); + + describe('Collections Tools', () => { + it('should export correct number of tools', () => { + expect(collectionsTools.length).toBeGreaterThanOrEqual(10); + }); + + it('should have valid structure for all tools', () => { + for (const tool of collectionsTools) { + validateTool(tool); + validateRequiredProps(tool); + } + }); + + it('should include core collection operations', () => { + const names = collectionsTools.map(t => t.name); + expect(names).toContain('list_collections'); + expect(names).toContain('get_collection'); + expect(names).toContain('create_collection'); + expect(names).toContain('update_collection'); + expect(names).toContain('delete_collection'); + }); + }); + + describe('Users Tools', () => { + it('should export correct number of tools', () => { + expect(usersTools.length).toBeGreaterThanOrEqual(5); + }); + + it('should have valid structure for all tools', () => { + for (const tool of usersTools) { + validateTool(tool); + validateRequiredProps(tool); + } + }); + + it('should include core user operations', () => { + const names = usersTools.map(t => t.name); + expect(names).toContain('outline_list_users'); + expect(names).toContain('outline_get_user'); + }); + }); + + describe('Groups Tools', () => { + it('should export correct number of tools', () => { + expect(groupsTools.length).toBeGreaterThanOrEqual(5); + }); + + it('should have valid structure for all tools', () => { + for (const tool of groupsTools) { + validateTool(tool); + validateRequiredProps(tool); + } + }); + }); + + describe('Comments Tools', () => { + it('should export correct number of tools', () => { + expect(commentsTools.length).toBeGreaterThanOrEqual(4); + }); + + it('should have valid structure for all tools', () => { + for (const tool of commentsTools) { + validateTool(tool); + validateRequiredProps(tool); + } + }); + }); + + describe('Shares Tools', () => { + it('should export correct number of tools', () => { + expect(sharesTools.length).toBeGreaterThanOrEqual(3); + }); + + it('should have valid structure for all tools', () => { + for (const tool of sharesTools) { + validateTool(tool); + validateRequiredProps(tool); + } + }); + }); + + describe('Revisions Tools', () => { + it('should export correct number of tools', () => { + expect(revisionsTools.length).toBeGreaterThanOrEqual(2); + }); + + it('should have valid structure for all tools', () => { + for (const tool of revisionsTools) { + validateTool(tool); + validateRequiredProps(tool); + } + }); + }); + + describe('Events Tools', () => { + it('should export correct number of tools', () => { + expect(eventsTools.length).toBeGreaterThanOrEqual(2); + }); + + it('should have valid structure for all tools', () => { + for (const tool of eventsTools) { + validateTool(tool); + validateRequiredProps(tool); + } + }); + }); + + describe('Attachments Tools', () => { + it('should export correct number of tools', () => { + expect(attachmentsTools.length).toBeGreaterThanOrEqual(3); + }); + + it('should have valid structure for all tools', () => { + for (const tool of attachmentsTools) { + validateTool(tool); + validateRequiredProps(tool); + } + }); + }); + + describe('File Operations Tools', () => { + it('should export tools', () => { + expect(fileOperationsTools.length).toBeGreaterThanOrEqual(2); + }); + + it('should have valid structure for all tools', () => { + for (const tool of fileOperationsTools) { + validateTool(tool); + validateRequiredProps(tool); + } + }); + }); + + describe('OAuth Tools', () => { + it('should export correct number of tools', () => { + expect(oauthTools.length).toBeGreaterThanOrEqual(4); + }); + + it('should have valid structure for all tools', () => { + for (const tool of oauthTools) { + validateTool(tool); + validateRequiredProps(tool); + } + }); + }); + + describe('Auth Tools', () => { + it('should export tools', () => { + expect(authTools.length).toBeGreaterThanOrEqual(1); + }); + + it('should have valid structure for all tools', () => { + for (const tool of authTools) { + validateTool(tool); + validateRequiredProps(tool); + } + }); + }); + + describe('Stars Tools', () => { + it('should export correct number of tools', () => { + expect(starsTools.length).toBeGreaterThanOrEqual(2); + }); + + it('should have valid structure for all tools', () => { + for (const tool of starsTools) { + validateTool(tool); + validateRequiredProps(tool); + } + }); + }); + + describe('Pins Tools', () => { + it('should export correct number of tools', () => { + expect(pinsTools.length).toBeGreaterThanOrEqual(2); + }); + + it('should have valid structure for all tools', () => { + for (const tool of pinsTools) { + validateTool(tool); + validateRequiredProps(tool); + } + }); + }); + + describe('Views Tools', () => { + it('should export tools', () => { + expect(viewsTools.length).toBeGreaterThanOrEqual(1); + }); + + it('should have valid structure for all tools', () => { + for (const tool of viewsTools) { + validateTool(tool); + validateRequiredProps(tool); + } + }); + }); + + describe('Reactions Tools', () => { + it('should export correct number of tools', () => { + expect(reactionsTools.length).toBeGreaterThanOrEqual(2); + }); + + it('should have valid structure for all tools', () => { + for (const tool of reactionsTools) { + validateTool(tool); + validateRequiredProps(tool); + } + }); + }); + + describe('API Keys Tools', () => { + it('should export correct number of tools', () => { + expect(apiKeysTools.length).toBeGreaterThanOrEqual(3); + }); + + it('should have valid structure for all tools', () => { + for (const tool of apiKeysTools) { + validateTool(tool); + validateRequiredProps(tool); + } + }); + }); + + describe('Webhooks Tools', () => { + it('should export correct number of tools', () => { + expect(webhooksTools.length).toBeGreaterThanOrEqual(3); + }); + + it('should have valid structure for all tools', () => { + for (const tool of webhooksTools) { + validateTool(tool); + validateRequiredProps(tool); + } + }); + }); + + describe('Backlinks Tools', () => { + it('should export tools', () => { + expect(backlinksTools.length).toBeGreaterThanOrEqual(1); + }); + + it('should have valid structure for all tools', () => { + for (const tool of backlinksTools) { + validateTool(tool); + validateRequiredProps(tool); + } + }); + }); + + describe('Search Queries Tools', () => { + it('should export tools', () => { + expect(searchQueriesTools.length).toBeGreaterThanOrEqual(1); + }); + + it('should have valid structure for all tools', () => { + for (const tool of searchQueriesTools) { + validateTool(tool); + validateRequiredProps(tool); + } + }); + }); + + describe('Teams Tools', () => { + it('should export correct number of tools', () => { + expect(teamsTools.length).toBeGreaterThanOrEqual(3); + }); + + it('should have valid structure for all tools', () => { + for (const tool of teamsTools) { + validateTool(tool); + validateRequiredProps(tool); + } + }); + }); + + describe('Integrations Tools', () => { + it('should export correct number of tools', () => { + expect(integrationsTools.length).toBeGreaterThanOrEqual(3); + }); + + it('should have valid structure for all tools', () => { + for (const tool of integrationsTools) { + validateTool(tool); + validateRequiredProps(tool); + } + }); + }); + + describe('Notifications Tools', () => { + it('should export correct number of tools', () => { + expect(notificationsTools.length).toBeGreaterThanOrEqual(2); + }); + + it('should have valid structure for all tools', () => { + for (const tool of notificationsTools) { + validateTool(tool); + validateRequiredProps(tool); + } + }); + }); + + describe('Subscriptions Tools', () => { + it('should export correct number of tools', () => { + expect(subscriptionsTools.length).toBeGreaterThanOrEqual(2); + }); + + it('should have valid structure for all tools', () => { + for (const tool of subscriptionsTools) { + validateTool(tool); + validateRequiredProps(tool); + } + }); + }); + + describe('Templates Tools', () => { + it('should export correct number of tools', () => { + expect(templatesTools.length).toBeGreaterThanOrEqual(3); + }); + + it('should have valid structure for all tools', () => { + for (const tool of templatesTools) { + validateTool(tool); + validateRequiredProps(tool); + } + }); + }); + + describe('Imports Tools', () => { + it('should export tools', () => { + expect(importsTools.length).toBeGreaterThanOrEqual(2); + }); + + it('should have valid structure for all tools', () => { + for (const tool of importsTools) { + validateTool(tool); + validateRequiredProps(tool); + } + }); + }); + + describe('Emojis Tools', () => { + it('should export correct number of tools', () => { + expect(emojisTools.length).toBeGreaterThanOrEqual(2); + }); + + it('should have valid structure for all tools', () => { + for (const tool of emojisTools) { + validateTool(tool); + validateRequiredProps(tool); + } + }); + }); + + describe('User Permissions Tools', () => { + it('should export correct number of tools', () => { + expect(userPermissionsTools.length).toBeGreaterThanOrEqual(2); + }); + + it('should have valid structure for all tools', () => { + for (const tool of userPermissionsTools) { + validateTool(tool); + validateRequiredProps(tool); + } + }); + }); + + describe('Bulk Operations Tools', () => { + it('should export correct number of tools', () => { + expect(bulkOperationsTools.length).toBeGreaterThanOrEqual(4); + }); + + it('should have valid structure for all tools', () => { + for (const tool of bulkOperationsTools) { + validateTool(tool); + validateRequiredProps(tool); + } + }); + }); + + describe('Advanced Search Tools', () => { + it('should export correct number of tools', () => { + expect(advancedSearchTools.length).toBeGreaterThanOrEqual(3); + }); + + it('should have valid structure for all tools', () => { + for (const tool of advancedSearchTools) { + validateTool(tool); + validateRequiredProps(tool); + } + }); + }); + + describe('Analytics Tools', () => { + it('should export correct number of tools', () => { + expect(analyticsTools.length).toBeGreaterThanOrEqual(3); + }); + + it('should have valid structure for all tools', () => { + for (const tool of analyticsTools) { + validateTool(tool); + validateRequiredProps(tool); + } + }); + }); + + describe('Export/Import Tools', () => { + it('should export tools', () => { + expect(exportImportTools.length).toBeGreaterThanOrEqual(1); + }); + + it('should have valid structure for all tools', () => { + for (const tool of exportImportTools) { + validateTool(tool); + validateRequiredProps(tool); + } + }); + }); + + describe('Desk Sync Tools', () => { + it('should export tools', () => { + expect(deskSyncTools.length).toBeGreaterThanOrEqual(1); + }); + + it('should have valid structure for all tools', () => { + for (const tool of deskSyncTools) { + validateTool(tool); + validateRequiredProps(tool); + } + }); + }); + + describe('Total Tools Count', () => { + it('should have at least 164 tools total', () => { + const allTools = [ + ...documentsTools, + ...collectionsTools, + ...usersTools, + ...groupsTools, + ...commentsTools, + ...sharesTools, + ...revisionsTools, + ...eventsTools, + ...attachmentsTools, + ...fileOperationsTools, + ...oauthTools, + ...authTools, + ...starsTools, + ...pinsTools, + ...viewsTools, + ...reactionsTools, + ...apiKeysTools, + ...webhooksTools, + ...backlinksTools, + ...searchQueriesTools, + ...teamsTools, + ...integrationsTools, + ...notificationsTools, + ...subscriptionsTools, + ...templatesTools, + ...importsTools, + ...emojisTools, + ...userPermissionsTools, + ...bulkOperationsTools, + ...advancedSearchTools, + ...analyticsTools, + ...exportImportTools, + ...deskSyncTools + ]; + + expect(allTools.length).toBeGreaterThanOrEqual(164); + }); + + it('should have unique tool names', () => { + const allTools = [ + ...documentsTools, + ...collectionsTools, + ...usersTools, + ...groupsTools, + ...commentsTools, + ...sharesTools, + ...revisionsTools, + ...eventsTools, + ...attachmentsTools, + ...fileOperationsTools, + ...oauthTools, + ...authTools, + ...starsTools, + ...pinsTools, + ...viewsTools, + ...reactionsTools, + ...apiKeysTools, + ...webhooksTools, + ...backlinksTools, + ...searchQueriesTools, + ...teamsTools, + ...integrationsTools, + ...notificationsTools, + ...subscriptionsTools, + ...templatesTools, + ...importsTools, + ...emojisTools, + ...userPermissionsTools, + ...bulkOperationsTools, + ...advancedSearchTools, + ...analyticsTools, + ...exportImportTools, + ...deskSyncTools + ]; + + const names = allTools.map(t => t.name); + const uniqueNames = [...new Set(names)]; + expect(names.length).toBe(uniqueNames.length); + }); + }); +}); diff --git a/src/utils/__tests__/pagination.test.ts b/src/utils/__tests__/pagination.test.ts new file mode 100644 index 0000000..c40a13d --- /dev/null +++ b/src/utils/__tests__/pagination.test.ts @@ -0,0 +1,204 @@ +/** + * Pagination Utilities Tests + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ + +import { + encodeCursor, + decodeCursor, + buildCursorQuery, + processCursorResults, + offsetToCursorResult, + validatePaginationArgs +} from '../pagination'; + +describe('Pagination Utilities', () => { + describe('encodeCursor / decodeCursor', () => { + it('should encode and decode cursor data', () => { + const data = { v: '2024-01-15T10:00:00Z', d: 'desc' as const, s: 'abc123' }; + const encoded = encodeCursor(data); + const decoded = decodeCursor(encoded); + expect(decoded).toEqual(data); + }); + + it('should handle numeric values', () => { + const data = { v: 12345, d: 'asc' as const }; + const encoded = encodeCursor(data); + const decoded = decodeCursor(encoded); + expect(decoded).toEqual(data); + }); + + it('should return null for invalid cursor', () => { + expect(decodeCursor('invalid-base64!')).toBeNull(); + expect(decodeCursor('')).toBeNull(); + }); + + it('should use base64url encoding (URL safe)', () => { + const data = { v: 'some+value/with=chars', d: 'desc' as const }; + const encoded = encodeCursor(data); + expect(encoded).not.toContain('+'); + expect(encoded).not.toContain('/'); + }); + }); + + describe('buildCursorQuery', () => { + it('should return defaults when no args provided', () => { + const result = buildCursorQuery({}); + expect(result.cursorCondition).toBe(''); + expect(result.orderBy).toContain('DESC'); + expect(result.limit).toBe(26); // 25 + 1 for hasMore detection + expect(result.params).toEqual([]); + }); + + it('should respect custom limit', () => { + const result = buildCursorQuery({ limit: 50 }); + expect(result.limit).toBe(51); // 50 + 1 + }); + + it('should cap limit at max', () => { + const result = buildCursorQuery({ limit: 200 }); + expect(result.limit).toBe(101); // 100 + 1 + }); + + it('should build cursor condition when cursor provided', () => { + const cursor = encodeCursor({ v: '2024-01-15T10:00:00Z', d: 'desc', s: 'abc123' }); + const result = buildCursorQuery({ cursor, direction: 'desc' }); + expect(result.cursorCondition).toContain('<'); + expect(result.params.length).toBe(2); + }); + + it('should use correct operator for asc direction', () => { + const cursor = encodeCursor({ v: '2024-01-15T10:00:00Z', d: 'asc' }); + const result = buildCursorQuery({ cursor, direction: 'asc' }); + expect(result.cursorCondition).toContain('>'); + }); + + it('should validate cursor field names to prevent SQL injection', () => { + expect(() => buildCursorQuery({}, { cursorField: 'DROP TABLE users; --' })).toThrow(); + expect(() => buildCursorQuery({}, { cursorField: 'valid_field' })).not.toThrow(); + }); + }); + + describe('processCursorResults', () => { + it('should detect hasMore when extra row exists', () => { + const rows = [ + { id: '1', createdAt: '2024-01-03' }, + { id: '2', createdAt: '2024-01-02' }, + { id: '3', createdAt: '2024-01-01' } + ]; + const result = processCursorResults(rows, 2); + expect(result.hasMore).toBe(true); + expect(result.items.length).toBe(2); + expect(result.nextCursor).not.toBeNull(); + }); + + it('should not have hasMore when no extra row', () => { + const rows = [ + { id: '1', createdAt: '2024-01-02' }, + { id: '2', createdAt: '2024-01-01' } + ]; + const result = processCursorResults(rows, 2); + expect(result.hasMore).toBe(false); + expect(result.items.length).toBe(2); + expect(result.nextCursor).toBeNull(); + }); + + it('should generate prevCursor for non-empty results', () => { + const rows = [{ id: '1', createdAt: '2024-01-01' }]; + const result = processCursorResults(rows, 10); + expect(result.prevCursor).not.toBeNull(); + }); + + it('should handle empty results', () => { + const result = processCursorResults([], 10); + expect(result.items.length).toBe(0); + expect(result.hasMore).toBe(false); + expect(result.nextCursor).toBeNull(); + expect(result.prevCursor).toBeNull(); + }); + + it('should use custom cursor fields', () => { + const rows = [ + { uuid: 'a', timestamp: '2024-01-01' }, + { uuid: 'b', timestamp: '2024-01-02' } + ]; + const result = processCursorResults(rows, 1, 'timestamp', 'uuid'); + expect(result.hasMore).toBe(true); + expect(result.nextCursor).not.toBeNull(); + + // Verify cursor contains the correct field values + const decoded = decodeCursor(result.nextCursor!); + expect(decoded?.v).toBe('2024-01-01'); + expect(decoded?.s).toBe('a'); + }); + }); + + describe('offsetToCursorResult', () => { + it('should convert offset pagination to cursor format', () => { + const items = [{ id: '1' }, { id: '2' }]; + const result = offsetToCursorResult(items, 0, 2, 5); + expect(result.items).toEqual(items); + expect(result.hasMore).toBe(true); + expect(result.totalCount).toBe(5); + expect(result.nextCursor).not.toBeNull(); + }); + + it('should set hasMore false when at end', () => { + const items = [{ id: '5' }]; + const result = offsetToCursorResult(items, 4, 10, 5); + expect(result.hasMore).toBe(false); + }); + + it('should set prevCursor null for first page', () => { + const items = [{ id: '1' }]; + const result = offsetToCursorResult(items, 0, 10); + expect(result.prevCursor).toBeNull(); + }); + + it('should set prevCursor for non-first pages', () => { + const items = [{ id: '11' }]; + const result = offsetToCursorResult(items, 10, 10); + expect(result.prevCursor).not.toBeNull(); + }); + }); + + describe('validatePaginationArgs', () => { + it('should return defaults when no args provided', () => { + const result = validatePaginationArgs({}); + expect(result.limit).toBe(25); + expect(result.cursor).toBeNull(); + expect(result.direction).toBe('desc'); + }); + + it('should respect provided values', () => { + const result = validatePaginationArgs({ + limit: 50, + cursor: 'abc123', + direction: 'asc' + }); + expect(result.limit).toBe(50); + expect(result.cursor).toBe('abc123'); + expect(result.direction).toBe('asc'); + }); + + it('should clamp limit to min 1', () => { + const result = validatePaginationArgs({ limit: 0 }); + expect(result.limit).toBe(1); + }); + + it('should clamp limit to max', () => { + const result = validatePaginationArgs({ limit: 200 }); + expect(result.limit).toBe(100); + }); + + it('should use custom max limit', () => { + const result = validatePaginationArgs({ limit: 50 }, { maxLimit: 25 }); + expect(result.limit).toBe(25); + }); + + it('should use custom default limit', () => { + const result = validatePaginationArgs({}, { defaultLimit: 10 }); + expect(result.limit).toBe(10); + }); + }); +}); diff --git a/src/utils/__tests__/query-builder.test.ts b/src/utils/__tests__/query-builder.test.ts new file mode 100644 index 0000000..9a1e218 --- /dev/null +++ b/src/utils/__tests__/query-builder.test.ts @@ -0,0 +1,297 @@ +/** + * Query Builder Tests + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ + +import { + SafeQueryBuilder, + createQueryBuilder, + buildSelectQuery, + buildCountQuery +} from '../query-builder'; + +describe('Query Builder', () => { + describe('SafeQueryBuilder', () => { + let builder: SafeQueryBuilder; + + beforeEach(() => { + builder = new SafeQueryBuilder(); + }); + + describe('addParam', () => { + it('should add params and return placeholders', () => { + expect(builder.addParam('value1')).toBe('$1'); + expect(builder.addParam('value2')).toBe('$2'); + expect(builder.addParam('value3')).toBe('$3'); + expect(builder.getParams()).toEqual(['value1', 'value2', 'value3']); + }); + + it('should handle different types', () => { + builder.addParam('string'); + builder.addParam(123); + builder.addParam(true); + builder.addParam(null); + expect(builder.getParams()).toEqual(['string', 123, true, null]); + }); + }); + + describe('getNextIndex', () => { + it('should return the next parameter index', () => { + expect(builder.getNextIndex()).toBe(1); + builder.addParam('value'); + expect(builder.getNextIndex()).toBe(2); + }); + }); + + describe('buildILike', () => { + it('should build ILIKE condition with wildcards', () => { + const condition = builder.buildILike('"name"', 'test'); + expect(condition).toBe('"name" ILIKE $1'); + expect(builder.getParams()).toEqual(['%test%']); + }); + + it('should sanitize input', () => { + const condition = builder.buildILike('"name"', ' test\0value '); + expect(builder.getParams()).toEqual(['%testvalue%']); + }); + }); + + describe('buildILikeExact', () => { + it('should build ILIKE condition without wildcards', () => { + const condition = builder.buildILikeExact('"email"', 'test@example.com'); + expect(condition).toBe('"email" ILIKE $1'); + expect(builder.getParams()).toEqual(['test@example.com']); + }); + }); + + describe('buildILikePrefix', () => { + it('should build ILIKE condition with trailing wildcard', () => { + const condition = builder.buildILikePrefix('"title"', 'intro'); + expect(condition).toBe('"title" ILIKE $1'); + expect(builder.getParams()).toEqual(['intro%']); + }); + }); + + describe('buildIn', () => { + it('should build IN clause using ANY', () => { + const condition = builder.buildIn('"status"', ['active', 'pending']); + expect(condition).toBe('"status" = ANY($1)'); + expect(builder.getParams()).toEqual([['active', 'pending']]); + }); + + it('should return FALSE for empty array', () => { + const condition = builder.buildIn('"status"', []); + expect(condition).toBe('FALSE'); + expect(builder.getParams()).toEqual([]); + }); + }); + + describe('buildNotIn', () => { + it('should build NOT IN clause using ALL', () => { + const condition = builder.buildNotIn('"status"', ['deleted', 'archived']); + expect(condition).toBe('"status" != ALL($1)'); + }); + + it('should return TRUE for empty array', () => { + const condition = builder.buildNotIn('"status"', []); + expect(condition).toBe('TRUE'); + }); + }); + + describe('comparison operators', () => { + it('should build equals condition', () => { + expect(builder.buildEquals('"id"', 1)).toBe('"id" = $1'); + }); + + it('should build not equals condition', () => { + expect(builder.buildNotEquals('"id"', 1)).toBe('"id" != $1'); + }); + + it('should build greater than condition', () => { + expect(builder.buildGreaterThan('"count"', 10)).toBe('"count" > $1'); + }); + + it('should build greater than or equals condition', () => { + expect(builder.buildGreaterThanOrEquals('"count"', 10)).toBe('"count" >= $1'); + }); + + it('should build less than condition', () => { + expect(builder.buildLessThan('"count"', 10)).toBe('"count" < $1'); + }); + + it('should build less than or equals condition', () => { + expect(builder.buildLessThanOrEquals('"count"', 10)).toBe('"count" <= $1'); + }); + }); + + describe('buildBetween', () => { + it('should build BETWEEN condition', () => { + const condition = builder.buildBetween('"date"', '2024-01-01', '2024-12-31'); + expect(condition).toBe('"date" BETWEEN $1 AND $2'); + expect(builder.getParams()).toEqual(['2024-01-01', '2024-12-31']); + }); + }); + + describe('buildIsNull / buildIsNotNull', () => { + it('should build IS NULL condition', () => { + expect(builder.buildIsNull('"deletedAt"')).toBe('"deletedAt" IS NULL'); + }); + + it('should build IS NOT NULL condition', () => { + expect(builder.buildIsNotNull('"publishedAt"')).toBe('"publishedAt" IS NOT NULL'); + }); + }); + + describe('buildUUIDEquals', () => { + it('should accept valid UUIDs', () => { + const uuid = '550e8400-e29b-41d4-a716-446655440000'; + const condition = builder.buildUUIDEquals('"userId"', uuid); + expect(condition).toBe('"userId" = $1'); + expect(builder.getParams()).toEqual([uuid]); + }); + + it('should throw for invalid UUIDs', () => { + expect(() => builder.buildUUIDEquals('"userId"', 'invalid')).toThrow('Invalid UUID'); + }); + }); + + describe('buildUUIDIn', () => { + it('should accept array of valid UUIDs', () => { + const uuids = [ + '550e8400-e29b-41d4-a716-446655440000', + '6ba7b810-9dad-11d1-80b4-00c04fd430c8' + ]; + const condition = builder.buildUUIDIn('"id"', uuids); + expect(condition).toBe('"id" = ANY($1)'); + }); + + it('should throw if any UUID is invalid', () => { + const uuids = ['550e8400-e29b-41d4-a716-446655440000', 'invalid']; + expect(() => builder.buildUUIDIn('"id"', uuids)).toThrow('Invalid UUID'); + }); + }); + + describe('conditions management', () => { + it('should add and build WHERE clause', () => { + builder.addCondition(builder.buildEquals('"status"', 'active')); + builder.addCondition(builder.buildIsNull('"deletedAt"')); + const where = builder.buildWhereClause(); + expect(where).toBe('WHERE "status" = $1 AND "deletedAt" IS NULL'); + }); + + it('should support custom separator', () => { + builder.addCondition('"a" = 1'); + builder.addCondition('"b" = 2'); + const where = builder.buildWhereClause(' OR '); + expect(where).toBe('WHERE "a" = 1 OR "b" = 2'); + }); + + it('should return empty string for no conditions', () => { + expect(builder.buildWhereClause()).toBe(''); + }); + + it('should add condition only if value is truthy', () => { + builder.addConditionIf('"a" = 1', 'value'); + builder.addConditionIf('"b" = 2', undefined); + builder.addConditionIf('"c" = 3', null); + builder.addConditionIf('"d" = 4', ''); + expect(builder.getConditions()).toEqual(['"a" = 1']); + }); + }); + + describe('reset', () => { + it('should clear all state', () => { + builder.addParam('value'); + builder.addCondition('"a" = 1'); + builder.reset(); + expect(builder.getParams()).toEqual([]); + expect(builder.getConditions()).toEqual([]); + expect(builder.getNextIndex()).toBe(1); + }); + }); + + describe('clone', () => { + it('should create independent copy', () => { + builder.addParam('value1'); + builder.addCondition('"a" = 1'); + + const clone = builder.clone(); + clone.addParam('value2'); + clone.addCondition('"b" = 2'); + + expect(builder.getParams()).toEqual(['value1']); + expect(builder.getConditions()).toEqual(['"a" = 1']); + expect(clone.getParams()).toEqual(['value1', 'value2']); + expect(clone.getConditions()).toEqual(['"a" = 1', '"b" = 2']); + }); + }); + }); + + describe('createQueryBuilder', () => { + it('should create new builder instance', () => { + const builder = createQueryBuilder(); + expect(builder).toBeInstanceOf(SafeQueryBuilder); + }); + }); + + describe('buildSelectQuery', () => { + it('should build basic SELECT query', () => { + const builder = createQueryBuilder(); + builder.addCondition(builder.buildIsNull('"deletedAt"')); + + const { query, params } = buildSelectQuery( + 'documents', + ['id', 'title', 'content'], + builder + ); + + expect(query).toBe('SELECT id, title, content FROM documents WHERE "deletedAt" IS NULL'); + expect(params).toEqual([]); + }); + + it('should add ORDER BY', () => { + const builder = createQueryBuilder(); + const { query } = buildSelectQuery( + 'documents', + ['*'], + builder, + { orderBy: '"createdAt"', orderDirection: 'DESC' } + ); + + expect(query).toContain('ORDER BY "createdAt" DESC'); + }); + + it('should add LIMIT and OFFSET', () => { + const builder = createQueryBuilder(); + const { query, params } = buildSelectQuery( + 'documents', + ['*'], + builder, + { limit: 10, offset: 20 } + ); + + expect(query).toContain('LIMIT $1'); + expect(query).toContain('OFFSET $2'); + expect(params).toEqual([10, 20]); + }); + }); + + describe('buildCountQuery', () => { + it('should build COUNT query', () => { + const builder = createQueryBuilder(); + builder.addCondition(builder.buildEquals('"status"', 'active')); + + const { query, params } = buildCountQuery('documents', builder); + + expect(query).toBe('SELECT COUNT(*) as count FROM documents WHERE "status" = $1'); + expect(params).toEqual(['active']); + }); + + it('should handle no conditions', () => { + const builder = createQueryBuilder(); + const { query } = buildCountQuery('documents', builder); + + expect(query).toBe('SELECT COUNT(*) as count FROM documents '); + }); + }); +}); diff --git a/src/utils/__tests__/security.test.ts b/src/utils/__tests__/security.test.ts new file mode 100644 index 0000000..8dcf16b --- /dev/null +++ b/src/utils/__tests__/security.test.ts @@ -0,0 +1,324 @@ +/** + * Security Utilities Tests + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ + +import { + checkRateLimit, + sanitizeInput, + isValidUUID, + isValidUrlId, + isValidEmail, + isValidHttpUrl, + escapeHtml, + validatePagination, + validateSortDirection, + validateSortField, + validateDaysInterval, + isValidISODate, + validatePeriod, + clearRateLimitStore, + startRateLimitCleanup, + stopRateLimitCleanup +} from '../security'; + +describe('Security Utilities', () => { + describe('isValidUUID', () => { + it('should accept valid v4 UUIDs', () => { + expect(isValidUUID('550e8400-e29b-41d4-a716-446655440000')).toBe(true); + expect(isValidUUID('6ba7b810-9dad-11d1-80b4-00c04fd430c8')).toBe(true); + }); + + it('should reject invalid UUIDs', () => { + expect(isValidUUID('')).toBe(false); + expect(isValidUUID('not-a-uuid')).toBe(false); + expect(isValidUUID('550e8400-e29b-41d4-a716')).toBe(false); + expect(isValidUUID('550e8400e29b41d4a716446655440000')).toBe(false); + expect(isValidUUID('550e8400-e29b-41d4-a716-44665544000g')).toBe(false); + }); + + it('should be case insensitive', () => { + expect(isValidUUID('550E8400-E29B-41D4-A716-446655440000')).toBe(true); + expect(isValidUUID('550e8400-E29B-41d4-A716-446655440000')).toBe(true); + }); + }); + + describe('isValidUrlId', () => { + it('should accept valid URL IDs', () => { + expect(isValidUrlId('abc123')).toBe(true); + expect(isValidUrlId('my-document')).toBe(true); + expect(isValidUrlId('my_document')).toBe(true); + expect(isValidUrlId('MyDocument123')).toBe(true); + }); + + it('should reject invalid URL IDs', () => { + expect(isValidUrlId('')).toBe(false); + expect(isValidUrlId('my document')).toBe(false); + expect(isValidUrlId('my/document')).toBe(false); + expect(isValidUrlId('my.document')).toBe(false); + expect(isValidUrlId('my@document')).toBe(false); + }); + }); + + describe('isValidEmail', () => { + it('should accept valid emails', () => { + expect(isValidEmail('test@example.com')).toBe(true); + expect(isValidEmail('user.name@domain.org')).toBe(true); + expect(isValidEmail('user+tag@example.co.uk')).toBe(true); + }); + + it('should reject invalid emails', () => { + expect(isValidEmail('')).toBe(false); + expect(isValidEmail('notanemail')).toBe(false); + expect(isValidEmail('@nodomain.com')).toBe(false); + expect(isValidEmail('no@domain')).toBe(false); + expect(isValidEmail('spaces in@email.com')).toBe(false); + }); + }); + + describe('isValidHttpUrl', () => { + it('should accept valid HTTP(S) URLs', () => { + expect(isValidHttpUrl('http://example.com')).toBe(true); + expect(isValidHttpUrl('https://example.com')).toBe(true); + expect(isValidHttpUrl('https://example.com/path?query=1')).toBe(true); + expect(isValidHttpUrl('http://localhost:3000')).toBe(true); + }); + + it('should reject dangerous protocols', () => { + expect(isValidHttpUrl('javascript:alert(1)')).toBe(false); + expect(isValidHttpUrl('data:text/html,')).toBe(false); + expect(isValidHttpUrl('file:///etc/passwd')).toBe(false); + expect(isValidHttpUrl('ftp://example.com')).toBe(false); + }); + + it('should reject invalid URLs', () => { + expect(isValidHttpUrl('')).toBe(false); + expect(isValidHttpUrl('not-a-url')).toBe(false); + expect(isValidHttpUrl('//example.com')).toBe(false); + }); + }); + + describe('sanitizeInput', () => { + it('should remove null bytes', () => { + expect(sanitizeInput('hello\0world')).toBe('helloworld'); + expect(sanitizeInput('\0test\0')).toBe('test'); + }); + + it('should trim whitespace', () => { + expect(sanitizeInput(' hello ')).toBe('hello'); + expect(sanitizeInput('\n\thello\t\n')).toBe('hello'); + }); + + it('should handle empty strings', () => { + expect(sanitizeInput('')).toBe(''); + expect(sanitizeInput(' ')).toBe(''); + }); + + it('should preserve normal strings', () => { + expect(sanitizeInput('normal text')).toBe('normal text'); + }); + }); + + describe('escapeHtml', () => { + it('should escape HTML entities', () => { + expect(escapeHtml('