diff --git a/src/main/domain/token-store.test.ts b/src/main/domain/token-store.test.ts new file mode 100644 index 0000000..2337899 --- /dev/null +++ b/src/main/domain/token-store.test.ts @@ -0,0 +1,120 @@ +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 { MemorySecureStorage } from '../infra/secure-storage'; +import { createTokenStore, type TokenStore } from './token-store'; + +let tmpDir: string; +let db: DbHandle; +let store: TokenStore; +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tokens-')); + db = openDatabase(path.join(tmpDir, 'app.db')); + store = createTokenStore(db, new MemorySecureStorage()); +}); +afterEach(() => { + db.close(); + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } +}); + +describe('createTokenStore', () => { + test('upsert new account returns record with id > 0', () => { + const rec = store.upsert({ + provider: 'twitch', + twitchUserId: 'u1', + login: 'alice', + accessToken: 'aaa.aaa.aaa', + }); + expect(rec.id).toBeGreaterThan(0); + expect(rec.login).toBe('alice'); + expect(rec.provider).toBe('twitch'); + expect(rec.twitchUserId).toBe('u1'); + }); + + test('upsert same (provider, twitch_user_id) updates, no duplicate row', () => { + store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'alice', accessToken: 't1' }); + const updated = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'alice2', accessToken: 't2' }); + expect(updated.login).toBe('alice2'); + const all = store.list('twitch'); + expect(all).toHaveLength(1); + expect(all[0].login).toBe('alice2'); + }); + + test('list() returns all accounts, list(provider) filters', () => { + store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'x' }); + store.upsert({ provider: 'twitch', twitchUserId: 'u2', login: 'b', accessToken: 'y' }); + store.upsert({ provider: 'youtube', twitchUserId: undefined, login: 'c', accessToken: 'z' }); + expect(store.list()).toHaveLength(3); + expect(store.list('twitch')).toHaveLength(2); + expect(store.list('youtube')).toHaveLength(1); + }); + + test('getDefault returns null when nothing default', () => { + store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'x' }); + expect(store.getDefault('twitch')).toBeNull(); + }); + + test('upsert with isDefault=true makes it default, demotes siblings', () => { + const a = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'x', isDefault: true }); + const b = store.upsert({ provider: 'twitch', twitchUserId: 'u2', login: 'b', accessToken: 'y', isDefault: true }); + + const def = store.getDefault('twitch'); + expect(def?.id).toBe(b.id); + + const aAgain = store.list('twitch').find(r => r.id === a.id); + expect(aAgain?.isDefault).toBe(false); + }); + + test('setDefault toggles is_default exclusivity within provider', () => { + const a = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'x', isDefault: true }); + const b = store.upsert({ provider: 'twitch', twitchUserId: 'u2', login: 'b', accessToken: 'y' }); + + store.setDefault(b.id); + expect(store.getDefault('twitch')?.id).toBe(b.id); + + const aAgain = store.list('twitch').find(r => r.id === a.id); + expect(aAgain?.isDefault).toBe(false); + }); + + test('getAccessToken returns decrypted plaintext', () => { + const rec = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'super-secret-token' }); + expect(store.getAccessToken(rec.id)).toBe('super-secret-token'); + }); + + test('getRefreshToken returns null if not provided, value if provided', () => { + const noRefresh = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 't1' }); + expect(store.getRefreshToken(noRefresh.id)).toBeNull(); + + const withRefresh = store.upsert({ + provider: 'twitch', twitchUserId: 'u2', login: 'b', + accessToken: 't2', refreshToken: 'refresh-xyz', + }); + expect(store.getRefreshToken(withRefresh.id)).toBe('refresh-xyz'); + }); + + test('scopes roundtrip as array', () => { + const rec = store.upsert({ + provider: 'twitch', twitchUserId: 'u1', login: 'a', + accessToken: 't', scopes: ['user:read:email', 'channel:read:subscriptions'], + }); + expect(rec.scopes).toEqual(['user:read:email', 'channel:read:subscriptions']); + }); + + test('delete removes the record', () => { + const rec = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'x' }); + store.delete(rec.id); + expect(store.list('twitch')).toHaveLength(0); + expect(() => store.getAccessToken(rec.id)).toThrow(); + }); + + test('expiresAt roundtrip', () => { + const future = Math.floor(Date.now() / 1000) + 3600; + const rec = store.upsert({ + provider: 'twitch', twitchUserId: 'u1', login: 'a', + accessToken: 't', expiresAt: future, + }); + expect(rec.expiresAt).toBe(future); + }); +}); diff --git a/src/main/domain/token-store.ts b/src/main/domain/token-store.ts new file mode 100644 index 0000000..9793567 --- /dev/null +++ b/src/main/domain/token-store.ts @@ -0,0 +1,203 @@ +import type { DbHandle } from '../infra/db'; +import type { SecureStorage } from '../infra/secure-storage'; + +export interface TokenRecord { + id: number; + provider: string; + twitchUserId: string | null; + login: string | null; + displayName: string | null; + expiresAt: number | null; + scopes: string[]; + isDefault: boolean; + createdAt: number; + updatedAt: number; +} + +export interface TokenWriteInput { + provider: string; + twitchUserId?: string; + login?: string; + displayName?: string; + accessToken: string; + refreshToken?: string; + expiresAt?: number; + scopes?: string[]; + isDefault?: boolean; +} + +export interface TokenStore { + upsert(input: TokenWriteInput): TokenRecord; + list(provider?: string): TokenRecord[]; + getDefault(provider: string): TokenRecord | null; + setDefault(id: number): void; + getAccessToken(id: number): string; + getRefreshToken(id: number): string | null; + delete(id: number): void; +} + +interface TokenRow { + id: number; + provider: string; + twitch_user_id: string | null; + login: string | null; + display_name: string | null; + encrypted_access_token: string; + encrypted_refresh_token: string | null; + expires_at: number | null; + scopes_json: string | null; + is_default: number; + created_at: number; + updated_at: number; +} + +function rowToRecord(row: TokenRow): TokenRecord { + let scopes: string[] = []; + if (row.scopes_json) { + try { + const parsed = JSON.parse(row.scopes_json); + if (Array.isArray(parsed)) { + scopes = parsed.filter((s): s is string => typeof s === 'string'); + } + } catch { /* malformed scopes payload — treat as empty */ } + } + return { + id: row.id, + provider: row.provider, + twitchUserId: row.twitch_user_id, + login: row.login, + displayName: row.display_name, + expiresAt: row.expires_at, + scopes, + isDefault: row.is_default === 1, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +export function createTokenStore(db: DbHandle, storage: SecureStorage): TokenStore { + function getRowOrThrow(id: number): TokenRow { + const row = db.get('SELECT * FROM oauth_accounts WHERE id = ?', [id]); + if (!row) throw new Error(`token-store: account id=${id} not found`); + return row; + } + + return { + upsert(input: TokenWriteInput): TokenRecord { + const now = Math.floor(Date.now() / 1000); + const encryptedAccess = storage.encrypt(input.accessToken); + const encryptedRefresh = input.refreshToken !== undefined + ? storage.encrypt(input.refreshToken) + : null; + const scopesJson = input.scopes && input.scopes.length > 0 + ? JSON.stringify(input.scopes) + : null; + const isDefault = input.isDefault ? 1 : 0; + const twitchUserId = input.twitchUserId ?? null; + + let resultId: number | null = null; + + db.transaction(() => { + // Insert or update conditional on UNIQUE(provider, twitch_user_id). + // Sqlite's ON CONFLICT braucht den vollstaendigen Konflikt-Ausdruck. + db.run( + `INSERT INTO oauth_accounts( + provider, twitch_user_id, login, display_name, + encrypted_access_token, encrypted_refresh_token, + expires_at, scopes_json, is_default, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(provider, twitch_user_id) DO UPDATE SET + login = excluded.login, + display_name = excluded.display_name, + encrypted_access_token = excluded.encrypted_access_token, + encrypted_refresh_token = excluded.encrypted_refresh_token, + expires_at = excluded.expires_at, + scopes_json = excluded.scopes_json, + is_default = excluded.is_default, + updated_at = excluded.updated_at`, + [ + input.provider, + twitchUserId, + input.login ?? null, + input.displayName ?? null, + encryptedAccess, + encryptedRefresh, + input.expiresAt ?? null, + scopesJson, + isDefault, + now, + now, + ] + ); + + // Wenn dieser Eintrag default ist: alle anderen mit gleichem provider auf 0 setzen. + if (isDefault === 1) { + db.run( + `UPDATE oauth_accounts + SET is_default = 0, updated_at = ? + WHERE provider = ? + AND NOT (twitch_user_id IS ? AND provider IS ?)`, + [now, input.provider, twitchUserId, input.provider] + ); + } + + const lookup = db.get<{ id: number }>( + `SELECT id FROM oauth_accounts + WHERE provider = ? + AND (twitch_user_id IS ? OR (twitch_user_id IS NULL AND ? IS NULL))`, + [input.provider, twitchUserId, twitchUserId] + ); + resultId = lookup?.id ?? null; + }); + + if (resultId === null) throw new Error('token-store: upsert lookup failed'); + return rowToRecord(getRowOrThrow(resultId)); + }, + + list(provider?: string): TokenRecord[] { + const rows = provider + ? db.all('SELECT * FROM oauth_accounts WHERE provider = ? ORDER BY id', [provider]) + : db.all('SELECT * FROM oauth_accounts ORDER BY id'); + return rows.map(rowToRecord); + }, + + getDefault(provider: string): TokenRecord | null { + const row = db.get( + 'SELECT * FROM oauth_accounts WHERE provider = ? AND is_default = 1 LIMIT 1', + [provider] + ); + return row ? rowToRecord(row) : null; + }, + + setDefault(id: number): void { + const target = getRowOrThrow(id); + const now = Math.floor(Date.now() / 1000); + db.transaction(() => { + db.run( + 'UPDATE oauth_accounts SET is_default = 0, updated_at = ? WHERE provider = ?', + [now, target.provider] + ); + db.run( + 'UPDATE oauth_accounts SET is_default = 1, updated_at = ? WHERE id = ?', + [now, id] + ); + }); + }, + + getAccessToken(id: number): string { + const row = getRowOrThrow(id); + return storage.decrypt(row.encrypted_access_token); + }, + + getRefreshToken(id: number): string | null { + const row = getRowOrThrow(id); + return row.encrypted_refresh_token + ? storage.decrypt(row.encrypted_refresh_token) + : null; + }, + + delete(id: number): void { + db.run('DELETE FROM oauth_accounts WHERE id = ?', [id]); + }, + }; +}