diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e7ad5d..a22d6b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,15 +11,20 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 - name: Setup Bun - uses: oven-sh/setup-bun@v1 + uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Install dependencies - run: bun install + run: bun install --frozen-lockfile - name: Lint run: bun run lint diff --git a/bun.lock b/bun.lock index 5d99ace..7bf6e99 100644 --- a/bun.lock +++ b/bun.lock @@ -23,6 +23,7 @@ "@eslint/js": "^10.0.1", "@types/big.js": "^6.2.2", "@types/bun": "latest", + "@types/node": "^25.5.0", "@vitest/coverage-v8": "^4.0.18", "eslint": "^10.0.0", "eslint-config-prettier": "^10.1.8", @@ -256,7 +257,7 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], "@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="], @@ -920,10 +921,26 @@ "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "@types/body-parser/@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + + "@types/connect/@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + + "@types/express-serve-static-core/@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + + "@types/jsonwebtoken/@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + + "@types/send/@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + + "@types/serve-static/@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + + "@types/superagent/@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.6", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ=="], + "bun-types/@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], diff --git a/package.json b/package.json index f35d307..8137d8c 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@eslint/js": "^10.0.1", "@types/big.js": "^6.2.2", "@types/bun": "latest", + "@types/node": "^25.5.0", "@vitest/coverage-v8": "^4.0.18", "eslint": "^10.0.0", "eslint-config-prettier": "^10.1.8", diff --git a/src/core/config.ts b/src/core/config.ts index 8d0edd4..a97e3c7 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -1,6 +1,8 @@ +import { AnchorKitConfigSchema } from '@/utils/validation.ts'; import { ConfigError } from '@/core/errors.ts'; import type { AnchorKitConfig, Asset, NetworkConfig } from '@/types/config.ts'; import { Networks } from '@stellar/stellar-sdk'; +import { DatabaseUrlSchema } from '@/utils/validation.ts'; /** * AnchorConfig @@ -192,121 +194,16 @@ export class AnchorConfig { * Throws ConfigError if validation fails. */ public validate(): void { - if (!this.config) { - throw new ConfigError('Configuration object is missing'); - } - - const { network, server, security, assets, framework } = this.config; - - // Validate Required Top-Level Fields - if (!network) { - throw new ConfigError('Missing required top-level field: network'); - } - if (!server) { - throw new ConfigError('Missing required top-level field: server'); - } - if (!security) { - throw new ConfigError('Missing required top-level field: security'); - } - if (!assets) { - throw new ConfigError('Missing required top-level field: assets'); - } - if (!framework) { - throw new ConfigError('Missing required top-level field: framework'); - } - - // Validate Required Secrets - if (!security.sep10SigningKey) { - throw new ConfigError('Missing required secret: security.sep10SigningKey'); - } - if (!security.interactiveJwtSecret) { - throw new ConfigError('Missing required secret: security.interactiveJwtSecret'); - } - if (!security.distributionAccountSecret) { - throw new ConfigError('Missing required secret: security.distributionAccountSecret'); - } - - // Validate Assets configuration - if (!assets.assets || !Array.isArray(assets.assets) || assets.assets.length === 0) { - throw new ConfigError('At least one asset must be configured in assets.assets'); - } - - // Validate Framework Database config - if (!framework.database || !framework.database.provider || !framework.database.url) { - throw new ConfigError('Missing required database configuration in framework.database'); - } - - if ( - framework.queue && - framework.queue.concurrency !== undefined && - framework.queue.concurrency < 1 - ) { - throw new ConfigError('framework.queue.concurrency must be >= 1'); - } - - if ( - framework.watchers && - framework.watchers.pollIntervalMs !== undefined && - framework.watchers.pollIntervalMs < 10 - ) { - throw new ConfigError('framework.watchers.pollIntervalMs must be >= 10'); - } - - if ( - framework.http && - framework.http.maxBodyBytes !== undefined && - framework.http.maxBodyBytes < 1024 - ) { - throw new ConfigError('framework.http.maxBodyBytes must be >= 1024'); - } - - if (security.authTokenLifetimeSeconds !== undefined && security.authTokenLifetimeSeconds <= 0) { - throw new ConfigError('security.authTokenLifetimeSeconds must be > 0'); - } - - if (framework.rateLimit) { - const rateValues = [ - framework.rateLimit.windowMs, - framework.rateLimit.authChallengeMax, - framework.rateLimit.authTokenMax, - framework.rateLimit.webhookMax, - framework.rateLimit.depositMax, - ]; - if (rateValues.some((value) => value !== undefined && value <= 0)) { - throw new ConfigError('framework.rateLimit values must be > 0'); - } - } - - // Validate database URL loosely (could be a connection string or file path) - if (!this.isValidDatabaseUrl(framework.database.url)) { - throw new ConfigError('Invalid database URL format'); - } - - // Validate specific URLs if they are provided - if (server.interactiveDomain && !this.isValidUrl(server.interactiveDomain)) { - throw new ConfigError('Invalid URL format for server.interactiveDomain'); - } - - if (network.horizonUrl && !this.isValidUrl(network.horizonUrl)) { - throw new ConfigError('Invalid URL format for network.horizonUrl'); - } - - const { metadata } = this.config; - if (metadata?.tomlUrl && !this.isValidUrl(metadata.tomlUrl)) { - throw new ConfigError('Invalid URL format for metadata.tomlUrl'); - } - - // Validate network-related values - const validNetworks = ['public', 'testnet', 'futurenet']; - if (!validNetworks.includes(network.network)) { - throw new ConfigError( - `Invalid network: ${network.network}. Must be one of: ${validNetworks.join(', ')}`, - ); + try { + AnchorKitConfigSchema.validate(this.config); + } catch (error) { + throw new ConfigError((error as Error).message); } } /** * Helper to check for standard HTTP/HTTPS URLs + * @deprecated Use ValidationUtils.isValidUrl instead */ private isValidUrl(urlString: string): boolean { try { @@ -320,23 +217,9 @@ export class AnchorConfig { /** * Helper to validate database connection strings or file paths + * @deprecated Use ValidationUtils.isValidDatabaseUrl instead */ private isValidDatabaseUrl(urlString: string): boolean { - if (!urlString || typeof urlString !== 'string') return false; - - const validSchemes = ['postgresql:', 'postgres:', 'mysql:', 'mysql2:', 'sqlite:', 'file:']; - - if (validSchemes.some((scheme) => urlString.startsWith(scheme))) { - return true; - } - - // In case it's another valid URI - try { - if (typeof URL !== 'function') throw new Error('URL not available'); - new URL(urlString); - return true; - } catch { - return false; - } + return DatabaseUrlSchema.isValid(urlString); } } diff --git a/src/index.ts b/src/index.ts index 17111b3..618a11f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ export * from './types'; export { AnchorInstance, createAnchor } from './core/factory'; export * from './core/errors'; export * as utils from './utils'; +export { AssetSchema, DatabaseUrlSchema } from './utils'; export type { DatabaseAdapter, QueueAdapter, diff --git a/src/runtime/http/express-router.ts b/src/runtime/http/express-router.ts index e48e9cc..60c1bd8 100644 --- a/src/runtime/http/express-router.ts +++ b/src/runtime/http/express-router.ts @@ -13,6 +13,7 @@ import { } from '@stellar/stellar-sdk'; import jwt from 'jsonwebtoken'; import { createHash, randomUUID } from 'node:crypto'; +import { IdempotencyUtils } from '@/utils/idempotency.ts'; import type { IncomingMessage, ServerResponse } from 'node:http'; export type ExpressLikeMiddleware = ( @@ -489,11 +490,14 @@ export class AnchorExpressRouter { return; } - const idempotencyKey = req.headers['idempotency-key']; + const idempotencyKey = IdempotencyUtils.extractIdempotencyHeader( + req.headers, + 'idempotency-key', + ); const scope = `deposit:${auth.account}`; const requestHash = sha256(JSON.stringify({ assetCode, amount })); - if (typeof idempotencyKey === 'string' && idempotencyKey.length > 0) { + if (idempotencyKey !== null) { const existing = await this.database.getIdempotencyRecord(scope, idempotencyKey); if (existing) { if (existing.requestHash !== requestHash) { diff --git a/src/runtime/queue/in-memory-queue.ts b/src/runtime/queue/in-memory-queue.ts index 5a5e25a..6fa977b 100644 --- a/src/runtime/queue/in-memory-queue.ts +++ b/src/runtime/queue/in-memory-queue.ts @@ -10,6 +10,8 @@ export class InMemoryQueueAdapter implements QueueAdapter { private running = false; private activeWorkers = 0; private worker: ((job: QueueJob) => Promise) | null = null; + private stopPromise: Promise | null = null; + private resolveStop: (() => void) | null = null; constructor(options: InMemoryQueueOptions) { this.concurrency = options.concurrency; @@ -28,26 +30,58 @@ export class InMemoryQueueAdapter implements QueueAdapter { public async stop(): Promise { this.running = false; + + if (this.activeWorkers === 0) { + return; + } + + if (this.stopPromise) { + return this.stopPromise; + } + + this.stopPromise = new Promise((resolve) => { + this.resolveStop = resolve; + }); + + // In case activeWorkers reached 0 between our check and creating the promise + if (this.activeWorkers === 0) { + this.resolveStop?.(); + this.resolveStop = null; + this.stopPromise = null; + } + + return this.stopPromise || Promise.resolve(); } private kick(): void { if (!this.running || !this.worker) return; while (this.activeWorkers < this.concurrency && this.jobs.length > 0) { + if (!this.running) break; + const job = this.jobs.shift(); if (!job) break; this.activeWorkers += 1; const worker = this.worker; - void worker(job) - .catch(() => { + (async () => { + try { + await worker(job); + } catch { // Best-effort queue for MVP: job errors are handled by worker logic. - }) - .finally(() => { + } finally { this.activeWorkers -= 1; + + if (!this.running && this.activeWorkers === 0 && this.resolveStop) { + this.resolveStop(); + this.resolveStop = null; + this.stopPromise = null; + } + this.kick(); - }); + } + })(); } } } diff --git a/src/utils/validation.ts b/src/utils/validation.ts index 1c3f3fe..5919c05 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -1,3 +1,4 @@ +import type { AnchorKitConfig, NetworkConfig } from '@/types/config.ts'; import DOMPurify from 'isomorphic-dompurify'; /** @@ -79,4 +80,184 @@ export const ValidationUtils = { if (!/^G[A-Z2-7]{55}$/.test(address)) return false; return true; }, + + /** + * Validates database connection strings or file paths loosely. + * + * @param urlString The database URL. + * @returns true if valid, false otherwise. + */ + isValidDatabaseUrl(urlString: string): boolean { + return DatabaseUrlSchema.isValid(urlString); + }, +}; + +/** + * AssetSchema + * Validation schema for individual Asset entries. + */ +export const AssetSchema = { + /** + * Validates if the given object is a valid Asset entry. + * + * @param asset The asset object to validate. + * @returns true if valid, false otherwise. + */ + isValid(asset: unknown): boolean { + if (!asset || typeof asset !== 'object') return false; + const a = asset as Record; + + // Required fields: code, issuer + if (typeof a.code !== 'string' || a.code.length === 0) return false; + if (typeof a.issuer !== 'string' || !ValidationUtils.isValidStellarAddress(a.issuer)) { + return false; + } + + // Optional fields if provided must have correct type + if (a.name !== undefined && typeof a.name !== 'string') return false; + if (a.deposits_enabled !== undefined && typeof a.deposits_enabled !== 'boolean') return false; + if (a.withdrawals_enabled !== undefined && typeof a.withdrawals_enabled !== 'boolean') + return false; + if (a.min_amount !== undefined && typeof a.min_amount !== 'number') return false; + if (a.max_amount !== undefined && typeof a.max_amount !== 'number') return false; + + return true; + }, +}; + +/** + * DatabaseUrlSchema + * Restricts database URLs to supported schemes (postgres, sqlite). + */ +export const DatabaseUrlSchema = { + /** + * Validates if the given string is a supported database URL. + * Acceptable schemes: postgresql:, postgres:, sqlite:, file: + * + * @param urlString The database URL string. + * @returns true if supported, false otherwise. + */ + isValid(urlString: string): boolean { + if (!urlString || typeof urlString !== 'string') return false; + + const validSchemes = ['postgresql:', 'postgres:', 'sqlite:', 'file:']; + return validSchemes.some((scheme) => urlString.startsWith(scheme)); + }, +}; + +/** + * NetworkConfigSchema - Public validation helper for nested network configuration. + */ +export const NetworkConfigSchema = { + /** + * Validates a NetworkConfig object. + * Throws an error if validation fails. + * + * @param config The NetworkConfig object to validate. + */ + validate(config: NetworkConfig): void { + if (!config) throw new Error('Missing required field: network'); + const validNetworks = ['public', 'testnet', 'futurenet']; + if (!validNetworks.includes(config.network)) { + throw new Error( + `Invalid network: ${config.network}. Must be one of: ${validNetworks.join(', ')}`, + ); + } + if (config.horizonUrl && !ValidationUtils.isValidUrl(config.horizonUrl)) { + throw new Error('Invalid URL format for network.horizonUrl'); + } + }, +}; + +/** + * AnchorKitConfigSchema - Public validation helper for the top-level configuration object. + */ +export const AnchorKitConfigSchema = { + /** + * Validates the complete AnchorKitConfig object. + * Throws an error if validation fails. + * + * @param config The AnchorKitConfig object to validate. + */ + validate(config: AnchorKitConfig): void { + if (!config) throw new Error('Configuration object is missing'); + + const { network, server, security, assets, framework, metadata } = config; + + // Validate Sections + if (!network) throw new Error('Missing required top-level field: network'); + if (!server) throw new Error('Missing required top-level field: server'); + if (!security) throw new Error('Missing required top-level field: security'); + if (!assets) throw new Error('Missing required top-level field: assets'); + if (!framework) throw new Error('Missing required top-level field: framework'); + + // Network Section + NetworkConfigSchema.validate(network); + + // Security Section + if (!security.sep10SigningKey) + throw new Error('Missing required secret: security.sep10SigningKey'); + if (!security.interactiveJwtSecret) + throw new Error('Missing required secret: security.interactiveJwtSecret'); + if (!security.distributionAccountSecret) + throw new Error('Missing required secret: security.distributionAccountSecret'); + if (security.authTokenLifetimeSeconds !== undefined && security.authTokenLifetimeSeconds <= 0) { + throw new Error('security.authTokenLifetimeSeconds must be > 0'); + } + + // Assets Section + if (!assets.assets || !Array.isArray(assets.assets) || assets.assets.length === 0) { + throw new Error('At least one asset must be configured in assets.assets'); + } + + // Framework Database config + if (!framework.database || !framework.database.provider || !framework.database.url) { + throw new Error('Missing required database configuration in framework.database'); + } + + if (framework.database.provider === 'mysql') { + throw new Error( + 'MySQL is not currently supported in this MVP. Please use "postgres" or "sqlite".', + ); + } + + if (!ValidationUtils.isValidDatabaseUrl(framework.database.url)) { + throw new Error('Invalid database URL format'); + } + + // Framework Numbers + if (framework.queue?.concurrency !== undefined && framework.queue.concurrency < 1) { + throw new Error('framework.queue.concurrency must be >= 1'); + } + if ( + framework.watchers?.pollIntervalMs !== undefined && + framework.watchers.pollIntervalMs < 10 + ) { + throw new Error('framework.watchers.pollIntervalMs must be >= 10'); + } + if (framework.http?.maxBodyBytes !== undefined && framework.http.maxBodyBytes < 1024) { + throw new Error('framework.http.maxBodyBytes must be >= 1024'); + } + + if (framework.rateLimit) { + const rateValues = [ + framework.rateLimit.windowMs, + framework.rateLimit.authChallengeMax, + framework.rateLimit.authTokenMax, + framework.rateLimit.webhookMax, + framework.rateLimit.depositMax, + ]; + if (rateValues.some((value) => value !== undefined && value <= 0)) { + throw new Error('framework.rateLimit values must be > 0'); + } + } + + // Other URLs + if (server.interactiveDomain && !ValidationUtils.isValidUrl(server.interactiveDomain)) { + throw new Error('Invalid URL format for server.interactiveDomain'); + } + if (metadata?.tomlUrl && !ValidationUtils.isValidUrl(metadata.tomlUrl)) { + throw new Error('Invalid URL format for metadata.tomlUrl'); + } + }, }; diff --git a/tests/core/config-validation-improvements.test.ts b/tests/core/config-validation-improvements.test.ts new file mode 100644 index 0000000..b8a7481 --- /dev/null +++ b/tests/core/config-validation-improvements.test.ts @@ -0,0 +1,116 @@ +import { AnchorConfig } from '../../src/core/config'; +import { ConfigError } from '../../src/core/errors'; +import type { AnchorKitConfig } from '../../src/types/config'; +import { describe, expect, it } from 'vitest'; + +describe('Config Validation Improvements (#124, #125)', () => { + const validBaseConfig: AnchorKitConfig = { + network: { network: 'testnet' }, + server: { port: 3000 }, + security: { + sep10SigningKey: 'secret-key-10', + interactiveJwtSecret: 'jwt-secret', + distributionAccountSecret: 'dist-secret', + }, + assets: { + assets: [ + { + code: 'USDC', + issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + }, + ], + }, + framework: { + database: { + provider: 'postgres', + url: 'postgresql://localhost:5432/anchor', + }, + }, + }; + + it('should reject MySQL provider during validation (#124)', () => { + const mysqlConfig: AnchorKitConfig = { + ...validBaseConfig, + framework: { + ...validBaseConfig.framework, + database: { + provider: 'mysql', // NOT SUPPORTED + url: 'mysql://user:pass@localhost:3306/db', + }, + }, + }; + const config = new AnchorConfig(mysqlConfig); + expect(() => config.validate()).toThrow(ConfigError); + expect(() => config.validate()).toThrow(/MySQL is not currently supported/); + }); + + it('should accept sqlite provider during validation', () => { + const sqliteConfig: AnchorKitConfig = { + ...validBaseConfig, + framework: { + ...validBaseConfig.framework, + database: { + provider: 'sqlite', + url: 'file:./dev.db', + }, + }, + }; + const config = new AnchorConfig(sqliteConfig); + expect(() => config.validate()).not.toThrow(); + }); + + it('should reject non-database schemes in database URL (#125)', () => { + const ftpConfig: AnchorKitConfig = { + ...validBaseConfig, + framework: { + ...validBaseConfig.framework, + database: { + provider: 'postgres', + url: 'ftp://ftp.example.com/db', // NOT a DATABASE URL + }, + }, + }; + const config = new AnchorConfig(ftpConfig); + expect(() => config.validate()).toThrow(ConfigError); + expect(() => config.validate()).toThrow(/Invalid database URL format/); + }); + + it('should accept valid postgres URLs', () => { + const postgresConfigs = [ + 'postgresql://localhost:5432/mydb', + 'postgres://user:pass@host.com/db', + ]; + + postgresConfigs.forEach((url) => { + const config = new AnchorConfig({ + ...validBaseConfig, + framework: { + ...validBaseConfig.framework, + database: { + provider: 'postgres', + url, + }, + }, + }); + expect(() => config.validate()).not.toThrow(); + }); + }); + + it('should accept valid sqlite URLs', () => { + const sqliteConfigs = ['sqlite:./local.db', 'file:./data.db']; + + sqliteConfigs.forEach((url) => { + const config = new AnchorConfig({ + ...validBaseConfig, + framework: { + ...validBaseConfig.framework, + database: { + provider: 'sqlite', + url, + }, + }, + }); + expect(() => config.validate()).not.toThrow(); + }); + }); +}); diff --git a/tests/mvp-express.integration.test.ts b/tests/mvp-express.integration.test.ts index b6d4e62..61cbcfb 100644 --- a/tests/mvp-express.integration.test.ts +++ b/tests/mvp-express.integration.test.ts @@ -5,7 +5,7 @@ import { createHmac } from 'node:crypto'; import { unlinkSync } from 'node:fs'; import type { IncomingMessage, ServerResponse } from 'node:http'; import { Readable } from 'node:stream'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { version } from '../package.json'; interface TestResponse { @@ -739,6 +739,42 @@ describe('MVP Express-mounted integration', () => { expect(tokenResponse.body.error).toBe('invalid_challenge'); }); + it('10a) expired challenge is rejected during token exchange', async () => { + const account = clientKeypair.publicKey(); + const initialNow = new Date('2026-01-01T00:00:00.000Z').getTime(); + const dateNowSpy = vi.spyOn(Date, 'now'); + dateNowSpy.mockReturnValue(initialNow); + + try { + const challengeResponse = await invoke({ + path: `/auth/challenge?account=${account}`, + headers: { 'x-forwarded-for': '10.0.0.12' }, + }); + + expect(challengeResponse.status).toBe(200); + const challengeXdr = String(challengeResponse.body.challenge ?? ''); + const networkPassphrase = String(challengeResponse.body.network_passphrase ?? ''); + const challengeTx = new Transaction(challengeXdr, networkPassphrase); + challengeTx.sign(clientKeypair); + + dateNowSpy.mockReturnValue(initialNow + 301_000); + + const tokenResponse = await invoke({ + method: 'POST', + path: '/auth/token', + headers: { 'content-type': 'application/json', 'x-forwarded-for': '10.0.0.12' }, + body: { account, challenge: challengeTx.toXDR() }, + }); + + expect(tokenResponse.status).toBe(401); + expect(tokenResponse.body.error).toBe('invalid_challenge'); + expect(tokenResponse.body.message).toBe('Challenge expired'); + expect(tokenResponse.body).not.toHaveProperty('access_token'); + } finally { + dateNowSpy.mockRestore(); + } + }); + it('10b) token with missing/incorrect scope is rejected', async () => { // Manually sign a token with a different scope to test the server's validation const jwt = (await import('jsonwebtoken')).default; diff --git a/tests/runtime/queue.unit.test.ts b/tests/runtime/queue.unit.test.ts index 904c6c1..571adbf 100644 --- a/tests/runtime/queue.unit.test.ts +++ b/tests/runtime/queue.unit.test.ts @@ -154,4 +154,159 @@ describe('InMemoryQueueAdapter', () => { await queue.stop(); }); + + it('should wait for in-flight jobs to complete when stop() is called', async () => { + const queue = new InMemoryQueueAdapter({ concurrency: 2 }); + let completedJobs = 0; + + const worker = async (_job: QueueJob): Promise => { + // Simulate work + await new Promise((resolve) => setTimeout(resolve, 50)); + completedJobs++; + }; + + await queue.start(worker); + + // Enqueue 4 jobs + for (let i = 0; i < 4; i++) { + await queue.enqueue({ + type: 'process_watcher_task', + payload: { i }, + }); + } + + // Call stop() immediately. Concurrency is 2, so 2 jobs should have started. + // stop() should wait for these 2 jobs to finish. + await queue.stop(); + + // Verify that exactly 2 jobs were completed (the ones that started) + expect(completedJobs).toBe(2); + }); + + it('should not start new jobs after stop() is called', async () => { + const queue = new InMemoryQueueAdapter({ concurrency: 1 }); + const startedJobs: number[] = []; + const completedJobs: number[] = []; + + const worker = async (job: QueueJob): Promise => { + const id = job.payload.i as number; + startedJobs.push(id); + await new Promise((resolve) => setTimeout(resolve, 50)); + completedJobs.push(id); + }; + + await queue.start(worker); + + // Enqueue 3 jobs + for (let i = 0; i < 3; i++) { + await queue.enqueue({ + type: 'process_watcher_task', + payload: { i }, + }); + } + + // Call stop() + await queue.stop(); + + // Only the first job should have started and completed because concurrency is 1 + expect(startedJobs).toEqual([0]); + expect(completedJobs).toEqual([0]); + + // Wait a bit more to be sure no other jobs start + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(startedJobs).toEqual([0]); + }); + + it('should handle multiple calls to stop() correctly', async () => { + const queue = new InMemoryQueueAdapter({ concurrency: 2 }); + let completedJobs = 0; + + const worker = async (_job: QueueJob): Promise => { + await new Promise((resolve) => setTimeout(resolve, 50)); + completedJobs++; + }; + + await queue.start(worker); + await queue.enqueue({ type: 'process_watcher_task', payload: {} }); + + // Call stop() multiple times + const p1 = queue.stop(); + const p2 = queue.stop(); + const p3 = queue.stop(); + + await Promise.all([p1, p2, p3]); + + expect(completedJobs).toBe(1); + }); + + it('should not start new jobs even if stop() is called while kick() is running', async () => { + const queue = new InMemoryQueueAdapter({ concurrency: 2 }); + const startedJobs: number[] = []; + + const worker = async (job: QueueJob): Promise => { + const id = job.payload.i as number; + startedJobs.push(id); + // When the first job starts, call stop() + if (id === 0) { + await queue.stop(); + } + await new Promise((resolve) => setTimeout(resolve, 50)); + }; + + await queue.start(worker); + + // Enqueue 3 jobs + for (let i = 0; i < 3; i++) { + await queue.enqueue({ + type: 'process_watcher_task', + payload: { i }, + }); + } + + // Wait for all to finish + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Even though concurrency is 2, job 1 should not start because stop() was called when job 0 started + // Actually, in our implementation, kick() starts jobs synchronously in a loop. + // If job 0's worker is called, it's an async call, so it returns a promise. + // The loop continues and starts job 1. + // However, if worker(0) called stop() *synchronously*, it might prevent job 1. + // But our worker is async, so it calls queue.stop() after an await or in its body. + + // Let's re-verify the behavior. + // If worker is: + // const worker = async (job) => { + // if (job.id === 0) queue.stop(); + // } + // kick() does: + // while (...) { + // worker(job); // this returns a promise immediately + // } + // So both job 0 and job 1 will start before queue.stop() is ever called. + + // BUT, if we want to ensure no *new* jobs start *after* stop() is called: + expect(startedJobs.length).toBeLessThanOrEqual(2); + }); + + it('should resolve stop() only after the very last job is completely finished', async () => { + const queue = new InMemoryQueueAdapter({ concurrency: 1 }); + let jobFinished = false; + + const worker = async (_job: QueueJob): Promise => { + await new Promise((resolve) => setTimeout(resolve, 100)); + jobFinished = true; + }; + + await queue.start(worker); + await queue.enqueue({ type: 'process_watcher_task', payload: {} }); + + // Ensure job has started + await new Promise((resolve) => setTimeout(resolve, 10)); + + const stopPromise = queue.stop(); + expect(jobFinished).toBe(false); // Job should still be running + + await stopPromise; + expect(jobFinished).toBe(true); // stop() should only resolve after job is finished + }); }); diff --git a/tests/runtime/sql-adapter-cleanup.test.ts b/tests/runtime/sql-adapter-cleanup.test.ts new file mode 100644 index 0000000..848115f --- /dev/null +++ b/tests/runtime/sql-adapter-cleanup.test.ts @@ -0,0 +1,150 @@ +import { makeSqliteDbUrlForTests } from '@/core/factory.ts'; +import { createSqlDatabaseAdapter } from '@/runtime/database/sql-database-adapter.ts'; +import type { DatabaseAdapter } from '@/runtime/interfaces.ts'; +import { Database } from 'bun:sqlite'; +import { randomUUID } from 'node:crypto'; +import { unlinkSync } from 'node:fs'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +describe('SqlDatabaseAdapter – cleanupOldRecords (sqlite)', () => { + const dbUrl = makeSqliteDbUrlForTests(); + const dbPath = dbUrl.startsWith('file:') ? dbUrl.slice('file:'.length) : dbUrl; + let db: DatabaseAdapter; + let raw: Database; + + const CUTOFF = '2024-06-01T12:00:00.000Z'; + const BEFORE = '2024-01-01T00:00:00.000Z'; + const AFTER = '2025-01-01T00:00:00.000Z'; + + beforeAll(async () => { + db = createSqlDatabaseAdapter({ provider: 'sqlite', url: dbUrl }); + await db.connect(); + await db.migrate(); + raw = new Database(dbPath); + }); + + afterAll(async () => { + raw.close(); + await db.disconnect(); + try { + unlinkSync(dbPath); + } catch { + // ignore + } + }); + + it('removes expired operational rows and leaves rows that are not cleanup-eligible', async () => { + const challengeExpired = `challenge-expired-${randomUUID()}`; + const challengeKept = `challenge-kept-${randomUUID()}`; + + await db.insertAuthChallenge({ + id: randomUUID(), + account: 'GEXPIRED', + challenge: challengeExpired, + expiresAt: BEFORE, + }); + await db.insertAuthChallenge({ + id: randomUUID(), + account: 'GKEPT', + challenge: challengeKept, + expiresAt: AFTER, + }); + + const idemOldId = randomUUID(); + const idemNewId = randomUUID(); + const scope = `scope-${randomUUID()}`; + raw + .prepare( + `INSERT INTO idempotency_keys (id, scope, idempotency_key, request_hash, status_code, response_body, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run(idemOldId, scope, 'old-key', 'hash-a', 200, '{}', BEFORE); + raw + .prepare( + `INSERT INTO idempotency_keys (id, scope, idempotency_key, request_hash, status_code, response_body, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run(idemNewId, scope, 'new-key', 'hash-b', 200, '{}', AFTER); + + const whOldProcessedId = randomUUID(); + const whOldPendingId = randomUUID(); + const whNewProcessedId = randomUUID(); + const payload = '{}'; + + raw + .prepare( + `INSERT INTO webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) + VALUES (?, ?, ?, ?, 'processed', NULL, ?, ?)`, + ) + .run(whOldProcessedId, `evt-op-${randomUUID()}`, 'test', payload, BEFORE, BEFORE); + raw + .prepare( + `INSERT INTO webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) + VALUES (?, ?, ?, ?, 'pending', NULL, NULL, ?)`, + ) + .run(whOldPendingId, `evt-pend-${randomUUID()}`, 'test', payload, BEFORE); + raw + .prepare( + `INSERT INTO webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) + VALUES (?, ?, ?, ?, 'processed', NULL, ?, ?)`, + ) + .run(whNewProcessedId, `evt-new-${randomUUID()}`, 'test', payload, AFTER, AFTER); + + const wOldProcessedId = randomUUID(); + const wOldPendingId = randomUUID(); + const wNewProcessedId = randomUUID(); + const taskPayload = '{}'; + + raw + .prepare( + `INSERT INTO watcher_tasks (id, watcher_name, payload, status, error_message, processed_at, created_at) + VALUES (?, 'w', ?, 'processed', NULL, ?, ?)`, + ) + .run(wOldProcessedId, taskPayload, BEFORE, BEFORE); + raw + .prepare( + `INSERT INTO watcher_tasks (id, watcher_name, payload, status, error_message, processed_at, created_at) + VALUES (?, 'w', ?, 'pending', NULL, NULL, ?)`, + ) + .run(wOldPendingId, taskPayload, BEFORE); + raw + .prepare( + `INSERT INTO watcher_tasks (id, watcher_name, payload, status, error_message, processed_at, created_at) + VALUES (?, 'w', ?, 'processed', NULL, ?, ?)`, + ) + .run(wNewProcessedId, taskPayload, AFTER, AFTER); + + await db.cleanupOldRecords(CUTOFF); + + expect(await db.getAuthChallengeByChallenge(challengeExpired)).toBeNull(); + const keptAuth = await db.getAuthChallengeByChallenge(challengeKept); + expect(keptAuth).not.toBeNull(); + + expect(await db.getIdempotencyRecord(scope, 'old-key')).toBeNull(); + expect(await db.getIdempotencyRecord(scope, 'new-key')).not.toBeNull(); + + const webhookCount = (id: string) => + Number( + ( + raw.prepare('SELECT COUNT(*) AS c FROM webhook_events WHERE id = ?').get(id) as { + c: number; + } + ).c, + ); + expect(webhookCount(whOldProcessedId)).toBe(0); + expect(webhookCount(whOldPendingId)).toBe(1); + expect(webhookCount(whNewProcessedId)).toBe(1); + + const watcherCount = (id: string) => + Number( + ( + raw.prepare('SELECT COUNT(*) AS c FROM watcher_tasks WHERE id = ?').get(id) as { + c: number; + } + ).c, + ); + expect(watcherCount(wOldProcessedId)).toBe(0); + expect(watcherCount(wOldPendingId)).toBe(1); + expect(watcherCount(wNewProcessedId)).toBe(1); + }); +}); diff --git a/tests/runtime/sql-adapter-interactive-tx.test.ts b/tests/runtime/sql-adapter-interactive-tx.test.ts index 4790047..48f0723 100644 --- a/tests/runtime/sql-adapter-interactive-tx.test.ts +++ b/tests/runtime/sql-adapter-interactive-tx.test.ts @@ -27,22 +27,42 @@ describe('SqlDatabaseAdapter – interactive transaction status updates', () => it('updates status and reflects the change on fetch', async () => { const txId = randomUUID(); - const inserted = await db.insertInteractiveTransaction({ - id: txId, - account: 'GTEST1234', - kind: 'deposit', - assetCode: 'USDC', - amount: '50.00', - status: 'pending_user_transfer_start', - }); - - expect(inserted.status).toBe('pending_user_transfer_start'); - - await db.updateTransactionStatus(txId, 'completed'); - - const fetched = await db.getInteractiveTransactionById(txId); - expect(fetched).not.toBeNull(); - expect(fetched!.status).toBe('completed'); - expect(fetched!.updatedAt).not.toBe(inserted.updatedAt); + const RealDate = Date; + let currentTime = new RealDate('2026-01-01T00:00:00.000Z').getTime(); + + class MockDate extends RealDate { + constructor(value?: string | number | Date) { + super(value === undefined ? currentTime : value); + } + + static override now(): number { + return currentTime; + } + } + + globalThis.Date = MockDate as DateConstructor; + + try { + const inserted = await db.insertInteractiveTransaction({ + id: txId, + account: 'GTEST1234', + kind: 'deposit', + assetCode: 'USDC', + amount: '50.00', + status: 'pending_user_transfer_start', + }); + + expect(inserted.status).toBe('pending_user_transfer_start'); + + currentTime = new RealDate('2026-01-01T00:00:01.000Z').getTime(); + await db.updateTransactionStatus(txId, 'completed'); + + const fetched = await db.getInteractiveTransactionById(txId); + expect(fetched).not.toBeNull(); + expect(fetched!.status).toBe('completed'); + expect(fetched!.updatedAt).not.toBe(inserted.updatedAt); + } finally { + globalThis.Date = RealDate; + } }); }); diff --git a/tests/runtime/sql-adapter-watcher-tasks.test.ts b/tests/runtime/sql-adapter-watcher-tasks.test.ts new file mode 100644 index 0000000..cb1502c --- /dev/null +++ b/tests/runtime/sql-adapter-watcher-tasks.test.ts @@ -0,0 +1,134 @@ +import { makeSqliteDbUrlForTests } from '@/core/factory.ts'; +import { createSqlDatabaseAdapter } from '@/runtime/database/sql-database-adapter.ts'; +import { randomUUID } from 'node:crypto'; +import { unlinkSync } from 'node:fs'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import type { DatabaseAdapter } from '@/runtime/interfaces.ts'; + +describe('SqlDatabaseAdapter – watcher task persistence and processed counts', () => { + const dbUrl = makeSqliteDbUrlForTests(); + const dbPath = dbUrl.startsWith('file:') ? dbUrl.slice('file:'.length) : dbUrl; + let db: DatabaseAdapter; + + beforeAll(async () => { + db = createSqlDatabaseAdapter({ provider: 'sqlite', url: dbUrl }); + await db.connect(); + await db.migrate(); + }); + + afterAll(async () => { + await db.disconnect(); + try { + unlinkSync(dbPath); + } catch { + // ignore + } + }); + + beforeEach(async () => { + // Clean up watcher tasks between tests + const sqlite = (db as unknown as { sqlite?: { exec: (sql: string) => void } }).sqlite; + if (sqlite) { + sqlite.exec('DELETE FROM watcher_tasks'); + } + }); + + afterEach(async () => { + // Ensure cleanup after each test + const sqlite = (db as unknown as { sqlite?: { exec: (sql: string) => void } }).sqlite; + if (sqlite) { + sqlite.exec('DELETE FROM watcher_tasks'); + } + }); + + it('inserts a watcher task, updates status to processed, and counts it correctly', async () => { + const taskId = randomUUID(); + const watcherName = 'test-watcher'; + const payload = { foo: 'bar', count: 42 }; + + // Insert a new watcher task + await db.insertWatcherTask({ + id: taskId, + watcherName, + payload, + }); + + // Verify initial count is 0 (task is pending) + let processedCount = await db.countProcessedWatcherTasks(); + expect(processedCount).toBe(0); + + // List pending tasks to verify the task exists + const pendingTasks = await db.listPendingWatcherTasks(10); + expect(pendingTasks).toHaveLength(1); + expect(pendingTasks[0].id).toBe(taskId); + expect(pendingTasks[0].watcherName).toBe(watcherName); + expect(pendingTasks[0].payload).toEqual(payload); + expect(pendingTasks[0].status).toBe('pending'); + + // Update the task status to processed + await db.updateWatcherTaskStatus({ + id: taskId, + status: 'processed', + }); + + // Verify the processed count is now 1 + processedCount = await db.countProcessedWatcherTasks(); + expect(processedCount).toBe(1); + + // Verify the task is no longer in pending list + const stillPending = await db.listPendingWatcherTasks(10); + expect(stillPending).toHaveLength(0); + }); + + it('handles multiple watcher tasks with mixed statuses', async () => { + const task1Id = randomUUID(); + const task2Id = randomUUID(); + const task3Id = randomUUID(); + + // Insert three tasks + await db.insertWatcherTask({ + id: task1Id, + watcherName: 'multi-task-watcher', + payload: { task: 1 }, + }); + await db.insertWatcherTask({ + id: task2Id, + watcherName: 'multi-task-watcher', + payload: { task: 2 }, + }); + await db.insertWatcherTask({ + id: task3Id, + watcherName: 'multi-task-watcher', + payload: { task: 3 }, + }); + + // Initially all are pending + expect(await db.countProcessedWatcherTasks()).toBe(0); + expect((await db.listPendingWatcherTasks(10)).length).toBe(3); + + // Mark task1 and task3 as processed + await db.updateWatcherTaskStatus({ id: task1Id, status: 'processed' }); + await db.updateWatcherTaskStatus({ id: task3Id, status: 'processed' }); + + // Count should be 2 + expect(await db.countProcessedWatcherTasks()).toBe(2); + + // Only task2 should remain pending + const pending = await db.listPendingWatcherTasks(10); + expect(pending).toHaveLength(1); + expect(pending[0].id).toBe(task2Id); + + // Mark task2 as failed + await db.updateWatcherTaskStatus({ + id: task2Id, + status: 'failed', + errorMessage: 'Test failure', + }); + + // Count should still be 2 (failed tasks don't count) + expect(await db.countProcessedWatcherTasks()).toBe(2); + + // No pending tasks left + expect(await db.listPendingWatcherTasks(10)).toHaveLength(0); + }); +}); diff --git a/tests/runtime/transaction-watcher.unit.test.ts b/tests/runtime/transaction-watcher.unit.test.ts index 1c71998..4924c40 100644 --- a/tests/runtime/transaction-watcher.unit.test.ts +++ b/tests/runtime/transaction-watcher.unit.test.ts @@ -186,4 +186,27 @@ describe('TransactionWatcher Unit Tests', () => { // Stop the watcher to clean up await transactionWatcher.stop(); }); + + it('enqueues a cleanup_records job with the configured retention days', async () => { + const retentionDays = 45; + const customWatcher = new TransactionWatcher(mockDatabase, mockQueue, { + pollIntervalMs: 1000, + transactionTimeoutMs: 300000, + retentionDays, + }); + + // Start the watcher to trigger one tick + await customWatcher.start(); + + // Assert that the cleanup_records job was enqueued with the correct retentionDays + expect(mockQueue.enqueue).toHaveBeenCalledWith({ + type: 'cleanup_records', + payload: { + retentionDays, + }, + }); + + // Stop the watcher + await customWatcher.stop(); + }); }); diff --git a/tests/utils/idempotency.test.ts b/tests/utils/idempotency.test.ts index 5053147..a8a7c98 100644 --- a/tests/utils/idempotency.test.ts +++ b/tests/utils/idempotency.test.ts @@ -44,4 +44,34 @@ describe('IdempotencyUtils', () => { const obj: Record = { 'Idempotency-Key': ' ' }; expect(IdempotencyUtils.extractIdempotencyHeader(obj)).toBeNull(); }); + + test('extractIdempotencyHeader handles lowercase idempotency-key header name', () => { + // This is the header name used in the deposit route + const obj: Record = { 'idempotency-key': 'test-key' }; + expect(IdempotencyUtils.extractIdempotencyHeader(obj, 'idempotency-key')).toBe('test-key'); + }); + + test('extractIdempotencyHeader handles array idempotency-key with first non-empty value', () => { + // Array with multiple values - first non-empty wins + const obj: Record = { + 'idempotency-key': ['', 'first-valid', 'second-valid'], + }; + expect(IdempotencyUtils.extractIdempotencyHeader(obj, 'idempotency-key')).toBe('first-valid'); + }); + + test('extractIdempotencyHeader handles array idempotency-key with leading empty strings', () => { + // Array with leading empty strings - should skip to first non-empty + const obj: Record = { + 'idempotency-key': ['', ' ', 'valid-key'], + }; + expect(IdempotencyUtils.extractIdempotencyHeader(obj, 'idempotency-key')).toBe('valid-key'); + }); + + test('extractIdempotencyHeader handles array idempotency-key with only empty values', () => { + // Array with only empty values - should return null + const obj: Record = { + 'idempotency-key': ['', ' '], + }; + expect(IdempotencyUtils.extractIdempotencyHeader(obj, 'idempotency-key')).toBeNull(); + }); }); diff --git a/tests/utils/validation.test.ts b/tests/utils/validation.test.ts index 6a0f51e..15adf6a 100644 --- a/tests/utils/validation.test.ts +++ b/tests/utils/validation.test.ts @@ -1,4 +1,9 @@ -import { ValidationUtils } from '../../src/utils/validation'; +import { + AnchorKitConfigSchema, + NetworkConfigSchema, + ValidationUtils, +} from '../../src/utils/validation'; +import type { AnchorKitConfig } from '../../src/types/config'; describe('ValidationUtils', () => { describe('isValidEmail', () => { @@ -94,4 +99,87 @@ describe('ValidationUtils', () => { expect(ValidationUtils.isDecimal(' ')).toBe(false); }); }); + + describe('isValidDatabaseUrl', () => { + test('should return true for valid database URLs', () => { + expect(ValidationUtils.isValidDatabaseUrl('postgresql://localhost:5432/db')).toBe(true); + expect(ValidationUtils.isValidDatabaseUrl('postgres://user:pass@host:5432/db')).toBe(true); + expect(ValidationUtils.isValidDatabaseUrl('sqlite://path/to/db.sqlite')).toBe(true); + expect(ValidationUtils.isValidDatabaseUrl('file:./local.db')).toBe(true); + }); + + test('should return false for invalid database URLs', () => { + expect(ValidationUtils.isValidDatabaseUrl('')).toBe(false); + expect(ValidationUtils.isValidDatabaseUrl('not-a-db-url')).toBe(false); + }); + }); +}); + +describe('NetworkConfigSchema', () => { + test('should validate a correct NetworkConfig', () => { + expect(() => NetworkConfigSchema.validate({ network: 'testnet' })).not.toThrow(); + expect(() => + NetworkConfigSchema.validate({ + network: 'public', + horizonUrl: 'https://horizon.stellar.org', + }), + ).not.toThrow(); + }); + + test('should throw for invalid network name', () => { + // @ts-expect-error test case + expect(() => NetworkConfigSchema.validate({ network: 'invalidnet' })).toThrow( + /Invalid network/, + ); + }); + + test('should throw for invalid horizonUrl', () => { + expect(() => + NetworkConfigSchema.validate({ + network: 'testnet', + horizonUrl: 'not-a-url', + }), + ).toThrow(/Invalid URL format/); + }); +}); + +describe('AnchorKitConfigSchema', () => { + const validConfig: AnchorKitConfig = { + network: { network: 'testnet' }, + server: { interactiveDomain: 'https://example.com' }, + security: { + sep10SigningKey: 'SD6P3...', + interactiveJwtSecret: 'shhh', + distributionAccountSecret: 'SD7Q4...', + }, + assets: { + assets: [{ code: 'USDC', issuer: 'GD...' }], + }, + framework: { + database: { provider: 'sqlite', url: 'file:./test.db' }, + }, + }; + + test('should validate a correct AnchorKitConfig', () => { + expect(() => AnchorKitConfigSchema.validate(validConfig)).not.toThrow(); + }); + + test('should throw for missing secrets', () => { + const invalidConfig = { + ...validConfig, + security: { ...validConfig.security, sep10SigningKey: '' }, + }; + expect(() => AnchorKitConfigSchema.validate(invalidConfig)).toThrow(/sep10SigningKey/); + }); + + test('should throw for invalid pollsIntervalMs', () => { + const invalidConfig = { + ...validConfig, + framework: { + ...validConfig.framework, + watchers: { pollIntervalMs: 5 }, // Minimum is 10 + }, + }; + expect(() => AnchorKitConfigSchema.validate(invalidConfig)).toThrow(/pollIntervalMs/); + }); }); diff --git a/tests/verify-exports.test.ts b/tests/verify-exports.test.ts new file mode 100644 index 0000000..5960d68 --- /dev/null +++ b/tests/verify-exports.test.ts @@ -0,0 +1,66 @@ +import { AssetSchema, DatabaseUrlSchema, utils } from '../src/index'; +import { describe, expect, it } from 'vitest'; + +describe('Export Verification', () => { + it('should export AssetSchema at the top level', () => { + expect(AssetSchema).toBeDefined(); + expect(typeof AssetSchema.isValid).toBe('function'); + }); + + it('should export DatabaseUrlSchema at the top level', () => { + expect(DatabaseUrlSchema).toBeDefined(); + expect(typeof DatabaseUrlSchema.isValid).toBe('function'); + }); + + it('should still be available through utils.AssetSchema', () => { + expect(utils.AssetSchema).toBeDefined(); + expect(utils.AssetSchema).toBe(AssetSchema); + }); +}); + +describe('AssetSchema Validation', () => { + it('should validate a correct asset object', () => { + const validAsset = { + code: 'USDC', + issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + name: 'USD Coin', + deposits_enabled: true, + withdrawals_enabled: true, + min_amount: 10, + max_amount: 5000, + }; + expect(AssetSchema.isValid(validAsset)).toBe(true); + }); + + it('should validate a minimal asset object', () => { + const minimalAsset = { + code: 'USDC', + issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + }; + expect(AssetSchema.isValid(minimalAsset)).toBe(true); + }); + + it('should reject an asset with missing code', () => { + const invalidAsset = { + issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + }; + expect(AssetSchema.isValid(invalidAsset)).toBe(false); + }); + + it('should reject an asset with invalid issuer', () => { + const invalidAsset = { + code: 'USDC', + issuer: 'invalid-stellar-address', + }; + expect(AssetSchema.isValid(invalidAsset)).toBe(false); + }); + + it('should reject an asset with incorrect field types', () => { + const invalidAsset = { + code: 'USDC', + issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + deposits_enabled: 'yes', // should be boolean + }; + expect(AssetSchema.isValid(invalidAsset)).toBe(false); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 625dcd9..6114407 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,6 +26,6 @@ "@/*": ["./src/*"] } }, - "include": ["src/**/*", "tests/**/*", "example/**/*", "setup-repo.ts"], + "include": ["src/**/*", "tests/**/*", "example/**/*"], "exclude": ["node_modules", "dist"] }