Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions src/utils/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,86 @@ export const ValidationUtils = {
return true;
},
};

// ---------------------------------------------------------------------------
// ServerConfigSchema
// ---------------------------------------------------------------------------

import type { ServerConfig } from '../types/config.ts';

export interface SchemaField {
type: string;
required: boolean;
description: string;
validate: (value: unknown) => boolean;
}

/**
* ServerConfigSchema
* Runtime schema for validating partial ServerConfig objects.
*
* @example
* import { ServerConfigSchema } from 'anchor-kit';
* ServerConfigSchema.port.validate(3000); // true
*/
export const ServerConfigSchema: Record<keyof Required<ServerConfig>, SchemaField> = {
host: {
type: 'string',
required: false,
description: 'Server host address. Defaults to 0.0.0.0',
validate: (value) => typeof value === 'string' && value.length > 0,
},
port: {
type: 'number',
required: false,
description: 'Server port number. Defaults to 3000.',
validate: (value) =>
typeof value === 'number' &&
Number.isInteger(value) &&
value > 0 &&
value <= 65535,
},
debug: {
type: 'boolean',
required: false,
description: 'Enable debug mode for verbose logging. Defaults to false.',
validate: (value) => typeof value === 'boolean',
},
interactiveDomain: {
type: 'string',
required: false,
description: 'Interactive web portal domain/URL for SEP-24 flows.',
validate: (value) => {
if (typeof value !== 'string' || value.length === 0) return false;
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
},
},
};
Comment on lines +286 to +334
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The ServerConfigSchema is incomplete. It is typed as Record<keyof Required<ServerConfig>, SchemaField>, which requires all properties of ServerConfig to be defined in the schema. However, corsOrigins and requestTimeout from the ServerConfig interface are missing.

This will likely cause a TypeScript compilation error. If not, it's a significant bug because the validation logic will not cover these properties, which could lead to invalid configuration being silently accepted.

Please add the missing properties to the schema with appropriate validation.

export const ServerConfigSchema: Record<keyof Required<ServerConfig>, SchemaField> = {
  host: {
    type: 'string',
    required: false,
    description: 'Server host address. Defaults to 0.0.0.0',
    validate: (value) => typeof value === 'string' && value.length > 0,
  },
  port: {
    type: 'number',
    required: false,
    description: 'Server port number. Defaults to 3000.',
    validate: (value) =>
      typeof value === 'number' &&
      Number.isInteger(value) &&
      value > 0 &&
      value <= 65535,
  },
  debug: {
    type: 'boolean',
    required: false,
    description: 'Enable debug mode for verbose logging. Defaults to false.',
    validate: (value) => typeof value === 'boolean',
  },
  interactiveDomain: {
    type: 'string',
    required: false,
    description: 'Interactive web portal domain/URL for SEP-24 flows.',
    validate: (value) => {
      if (typeof value !== 'string' || value.length === 0) return false;
      try {
        const url = new URL(value);
        return url.protocol === 'http:' || url.protocol === 'https:';
      } catch {
        return false;
      }
    },
  },
  corsOrigins: {
    type: 'string[]',
    required: false,
    description: 'Allowed origins for CORS.',
    validate: (value) =>
      Array.isArray(value) &&
      value.every((item) => typeof item === 'string' && item.length > 0),
  },
  requestTimeout: {
    type: 'number',
    required: false,
    description: 'Request timeout in milliseconds.',
    validate: (value) =>
      typeof value === 'number' && Number.isInteger(value) && value > 0,
  },
};


/**
* validateServerConfig
* Validates a partial ServerConfig object. Returns array of error strings.
*
* @example
* validateServerConfig({ port: -1 }); // ['port: invalid value']
*/
export function validateServerConfig(config: Partial<ServerConfig>): string[] {
const errors: string[] = [];
for (const [key, field] of Object.entries(ServerConfigSchema) as [
keyof ServerConfig,
SchemaField,
][]) {
const value = config[key];
if (value === undefined || value === null) {
if (field.required) errors.push(`${key}: is required`);
continue;
}
if (!field.validate(value)) errors.push(`${key}: invalid value`);
}
return errors;
}
121 changes: 121 additions & 0 deletions tests/utils/server-config-schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest';
import { ServerConfigSchema, validateServerConfig } from '../../src/utils/validation';

