import { and, eq, inArray, not } from 'drizzle-orm'; import { describe, expect, test } from 'vitest'; import { db } from '@openreader/database'; import { adminProviders, adminSettings } from '@openreader/database/schema'; import { RUNTIME_KEYS, seedRuntimeConfigFromValues } from '../../src/lib/server/admin/settings'; import { __seedInternals } from '../../src/lib/server/admin/seed'; type SettingRow = { key: string; valueJson: unknown; source: string; updatedAt: number | null; }; type ProviderRow = { id: string; slug: string; displayName: string; providerType: string; baseUrl: string | null; apiKeyCiphertext: string; apiKeyIv: string; apiKeyLast4: string | null; defaultModel: string | null; defaultInstructions: string | null; enabled: number; createdAt: number; updatedAt: number; }; async function withEnv( values: Record, run: () => Promise, ): Promise { const original = new Map(); for (const key of Object.keys(values)) { original.set(key, process.env[key]); const next = values[key]; if (next === undefined) { delete process.env[key]; } else { process.env[key] = next; } } try { await run(); } finally { for (const [key, previous] of original.entries()) { if (previous === undefined) delete process.env[key]; else process.env[key] = previous; } } } function parseStoredValue(stored: unknown): unknown { if (typeof stored === 'string') { try { return JSON.parse(stored); } catch { return stored; } } return stored; } async function snapshotSettings(keys: string[]): Promise { const rows = await db .select({ key: adminSettings.key, valueJson: adminSettings.valueJson, source: adminSettings.source, updatedAt: adminSettings.updatedAt, }) .from(adminSettings) .where(inArray(adminSettings.key, keys)); return rows as SettingRow[]; } async function restoreSettings(keys: string[], snapshot: SettingRow[]): Promise { await db.delete(adminSettings).where(inArray(adminSettings.key, keys)); if (snapshot.length === 0) return; const now = Date.now(); for (const row of snapshot) { await db .insert(adminSettings) .values({ key: row.key, valueJson: row.valueJson as never, source: row.source, updatedAt: row.updatedAt ?? now, }) .onConflictDoUpdate({ target: adminSettings.key, set: { valueJson: row.valueJson as never, source: row.source, updatedAt: row.updatedAt ?? now, }, }); } } async function snapshotProvidersBySlug(slugs: string[]): Promise { const rows = await db.select().from(adminProviders).where(inArray(adminProviders.slug, slugs)); return rows as ProviderRow[]; } async function restoreProvidersBySlug(slugs: string[], snapshot: ProviderRow[]): Promise { await db.delete(adminProviders).where(inArray(adminProviders.slug, slugs)); if (snapshot.length === 0) return; for (const row of snapshot) { await db .insert(adminProviders) .values(row) .onConflictDoUpdate({ target: adminProviders.slug, set: { displayName: row.displayName, providerType: row.providerType, baseUrl: row.baseUrl, apiKeyCiphertext: row.apiKeyCiphertext, apiKeyIv: row.apiKeyIv, apiKeyLast4: row.apiKeyLast4, defaultModel: row.defaultModel, defaultInstructions: row.defaultInstructions, enabled: row.enabled, updatedAt: row.updatedAt, }, }); } } async function hasNonTestProviderRows(testSlugs: string[]): Promise { const rows = await db .select({ slug: adminProviders.slug }) .from(adminProviders) .where(not(inArray(adminProviders.slug, testSlugs))) .limit(1); return rows.length > 0; } describe('runtime seed JSON parsing', () => { test('rejects malformed JSON input', () => { expect(() => __seedInternals.parseRuntimeSeedDocument('{not json')).toThrow(/invalid/i); }); test('rejects unknown top-level seed keys', () => { const raw = JSON.stringify({ version: 1, runtimeConfig: { enableUserSignups: true }, extra: true, }); expect(() => __seedInternals.parseRuntimeSeedDocument(raw)).toThrow(/unknown top-level/i); }); test('providers section disables env fallback path', () => { expect(__seedInternals.shouldUseEnvProviderFallback(false)).toBe(true); expect(__seedInternals.shouldUseEnvProviderFallback(true)).toBe(false); }); test('accepts seeded providers without an API key', () => { const parsed = __seedInternals.parseRuntimeSeedDocument(JSON.stringify({ version: 1, providers: [{ slug: 'keyless-provider', displayName: 'Keyless Provider', providerType: 'custom-openai', baseUrl: 'http://localhost:8880/v1', }], })); expect(parsed.seed.providers?.[0]).toMatchObject({ slug: 'keyless-provider', displayName: 'Keyless Provider', providerType: 'custom-openai', baseUrl: 'http://localhost:8880/v1', }); expect(parsed.seed.providers?.[0]).not.toHaveProperty('apiKey'); }); }); describe('runtime config JSON seeding', () => { test('seeds validated values with json-seed source and does not overwrite', async () => { const keys: string[] = ['enableUserSignups', 'ttsUpstreamMaxRetries']; const snapshot = await snapshotSettings(keys); try { await db.delete(adminSettings).where(inArray(adminSettings.key, keys)); const first = await seedRuntimeConfigFromValues({ enableUserSignups: false, ttsUpstreamMaxRetries: 9, }, 'json-seed'); expect(first.unknown).toEqual([]); expect(first.invalid).toEqual([]); expect(first.seeded.sort()).toEqual(['enableUserSignups', 'ttsUpstreamMaxRetries']); const rowsAfterFirst = await snapshotSettings(keys); const byKeyFirst = new Map(rowsAfterFirst.map((row) => [row.key, row])); expect(parseStoredValue(byKeyFirst.get('enableUserSignups')?.valueJson)).toBe(false); expect(parseStoredValue(byKeyFirst.get('ttsUpstreamMaxRetries')?.valueJson)).toBe(9); expect(byKeyFirst.get('enableUserSignups')?.source).toBe('json-seed'); expect(byKeyFirst.get('ttsUpstreamMaxRetries')?.source).toBe('json-seed'); const second = await seedRuntimeConfigFromValues({ enableUserSignups: true, ttsUpstreamMaxRetries: 1, }, 'json-seed'); expect(second.seeded).toEqual([]); const rowsAfterSecond = await snapshotSettings(keys); const byKeySecond = new Map(rowsAfterSecond.map((row) => [row.key, row])); expect(parseStoredValue(byKeySecond.get('enableUserSignups')?.valueJson)).toBe(false); expect(parseStoredValue(byKeySecond.get('ttsUpstreamMaxRetries')?.valueJson)).toBe(9); } finally { await restoreSettings(keys, snapshot); } }); test('strictly rejects unknown/invalid entries without writes', async () => { const key = 'enableUserSignups'; const snapshot = await snapshotSettings([key]); try { await db.delete(adminSettings).where(eq(adminSettings.key, key)); const result = await seedRuntimeConfigFromValues({ unknownRuntimeSetting: true, enableUserSignups: 'false', }, 'json-seed'); expect(result.unknown).toEqual(['unknownRuntimeSetting']); expect(result.invalid).toEqual(['enableUserSignups']); expect(result.seeded).toEqual([]); const rows = await snapshotSettings([key]); expect(rows).toHaveLength(0); } finally { await restoreSettings([key], snapshot); } }); test('supports full runtime config JSON seeding across all keys', async () => { const keys: string[] = [...RUNTIME_KEYS]; const snapshot = await snapshotSettings(keys); const fullPayload: Record = { defaultTtsProvider: 'seed-shared-provider', changelogFeedUrl: 'https://example.com/changelog/manifest.json', enableUserSignups: false, restrictUserApiKeys: false, enableTtsProvidersTab: false, enableAudiobookExport: false, enableDocxConversion: false, showAllProviderModels: false, disableTtsRateLimit: false, ttsDailyLimitAnonymous: 12345, ttsDailyLimitAuthenticated: 23456, ttsIpDailyLimitAnonymous: 34567, ttsIpDailyLimitAuthenticated: 45678, ttsCacheMaxSizeBytes: 16 * 1024 * 1024, ttsCacheTtlMs: 600_000, ttsUpstreamMaxRetries: 3, ttsUpstreamTimeoutMs: 120_000, disableComputeRateLimit: false, computeParseBurstMax: 4, computeParseBurstWindowSec: 30, computeParseSustainedMax: 12, computeParseSustainedWindowSec: 300, maxUploadMb: 150, }; try { await db.delete(adminSettings).where(inArray(adminSettings.key, keys)); const result = await seedRuntimeConfigFromValues(fullPayload, 'json-seed'); expect(result.unknown).toEqual([]); expect(result.invalid).toEqual([]); expect(result.seeded).toHaveLength(RUNTIME_KEYS.length); const rows = await snapshotSettings(keys); const byKey = new Map(rows.map((row) => [row.key, row])); expect(parseStoredValue(byKey.get('defaultTtsProvider')?.valueJson)).toBe('seed-shared-provider'); expect(parseStoredValue(byKey.get('enableUserSignups')?.valueJson)).toBe(false); expect(parseStoredValue(byKey.get('ttsUpstreamTimeoutMs')?.valueJson)).toBe(120_000); expect(parseStoredValue(byKey.get('maxUploadMb')?.valueJson)).toBe(150); expect(byKey.get('defaultTtsProvider')?.source).toBe('json-seed'); } finally { await restoreSettings(keys, snapshot); } }); test('schema key set is complete for JSON seed support', () => { expect(RUNTIME_KEYS.length).toBeGreaterThan(0); expect(RUNTIME_KEYS).toContain('defaultTtsProvider'); expect(RUNTIME_KEYS).toContain('ttsUpstreamTimeoutMs'); }); }); describe('provider seeding and fallback precedence', () => { const testSlugs = ['json-seeded-provider', 'default-openai']; test('seeds providers from JSON and skips API_BASE/API_KEY fallback', async () => { const providerSnapshot = await snapshotProvidersBySlug(testSlugs); const runtimeSeed = JSON.stringify({ version: 1, providers: [ { slug: 'json-seeded-provider', displayName: 'JSON Seeded Provider', providerType: 'custom-openai', baseUrl: 'http://localhost:5555/v1', apiKey: 'seeded_provider_key_1234', defaultModel: 'kokoro', enabled: true, }, ], }); try { await db.delete(adminProviders).where(inArray(adminProviders.slug, testSlugs)); await withEnv({ RUNTIME_SEED_JSON: runtimeSeed, RUNTIME_SEED_JSON_PATH: undefined, API_BASE: 'http://localhost:9999/v1', API_KEY: 'fallback_should_not_be_used', AUTH_SECRET: 'seed-test-auth-secret-123', }, async () => { await __seedInternals.runSeed(); }); const rows = await db .select({ slug: adminProviders.slug, displayName: adminProviders.displayName, providerType: adminProviders.providerType, baseUrl: adminProviders.baseUrl, defaultModel: adminProviders.defaultModel, apiKeyLast4: adminProviders.apiKeyLast4, }) .from(adminProviders) .where(inArray(adminProviders.slug, testSlugs)); expect(rows).toHaveLength(1); expect(rows[0]).toMatchObject({ slug: 'json-seeded-provider', displayName: 'JSON Seeded Provider', providerType: 'custom-openai', baseUrl: 'http://localhost:5555/v1', defaultModel: 'kokoro', apiKeyLast4: '1234', }); } finally { await restoreProvidersBySlug(testSlugs, providerSnapshot); } }); test('falls back to API_BASE/API_KEY when JSON providers are absent', async () => { const providerSnapshot = await snapshotProvidersBySlug(testSlugs); const runtimeSeed = JSON.stringify({ version: 1, runtimeConfig: { enableUserSignups: true } }); try { await db.delete(adminProviders).where(inArray(adminProviders.slug, testSlugs)); const blockedByOtherRows = await hasNonTestProviderRows(testSlugs); if (blockedByOtherRows) return; await withEnv({ RUNTIME_SEED_JSON: runtimeSeed, RUNTIME_SEED_JSON_PATH: undefined, API_BASE: 'http://localhost:8880/v1', API_KEY: 'fallback_env_api_key_9876', AUTH_SECRET: 'seed-test-auth-secret-456', }, async () => { await __seedInternals.runSeed(); }); const rows = await db .select({ slug: adminProviders.slug, displayName: adminProviders.displayName, providerType: adminProviders.providerType, baseUrl: adminProviders.baseUrl, defaultModel: adminProviders.defaultModel, apiKeyLast4: adminProviders.apiKeyLast4, }) .from(adminProviders); expect(rows).toHaveLength(1); expect(rows[0]).toMatchObject({ slug: 'default-openai', displayName: 'Default (from env)', providerType: 'custom-openai', baseUrl: 'http://localhost:8880/v1', defaultModel: 'kokoro', apiKeyLast4: '9876', }); } finally { await restoreProvidersBySlug(testSlugs, providerSnapshot); } }); test('falls back to API_BASE with a blank API_KEY', async () => { const providerSnapshot = await snapshotProvidersBySlug(testSlugs); const runtimeSeed = JSON.stringify({ version: 1, runtimeConfig: { enableUserSignups: true } }); try { await db.delete(adminProviders).where(inArray(adminProviders.slug, testSlugs)); const blockedByOtherRows = await hasNonTestProviderRows(testSlugs); if (blockedByOtherRows) return; await withEnv({ RUNTIME_SEED_JSON: runtimeSeed, RUNTIME_SEED_JSON_PATH: undefined, API_BASE: 'http://localhost:8880/v1', API_KEY: '', AUTH_SECRET: 'seed-test-auth-secret-keyless', }, async () => { await __seedInternals.runSeed(); }); const rows = await db .select({ slug: adminProviders.slug, baseUrl: adminProviders.baseUrl, apiKeyLast4: adminProviders.apiKeyLast4, }) .from(adminProviders); expect(rows).toHaveLength(1); expect(rows[0]).toMatchObject({ slug: 'default-openai', baseUrl: 'http://localhost:8880/v1', apiKeyLast4: '', }); } finally { await restoreProvidersBySlug(testSlugs, providerSnapshot); } }); test('does not use API_BASE/API_KEY fallback when providers key is present but empty', async () => { const providerSnapshot = await snapshotProvidersBySlug(testSlugs); const runtimeSeed = JSON.stringify({ version: 1, providers: [] }); try { await db.delete(adminProviders).where(inArray(adminProviders.slug, testSlugs)); const blockedByOtherRows = await hasNonTestProviderRows(testSlugs); if (blockedByOtherRows) return; await withEnv({ RUNTIME_SEED_JSON: runtimeSeed, RUNTIME_SEED_JSON_PATH: undefined, API_BASE: 'http://localhost:7777/v1', API_KEY: 'fallback_should_stay_unused_1111', AUTH_SECRET: 'seed-test-auth-secret-789', }, async () => { await __seedInternals.runSeed(); }); const rows = await db .select({ slug: adminProviders.slug }) .from(adminProviders) .where(eq(adminProviders.slug, 'default-openai')) as Array<{ slug: string }>; expect(rows).toHaveLength(0); } finally { await restoreProvidersBySlug(testSlugs, providerSnapshot); } }); test('throws on malformed runtime seed JSON during full seed execution', async () => { await withEnv({ RUNTIME_SEED_JSON: '{bad json', RUNTIME_SEED_JSON_PATH: undefined, API_BASE: undefined, API_KEY: undefined, AUTH_SECRET: 'seed-test-auth-secret-abc', }, async () => { await expect(__seedInternals.runSeed()).rejects.toThrow(/invalid/i); }); }); });