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); }); });