release: veröffentliche Twitch VOD Manager 1.0.1
Startet die öffentliche Versionslinie mit einer bereinigten Ein-Commit-Historie, stellt den Updater auf GitHub Releases um, entfernt interne Release-Ziele und beschränkt den gepackten Anwendungssatz auf notwendige Laufzeitdateien. Enthält aktualisierte produktive Abhängigkeiten ohne bekannte npm-Audit-Funde sowie die geprüfte öffentliche Quell-Positivliste.
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import { test, expect, describe, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { hashBuffer, hashFile } from './chunk-hash';
|
||||
|
||||
let tmpDir: string;
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chunkhash-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
describe('hashBuffer', () => {
|
||||
test('"hello" sha1', () => {
|
||||
expect(hashBuffer(Buffer.from('hello', 'utf-8')))
|
||||
.toBe('aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d');
|
||||
});
|
||||
|
||||
test('empty buffer sha1', () => {
|
||||
expect(hashBuffer(Buffer.alloc(0)))
|
||||
.toBe('da39a3ee5e6b4b0d3255bfef95601890afd80709');
|
||||
});
|
||||
|
||||
test('large buffer hashes deterministically', () => {
|
||||
const big = Buffer.alloc(1024 * 1024, 0x42); // 1MB of 'B' bytes
|
||||
const a = hashBuffer(big);
|
||||
const b = hashBuffer(big);
|
||||
expect(a).toBe(b);
|
||||
expect(a).toHaveLength(40); // sha1 = 40 hex chars
|
||||
});
|
||||
|
||||
test('different content produces different hashes', () => {
|
||||
expect(hashBuffer(Buffer.from('a'))).not.toBe(hashBuffer(Buffer.from('b')));
|
||||
});
|
||||
});
|
||||
|
||||
describe('hashFile', () => {
|
||||
test('file hash matches buffer hash for same content', async () => {
|
||||
const content = 'roundtrip-test-payload';
|
||||
const filePath = path.join(tmpDir, 'a.bin');
|
||||
fs.writeFileSync(filePath, content, 'utf-8');
|
||||
const fileHash = await hashFile(filePath);
|
||||
const bufHash = hashBuffer(Buffer.from(content, 'utf-8'));
|
||||
expect(fileHash).toBe(bufHash);
|
||||
});
|
||||
|
||||
test('empty file = empty-buffer sha1', async () => {
|
||||
const filePath = path.join(tmpDir, 'empty.bin');
|
||||
fs.writeFileSync(filePath, '');
|
||||
const fileHash = await hashFile(filePath);
|
||||
expect(fileHash).toBe('da39a3ee5e6b4b0d3255bfef95601890afd80709');
|
||||
});
|
||||
|
||||
test('large file (4MB) hashes correctly', async () => {
|
||||
const filePath = path.join(tmpDir, 'big.bin');
|
||||
const payload = Buffer.alloc(4 * 1024 * 1024, 0x55);
|
||||
fs.writeFileSync(filePath, payload);
|
||||
const fileHash = await hashFile(filePath);
|
||||
expect(fileHash).toBe(hashBuffer(payload));
|
||||
});
|
||||
|
||||
test('missing file rejects', async () => {
|
||||
await expect(hashFile(path.join(tmpDir, 'does-not-exist'))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as crypto from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
|
||||
export function hashBuffer(b: Buffer): string {
|
||||
return crypto.createHash('sha1').update(b).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming sha1-Hash einer Datei. Async, damit grosse Recorded-Segments
|
||||
* (oft mehrere MB) nicht den Event-Loop blockieren.
|
||||
*/
|
||||
export function hashFile(filePath: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = crypto.createHash('sha1');
|
||||
const stream = fs.createReadStream(filePath);
|
||||
stream.on('error', reject);
|
||||
stream.on('data', (chunk: Buffer | string) => {
|
||||
if (typeof chunk === 'string') {
|
||||
hash.update(chunk, 'utf-8');
|
||||
} else {
|
||||
hash.update(chunk);
|
||||
}
|
||||
});
|
||||
stream.on('end', () => resolve(hash.digest('hex')));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
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 './db';
|
||||
|
||||
let tmpDir: string;
|
||||
let db: DbHandle | null = null;
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-test-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
try { db?.close(); } catch { /* ignore */ }
|
||||
db = null;
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
describe('openDatabase', () => {
|
||||
test('creates a new file', () => {
|
||||
const target = path.join(tmpDir, 'a.db');
|
||||
db = openDatabase(target);
|
||||
expect(fs.existsSync(target)).toBe(true);
|
||||
expect(typeof db.run).toBe('function');
|
||||
expect(typeof db.get).toBe('function');
|
||||
expect(typeof db.all).toBe('function');
|
||||
expect(typeof db.close).toBe('function');
|
||||
expect(typeof db.transaction).toBe('function');
|
||||
expect(typeof db.runBatch).toBe('function');
|
||||
});
|
||||
|
||||
test('schema_meta row exists with schema_version=5', () => {
|
||||
db = openDatabase(path.join(tmpDir, 'b.db'));
|
||||
const row = db.get<{ value: string }>('SELECT value FROM schema_meta WHERE key = ?', ['schema_version']);
|
||||
expect(row?.value).toBe('5');
|
||||
});
|
||||
|
||||
test('WAL mode active', () => {
|
||||
db = openDatabase(path.join(tmpDir, 'c.db'));
|
||||
const row = db.get<{ journal_mode: string }>('PRAGMA journal_mode');
|
||||
expect(row?.journal_mode).toBe('wal');
|
||||
});
|
||||
|
||||
test('idempotent open: existing file keeps schema_version=5', () => {
|
||||
const target = path.join(tmpDir, 'd.db');
|
||||
db = openDatabase(target);
|
||||
db.close();
|
||||
db = openDatabase(target);
|
||||
const row = db.get<{ value: string }>('SELECT value FROM schema_meta WHERE key = ?', ['schema_version']);
|
||||
expect(row?.value).toBe('5');
|
||||
});
|
||||
|
||||
test('run + get + all roundtrip on downloaded_vods', () => {
|
||||
db = openDatabase(path.join(tmpDir, 'e.db'));
|
||||
db.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', ['1234']);
|
||||
db.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', ['5678']);
|
||||
const one = db.get<{ vod_id: string }>('SELECT vod_id FROM downloaded_vods WHERE vod_id = ?', ['1234']);
|
||||
expect(one?.vod_id).toBe('1234');
|
||||
const all = db.all<{ vod_id: string }>('SELECT vod_id FROM downloaded_vods ORDER BY vod_id');
|
||||
expect(all.map(r => r.vod_id)).toEqual(['1234', '5678']);
|
||||
});
|
||||
|
||||
test('transaction commits as bracket', () => {
|
||||
db = openDatabase(path.join(tmpDir, 'f.db'));
|
||||
const handle = db;
|
||||
const inserted = handle.transaction(() => {
|
||||
handle.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', ['t1']);
|
||||
handle.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', ['t2']);
|
||||
return 2;
|
||||
});
|
||||
expect(inserted).toBe(2);
|
||||
const c = handle.get<{ c: number }>('SELECT COUNT(*) AS c FROM downloaded_vods');
|
||||
expect(c?.c).toBe(2);
|
||||
});
|
||||
|
||||
test('chunk_index table accepts insert + UNIQUE(item_id, chunk_seq)', () => {
|
||||
db = openDatabase(path.join(tmpDir, 'chunk.db'));
|
||||
db.run(
|
||||
'INSERT INTO chunk_index(item_id, chunk_seq, sha1_hex, bytes) VALUES (?, ?, ?, ?)',
|
||||
['item1', 0, 'abc123', 1024]
|
||||
);
|
||||
const handle = db;
|
||||
expect(() => {
|
||||
handle.run(
|
||||
'INSERT INTO chunk_index(item_id, chunk_seq, sha1_hex, bytes) VALUES (?, ?, ?, ?)',
|
||||
['item1', 0, 'different', 2048]
|
||||
);
|
||||
}).toThrow(); // UNIQUE violation
|
||||
const rows = handle.all<{ sha1_hex: string }>('SELECT sha1_hex FROM chunk_index WHERE item_id = ?', ['item1']);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].sha1_hex).toBe('abc123');
|
||||
});
|
||||
|
||||
test('oauth_accounts table exists and accepts insert', () => {
|
||||
db = openDatabase(path.join(tmpDir, 'oauth.db'));
|
||||
db.run(
|
||||
`INSERT INTO oauth_accounts(provider, twitch_user_id, login, encrypted_access_token)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
['twitch', 'user-123', 'alice', 'ciphertext-blob']
|
||||
);
|
||||
const row = db.get<{ login: string; provider: string }>(
|
||||
'SELECT login, provider FROM oauth_accounts WHERE twitch_user_id = ?',
|
||||
['user-123']
|
||||
);
|
||||
expect(row?.login).toBe('alice');
|
||||
expect(row?.provider).toBe('twitch');
|
||||
});
|
||||
|
||||
test('oauth_accounts UNIQUE(provider, twitch_user_id) enforced', () => {
|
||||
db = openDatabase(path.join(tmpDir, 'oauth-unique.db'));
|
||||
db.run(
|
||||
`INSERT INTO oauth_accounts(provider, twitch_user_id, login, encrypted_access_token)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
['twitch', 'u1', 'a', 'x']
|
||||
);
|
||||
const handle = db;
|
||||
expect(() => {
|
||||
handle.run(
|
||||
`INSERT INTO oauth_accounts(provider, twitch_user_id, login, encrypted_access_token)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
['twitch', 'u1', 'b', 'y']
|
||||
);
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
test('transaction rolls back on throw', () => {
|
||||
db = openDatabase(path.join(tmpDir, 'g.db'));
|
||||
const handle = db;
|
||||
expect(() => {
|
||||
handle.transaction(() => {
|
||||
handle.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', ['x1']);
|
||||
throw new Error('boom');
|
||||
});
|
||||
}).toThrow('boom');
|
||||
const c = handle.get<{ c: number }>('SELECT COUNT(*) AS c FROM downloaded_vods');
|
||||
expect(c?.c).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import Database, { type Database as DatabaseT } from 'better-sqlite3';
|
||||
import { SCHEMA_V5_SQL } from './schema-v5';
|
||||
|
||||
/**
|
||||
* Public DB-Handle. Schmaler Wrapper um better-sqlite3.
|
||||
*/
|
||||
export interface DbHandle {
|
||||
run(sql: string, params?: unknown[]): void;
|
||||
get<T = unknown>(sql: string, params?: unknown[]): T | undefined;
|
||||
all<T = unknown>(sql: string, params?: unknown[]): T[];
|
||||
transaction<R>(fn: () => R): R;
|
||||
runBatch(sql: string): void;
|
||||
close(): void;
|
||||
readonly raw: DatabaseT;
|
||||
}
|
||||
|
||||
function splitStatements(sql: string): string[] {
|
||||
return sql
|
||||
.split(';')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0);
|
||||
}
|
||||
|
||||
function runMultiStatement(db: DatabaseT, sql: string): void {
|
||||
for (const stmt of splitStatements(sql)) {
|
||||
db.prepare(stmt).run();
|
||||
}
|
||||
}
|
||||
|
||||
export function openDatabase(filePath: string): DbHandle {
|
||||
const db = new Database(filePath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('busy_timeout = 5000');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
runMultiStatement(db, SCHEMA_V5_SQL);
|
||||
|
||||
const handle: DbHandle = {
|
||||
run(sql, params) {
|
||||
db.prepare(sql).run(...(params ?? []) as unknown[]);
|
||||
},
|
||||
get<T>(sql: string, params?: unknown[]): T | undefined {
|
||||
return db.prepare(sql).get(...(params ?? []) as unknown[]) as T | undefined;
|
||||
},
|
||||
all<T>(sql: string, params?: unknown[]): T[] {
|
||||
return db.prepare(sql).all(...(params ?? []) as unknown[]) as T[];
|
||||
},
|
||||
transaction<R>(fn: () => R): R {
|
||||
return db.transaction(fn)();
|
||||
},
|
||||
runBatch(sql) {
|
||||
runMultiStatement(db, sql);
|
||||
},
|
||||
close() {
|
||||
db.close();
|
||||
},
|
||||
get raw() { return db; },
|
||||
};
|
||||
return handle;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { test, expect, describe } from 'vitest';
|
||||
import { parseDuration, formatDuration, formatDurationDashed } from './duration';
|
||||
|
||||
describe('parseDuration', () => {
|
||||
test('1h2m3s = 3723', () => {
|
||||
expect(parseDuration('1h2m3s')).toBe(3723);
|
||||
});
|
||||
test('45m = 2700', () => {
|
||||
expect(parseDuration('45m')).toBe(2700);
|
||||
});
|
||||
test('10s = 10', () => {
|
||||
expect(parseDuration('10s')).toBe(10);
|
||||
});
|
||||
test('empty string = 0', () => {
|
||||
expect(parseDuration('')).toBe(0);
|
||||
});
|
||||
test('unknown format = 0', () => {
|
||||
expect(parseDuration('abcdef')).toBe(0);
|
||||
});
|
||||
test('partial 2h = 7200', () => {
|
||||
expect(parseDuration('2h')).toBe(7200);
|
||||
});
|
||||
test('h and s without m = 3601', () => {
|
||||
expect(parseDuration('1h1s')).toBe(3601);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDuration', () => {
|
||||
test('3723 = 01:02:03', () => {
|
||||
expect(formatDuration(3723)).toBe('01:02:03');
|
||||
});
|
||||
test('0 = 00:00:00', () => {
|
||||
expect(formatDuration(0)).toBe('00:00:00');
|
||||
});
|
||||
test('negative = 00:00:00', () => {
|
||||
expect(formatDuration(-1)).toBe('00:00:00');
|
||||
});
|
||||
test('Infinity = 00:00:00', () => {
|
||||
expect(formatDuration(Infinity)).toBe('00:00:00');
|
||||
});
|
||||
test('NaN = 00:00:00', () => {
|
||||
expect(formatDuration(NaN)).toBe('00:00:00');
|
||||
});
|
||||
test('3600 = 01:00:00', () => {
|
||||
expect(formatDuration(3600)).toBe('01:00:00');
|
||||
});
|
||||
test('86399 = 23:59:59', () => {
|
||||
expect(formatDuration(86399)).toBe('23:59:59');
|
||||
});
|
||||
test('fractional seconds floored', () => {
|
||||
expect(formatDuration(3723.9)).toBe('01:02:03');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDurationDashed', () => {
|
||||
test('3723 = 01-02-03', () => {
|
||||
expect(formatDurationDashed(3723)).toBe('01-02-03');
|
||||
});
|
||||
test('negative = 00-00-00', () => {
|
||||
expect(formatDurationDashed(-1)).toBe('00-00-00');
|
||||
});
|
||||
test('NaN = 00-00-00', () => {
|
||||
expect(formatDurationDashed(NaN)).toBe('00-00-00');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
export function parseDuration(duration: string): number {
|
||||
let seconds = 0;
|
||||
const hours = duration.match(/(\d+)h/);
|
||||
const minutes = duration.match(/(\d+)m/);
|
||||
const secs = duration.match(/(\d+)s/);
|
||||
|
||||
if (hours) seconds += parseInt(hours[1]) * 3600;
|
||||
if (minutes) seconds += parseInt(minutes[1]) * 60;
|
||||
if (secs) seconds += parseInt(secs[1]);
|
||||
|
||||
return seconds;
|
||||
}
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
if (!isFinite(seconds) || seconds < 0) return '00:00:00';
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function formatDurationDashed(seconds: number): string {
|
||||
if (!isFinite(seconds) || seconds < 0) return '00-00-00';
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${h.toString().padStart(2, '0')}-${m.toString().padStart(2, '0')}-${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { test, expect, describe } from 'vitest';
|
||||
import {
|
||||
sanitizeFilenamePart,
|
||||
formatTwitchDurationFromSeconds,
|
||||
formatDateWithPattern,
|
||||
getMergeGroupPhaseText,
|
||||
} from './format-helpers';
|
||||
|
||||
describe('sanitizeFilenamePart', () => {
|
||||
test('replaces Windows-invalid chars with underscore', () => {
|
||||
expect(sanitizeFilenamePart('a<b>c:d"e|f?g*h')).toBe('a_b_c_d_e_f_g_h');
|
||||
});
|
||||
test('replaces path separators', () => {
|
||||
expect(sanitizeFilenamePart('a/b\\c')).toBe('a_b_c');
|
||||
});
|
||||
test('strips control chars', () => {
|
||||
expect(sanitizeFilenamePart('a\x00b\x1fc')).toBe('a_b_c');
|
||||
});
|
||||
test('trims whitespace', () => {
|
||||
expect(sanitizeFilenamePart(' hi ')).toBe('hi');
|
||||
});
|
||||
test('empty falls back to default', () => {
|
||||
expect(sanitizeFilenamePart('')).toBe('unnamed');
|
||||
});
|
||||
test('custom fallback', () => {
|
||||
expect(sanitizeFilenamePart('', 'FB')).toBe('FB');
|
||||
});
|
||||
test('only-invalid-chars falls back', () => {
|
||||
expect(sanitizeFilenamePart('////').trim()).not.toBe('');
|
||||
// '////' becomes '____' which is non-empty, so no fallback
|
||||
expect(sanitizeFilenamePart('////')).toBe('____');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTwitchDurationFromSeconds', () => {
|
||||
test('0 = 0s', () => {
|
||||
expect(formatTwitchDurationFromSeconds(0)).toBe('0s');
|
||||
});
|
||||
test('45 = 45s', () => {
|
||||
expect(formatTwitchDurationFromSeconds(45)).toBe('45s');
|
||||
});
|
||||
test('65 = 1m5s', () => {
|
||||
expect(formatTwitchDurationFromSeconds(65)).toBe('1m5s');
|
||||
});
|
||||
test('3725 = 1h2m5s', () => {
|
||||
expect(formatTwitchDurationFromSeconds(3725)).toBe('1h2m5s');
|
||||
});
|
||||
test('3600 = 1h0m0s', () => {
|
||||
expect(formatTwitchDurationFromSeconds(3600)).toBe('1h0m0s');
|
||||
});
|
||||
test('negative clamped to 0', () => {
|
||||
expect(formatTwitchDurationFromSeconds(-5)).toBe('0s');
|
||||
});
|
||||
test('NaN clamped to 0', () => {
|
||||
expect(formatTwitchDurationFromSeconds(NaN)).toBe('0s');
|
||||
});
|
||||
test('Infinity clamped to 0', () => {
|
||||
expect(formatTwitchDurationFromSeconds(Infinity)).toBe('0s');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDateWithPattern', () => {
|
||||
const d = new Date(2026, 4, 11, 23, 5, 7); // 2026-05-11 23:05:07
|
||||
|
||||
test('yyyy-MM-dd', () => {
|
||||
expect(formatDateWithPattern(d, 'yyyy-MM-dd')).toBe('2026-05-11');
|
||||
});
|
||||
test('yy MM dd', () => {
|
||||
expect(formatDateWithPattern(d, 'yy/MM/dd')).toBe('26/05/11');
|
||||
});
|
||||
test('HH:mm:ss', () => {
|
||||
expect(formatDateWithPattern(d, 'HH:mm:ss')).toBe('23:05:07');
|
||||
});
|
||||
test('combined pattern', () => {
|
||||
expect(formatDateWithPattern(d, 'yyyy-MM-dd_HH-mm-ss')).toBe('2026-05-11_23-05-07');
|
||||
});
|
||||
test('backslashes are stripped after token substitution', () => {
|
||||
// Note: \ does NOT escape the date-token (no negative-lookbehind in regex).
|
||||
// It only removes the literal backslash from the output. So 'yyyy\\X' → 'YYYYX'.
|
||||
expect(formatDateWithPattern(d, 'yyyy\\X')).toBe('2026X');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMergeGroupPhaseText', () => {
|
||||
test('known DE phases', () => {
|
||||
expect(getMergeGroupPhaseText('downloading', 'de')).toBe('VOD wird heruntergeladen');
|
||||
expect(getMergeGroupPhaseText('merging', 'de')).toBe('Zusammenfugen...');
|
||||
expect(getMergeGroupPhaseText('splitting', 'de')).toBe('Part wird erstellt');
|
||||
expect(getMergeGroupPhaseText('cleanup', 'de')).toBe('Aufraumen...');
|
||||
});
|
||||
test('known EN phases', () => {
|
||||
expect(getMergeGroupPhaseText('downloading', 'en')).toBe('Downloading VOD');
|
||||
expect(getMergeGroupPhaseText('merging', 'en')).toBe('Merging...');
|
||||
expect(getMergeGroupPhaseText('splitting', 'en')).toBe('Splitting Part');
|
||||
expect(getMergeGroupPhaseText('cleanup', 'en')).toBe('Cleaning up...');
|
||||
});
|
||||
test('unknown phase passes through', () => {
|
||||
expect(getMergeGroupPhaseText('unknown', 'de')).toBe('unknown');
|
||||
});
|
||||
test('unknown language falls back to DE', () => {
|
||||
expect(getMergeGroupPhaseText('downloading', 'fr')).toBe('VOD wird heruntergeladen');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
// Pure-Format-Helpers, extrahiert aus main.ts. Keine Globals, keine I/O.
|
||||
|
||||
const FILENAME_INVALID_RE = /[<>:"|?*\x00-\x1f]/g;
|
||||
const FILENAME_PATH_SEP_RE = /[\\/]/g;
|
||||
|
||||
/**
|
||||
* Entfernt Windows-Filesystem-verbotene Zeichen und Pfad-Separatoren aus einem
|
||||
* Datei-Namen-Teilstring. Fallback wird zurueckgegeben, wenn nach Cleanup
|
||||
* nichts uebrig bleibt.
|
||||
*/
|
||||
export function sanitizeFilenamePart(input: string, fallback = 'unnamed'): string {
|
||||
const cleaned = (input || '')
|
||||
.replace(FILENAME_INVALID_RE, '_')
|
||||
.replace(FILENAME_PATH_SEP_RE, '_')
|
||||
.trim();
|
||||
return cleaned || fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Twitch-Style Duration-Format: `1h2m3s`, `2m5s`, `42s`. Negative oder
|
||||
* NaN-Inputs werden auf 0 geclamt.
|
||||
*/
|
||||
export function formatTwitchDurationFromSeconds(totalSeconds: number): string {
|
||||
const seconds = Math.max(0, Math.floor(Number.isFinite(totalSeconds) ? totalSeconds : 0));
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
|
||||
if (h > 0) return `${h}h${m}m${s}s`;
|
||||
if (m > 0) return `${m}m${s}s`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
const DATE_TOKEN_RE = /yyyy|yy|MM|M|dd|d|HH|H|hh|h|mm|m|ss|s/g;
|
||||
|
||||
/**
|
||||
* Date-Formatter mit Pattern-Tokens (yyyy, yy, MM, M, dd, d, HH, H, hh, h,
|
||||
* mm, m, ss, s). Backslash-escapes (\T) lassen das Folgezeichen literal.
|
||||
*/
|
||||
export function formatDateWithPattern(date: Date, pattern: string): string {
|
||||
const tokenMap: Record<string, string> = {
|
||||
yyyy: date.getFullYear().toString(),
|
||||
yy: date.getFullYear().toString().slice(-2),
|
||||
MM: (date.getMonth() + 1).toString().padStart(2, '0'),
|
||||
M: (date.getMonth() + 1).toString(),
|
||||
dd: date.getDate().toString().padStart(2, '0'),
|
||||
d: date.getDate().toString(),
|
||||
HH: date.getHours().toString().padStart(2, '0'),
|
||||
H: date.getHours().toString(),
|
||||
hh: date.getHours().toString().padStart(2, '0'),
|
||||
h: date.getHours().toString(),
|
||||
mm: date.getMinutes().toString().padStart(2, '0'),
|
||||
m: date.getMinutes().toString(),
|
||||
ss: date.getSeconds().toString().padStart(2, '0'),
|
||||
s: date.getSeconds().toString(),
|
||||
};
|
||||
|
||||
return pattern
|
||||
.replace(DATE_TOKEN_RE, token => tokenMap[token] ?? token)
|
||||
.replace(/\\(.)/g, '$1');
|
||||
}
|
||||
|
||||
export type MergeGroupLanguage = 'de' | 'en';
|
||||
|
||||
/**
|
||||
* Label fuer den aktuellen Merge-Group-Phase-Status. Pure variant — Sprache
|
||||
* wird vom Caller injiziert.
|
||||
*/
|
||||
export function getMergeGroupPhaseText(phase: string, language: MergeGroupLanguage | string): string {
|
||||
const isEnglish = language === 'en';
|
||||
switch (phase) {
|
||||
case 'downloading': return isEnglish ? 'Downloading VOD' : 'VOD wird heruntergeladen';
|
||||
case 'merging': return isEnglish ? 'Merging...' : 'Zusammenfugen...';
|
||||
case 'splitting': return isEnglish ? 'Splitting Part' : 'Part wird erstellt';
|
||||
case 'cleanup': return isEnglish ? 'Cleaning up...' : 'Aufraumen...';
|
||||
default: return phase;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { test, expect, describe, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { writeFileAtomicSync } from './fs-atomic';
|
||||
|
||||
let tmpDir: string;
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fsatomic-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
describe('writeFileAtomicSync', () => {
|
||||
test('writes a string payload', () => {
|
||||
const target = path.join(tmpDir, 'a.txt');
|
||||
writeFileAtomicSync(target, 'hello');
|
||||
expect(fs.readFileSync(target, 'utf-8')).toBe('hello');
|
||||
});
|
||||
|
||||
test('writes a buffer payload', () => {
|
||||
const target = path.join(tmpDir, 'b.bin');
|
||||
writeFileAtomicSync(target, Buffer.from([1, 2, 3, 4]));
|
||||
expect(fs.readFileSync(target)).toEqual(Buffer.from([1, 2, 3, 4]));
|
||||
});
|
||||
|
||||
test('overwrites existing file', () => {
|
||||
const target = path.join(tmpDir, 'c.txt');
|
||||
fs.writeFileSync(target, 'old');
|
||||
writeFileAtomicSync(target, 'new');
|
||||
expect(fs.readFileSync(target, 'utf-8')).toBe('new');
|
||||
});
|
||||
|
||||
test('cleans up tmp file after success', () => {
|
||||
const target = path.join(tmpDir, 'd.txt');
|
||||
writeFileAtomicSync(target, 'x');
|
||||
expect(fs.existsSync(target + '.tmp')).toBe(false);
|
||||
});
|
||||
|
||||
test('utf-8 multibyte chars roundtrip', () => {
|
||||
const target = path.join(tmpDir, 'e.txt');
|
||||
writeFileAtomicSync(target, 'aeoeue-aeoeue');
|
||||
expect(fs.readFileSync(target, 'utf-8')).toBe('aeoeue-aeoeue');
|
||||
});
|
||||
|
||||
test('empty payload writes empty file', () => {
|
||||
const target = path.join(tmpDir, 'f.txt');
|
||||
writeFileAtomicSync(target, '');
|
||||
expect(fs.readFileSync(target, 'utf-8')).toBe('');
|
||||
expect(fs.statSync(target).size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as fs from 'fs';
|
||||
|
||||
/**
|
||||
* Atomic write via tmp + rename. Survives crash mid-write — either old or
|
||||
* new content, never partial. Windows fallback: copy + unlink if rename
|
||||
* fails (e.g. target locked by reader). fsync best-effort.
|
||||
*/
|
||||
export function writeFileAtomicSync(targetPath: string, payload: string | Buffer): void {
|
||||
const buffer = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, 'utf-8');
|
||||
const tmpPath = targetPath + '.tmp';
|
||||
|
||||
let fd: number | null = null;
|
||||
try {
|
||||
fd = fs.openSync(tmpPath, 'w');
|
||||
fs.writeSync(fd, buffer, 0, buffer.length, 0);
|
||||
try { fs.fsyncSync(fd); } catch { /* fsync may fail on some FS; rename is still safer than nothing */ }
|
||||
} finally {
|
||||
if (fd !== null) {
|
||||
try { fs.closeSync(fd); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
fs.renameSync(tmpPath, targetPath);
|
||||
} catch {
|
||||
fs.copyFileSync(tmpPath, targetPath);
|
||||
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { test, expect, describe } from 'vitest';
|
||||
import * as http from 'http';
|
||||
import { startLoopbackServer } from './loopback-server';
|
||||
|
||||
function httpGet(url: string): Promise<{ status: number; body: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.get(url, res => {
|
||||
let body = '';
|
||||
res.on('data', chunk => { body += chunk.toString(); });
|
||||
res.on('end', () => resolve({ status: res.statusCode ?? 0, body }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
describe('startLoopbackServer', () => {
|
||||
test('binds to 127.0.0.1 and returns url with pathPrefix', async () => {
|
||||
const server = await startLoopbackServer({ pathPrefix: '/cb' });
|
||||
expect(server.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/cb$/);
|
||||
server.close();
|
||||
});
|
||||
|
||||
test('captures redirect params (code + state)', async () => {
|
||||
const server = await startLoopbackServer({ pathPrefix: '/cb' });
|
||||
const captureP = server.awaitParams({ timeoutMs: 3000 });
|
||||
const response = await httpGet(`${server.url}?code=abc123&state=xyz`);
|
||||
expect(response.status).toBe(200);
|
||||
const params = await captureP;
|
||||
expect(params.get('code')).toBe('abc123');
|
||||
expect(params.get('state')).toBe('xyz');
|
||||
server.close();
|
||||
});
|
||||
|
||||
test('non-matching path returns 404, capture not triggered', async () => {
|
||||
const server = await startLoopbackServer({ pathPrefix: '/cb' });
|
||||
const captureP = server.awaitParams({ timeoutMs: 500 });
|
||||
const response = await httpGet(`${server.url.replace('/cb', '/other')}`);
|
||||
expect(response.status).toBe(404);
|
||||
await expect(captureP).rejects.toThrow(/timeout/);
|
||||
server.close();
|
||||
});
|
||||
|
||||
test('error param renders errorHtml', async () => {
|
||||
const server = await startLoopbackServer({ pathPrefix: '/cb' });
|
||||
const captureP = server.awaitParams({ timeoutMs: 3000 });
|
||||
const response = await httpGet(`${server.url}?error=access_denied`);
|
||||
expect(response.body).toContain('Fehler');
|
||||
const params = await captureP;
|
||||
expect(params.get('error')).toBe('access_denied');
|
||||
server.close();
|
||||
});
|
||||
|
||||
test('timeout rejects', async () => {
|
||||
const server = await startLoopbackServer({ pathPrefix: '/cb' });
|
||||
await expect(server.awaitParams({ timeoutMs: 200 })).rejects.toThrow(/timeout/);
|
||||
server.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import * as http from 'http';
|
||||
import { URL } from 'url';
|
||||
|
||||
/**
|
||||
* Ephemerer HTTP-Server auf localhost:PORT fuer OAuth-Redirect-Capture.
|
||||
* RFC 8252 (OAuth 2.0 for Native Apps) — System-Browser + Loopback-Redirect.
|
||||
*
|
||||
* Lifecycle:
|
||||
* const server = await startLoopbackServer({ pathPrefix: '/oauth/callback' });
|
||||
* console.log(server.url); // http://127.0.0.1:54321/oauth/callback
|
||||
* const params = await server.awaitParams({ timeoutMs: 5 * 60 * 1000 });
|
||||
* server.close();
|
||||
*
|
||||
* Bindet immer auf 127.0.0.1 (nicht 0.0.0.0) — der OS-Listener ist nur lokal
|
||||
* erreichbar, kein Firewall-Prompt unter Windows.
|
||||
*/
|
||||
|
||||
export interface LoopbackServerOptions {
|
||||
pathPrefix: string; // z.B. '/oauth/callback'
|
||||
port?: number; // 0 = OS waehlt freien Port
|
||||
successHtml?: string; // HTML-Antwort beim Capture
|
||||
errorHtml?: string;
|
||||
}
|
||||
|
||||
export interface LoopbackServer {
|
||||
readonly url: string;
|
||||
awaitParams(opts?: { timeoutMs?: number }): Promise<URLSearchParams>;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
const DEFAULT_SUCCESS = `<!doctype html><html><head><meta charset="utf-8"><title>Login erfolgreich</title>
|
||||
<style>body{font-family:system-ui,-apple-system,Segoe UI,sans-serif;background:#0e0e10;color:#efeff1;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
|
||||
.box{text-align:center;padding:2rem 3rem;background:#1f1f23;border-radius:8px}
|
||||
h1{color:#9146FF;margin:0 0 0.5rem}</style></head>
|
||||
<body><div class="box"><h1>Login erfolgreich</h1><p>Du kannst dieses Fenster jetzt schliessen.</p></div></body></html>`;
|
||||
|
||||
const DEFAULT_ERROR = `<!doctype html><html><head><meta charset="utf-8"><title>Fehler</title>
|
||||
<style>body{font-family:system-ui,-apple-system,Segoe UI,sans-serif;background:#0e0e10;color:#efeff1;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
|
||||
.box{text-align:center;padding:2rem 3rem;background:#1f1f23;border-radius:8px}
|
||||
h1{color:#ff4444;margin:0 0 0.5rem}</style></head>
|
||||
<body><div class="box"><h1>Fehler</h1><p>Login abgebrochen.</p></div></body></html>`;
|
||||
|
||||
export function startLoopbackServer(opts: LoopbackServerOptions): Promise<LoopbackServer> {
|
||||
const successHtml = opts.successHtml ?? DEFAULT_SUCCESS;
|
||||
const errorHtml = opts.errorHtml ?? DEFAULT_ERROR;
|
||||
const pathPrefix = opts.pathPrefix.startsWith('/') ? opts.pathPrefix : '/' + opts.pathPrefix;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let resolveCapture: ((p: URLSearchParams) => void) | null = null;
|
||||
let rejectCapture: ((e: Error) => void) | null = null;
|
||||
let captureSettled = false;
|
||||
|
||||
const captureP = new Promise<URLSearchParams>((res, rej) => {
|
||||
resolveCapture = res;
|
||||
rejectCapture = rej;
|
||||
});
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
||||
if (!url.pathname.startsWith(pathPrefix)) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
res.end('not found');
|
||||
return;
|
||||
}
|
||||
const params = url.searchParams;
|
||||
const hasError = params.has('error');
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(hasError ? errorHtml : successHtml);
|
||||
if (!captureSettled && resolveCapture) {
|
||||
captureSettled = true;
|
||||
resolveCapture(params);
|
||||
}
|
||||
} catch (e) {
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||
res.end('internal error');
|
||||
if (!captureSettled && rejectCapture) {
|
||||
captureSettled = true;
|
||||
rejectCapture(e instanceof Error ? e : new Error(String(e)));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
server.on('error', reject);
|
||||
server.listen(opts.port ?? 0, '127.0.0.1', () => {
|
||||
const addr = server.address();
|
||||
if (!addr || typeof addr === 'string') {
|
||||
server.close();
|
||||
reject(new Error('loopback-server: failed to determine bound port'));
|
||||
return;
|
||||
}
|
||||
const url = `http://127.0.0.1:${addr.port}${pathPrefix}`;
|
||||
|
||||
resolve({
|
||||
url,
|
||||
async awaitParams(awaitOpts) {
|
||||
const timeoutMs = awaitOpts?.timeoutMs ?? 5 * 60 * 1000;
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
const timeoutP = new Promise<URLSearchParams>((_, rej) => {
|
||||
timer = setTimeout(() => {
|
||||
if (!captureSettled && rejectCapture) {
|
||||
captureSettled = true;
|
||||
rejectCapture(new Error('loopback-server: timeout waiting for redirect'));
|
||||
}
|
||||
rej(new Error('loopback-server: timeout waiting for redirect'));
|
||||
}, timeoutMs);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([captureP, timeoutP]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
},
|
||||
close() {
|
||||
try { server.close(); } catch { /* already closed */ }
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// SQLite-Schema v5 fuer Twitch VOD Manager.
|
||||
// Inline-Konstante damit tsc kein non-TS-Asset kopieren muss.
|
||||
// Alle Tabellen mit IF NOT EXISTS — Schema-Bootstrap ist idempotent.
|
||||
// PRAGMA-Statements (WAL etc.) werden separat von db.ts vor dem Bootstrap gesetzt.
|
||||
|
||||
export const SCHEMA_V5_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS schema_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO schema_meta(key, value) VALUES ('schema_version', '5');
|
||||
INSERT OR IGNORE INTO schema_meta(key, value) VALUES ('created_at', CAST(strftime('%s','now') AS TEXT));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config_kv (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS queue_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
streamer_login TEXT,
|
||||
vod_id TEXT,
|
||||
clip_id TEXT,
|
||||
title TEXT,
|
||||
output_path TEXT,
|
||||
status TEXT NOT NULL,
|
||||
progress_pct REAL,
|
||||
error_message TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
completed_at INTEGER,
|
||||
payload_json TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_queue_status ON queue_items(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_queue_streamer ON queue_items(streamer_login);
|
||||
CREATE INDEX IF NOT EXISTS idx_queue_created ON queue_items(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS downloaded_vods (
|
||||
vod_id TEXT PRIMARY KEY,
|
||||
downloaded_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS streamers (
|
||||
login TEXT PRIMARY KEY,
|
||||
auto_record INTEGER NOT NULL DEFAULT 0,
|
||||
auto_vod_download INTEGER NOT NULL DEFAULT 0,
|
||||
added_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_streamers_autorec ON streamers(auto_record);
|
||||
CREATE INDEX IF NOT EXISTS idx_streamers_autodl ON streamers(auto_vod_download);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS archive_files (
|
||||
path TEXT PRIMARY KEY,
|
||||
streamer_login TEXT,
|
||||
size_bytes INTEGER,
|
||||
duration_seconds INTEGER,
|
||||
created_at INTEGER,
|
||||
verified INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_archive_streamer ON archive_files(streamer_login);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chunk_index (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id TEXT NOT NULL,
|
||||
chunk_seq INTEGER NOT NULL,
|
||||
sha1_hex TEXT NOT NULL,
|
||||
bytes INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||
UNIQUE(item_id, chunk_seq)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_chunk_item ON chunk_index(item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_chunk_sha1 ON chunk_index(sha1_hex);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oauth_accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider TEXT NOT NULL,
|
||||
twitch_user_id TEXT,
|
||||
login TEXT,
|
||||
display_name TEXT,
|
||||
encrypted_access_token TEXT NOT NULL,
|
||||
encrypted_refresh_token TEXT,
|
||||
expires_at INTEGER,
|
||||
scopes_json TEXT,
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||
UNIQUE(provider, twitch_user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_provider ON oauth_accounts(provider);
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_default ON oauth_accounts(is_default);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS migrations_applied (
|
||||
name TEXT PRIMARY KEY,
|
||||
applied_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||
payload TEXT
|
||||
);
|
||||
`;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { test, expect, describe } from 'vitest';
|
||||
import { MemorySecureStorage, createElectronSecureStorage, type SecureStorage } from './secure-storage';
|
||||
|
||||
describe('MemorySecureStorage', () => {
|
||||
test('isEncryptionAvailable returns false (kennzeichnet Memory-Mode)', () => {
|
||||
const s: SecureStorage = new MemorySecureStorage();
|
||||
expect(s.isEncryptionAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
test('roundtrip ascii', () => {
|
||||
const s = new MemorySecureStorage();
|
||||
const cipher = s.encrypt('hello');
|
||||
expect(cipher).not.toBe('hello'); // base64-Kodierung greift
|
||||
expect(s.decrypt(cipher)).toBe('hello');
|
||||
});
|
||||
|
||||
test('roundtrip multi-byte', () => {
|
||||
const s = new MemorySecureStorage();
|
||||
expect(s.decrypt(s.encrypt('aeoeue-test'))).toBe('aeoeue-test');
|
||||
});
|
||||
|
||||
test('roundtrip empty string', () => {
|
||||
const s = new MemorySecureStorage();
|
||||
expect(s.decrypt(s.encrypt(''))).toBe('');
|
||||
});
|
||||
|
||||
test('long token (simuliert OAuth access_token Groesse)', () => {
|
||||
const s = new MemorySecureStorage();
|
||||
const token = 'a'.repeat(256);
|
||||
expect(s.decrypt(s.encrypt(token))).toBe(token);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createElectronSecureStorage', () => {
|
||||
test('is exported as function', () => {
|
||||
expect(typeof createElectronSecureStorage).toBe('function');
|
||||
});
|
||||
|
||||
test('throws useful error if called outside Electron (vitest env)', () => {
|
||||
// In vitest (Node-only) ist electron entweder nicht installiert oder hat keine
|
||||
// app-context-Funktionen. Genaues Error-Wording ist nicht stable, aber Aufruf
|
||||
// muss throwen statt undefined zurueckgeben.
|
||||
expect(() => createElectronSecureStorage()).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
// Verschluesselt String-Payloads im OS-Keystore (Win Credential Manager via
|
||||
// Electron safeStorage). MemorySecureStorage ist fuer Tests/Headless-Envs —
|
||||
// gibt plaintext zurueck und meldet isEncryptionAvailable() === false, damit
|
||||
// Caller das in den Log schreiben oder verweigern koennen.
|
||||
|
||||
export interface SecureStorage {
|
||||
isEncryptionAvailable(): boolean;
|
||||
encrypt(plaintext: string): string;
|
||||
decrypt(ciphertext: string): string;
|
||||
}
|
||||
|
||||
export class MemorySecureStorage implements SecureStorage {
|
||||
isEncryptionAvailable(): boolean {
|
||||
return false;
|
||||
}
|
||||
encrypt(plaintext: string): string {
|
||||
// Base64 als Kennzeichnung — kein Schutz, nur damit `decrypt(encrypt(x)) === x`
|
||||
// semantisch konsistent ist (kein literal plaintext zwischen den Methoden).
|
||||
return Buffer.from(plaintext, 'utf-8').toString('base64');
|
||||
}
|
||||
decrypt(ciphertext: string): string {
|
||||
return Buffer.from(ciphertext, 'base64').toString('utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
interface SafeStorageLike {
|
||||
isEncryptionAvailable(): boolean;
|
||||
encryptString(plain: string): Buffer;
|
||||
decryptString(buf: Buffer): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrappt electron.safeStorage. Setzt voraus, dass `app.whenReady()` gefired ist.
|
||||
* Wird per Lazy-Require konstruiert, sodass Module ausserhalb von Electron
|
||||
* (zB Tests) das Modul importieren koennen ohne Crash.
|
||||
*/
|
||||
export function createElectronSecureStorage(): SecureStorage {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const electron = require('electron');
|
||||
const safeStorage = electron?.safeStorage as SafeStorageLike | undefined;
|
||||
if (!safeStorage) {
|
||||
throw new Error('Electron safeStorage not available (called before app.whenReady?)');
|
||||
}
|
||||
|
||||
return {
|
||||
isEncryptionAvailable(): boolean {
|
||||
return safeStorage.isEncryptionAvailable();
|
||||
},
|
||||
encrypt(plaintext: string): string {
|
||||
const buf = safeStorage.encryptString(plaintext);
|
||||
return buf.toString('base64');
|
||||
},
|
||||
decrypt(ciphertext: string): string {
|
||||
const buf = Buffer.from(ciphertext, 'base64');
|
||||
return safeStorage.decryptString(buf);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user