diff --git a/src/config/sdkConfig.ts b/src/config/sdkConfig.ts index 4d13938..eac7355 100644 --- a/src/config/sdkConfig.ts +++ b/src/config/sdkConfig.ts @@ -78,7 +78,10 @@ export type GuildPassClientConfig = { export function validateConfig(config: GuildPassClientConfig): void { if (!config.apiUrl) { - throw new GuildPassError('apiUrl is required', GuildPassErrorCode.INVALID_CONFIG); + throw new GuildPassError('apiUrl is required', GuildPassErrorCode.INVALID_CONFIG, undefined, { + field: 'apiUrl', + reason: 'required', + }); } try { @@ -90,6 +93,8 @@ export function validateConfig(config: GuildPassClientConfig): void { throw new GuildPassError( `Invalid apiUrl: "${config.apiUrl}"`, GuildPassErrorCode.INVALID_CONFIG, + undefined, + { field: 'apiUrl', reason: 'format' }, ); } @@ -100,10 +105,12 @@ export function validateConfig(config: GuildPassClientConfig): void { throw new GuildPassError( 'timeoutMs must be a positive number', GuildPassErrorCode.INVALID_CONFIG, + undefined, + { field: 'timeoutMs', reason: 'invalid_type' }, ); } - // INSERT CACHE VALIDATION + // INSERT CACHE VALIDATION if ( config.cacheTtl !== undefined && (typeof config.cacheTtl !== 'number' || config.cacheTtl < 0 || !Number.isFinite(config.cacheTtl)) @@ -111,6 +118,8 @@ export function validateConfig(config: GuildPassClientConfig): void { throw new GuildPassError( 'cacheTtl must be a non-negative finite number (milliseconds)', GuildPassErrorCode.INVALID_CONFIG, + undefined, + { field: 'cacheTtl', reason: 'invalid_range' }, ); } @@ -122,6 +131,8 @@ export function validateConfig(config: GuildPassClientConfig): void { throw new GuildPassError( `cache adapter must implement ${method}(): function`, GuildPassErrorCode.INVALID_CONFIG, + undefined, + { field: 'cache', reason: 'missing_method' }, ); } } @@ -136,6 +147,8 @@ export function validateConfig(config: GuildPassClientConfig): void { throw new GuildPassError( 'sendClientMetadata must be a boolean', GuildPassErrorCode.INVALID_CONFIG, + undefined, + { field: 'sendClientMetadata', reason: 'invalid_type' }, ); } @@ -146,6 +159,8 @@ export function validateConfig(config: GuildPassClientConfig): void { throw new GuildPassError( 'clientName must be a string', GuildPassErrorCode.INVALID_CONFIG, + undefined, + { field: 'clientName', reason: 'invalid_type' }, ); } @@ -156,6 +171,8 @@ export function validateConfig(config: GuildPassClientConfig): void { throw new GuildPassError( 'clientVersion must be a string', GuildPassErrorCode.INVALID_CONFIG, + undefined, + { field: 'clientVersion', reason: 'invalid_type' }, ); } // END METADATA VALIDATION @@ -165,26 +182,41 @@ export function validateConfig(config: GuildPassClientConfig): void { const r = config.retry; if (r.maxRetries !== undefined && (typeof r.maxRetries !== 'number' || !Number.isFinite(r.maxRetries) || r.maxRetries < 0)) { - throw new GuildPassError('retry.maxRetries must be a non-negative finite number', GuildPassErrorCode.INVALID_CONFIG); + throw new GuildPassError('retry.maxRetries must be a non-negative finite number', GuildPassErrorCode.INVALID_CONFIG, undefined, { + field: 'retry.maxRetries', + reason: 'invalid_range', + }); } if (r.baseDelayMs !== undefined && (typeof r.baseDelayMs !== 'number' || !Number.isFinite(r.baseDelayMs) || r.baseDelayMs < 0)) { - throw new GuildPassError('retry.baseDelayMs must be a non-negative finite number', GuildPassErrorCode.INVALID_CONFIG); + throw new GuildPassError('retry.baseDelayMs must be a non-negative finite number', GuildPassErrorCode.INVALID_CONFIG, undefined, { + field: 'retry.baseDelayMs', + reason: 'invalid_range', + }); } if (r.maxDelayMs !== undefined && (typeof r.maxDelayMs !== 'number' || !Number.isFinite(r.maxDelayMs) || r.maxDelayMs < 0)) { - throw new GuildPassError('retry.maxDelayMs must be a non-negative finite number', GuildPassErrorCode.INVALID_CONFIG); + throw new GuildPassError('retry.maxDelayMs must be a non-negative finite number', GuildPassErrorCode.INVALID_CONFIG, undefined, { + field: 'retry.maxDelayMs', + reason: 'invalid_range', + }); } if (r.baseDelayMs !== undefined && r.maxDelayMs !== undefined && r.maxDelayMs < r.baseDelayMs) { - throw new GuildPassError('retry.maxDelayMs cannot be less than baseDelayMs', GuildPassErrorCode.INVALID_CONFIG); + throw new GuildPassError('retry.maxDelayMs cannot be less than baseDelayMs', GuildPassErrorCode.INVALID_CONFIG, undefined, { + field: 'retry.maxDelayMs', + reason: 'invalid_range', + }); } if (r.retryableStatuses !== undefined && (!Array.isArray(r.retryableStatuses) || r.retryableStatuses.length === 0 || r.retryableStatuses.some((s) => typeof s !== 'number' || !Number.isFinite(s)))) { - throw new GuildPassError('retryableStatuses must be a non-empty array of valid HTTP status numbers', GuildPassErrorCode.INVALID_CONFIG); + throw new GuildPassError('retryableStatuses must be a non-empty array of valid HTTP status numbers', GuildPassErrorCode.INVALID_CONFIG, undefined, { + field: 'retry.retryableStatuses', + reason: 'invalid_format', + }); } } - // END RETRY VALIDATION + // END RETRY VALIDATION validateChainsConfig(config.chains); @@ -193,6 +225,8 @@ export function validateConfig(config: GuildPassClientConfig): void { throw new GuildPassError( 'A fetch-compatible transport is required. Provide config.fetch or use a runtime with globalThis.fetch.', GuildPassErrorCode.INVALID_CONFIG, + undefined, + { field: 'fetch', reason: 'missing' }, ); } } diff --git a/src/utils/validation.ts b/src/utils/validation.ts index e1e1e21..c8130cd 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -5,6 +5,19 @@ import { GuildPassErrorCode } from '../errors/errorCodes'; // GuildPass SDK: Pull in package or module bindings. import { isChecksumAddress } from './address'; +/** + * Structured metadata attached to validation errors so consumers can + * identify which field failed and why without parsing the message string. + */ +export interface ValidationErrorDetails { + /** The name of the field that failed validation (e.g. "address", "guildId"). */ + field: string; + /** Machine-readable reason code (e.g. "required", "format", "checksum"). */ + reason: string; + /** Optional type hint for the rejected value (e.g. "address", "guildId"). */ + valueType?: string; +} + /** * Validates an Ethereum address. * @@ -17,7 +30,10 @@ export const validateAddress = (address: string, options: { strict?: boolean } = // GuildPass SDK: Evaluate branch condition logic. if (!address) { // GuildPass SDK: Raise exceptional condition and throw error. - throw new GuildPassError('Address is required', GuildPassErrorCode.INVALID_INPUT); + throw new GuildPassError('Address is required', GuildPassErrorCode.INVALID_INPUT, undefined, { + field: 'address', + reason: 'required', + } as ValidationErrorDetails); // GuildPass SDK: End of logic containment structure block. } @@ -29,6 +45,12 @@ export const validateAddress = (address: string, options: { strict?: boolean } = throw new GuildPassError( `Invalid Ethereum address: ${address}`, GuildPassErrorCode.INVALID_ADDRESS, + undefined, + { + field: 'address', + reason: 'format', + valueType: 'address', + } as ValidationErrorDetails, ); // GuildPass SDK: End of logic containment structure block. } @@ -38,6 +60,12 @@ export const validateAddress = (address: string, options: { strict?: boolean } = throw new GuildPassError( `Address fails EIP-55 checksum: ${address}`, GuildPassErrorCode.INVALID_ADDRESS, + undefined, + { + field: 'address', + reason: 'checksum', + valueType: 'address', + } as ValidationErrorDetails, ); } // GuildPass SDK: End of logic containment structure block. @@ -54,7 +82,11 @@ export const validateGuildId = (guildId: string): void => { // GuildPass SDK: Verify constraint requirements before proceeding. if (!guildId || typeof guildId !== 'string' || guildId.trim().length === 0) { // GuildPass SDK: Raise exceptional condition and throw error. - throw new GuildPassError('Invalid Guild ID', GuildPassErrorCode.INVALID_INPUT); + throw new GuildPassError('Invalid Guild ID', GuildPassErrorCode.INVALID_INPUT, undefined, { + field: 'guildId', + reason: 'required', + valueType: 'guildId', + } as ValidationErrorDetails); // GuildPass SDK: End of logic containment structure block. } // GuildPass SDK: End of logic containment structure block. @@ -71,7 +103,11 @@ export const validateResourceId = (resourceId: string): void => { // GuildPass SDK: Evaluate branch condition logic. if (!resourceId || typeof resourceId !== 'string' || resourceId.trim().length === 0) { // GuildPass SDK: Propagate error state with specific code description. - throw new GuildPassError('Invalid Resource ID', GuildPassErrorCode.INVALID_INPUT); + throw new GuildPassError('Invalid Resource ID', GuildPassErrorCode.INVALID_INPUT, undefined, { + field: 'resourceId', + reason: 'required', + valueType: 'resourceId', + } as ValidationErrorDetails); // GuildPass SDK: End of logic containment structure block. } // GuildPass SDK: End of logic containment structure block. @@ -88,7 +124,11 @@ export const validateRoleId = (roleId: string): void => { // GuildPass SDK: Conditional check guard path. if (!roleId || typeof roleId !== 'string' || roleId.trim().length === 0) { // GuildPass SDK: Raise exceptional condition and throw error. - throw new GuildPassError('Invalid Role ID', GuildPassErrorCode.INVALID_INPUT); + throw new GuildPassError('Invalid Role ID', GuildPassErrorCode.INVALID_INPUT, undefined, { + field: 'roleId', + reason: 'required', + valueType: 'roleId', + } as ValidationErrorDetails); // GuildPass SDK: End of logic containment structure block. } // GuildPass SDK: End of logic containment structure block. diff --git a/tests/config-validation-metadata.test.ts b/tests/config-validation-metadata.test.ts new file mode 100644 index 0000000..8a68122 --- /dev/null +++ b/tests/config-validation-metadata.test.ts @@ -0,0 +1,90 @@ +// GuildPass SDK: Import external module dependencies. +import { describe, it, expect } from 'vitest'; +// GuildPass SDK: Pull in package or module bindings. +import { GuildPassError } from '../src/errors/GuildPassError'; +// GuildPass SDK: Import external module dependencies. +import { GuildPassErrorCode } from '../src/errors/errorCodes'; +// GuildPass SDK: Pull in package or module bindings. +import { validateConfig } from '../src/config/sdkConfig'; + +// GuildPass SDK: Validate unit assertion test case. +describe('validateConfig structured error metadata', () => { + // GuildPass SDK: Test suite container block. + it('throws with field=apiUrl when missing', () => { + try { + validateConfig({ apiUrl: '' }); + } catch (e) { + const err = e as GuildPassError; + expect(err.details).toBeDefined(); + expect(err.details.field).toBe('apiUrl'); + expect(err.details.reason).toBe('required'); + } + }); + + // GuildPass SDK: Test suite container block. + it('throws with field=apiUrl when URL format is invalid', () => { + try { + validateConfig({ apiUrl: 'not-a-url' }); + } catch (e) { + const err = e as GuildPassError; + expect(err.details.field).toBe('apiUrl'); + expect(err.details.reason).toBe('format'); + } + }); + + // GuildPass SDK: Test suite container block. + it('throws with field=timeoutMs for invalid value', () => { + try { + validateConfig({ apiUrl: 'https://example.com', timeoutMs: -1 }); + } catch (e) { + const err = e as GuildPassError; + expect(err.details.field).toBe('timeoutMs'); + expect(err.details.reason).toBe('invalid_type'); + } + }); + + // GuildPass SDK: Test suite container block. + it('throws with field=cacheTtl for negative value', () => { + try { + validateConfig({ apiUrl: 'https://example.com', cacheTtl: -5 }); + } catch (e) { + const err = e as GuildPassError; + expect(err.details.field).toBe('cacheTtl'); + expect(err.details.reason).toBe('invalid_range'); + } + }); + + // GuildPass SDK: Test suite container block. + it('throws with field=sendClientMetadata for non-boolean', () => { + try { + validateConfig({ apiUrl: 'https://example.com', sendClientMetadata: 'yes' as any }); + } catch (e) { + const err = e as GuildPassError; + expect(err.details.field).toBe('sendClientMetadata'); + expect(err.details.reason).toBe('invalid_type'); + } + }); + + // GuildPass SDK: Test suite container block. + it('throws with field=retry.maxRetries for negative', () => { + try { + validateConfig({ apiUrl: 'https://example.com', retry: { maxRetries: -1 } }); + } catch (e) { + const err = e as GuildPassError; + expect(err.details.field).toBe('retry.maxRetries'); + expect(err.details.reason).toBe('invalid_range'); + } + }); + + // GuildPass SDK: Test suite container block. + it('does not expose apiKey in error details', () => { + try { + validateConfig({ apiUrl: '' }); + } catch (e) { + const err = e as GuildPassError; + expect(err.details).not.toHaveProperty('value'); + expect(err.details).not.toHaveProperty('apiKey'); + } + }); + // GuildPass SDK: End of logic containment structure block. +}); diff --git a/tests/validation-metadata.test.ts b/tests/validation-metadata.test.ts new file mode 100644 index 0000000..8d5a4d1 --- /dev/null +++ b/tests/validation-metadata.test.ts @@ -0,0 +1,87 @@ +// GuildPass SDK: Import external module dependencies. +import { describe, it, expect } from 'vitest'; +// GuildPass SDK: Pull in package or module bindings. +import { GuildPassError } from '../src/errors/GuildPassError'; +// GuildPass SDK: Import external module dependencies. +import { GuildPassErrorCode } from '../src/errors/errorCodes'; +// GuildPass SDK: Pull in package or module bindings. +import { validateAddress, validateGuildId, validateResourceId, validateRoleId } from '../src/utils/validation'; + +// GuildPass SDK: Validate unit assertion test case. +describe('Validation structured error metadata', () => { + // GuildPass SDK: Test suite container block. + it('validateAddress throws with field=address when address is empty', () => { + expect(() => validateAddress('')).toThrow(GuildPassError); + try { + validateAddress(''); + } catch (e) { + const err = e as GuildPassError; + expect(err.details).toBeDefined(); + expect(err.details.field).toBe('address'); + expect(err.details.reason).toBe('required'); + } + }); + + // GuildPass SDK: Test suite container block. + it('validateAddress throws with reason=format for malformed address', () => { + try { + validateAddress('0xINVALID'); + } catch (e) { + const err = e as GuildPassError; + expect(err.details.field).toBe('address'); + expect(err.details.reason).toBe('format'); + expect(err.details.valueType).toBe('address'); + expect(err.code).toBe(GuildPassErrorCode.INVALID_ADDRESS); + } + }); + + // GuildPass SDK: Test suite container block. + it('validateGuildId throws with field=guildId when empty', () => { + try { + validateGuildId(''); + } catch (e) { + const err = e as GuildPassError; + expect(err.details.field).toBe('guildId'); + expect(err.details.reason).toBe('required'); + expect(err.details.valueType).toBe('guildId'); + } + }); + + // GuildPass SDK: Test suite container block. + it('validateResourceId throws with field=resourceId when empty', () => { + try { + validateResourceId(''); + } catch (e) { + const err = e as GuildPassError; + expect(err.details.field).toBe('resourceId'); + expect(err.details.reason).toBe('required'); + expect(err.details.valueType).toBe('resourceId'); + } + }); + + // GuildPass SDK: Test suite container block. + it('validateRoleId throws with field=roleId when empty', () => { + try { + validateRoleId(''); + } catch (e) { + const err = e as GuildPassError; + expect(err.details.field).toBe('roleId'); + expect(err.details.reason).toBe('required'); + expect(err.details.valueType).toBe('roleId'); + } + }); + + // GuildPass SDK: Test suite container block. + it('validateAddress does not expose sensitive value in details', () => { + try { + validateAddress('BAD_ADDR'); + } catch (e) { + const err = e as GuildPassError; + // The raw value is in the message for humans, + // but details should only contain safe metadata + expect(err.details).not.toHaveProperty('value'); + expect(err.details.field).toBe('address'); + } + }); + // GuildPass SDK: End of logic containment structure block. +});