Remove all references to legacy RUNTIME_SEED_* environment variables from documentation and codebase. Update docs and .env.example to document the new RUNTIME_SEED_JSON and RUNTIME_SEED_JSON_PATH variables for first-boot runtime config and provider seeding. Refactor admin seed logic and runtime config schema to eliminate env-var-based seeding in favor of JSON-based initialization. Update admin panel UI and badges to reflect new seed sources. Remove obsolete env parsing logic and tests for RUNTIME_SEED_* flags. Add new tests for JSON seed behavior. BREAKING CHANGE: RUNTIME_SEED_* environment variables are no longer supported; use RUNTIME_SEED_JSON or RUNTIME_SEED_JSON_PATH for first-boot runtime config and provider seeding.
424 lines
14 KiB
TypeScript
424 lines
14 KiB
TypeScript
import { and, eq, inArray, not } from 'drizzle-orm';
|
|
import { describe, expect, test } from 'vitest';
|
|
|
|
import { db } from '../../src/db';
|
|
import { adminProviders, adminSettings } from '../../src/db/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<string, string | undefined>,
|
|
run: () => Promise<void>,
|
|
): Promise<void> {
|
|
const original = new Map<string, string | undefined>();
|
|
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<SettingRow[]> {
|
|
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<void> {
|
|
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<ProviderRow[]> {
|
|
const rows = await db.select().from(adminProviders).where(inArray(adminProviders.slug, slugs));
|
|
return rows as ProviderRow[];
|
|
}
|
|
|
|
async function restoreProvidersBySlug(slugs: string[], snapshot: ProviderRow[]): Promise<void> {
|
|
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<boolean> {
|
|
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);
|
|
});
|
|
});
|
|
|
|
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<string, unknown> = {
|
|
defaultTtsProvider: 'seed-shared-provider',
|
|
changelogFeedUrl: 'https://example.com/changelog/manifest.json',
|
|
enableUserSignups: false,
|
|
restrictUserApiKeys: false,
|
|
enableTtsProvidersTab: false,
|
|
enableAudiobookExport: false,
|
|
enableDocxConversion: false,
|
|
enableDestructiveDeleteActions: 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('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);
|
|
});
|
|
});
|
|
});
|