Files
Twitch-VOD-Manager/src/main/domain/migrator.test.ts
T
Sucukdeluxe 6f02e9aa3a fix(persistence): preserve authoritative state on write failures
Keep SQLite authoritative after completed migration even when legacy JSON is later invalid, reject non-object config documents before any migration state is written, and guard async secret masking by input generation. Persist renderer-facing config and queue mutations before updating memory so SQLite errors reject IPC calls and retain the last durable queue snapshot.
2026-08-12 00:21:55 +02:00

277 lines
14 KiB
TypeScript

import { test, expect, describe, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { openDatabase, type DbHandle } from '../infra/db';
import { migrateJsonToSqlite } from './migrator';
import { MemorySecureStorage } from '../infra/secure-storage';
import { createSecretStore } from './secret-store';
let tmpDir: string;
let appDataDir: string;
let db: DbHandle;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'migrator-'));
appDataDir = path.join(tmpDir, 'appdata');
fs.mkdirSync(appDataDir, { recursive: true });
db = openDatabase(path.join(tmpDir, 'app.db'));
});
afterEach(() => {
db.close();
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
});
function writeJson(name: string, payload: unknown): string {
const target = path.join(appDataDir, name);
fs.writeFileSync(target, JSON.stringify(payload, null, 2), 'utf-8');
return target;
}
describe('migrateJsonToSqlite', () => {
test('no JSON files: writes migrations_applied marker', () => {
const result = migrateJsonToSqlite({ db, appDataDir });
expect(result.configMigrated).toBe(false);
expect(result.queueMigrated).toBe(false);
expect(result.downloadedVodsCount).toBe(0);
expect(result.streamersCount).toBe(0);
const marker = db.get<{ name: string }>('SELECT name FROM migrations_applied WHERE name = ?', ['authoritative-state-v1']);
expect(marker?.name).toBe('authoritative-state-v1');
});
test('migrates config.json keys into config_kv', () => {
writeJson('config.json', {
language: 'de',
performance_mode: 'speed',
metadata_cache_minutes: 30,
downloaded_vod_ids: ['1', '2', '3'],
auto_record_streamers: ['foo', 'bar'],
});
const result = migrateJsonToSqlite({ db, appDataDir });
expect(result.configMigrated).toBe(true);
const lang = db.get<{ value: string }>('SELECT value FROM config_kv WHERE key = ?', ['language']);
expect(JSON.parse(lang!.value)).toBe('de');
const perf = db.get<{ value: string }>('SELECT value FROM config_kv WHERE key = ?', ['performance_mode']);
expect(JSON.parse(perf!.value)).toBe('speed');
});
test('migrates downloaded_vod_ids', () => {
writeJson('config.json', { downloaded_vod_ids: ['100', '200', '300'] });
const result = migrateJsonToSqlite({ db, appDataDir });
expect(result.downloadedVodsCount).toBe(3);
const rows = db.all<{ vod_id: string }>('SELECT vod_id FROM downloaded_vods ORDER BY vod_id');
expect(rows.map(r => r.vod_id)).toEqual(['100', '200', '300']);
});
test('migrates streamers from both auto-record and auto-vod-download lists', () => {
writeJson('config.json', {
auto_record_streamers: ['Alice', '@bob'],
auto_vod_download_streamers: ['bob', 'carol'],
});
const result = migrateJsonToSqlite({ db, appDataDir });
expect(result.streamersCount).toBeGreaterThanOrEqual(3);
const alice = db.get<{ login: string; auto_record: number }>('SELECT login, auto_record FROM streamers WHERE login = ?', ['alice']);
expect(alice?.auto_record).toBe(1);
const bob = db.get<{ login: string; auto_record: number; auto_vod_download: number }>('SELECT login, auto_record, auto_vod_download FROM streamers WHERE login = ?', ['bob']);
expect(bob?.auto_record).toBe(1);
expect(bob?.auto_vod_download).toBe(1);
const carol = db.get<{ login: string; auto_vod_download: number }>('SELECT login, auto_vod_download FROM streamers WHERE login = ?', ['carol']);
expect(carol?.auto_vod_download).toBe(1);
});
test('migrates download_queue.json items', () => {
writeJson('download_queue.json', [
{ id: 'q1', status: 'pending', streamer: 'foo', vod_id: 'v1', created_at: 1000, updated_at: 1000 },
{ id: 'q2', status: 'completed', streamer: 'bar', vod_id: 'v2', created_at: 2000, updated_at: 3000, completed_at: 3000 },
]);
const result = migrateJsonToSqlite({ db, appDataDir });
expect(result.queueMigrated).toBe(true);
const all = db.all<{ id: string; status: string }>('SELECT id, status FROM queue_items ORDER BY id');
expect(all).toHaveLength(2);
expect(all[0].status).toBe('pending');
expect(all[1].status).toBe('completed');
});
test('idempotent second run', () => {
writeJson('config.json', { downloaded_vod_ids: ['1', '2'] });
migrateJsonToSqlite({ db, appDataDir });
const result2 = migrateJsonToSqlite({ db, appDataDir });
expect(result2.alreadyApplied).toBe(true);
const count = db.get<{ c: number }>('SELECT COUNT(*) AS c FROM downloaded_vods');
expect(count?.c).toBe(2);
});
test('writes .v4-backup of source JSONs', () => {
const configPath = writeJson('config.json', { language: 'en' });
migrateJsonToSqlite({ db, appDataDir });
expect(fs.existsSync(configPath + '.v4-backup')).toBe(true);
expect(fs.readFileSync(configPath + '.v4-backup', 'utf-8')).toContain('"language": "en"');
});
test('malformed JSON is logged + skipped', () => {
fs.writeFileSync(path.join(appDataDir, 'config.json'), '{ not valid json', 'utf-8');
const result = migrateJsonToSqlite({ db, appDataDir });
expect(result.configMigrated).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
expect(result.errors[0].source).toBe('config.json');
expect(db.get('SELECT name FROM migrations_applied WHERE name = ?', ['authoritative-state-v1'])).toBeUndefined();
});
test('rolls back all records and marker when migration is interrupted', () => {
writeJson('config.json', { language: 'de', metadata_cache_minutes: 30 });
writeJson('download_queue.json', [{ id: 'q1', status: 'pending', title: 'Queue item' }]);
let queueInsertReached = false;
const interruptedDb: DbHandle = {
...db,
run(sql, params) {
if (sql.includes('INSERT OR REPLACE INTO queue_items')) {
queueInsertReached = true;
throw new Error('simulated interruption');
}
db.run(sql, params);
},
};
const result = migrateJsonToSqlite({ db: interruptedDb, appDataDir });
expect(queueInsertReached).toBe(true);
expect(result.errors).toEqual([{ source: 'migration', message: 'simulated interruption' }]);
expect(db.all('SELECT * FROM config_kv')).toEqual([]);
expect(db.all('SELECT * FROM queue_items')).toEqual([]);
expect(db.get('SELECT name FROM migrations_applied WHERE name = ?', ['authoritative-state-v1'])).toBeUndefined();
});
test('rolls back config queue and secrets when the migration marker cannot be written', () => {
const configPath = writeJson('config.json', { language: 'de', client_secret: 'must-survive' });
writeJson('download_queue.json', [{ id: 'q1', status: 'pending' }]);
const interruptedDb: DbHandle = {
...db,
run(sql, params) {
if (sql.includes('INSERT INTO migrations_applied')) throw new Error('marker unavailable');
db.run(sql, params);
},
};
const secrets = createSecretStore(interruptedDb, new MemorySecureStorage());
const result = migrateJsonToSqlite({ db: interruptedDb, appDataDir, secrets });
expect(result.errors).toEqual([{ source: 'migration', message: 'marker unavailable' }]);
expect(db.all('SELECT * FROM config_kv')).toEqual([]);
expect(db.all('SELECT * FROM queue_items')).toEqual([]);
expect(db.all('SELECT * FROM app_secrets')).toEqual([]);
expect(db.get('SELECT name FROM migrations_applied WHERE name = ?', ['authoritative-state-v1'])).toBeUndefined();
expect(fs.readFileSync(configPath, 'utf-8')).toContain('must-survive');
});
test('imports legacy JSON once and ignores later JSON changes after restart', () => {
const configPath = writeJson('config.json', { language: 'de' });
migrateJsonToSqlite({ db, appDataDir });
fs.writeFileSync(configPath, JSON.stringify({ language: 'en' }), 'utf-8');
const second = migrateJsonToSqlite({ db, appDataDir });
const language = db.get<{ value: string }>('SELECT value FROM config_kv WHERE key = ?', ['language']);
expect(second.alreadyApplied).toBe(true);
expect(JSON.parse(language!.value)).toBe('de');
});
test('keeps SQLite config queue and secrets authoritative after a corrupted legacy config restart', () => {
const configPath = writeJson('config.json', { language: 'de', client_secret: 'persisted-secret' });
writeJson('download_queue.json', [{ id: 'q1', status: 'pending', title: 'Persisted queue item' }]);
let secrets = createSecretStore(db, new MemorySecureStorage());
migrateJsonToSqlite({ db, appDataDir, secrets });
db.close();
db = openDatabase(path.join(tmpDir, 'app.db'));
secrets = createSecretStore(db, new MemorySecureStorage());
fs.writeFileSync(configPath, '{ no longer valid JSON', 'utf-8');
const restart = migrateJsonToSqlite({ db, appDataDir, secrets });
expect(restart).toMatchObject({ alreadyApplied: true, errors: [] });
expect(JSON.parse(db.get<{ value: string }>('SELECT value FROM config_kv WHERE key = ?', ['language'])!.value)).toBe('de');
expect(db.all<{ id: string }>('SELECT id FROM queue_items ORDER BY queue_position')).toEqual([{ id: 'q1' }]);
expect(secrets.get('twitch_client_secret')).toBe('persisted-secret');
});
test.each([
['null', null],
['false', false],
['zero', 0],
['empty string', ''],
])('does not mark or partially migrate a syntactically valid but invalid %s config', (_, invalidConfig) => {
writeJson('config.json', invalidConfig);
writeJson('download_queue.json', [{ id: 'q1', status: 'pending' }]);
const result = migrateJsonToSqlite({ db, appDataDir });
expect(result.errors).toEqual([{ source: 'config.json', message: 'Config JSON must be an object' }]);
expect(db.all('SELECT * FROM config_kv')).toEqual([]);
expect(db.all('SELECT * FROM queue_items')).toEqual([]);
expect(db.get('SELECT name FROM migrations_applied WHERE name = ?', ['authoritative-state-v1'])).toBeUndefined();
});
test('does not let the former shadow-migration marker skip authoritative secret import', () => {
const configPath = writeJson('config.json', { language: 'de', client_secret: 'legacy-secret' });
db.run('INSERT INTO migrations_applied(name, payload) VALUES (?, ?)', ['v4-to-v5-jsons', '{}']);
const secrets = createSecretStore(db, new MemorySecureStorage());
const result = migrateJsonToSqlite({ db, appDataDir, secrets });
expect(result.alreadyApplied).toBe(false);
expect(result.errors).toEqual([]);
expect(secrets.get('twitch_client_secret')).toBe('legacy-secret');
expect(fs.readFileSync(configPath, 'utf-8')).not.toContain('legacy-secret');
});
test('migrates plaintext secrets into encrypted versioned records and scrubs JSON', () => {
const configPath = writeJson('config.json', {
client_secret: 'legacy-client-secret',
discord_webhook_url: 'https://discord.com/api/webhooks/legacy',
language: 'de',
});
const secrets = createSecretStore(db, new MemorySecureStorage());
const result = migrateJsonToSqlite({ db, appDataDir, secrets });
expect(result.errors).toEqual([]);
expect(secrets.get('twitch_client_secret')).toBe('legacy-client-secret');
expect(secrets.get('discord_webhook_url')).toBe('https://discord.com/api/webhooks/legacy');
expect(fs.readFileSync(configPath, 'utf-8')).not.toContain('legacy-client-secret');
expect(fs.readFileSync(configPath + '.v4-backup', 'utf-8')).not.toContain('/webhooks/legacy');
expect(db.get('SELECT key FROM config_kv WHERE key = ?', ['discord_webhook_url'])).toBeUndefined();
});
test('keeps plaintext legacy secrets untouched when production encryption is unavailable', () => {
const configPath = writeJson('config.json', { client_secret: 'must-survive', language: 'de' });
const secrets = createSecretStore(db, new MemorySecureStorage());
const result = migrateJsonToSqlite({ db, appDataDir, secrets, requireEncryption: true });
expect(result.errors).toEqual([{ source: 'migration', message: 'OS secret encryption is unavailable' }]);
expect(fs.readFileSync(configPath, 'utf-8')).toContain('must-survive');
expect(secrets.get('twitch_client_secret')).toBeNull();
expect(db.get('SELECT name FROM migrations_applied WHERE name = ?', ['authoritative-state-v1'])).toBeUndefined();
});
test('keeps plaintext legacy secrets when the sanitized backup cannot be published', () => {
const configPath = writeJson('config.json', { client_secret: 'must-survive', language: 'de' });
fs.mkdirSync(`${configPath}.v4-backup`);
const secrets = createSecretStore(db, new MemorySecureStorage());
const result = migrateJsonToSqlite({ db, appDataDir, secrets });
expect(result.errors).toHaveLength(1);
expect(fs.readFileSync(configPath, 'utf-8')).toContain('must-survive');
expect(db.all('SELECT * FROM config_kv')).toEqual([]);
expect(db.all('SELECT * FROM app_secrets')).toEqual([]);
expect(db.get('SELECT name FROM migrations_applied WHERE name = ?', ['authoritative-state-v1'])).toBeUndefined();
});
});