diff --git a/src/utils/validation.ts b/src/utils/validation.ts new file mode 100644 --- /dev/null +++ b/src/utils/validation.ts @@ -0,0 +1,109 @@ +import { z } from 'zod'; + +/** + * Validation schema for AT Protocol handle + * Supports standard Bluesky handles (user.bsky.social) and custom domains (example.com) + */ +export const handleSchema = z + .string() + .min(1, 'Handle cannot be empty') + .regex( + /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/, + 'Invalid handle format. Must be a valid domain (e.g., user.bsky.social or example.com)' + ); + +/** + * Validation schema for AT Protocol DID + */ +export const didSchema = z + .string() + .min(1, 'DID cannot be empty') + .regex( + /^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$/, + 'Invalid DID format. Must start with "did:" followed by method and identifier' + ); + +/** + * Validation schema for app password + * AT Protocol app passwords are typically 19 characters with dashes + */ +export const appPasswordSchema = z + .string() + .min(1, 'Password cannot be empty') + .max(1000, 'Password is too long'); + +/** + * Validation schema for identifier (handle or DID) + */ +export const identifierSchema = z.union([handleSchema, didSchema]); + +/** + * Validate a handle + * @throws {z.ZodError} if validation fails + */ +export function validateHandle(handle: string): string { + return handleSchema.parse(handle); +} + +/** + * Validate a DID + * @throws {z.ZodError} if validation fails + */ +export function validateDid(did: string): string { + return didSchema.parse(did); +} + +/** + * Validate an identifier (handle or DID) + * @throws {z.ZodError} if validation fails + */ +export function validateIdentifier(identifier: string): string { + return identifierSchema.parse(identifier); +} + +/** + * Validate an app password + * @throws {z.ZodError} if validation fails + */ +export function validateAppPassword(password: string): string { + return appPasswordSchema.parse(password); +} + +/** + * Safe validation that returns success/error instead of throwing + */ +export function safeValidateHandle( + handle: string +): { success: true; data: string } | { success: false; error: string } { + const result = handleSchema.safeParse(handle); + if (result.success) { + return { success: true, data: result.data }; + } + return { success: false, error: result.error.issues[0]?.message ?? 'Validation failed' }; +} + +/** + * Safe validation that returns success/error instead of throwing + */ +export function safeValidateDid( + did: string +): { success: true; data: string } | { success: false; error: string } { + const result = didSchema.safeParse(did); + if (result.success) { + return { success: true, data: result.data }; + } + return { success: false, error: result.error.issues[0]?.message ?? 'Validation failed' }; +} + +/** + * Safe validation that returns success/error instead of throwing + */ +export function safeValidateIdentifier( + identifier: string +): { success: true; data: string } | { success: false; error: string } { + const result = identifierSchema.safeParse(identifier); + if (result.success) { + return { success: true, data: result.data }; + } + return { success: false, error: result.error.issues[0]?.message ?? 'Validation failed' }; +} diff --git a/tests/utils/validation.test.ts b/tests/utils/validation.test.ts new file mode 100644 --- /dev/null +++ b/tests/utils/validation.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest'; +import { + safeValidateDid, + safeValidateHandle, + safeValidateIdentifier, + validateAppPassword, + validateDid, + validateHandle, + validateIdentifier, +} from '../../src/utils/validation.js'; + +describe('Handle Validation', () => { + describe('validateHandle', () => { + it('should accept standard Bluesky handles', () => { + expect(validateHandle('user.bsky.social')).toBe('user.bsky.social'); + expect(validateHandle('test.bsky.social')).toBe('test.bsky.social'); + }); + + it('should accept custom domain handles', () => { + expect(validateHandle('markbennett.ca')).toBe('markbennett.ca'); + expect(validateHandle('example.com')).toBe('example.com'); + expect(validateHandle('subdomain.example.com')).toBe('subdomain.example.com'); + }); + + it('should reject invalid handles', () => { + expect(() => validateHandle('')).toThrow('Handle cannot be empty'); + expect(() => validateHandle('invalid')).toThrow('Invalid handle format'); + expect(() => validateHandle('invalid..com')).toThrow('Invalid handle format'); + expect(() => validateHandle('.example.com')).toThrow('Invalid handle format'); + expect(() => validateHandle('example.com.')).toThrow('Invalid handle format'); + }); + }); + + describe('safeValidateHandle', () => { + it('should return success for valid handles', () => { + const result = safeValidateHandle('user.bsky.social'); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe('user.bsky.social'); + } + }); + + it('should return error for invalid handles', () => { + const result = safeValidateHandle('invalid'); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain('Invalid handle format'); + } + }); + }); +}); + +describe('DID Validation', () => { + describe('validateDid', () => { + it('should accept valid DIDs', () => { + expect(validateDid('did:plc:test123')).toBe('did:plc:test123'); + expect(validateDid('did:web:example.com')).toBe('did:web:example.com'); + expect(validateDid('did:key:z6MkhaXg')).toBe('did:key:z6MkhaXg'); + }); + + it('should reject invalid DIDs', () => { + expect(() => validateDid('')).toThrow('DID cannot be empty'); + expect(() => validateDid('not-a-did')).toThrow('Invalid DID format'); + expect(() => validateDid('did:')).toThrow('Invalid DID format'); + expect(() => validateDid('did:plc:')).toThrow('Invalid DID format'); + }); + }); + + describe('safeValidateDid', () => { + it('should return success for valid DIDs', () => { + const result = safeValidateDid('did:plc:test123'); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe('did:plc:test123'); + } + }); + + it('should return error for invalid DIDs', () => { + const result = safeValidateDid('not-a-did'); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain('Invalid DID format'); + } + }); + }); +}); + +describe('Identifier Validation', () => { + describe('validateIdentifier', () => { + it('should accept valid handles', () => { + expect(validateIdentifier('user.bsky.social')).toBe('user.bsky.social'); + expect(validateIdentifier('example.com')).toBe('example.com'); + }); + + it('should accept valid DIDs', () => { + expect(validateIdentifier('did:plc:test123')).toBe('did:plc:test123'); + expect(validateIdentifier('did:web:example.com')).toBe('did:web:example.com'); + }); + + it('should reject invalid identifiers', () => { + expect(() => validateIdentifier('')).toThrow(); + expect(() => validateIdentifier('invalid')).toThrow(); + }); + }); + + describe('safeValidateIdentifier', () => { + it('should return success for valid identifiers', () => { + expect(safeValidateIdentifier('user.bsky.social').success).toBe(true); + expect(safeValidateIdentifier('did:plc:test123').success).toBe(true); + }); + + it('should return error for invalid identifiers', () => { + const result = safeValidateIdentifier('invalid'); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toBeTruthy(); + } + }); + }); +}); + +describe('App Password Validation', () => { + describe('validateAppPassword', () => { + it('should accept valid passwords', () => { + expect(validateAppPassword('password123')).toBe('password123'); + expect(validateAppPassword('xxxx-xxxx-xxxx-xxxx')).toBe('xxxx-xxxx-xxxx-xxxx'); + expect(validateAppPassword('a'.repeat(100))).toBe('a'.repeat(100)); + }); + + it('should reject empty passwords', () => { + expect(() => validateAppPassword('')).toThrow('Password cannot be empty'); + }); + + it('should reject extremely long passwords', () => { + expect(() => validateAppPassword('a'.repeat(1001))).toThrow('Password is too long'); + }); + }); +});