From ac309bbf30b0fd01b2dd2aa1d488d731387170d1 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 6 Jul 2026 19:46:04 -0400 Subject: [PATCH] fix(config): validate system_config on the runtime read path getSystemConfig() now parses stored rows against SystemConfigSchema on load rather than casting them, and fails loudly on invalid config so malformed values never reach auth logic. Correct the cache TTL comment (the value is 5 minutes, not 30 seconds). Update the unit tests to use full valid config fixtures and cover the validation-failure path. Closes #13 --- .../system-config-runtime-validation.md | 10 ++++++ src/config/getSystemConfig.ts | 23 ++++++++---- tests/unit/config/getSystemConfig.spec.ts | 36 +++++++++++++------ 3 files changed, 53 insertions(+), 16 deletions(-) create mode 100644 .changeset/system-config-runtime-validation.md diff --git a/.changeset/system-config-runtime-validation.md b/.changeset/system-config-runtime-validation.md new file mode 100644 index 0000000..4eb1c3c --- /dev/null +++ b/.changeset/system-config-runtime-validation.md @@ -0,0 +1,10 @@ +--- +'seamless-auth-api': patch +--- + +Validate `system_config` on the runtime read path. + +`getSystemConfig()` now parses stored configuration against `SystemConfigSchema` on load +instead of trusting the database rows with a cast. Invalid configuration fails loudly (the +call throws and the error is logged) rather than flowing malformed values into auth logic. +Also corrects the cache TTL comment (the value is 5 minutes, not 30 seconds). diff --git a/src/config/getSystemConfig.ts b/src/config/getSystemConfig.ts index d714258..0d5181d 100644 --- a/src/config/getSystemConfig.ts +++ b/src/config/getSystemConfig.ts @@ -5,27 +5,38 @@ */ import { SystemConfig as SysConfigModel } from '../models/systemConfig.js'; -import { SystemConfig } from '../schemas/systemConfig.schema.js'; +import { SystemConfig, SystemConfigSchema } from '../schemas/systemConfig.schema.js'; +import getLogger from '../utils/logger.js'; -let cachedConfig: { [k: string]: unknown } | null; +const logger = getLogger('systemConfig'); + +let cachedConfig: SystemConfig | null = null; let lastLoadedAt = 0; -const CACHE_TTL_MS = 300_000; // 30 seconds +const CACHE_TTL_MS = 300_000; // 5 minutes export async function getSystemConfig(): Promise { const now = Date.now(); if (cachedConfig && now - lastLoadedAt < CACHE_TTL_MS) { - return cachedConfig as SystemConfig; + return cachedConfig; } const rows = await SysConfigModel.findAll(); - cachedConfig = Object.fromEntries(rows.map((row) => [row.key, row.value])); + const raw = Object.fromEntries(rows.map((row) => [row.key, row.value])); + + const parsed = SystemConfigSchema.safeParse(raw); + + if (!parsed.success) { + logger.error(`Invalid system_config on runtime load: ${parsed.error.message}`); + throw new Error('Invalid system_config: runtime configuration failed schema validation'); + } + cachedConfig = parsed.data; lastLoadedAt = now; - return cachedConfig as SystemConfig; + return cachedConfig; } export function invalidateSystemConfigCache() { diff --git a/tests/unit/config/getSystemConfig.spec.ts b/tests/unit/config/getSystemConfig.spec.ts index fe5ea74..137a9cc 100644 --- a/tests/unit/config/getSystemConfig.spec.ts +++ b/tests/unit/config/getSystemConfig.spec.ts @@ -1,30 +1,46 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { buildSystemConfig } from '../../factories/systemConfigFactory.js'; + vi.unmock('../../../src/config/getSystemConfig'); +const rowsFrom = (config: Record) => + Object.entries(config).map(([key, value]) => ({ key, value })); + describe('getSystemConfig', () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); }); - it('fetches config from DB when cache empty', async () => { + it('fetches and validates config from DB when cache empty', async () => { const { SystemConfig } = await import('../../../src/models/systemConfig'); - (SystemConfig.findAll as any).mockResolvedValue([{ key: 'app_name', value: 'TestApp' }]); + (SystemConfig.findAll as any).mockResolvedValue(rowsFrom(buildSystemConfig())); const { getSystemConfig } = await import('../../../src/config/getSystemConfig'); const result = await getSystemConfig(); expect(SystemConfig.findAll).toHaveBeenCalled(); - expect(result).toEqual({ app_name: 'TestApp' }); + expect(result.app_name).toBe('SeamlessAuth'); + expect(result.default_roles).toEqual(['user']); + }); + + it('throws when the stored config fails schema validation', async () => { + const { SystemConfig } = await import('../../../src/models/systemConfig'); + + (SystemConfig.findAll as any).mockResolvedValue([{ key: 'app_name', value: 'ab' }]); + + const { getSystemConfig } = await import('../../../src/config/getSystemConfig'); + + await expect(getSystemConfig()).rejects.toThrow(/Invalid system_config/); }); it('returns cached config when within TTL', async () => { const { SystemConfig } = await import('../../../src/models/systemConfig'); - (SystemConfig.findAll as any).mockResolvedValue([{ key: 'app_name', value: 'TestApp' }]); + (SystemConfig.findAll as any).mockResolvedValue(rowsFrom(buildSystemConfig())); const { getSystemConfig } = await import('../../../src/config/getSystemConfig'); @@ -39,8 +55,8 @@ describe('getSystemConfig', () => { const { SystemConfig } = await import('../../../src/models/systemConfig'); (SystemConfig.findAll as any) - .mockResolvedValueOnce([{ key: 'app_name', value: 'A' }]) - .mockResolvedValueOnce([{ key: 'app_name', value: 'B' }]); + .mockResolvedValueOnce(rowsFrom(buildSystemConfig({ app_name: 'AppOne' }))) + .mockResolvedValueOnce(rowsFrom(buildSystemConfig({ app_name: 'AppTwo' }))); const { getSystemConfig } = await import('../../../src/config/getSystemConfig'); @@ -53,7 +69,7 @@ describe('getSystemConfig', () => { const second = await getSystemConfig(); - expect(second).not.toEqual(first); + expect(second.app_name).not.toBe(first.app_name); expect(SystemConfig.findAll).toHaveBeenCalledTimes(2); }); @@ -61,8 +77,8 @@ describe('getSystemConfig', () => { const { SystemConfig } = await import('../../../src/models/systemConfig'); (SystemConfig.findAll as any) - .mockResolvedValueOnce([{ key: 'app_name', value: 'A' }]) - .mockResolvedValueOnce([{ key: 'app_name', value: 'B' }]); + .mockResolvedValueOnce(rowsFrom(buildSystemConfig({ app_name: 'AppOne' }))) + .mockResolvedValueOnce(rowsFrom(buildSystemConfig({ app_name: 'AppTwo' }))); const { getSystemConfig, invalidateSystemConfigCache } = await import('../../../src/config/getSystemConfig'); @@ -73,7 +89,7 @@ describe('getSystemConfig', () => { const result = await getSystemConfig(); - expect(result).toEqual({ app_name: 'B' }); + expect(result.app_name).toBe('AppTwo'); expect(SystemConfig.findAll).toHaveBeenCalledTimes(2); }); });