describe('ServerConfigSchema', () => {
it('is publicly importable from validation module', () => {
expect(ServerConfigSchema).toBeDefined();
});

it('has all expected ServerConfig fields', () => {
expect(ServerConfigSchema).toHaveProperty('host');
expect(ServerConfigSchema).toHaveProperty('port');
expect(ServerConfigSchema).toHaveProperty('debug');
expect(ServerConfigSchema).toHaveProperty('interactiveDomain');
});

it('each field has required schema properties', () => {
for (const [key, field] of Object.entries(ServerConfigSchema)) {
expect(field, `${key} should have type`).toHaveProperty('type');
expect(field, `${key} should have required`).toHaveProperty('required');
expect(field, `${key} should have description`).toHaveProperty('description');
expect(field, `${key} should have validate`).toHaveProperty('validate');
expect(typeof field.validate).toBe('function');
}
});

describe('host field', () => {
it('validates a valid host string', () => {
expect(ServerConfigSchema.host.validate('0.0.0.0')).toBe(true);
expect(ServerConfigSchema.host.validate('localhost')).toBe(true);
});
it('rejects non-string values', () => {
expect(ServerConfigSchema.host.validate(123)).toBe(false);
expect(ServerConfigSchema.host.validate('')).toBe(false);
});
});

describe('port field', () => {
it('validates valid port numbers', () => {
expect(ServerConfigSchema.port.validate(3000)).toBe(true);
expect(ServerConfigSchema.port.validate(1)).toBe(true);
expect(ServerConfigSchema.port.validate(65535)).toBe(true);
});
it('rejects invalid port numbers', () => {
expect(ServerConfigSchema.port.validate(0)).toBe(false);
expect(ServerConfigSchema.port.validate(-1)).toBe(false);
expect(ServerConfigSchema.port.validate(65536)).toBe(false);
expect(ServerConfigSchema.port.validate(3.14)).toBe(false);
expect(ServerConfigSchema.port.validate('3000')).toBe(false);
});
});

describe('debug field', () => {
it('validates boolean values', () => {
expect(ServerConfigSchema.debug.validate(true)).toBe(true);
expect(ServerConfigSchema.debug.validate(false)).toBe(true);
});
it('rejects non-boolean values', () => {
expect(ServerConfigSchema.debug.validate('true')).toBe(false);
expect(ServerConfigSchema.debug.validate(1)).toBe(false);
});
});

describe('interactiveDomain field', () => {
it('validates valid URLs', () => {
expect(ServerConfigSchema.interactiveDomain.validate('https://anchor.example.com')).toBe(true);
expect(ServerConfigSchema.interactiveDomain.validate('http://localhost:8080')).toBe(true);
});
it('rejects invalid URLs', () => {
expect(ServerConfigSchema.interactiveDomain.validate('not-a-url')).toBe(false);
expect(ServerConfigSchema.interactiveDomain.validate('')).toBe(false);
expect(ServerConfigSchema.interactiveDomain.validate(123)).toBe(false);
});
});
});

describe('validateServerConfig', () => {
it('returns empty array for valid config', () => {
const errors = validateServerConfig({
host: 'localhost',
port: 3000,
debug: false,
interactiveDomain: 'https://anchor.example.com',
});
expect(errors).toEqual([]);
});

it('returns empty array for empty config (all fields optional)', () => {
expect(validateServerConfig({})).toEqual([]);
});

it('returns error for invalid port', () => {
const errors = validateServerConfig({ port: -1 });
expect(errors).toContain('port: invalid value');
});

it('returns error for invalid interactiveDomain', () => {
const errors = validateServerConfig({ interactiveDomain: 'not-a-url' });
expect(errors).toContain('interactiveDomain: invalid value');
});

it('returns multiple errors for multiple invalid fields', () => {
const errors = validateServerConfig({
port: 0,
debug: 'yes' as unknown as boolean,
});
expect(errors.length).toBeGreaterThanOrEqual(2);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The assertion expect(errors.length).toBeGreaterThanOrEqual(2) is not very precise. For a more robust test that prevents future regressions, it's better to assert the exact length and content of the errors array.

    expect(errors).toHaveLength(2);
    expect(errors).toContain('port: invalid value');
    expect(errors).toContain('debug: invalid value');

});
});

describe('ServerConfigSchema public export', () => {
it('is exported from the utils validation module', async () => {
const mod = await import('../../src/utils/validation');
expect(mod.ServerConfigSchema).toBeDefined();
expect(mod.validateServerConfig).toBeDefined();
});

it('is accessible through the utils index', async () => {
const mod = await import('../../src/utils/index');
expect(mod.ServerConfigSchema).toBeDefined();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This test is incomplete. It only checks for ServerConfigSchema being exported from the utils index. Since validateServerConfig is also exported and intended for public use, the test should verify that it's also accessible from the index file.

    expect(mod.ServerConfigSchema).toBeDefined();
    expect(mod.validateServerConfig).toBeDefined();

});
});
Loading