mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -08:00
fix(epg): redact diagnostics and mirror committed settings after cleanup errors
This commit is contained in:
1 parent
a76050a491
commit
2e8441aceb
15 files changed
+413
-72
No files matched your search
@@ -180,6 +180,8 @@ Saving Settings → EPG reconciles cached XMLTV with committed global URLs and
|
||||
all enabled M3U playlist sources. Startup runs the same reconciliation after
|
||||
settings load and playlist migration. Ordinary saves skip unchanged normalized
|
||||
source sets; an explicitly edited EPG field can retry a failed cleanup.
|
||||
A cleanup failure after persistence still mirrors committed settings to Electron;
|
||||
the form stays dirty for retry. Failed storage writes never mirror to main.
|
||||
Failed settings reads and incomplete playlist migration never authorize pruning.
|
||||
Removed sources retire queued/running imports and dismiss retained error rows
|
||||
before worker-owned deletion. Retry waits for reconciliation and rechecks its
|
||||
|
||||
@@ -1665,6 +1665,8 @@ Saving Settings → EPG reconciles cached XMLTV with committed global URLs and
|
||||
all enabled M3U playlist sources. Startup runs the same reconciliation after
|
||||
settings load and playlist migration. Ordinary saves skip unchanged normalized
|
||||
source sets; an explicitly edited EPG field can retry a failed cleanup.
|
||||
A cleanup failure after persistence still mirrors committed settings to Electron;
|
||||
the form stays dirty for retry. Failed storage writes never mirror to main.
|
||||
Failed settings reads and incomplete playlist migration never authorize pruning.
|
||||
Removed sources retire queued/running imports and dismiss retained error rows
|
||||
before worker-owned deletion. Retry waits for reconciliation and rechecks its
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { Page } from '@playwright/test';
|
||||
import {
|
||||
buildM3uContent,
|
||||
@@ -102,6 +104,121 @@ test.describe('Electron EPG', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('@epg @electron mirrors saved desktop settings when source cleanup fails', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const app = await launchElectronApp(dataDir);
|
||||
try {
|
||||
await openSettings(app.mainWindow);
|
||||
await app.electronApp.evaluate(({ ipcMain }) => {
|
||||
ipcMain.removeHandler('EPG_RECONCILE_SOURCES');
|
||||
ipcMain.handle('EPG_RECONCILE_SOURCES', () => {
|
||||
throw new Error('Synthetic cleanup failure');
|
||||
});
|
||||
});
|
||||
await openSettingsSection(app.mainWindow, 'general');
|
||||
await app.mainWindow
|
||||
.getByTestId('portal-connectivity-toggle')
|
||||
.locator('input')
|
||||
.uncheck();
|
||||
await openSettingsSection(app.mainWindow, 'epg');
|
||||
await app.mainWindow
|
||||
.getByRole('button', { name: 'Add EPG source' })
|
||||
.click();
|
||||
await app.mainWindow
|
||||
.locator('.epg-source-row input')
|
||||
.fill('https://example.invalid/guide.xml');
|
||||
await app.mainWindow
|
||||
.getByTestId('save-settings')
|
||||
.click({ noWaitAfter: true });
|
||||
await expect(
|
||||
app.mainWindow.getByText(
|
||||
'Failed to clear EPG data. Please try again.',
|
||||
{ exact: true }
|
||||
)
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('settings-unsaved-bar')
|
||||
).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const config = JSON.parse(
|
||||
await readFile(
|
||||
join(dataDir, 'config', 'config.json'),
|
||||
'utf8'
|
||||
)
|
||||
);
|
||||
return config.PORTAL_CONNECTIVITY_GUARD;
|
||||
})
|
||||
.toBe(false);
|
||||
// Restore cleanup so the explicit retry can finish and close normally.
|
||||
await app.electronApp.evaluate(({ ipcMain }) => {
|
||||
ipcMain.removeHandler('EPG_RECONCILE_SOURCES');
|
||||
ipcMain.handle('EPG_RECONCILE_SOURCES', () => ({
|
||||
success: true,
|
||||
removedUrls: [],
|
||||
}));
|
||||
});
|
||||
await app.mainWindow
|
||||
.locator('.epg-source-row button')
|
||||
.nth(1)
|
||||
.click();
|
||||
await saveSettings(app.mainWindow);
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('settings-unsaved-bar')
|
||||
).not.toBeVisible();
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@epg @electron omits source secrets from main and parser-worker diagnostics', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const server = await createMutableTextServer(epgFixtureXml, {
|
||||
contentType: 'application/xml; charset=utf-8',
|
||||
resourcePath: '/epg-path-secret/guide.xml',
|
||||
});
|
||||
const app = await launchElectronApp(dataDir);
|
||||
const sourceUrl = `${server.resourceUrl}?custom=epg-query-secret`;
|
||||
let diagnostics = '';
|
||||
const capture = (chunk: Buffer) => {
|
||||
diagnostics += chunk.toString();
|
||||
};
|
||||
const process = app.electronApp.process();
|
||||
process.stdout?.on('data', capture);
|
||||
process.stderr?.on('data', capture);
|
||||
try {
|
||||
await app.mainWindow.evaluate(async (url) => {
|
||||
await window.electron.forceFetchEpg(url);
|
||||
await window.electron.clearEpgDataForSource(url);
|
||||
}, sourceUrl);
|
||||
await expect
|
||||
.poll(() => diagnostics)
|
||||
.toContain('EPG data cleared for source via worker');
|
||||
const failed = await app.mainWindow.evaluate(async (url) => {
|
||||
const missing = new URL(url);
|
||||
missing.pathname += '/missing';
|
||||
return window.electron.forceFetchEpg(missing.toString());
|
||||
}, sourceUrl);
|
||||
expect(failed.success).toBe(false);
|
||||
await expect
|
||||
.poll(() => diagnostics)
|
||||
.toContain('Request failed with status code 404');
|
||||
expect(diagnostics).toContain('[EPG Worker]');
|
||||
expect(diagnostics).toContain('Parsing complete:');
|
||||
expect(diagnostics).toContain('EPG parsing complete:');
|
||||
expect(diagnostics).not.toContain('epg-path-secret');
|
||||
expect(diagnostics).not.toContain('epg-query-secret');
|
||||
expect(await getEpgChannelCount(app.mainWindow)).toBe(0);
|
||||
} finally {
|
||||
process.stdout?.off('data', capture);
|
||||
process.stderr?.off('data', capture);
|
||||
await closeElectronApp(app);
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('@epg @electron adds an EPG source, fetches guide data, removes its stored EPG data on save', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { epgLogger } from '../util/epg-logger';
|
||||
import type { Worker } from 'worker_threads';
|
||||
import type {
|
||||
ElectronBridgeSecurityErrorCode,
|
||||
@@ -37,7 +38,7 @@ export function runEpgFetch(
|
||||
try {
|
||||
worker = context.runtime.createEpgWorker();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
epgLogger.error(
|
||||
context.loggerLabel,
|
||||
'Failed to create worker:',
|
||||
error
|
||||
@@ -124,7 +125,7 @@ export function runEpgFetch(
|
||||
const errorMessage = `EPG fetch timed out after ${
|
||||
context.fetchTimeoutMs / 1000
|
||||
}s without progress`;
|
||||
console.error(context.loggerLabel, `${errorMessage}: ${url}`);
|
||||
epgLogger.error(context.loggerLabel, errorMessage);
|
||||
context.sendProgressToRenderer(
|
||||
url,
|
||||
'error',
|
||||
@@ -176,9 +177,9 @@ export function runEpgFetch(
|
||||
break;
|
||||
|
||||
case 'EPG_COMPLETE':
|
||||
console.log(
|
||||
epgLogger.log(
|
||||
context.loggerLabel,
|
||||
`EPG parsing complete for ${url}:`,
|
||||
'EPG parsing complete:',
|
||||
message.stats
|
||||
);
|
||||
context.sendProgressToRenderer(
|
||||
@@ -196,7 +197,7 @@ export function runEpgFetch(
|
||||
break;
|
||||
|
||||
case 'EPG_ERROR':
|
||||
console.error(
|
||||
epgLogger.error(
|
||||
context.loggerLabel,
|
||||
'Worker error:',
|
||||
message.error
|
||||
@@ -226,7 +227,7 @@ export function runEpgFetch(
|
||||
}
|
||||
} catch (err) {
|
||||
if (settled || cancelIfRetired()) return;
|
||||
console.error(
|
||||
epgLogger.error(
|
||||
context.loggerLabel,
|
||||
'Error handling message:',
|
||||
err
|
||||
@@ -248,7 +249,7 @@ export function runEpgFetch(
|
||||
|
||||
worker.on('error', (error) => {
|
||||
if (settled || cancelIfRetired()) return;
|
||||
console.error(context.loggerLabel, 'Worker error event:', error);
|
||||
epgLogger.error(context.loggerLabel, 'Worker error event:', error);
|
||||
context.sendProgressToRenderer(
|
||||
url,
|
||||
'error',
|
||||
@@ -266,7 +267,7 @@ export function runEpgFetch(
|
||||
worker.on('exit', (code) => {
|
||||
if (settled || cancelIfRetired(true)) return;
|
||||
const errorMessage = `Worker exited unexpectedly (code ${code})`;
|
||||
console.error(context.loggerLabel, `${errorMessage}: ${url}`);
|
||||
epgLogger.error(context.loggerLabel, errorMessage);
|
||||
context.sendProgressToRenderer(
|
||||
url,
|
||||
'error',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { epgLogger } from '../util/epg-logger';
|
||||
import { epgSourceGeneration, requestEpgSource } from './epg-source-generation';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { ElectronBridgeTrustOptions } from '@iptvnator/shared/interfaces';
|
||||
@@ -69,18 +70,18 @@ export async function checkEpgFreshness(
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(loggerLabel, 'Error checking EPG freshness:', error);
|
||||
epgLogger.error(loggerLabel, 'Error checking EPG freshness:', error);
|
||||
return { staleUrls: urls, freshUrls: [] };
|
||||
}
|
||||
|
||||
if (freshUrls.length > 0) {
|
||||
console.log(
|
||||
epgLogger.log(
|
||||
loggerLabel,
|
||||
`EPG fresh (skipping): ${freshUrls.length} source(s)`
|
||||
);
|
||||
}
|
||||
if (staleUrls.length > 0) {
|
||||
console.log(
|
||||
epgLogger.log(
|
||||
loggerLabel,
|
||||
`EPG stale (will fetch): ${staleUrls.length} source(s)`
|
||||
);
|
||||
@@ -132,7 +133,7 @@ export async function handleFetchEpg(
|
||||
);
|
||||
|
||||
if (urlsToFetch.length === 0) {
|
||||
console.log(
|
||||
epgLogger.log(
|
||||
loggerLabel,
|
||||
`All ${staleUrls.length} stale URL(s) already fetched this session; skipping`
|
||||
);
|
||||
@@ -167,11 +168,7 @@ export async function handleFetchEpg(
|
||||
}
|
||||
await epgWorkerService.fetchEpgFromUrl(url, options);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
loggerLabel,
|
||||
`Error fetching EPG from ${url}:`,
|
||||
error
|
||||
);
|
||||
epgLogger.error(loggerLabel, 'Error fetching EPG source:', error);
|
||||
errors.push(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { epgLogger } from '../util/epg-logger';
|
||||
import { and, eq, inArray, isNull, or, sql, type SQL } from 'drizzle-orm';
|
||||
import {
|
||||
EpgChannelMetadata,
|
||||
@@ -154,7 +155,7 @@ export class EpgQueryService {
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error(
|
||||
epgLogger.error(
|
||||
this.loggerLabel,
|
||||
'Error getting channel programs:',
|
||||
error
|
||||
@@ -310,7 +311,7 @@ export class EpgQueryService {
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
epgLogger.error(
|
||||
this.loggerLabel,
|
||||
'Error getting batch current programs:',
|
||||
error
|
||||
@@ -335,7 +336,7 @@ export class EpgQueryService {
|
||||
|
||||
return { channels, programs: [] };
|
||||
} catch (error) {
|
||||
console.error(
|
||||
epgLogger.error(
|
||||
this.loggerLabel,
|
||||
'Error getting all channels:',
|
||||
error
|
||||
@@ -391,7 +392,7 @@ export class EpgQueryService {
|
||||
])
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
epgLogger.error(
|
||||
this.loggerLabel,
|
||||
'Error getting channel metadata:',
|
||||
error
|
||||
@@ -439,7 +440,7 @@ export class EpgQueryService {
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
epgLogger.error(
|
||||
this.loggerLabel,
|
||||
'Error getting channels by range:',
|
||||
error
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { epgLogger } from '../util/epg-logger';
|
||||
import { app } from 'electron';
|
||||
import * as path from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
@@ -30,7 +31,7 @@ export class EpgWorkerRuntime {
|
||||
try {
|
||||
worker = this.createEpgWorker();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
epgLogger.error(
|
||||
this.loggerLabel,
|
||||
'Failed to create worker for clear:',
|
||||
error
|
||||
@@ -51,7 +52,7 @@ export class EpgWorkerRuntime {
|
||||
const errorMessage = `${options.timeoutLabel} timed out after ${
|
||||
this.fetchTimeoutMs / 1000
|
||||
}s`;
|
||||
console.error(this.loggerLabel, errorMessage);
|
||||
epgLogger.error(this.loggerLabel, errorMessage);
|
||||
settle(() => {
|
||||
void this.terminateWorker(
|
||||
worker,
|
||||
@@ -72,7 +73,7 @@ export class EpgWorkerRuntime {
|
||||
.then(() => resolve(), reject);
|
||||
});
|
||||
} else if (message.type === 'EPG_ERROR') {
|
||||
console.error(
|
||||
epgLogger.error(
|
||||
this.loggerLabel,
|
||||
'Worker clear error:',
|
||||
message.error
|
||||
@@ -92,7 +93,7 @@ export class EpgWorkerRuntime {
|
||||
);
|
||||
|
||||
worker.on('error', (error) => {
|
||||
console.error(
|
||||
epgLogger.error(
|
||||
this.loggerLabel,
|
||||
'Worker error during clear:',
|
||||
error
|
||||
@@ -108,7 +109,7 @@ export class EpgWorkerRuntime {
|
||||
worker.on('exit', (code) => {
|
||||
if (settled) return;
|
||||
const errorMessage = `${options.exitLabel} exited unexpectedly (code ${code})`;
|
||||
console.error(this.loggerLabel, errorMessage);
|
||||
epgLogger.error(this.loggerLabel, errorMessage);
|
||||
settle(() => reject(new Error(errorMessage)));
|
||||
});
|
||||
});
|
||||
@@ -123,7 +124,7 @@ export class EpgWorkerRuntime {
|
||||
try {
|
||||
await worker.terminate();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
epgLogger.error(
|
||||
this.loggerLabel,
|
||||
`Failed to terminate ${context} worker:`,
|
||||
error
|
||||
|
||||
@@ -67,6 +67,62 @@ describe('EpgWorkerService worker lifecycle', () => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('keeps source credentials out of completion, reuse, cache and cleanup logs', async () => {
|
||||
const secretUrl =
|
||||
'https://secret-user:secret-password@example.com/private-key/guide.xml?custom=secret-query#secret-fragment';
|
||||
const first = service.fetchEpgFromUrl(secretUrl);
|
||||
const reused = service.fetchEpgFromUrl(secretUrl);
|
||||
mockWorkerInstances[0].emit('message', {
|
||||
type: 'EPG_COMPLETE',
|
||||
stats: { totalChannels: 1, totalPrograms: 2 },
|
||||
});
|
||||
await Promise.all([first, reused]);
|
||||
await service.fetchEpgFromUrl(secretUrl);
|
||||
const clear = service.clearEpgDataForSource(secretUrl);
|
||||
mockWorkerInstances[1].emit('message', { type: 'CLEAR_COMPLETE' });
|
||||
await clear;
|
||||
const output = JSON.stringify(consoleLogSpy.mock.calls);
|
||||
for (const secret of [
|
||||
'secret-user',
|
||||
'secret-password',
|
||||
'private-key',
|
||||
'secret-query',
|
||||
'secret-fragment',
|
||||
]) {
|
||||
expect(output).not.toContain(secret);
|
||||
}
|
||||
expect(output).toContain('EPG parsing complete');
|
||||
});
|
||||
|
||||
it('redacts worker errors without changing the rejected error', async () => {
|
||||
const secretUrl =
|
||||
'https://user:password@example.com/path-token/guide.xml?custom=query-token';
|
||||
const error = Object.assign(new Error(`Request failed: ${secretUrl}`), {
|
||||
config: {
|
||||
url: secretUrl,
|
||||
headers: { Authorization: 'Bearer header-secret' },
|
||||
},
|
||||
cause: new Error(
|
||||
'Redirect failed: https://example.org/redirect-token?custom=redirect-query'
|
||||
),
|
||||
});
|
||||
const pending = service.fetchEpgFromUrl(secretUrl);
|
||||
mockWorkerInstances[0].emit('error', error);
|
||||
await expect(pending).rejects.toBe(error);
|
||||
const output = JSON.stringify(consoleErrorSpy.mock.calls);
|
||||
for (const secret of [
|
||||
'password',
|
||||
'path-token',
|
||||
'query-token',
|
||||
'header-secret',
|
||||
'redirect-token',
|
||||
'redirect-query',
|
||||
]) {
|
||||
expect(output).not.toContain(secret);
|
||||
}
|
||||
expect(output).toContain('Request failed');
|
||||
});
|
||||
|
||||
describe('worker lifecycle', () => {
|
||||
it('spawns the worker with bootstrap paths and drives the FETCH_EPG flow', async () => {
|
||||
const options = { manuallyTrustedHosts: ['example.com'] } as any;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { epgLogger } from '../util/epg-logger';
|
||||
import {
|
||||
epgSourceGeneration,
|
||||
requestEpgSource,
|
||||
@@ -105,17 +106,14 @@ export class EpgWorkerService {
|
||||
// must keep awaiting that termination window.
|
||||
const inFlight = this.inFlightFetches.get(url);
|
||||
if (inFlight) {
|
||||
console.log(
|
||||
this.loggerLabel,
|
||||
`Reusing in-flight EPG fetch: ${url}`
|
||||
);
|
||||
epgLogger.log(this.loggerLabel, 'Reusing in-flight EPG fetch');
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
if (this.fetchedUrls.has(url)) {
|
||||
console.log(
|
||||
epgLogger.log(
|
||||
this.loggerLabel,
|
||||
`Skipping already fetched URL: ${url}`
|
||||
'Skipping already fetched EPG source'
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -150,7 +148,7 @@ export class EpgWorkerService {
|
||||
failedWorkerLabel: 'failed clear',
|
||||
erroredWorkerLabel: 'errored clear',
|
||||
onComplete: async (worker) => {
|
||||
console.log(this.loggerLabel, 'EPG data cleared via worker');
|
||||
epgLogger.log(this.loggerLabel, 'EPG data cleared via worker');
|
||||
this.fetchedUrls.clear();
|
||||
// Resolve only after every interrupted fetch worker has exited
|
||||
// too — they may still hold the SQLite lock the caller expects
|
||||
@@ -215,9 +213,9 @@ export class EpgWorkerService {
|
||||
failedWorkerLabel: 'failed source clear',
|
||||
erroredWorkerLabel: 'errored source clear',
|
||||
onComplete: async (worker) => {
|
||||
console.log(
|
||||
epgLogger.log(
|
||||
this.loggerLabel,
|
||||
`EPG data cleared for source via worker: ${normalizedSourceUrl}`
|
||||
'EPG data cleared for source via worker'
|
||||
);
|
||||
this.fetchedUrls.delete(normalizedSourceUrl);
|
||||
await this.runtime.terminateWorker(
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { epgLogger } from './epg-logger';
|
||||
|
||||
describe('EPG diagnostics', () => {
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('omits URLs with arbitrary path/query secrets, redirects and malformed authorities', () => {
|
||||
const log = jest.spyOn(console, 'log').mockImplementation();
|
||||
epgLogger.log('[EPG Worker]', {
|
||||
message:
|
||||
'Redirect: https://example.com/path-secret?custom=query-secret -> https://user:password@[/malformed-secret',
|
||||
response: { url: new URL('https://example.org/redirect-secret') },
|
||||
authorization: 'Bearer header-secret',
|
||||
stats: { totalChannels: 12, totalPrograms: 34 },
|
||||
});
|
||||
const output = JSON.stringify(log.mock.calls);
|
||||
for (const secret of [
|
||||
'path-secret',
|
||||
'query-secret',
|
||||
'password',
|
||||
'malformed-secret',
|
||||
'redirect-secret',
|
||||
'header-secret',
|
||||
]) {
|
||||
expect(output).not.toContain(secret);
|
||||
}
|
||||
expect(output).toContain('Redirect:');
|
||||
expect(log.mock.calls[0][1]).toEqual(
|
||||
expect.objectContaining({
|
||||
stats: { totalChannels: 12, totalPrograms: 34 },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('omits transport data and stacks while retaining safe error details without mutation', () => {
|
||||
const log = jest.spyOn(console, 'error').mockImplementation();
|
||||
const error = Object.assign(
|
||||
new Error('Failed https://example.org/path-secret'),
|
||||
{
|
||||
code: 'ECONNRESET',
|
||||
cause: new Error(
|
||||
'Redirect https://example.org/redirect-secret'
|
||||
),
|
||||
request: {
|
||||
path: '/feed/relative-path-secret?custom=relative-query-secret',
|
||||
_header:
|
||||
'GET /feed/header-path-secret?custom=header-query-secret HTTP/1.1',
|
||||
} as { self?: unknown; path: string; _header: string },
|
||||
}
|
||||
);
|
||||
error.request.self = error;
|
||||
epgLogger.error('[EPG Worker]', error);
|
||||
const output = JSON.stringify(log.mock.calls);
|
||||
expect(output).not.toContain('path-secret');
|
||||
expect(output).not.toContain('redirect-secret');
|
||||
expect(output).toContain('ECONNRESET');
|
||||
for (const secret of [
|
||||
'relative-path-secret',
|
||||
'relative-query-secret',
|
||||
'header-path-secret',
|
||||
'header-query-secret',
|
||||
]) {
|
||||
expect(output).not.toContain(secret);
|
||||
}
|
||||
expect(log.mock.calls[0][1]).not.toHaveProperty('request');
|
||||
expect(log.mock.calls[0][1]).not.toHaveProperty('stack');
|
||||
expect(error.message).toBe('Failed https://example.org/path-secret');
|
||||
expect(error.request.self).toBe(error);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { redactSensitiveData, REDACTED_VALUE } from '@iptvnator/shared/logging';
|
||||
|
||||
/** Shared redaction produces a bounded, acyclic plain-data tree. XMLTV feeds
|
||||
* can also put opaque secrets in arbitrary URL paths/query keys, so omit URLs
|
||||
* entirely from diagnostics (including redirects and malformed error URLs).
|
||||
* IPC progress and rejected errors keep their original values for callers.
|
||||
*/
|
||||
function omitUrls(value: unknown): unknown {
|
||||
if (typeof value === 'string') {
|
||||
return value.replace(
|
||||
/[a-z][a-z0-9+.-]*:\/\/[^\s"'<>]+/giu,
|
||||
REDACTED_VALUE
|
||||
);
|
||||
}
|
||||
if (Array.isArray(value)) return value.map(omitUrls);
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, entry]) => [
|
||||
omitUrls(key),
|
||||
omitUrls(entry),
|
||||
])
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// Axios/Node transport objects also contain relative request paths and raw
|
||||
// HTTP headers without a URL scheme. Never forward them or arbitrary error
|
||||
// fields to the logger, even after generic redaction.
|
||||
function summarizeError(value: unknown): unknown {
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.filter(([key]) =>
|
||||
[
|
||||
'name',
|
||||
'message',
|
||||
'code',
|
||||
'status',
|
||||
'statusCode',
|
||||
'cause',
|
||||
].includes(key)
|
||||
)
|
||||
.map(([key, entry]) => [key, summarizeError(entry)])
|
||||
);
|
||||
}
|
||||
|
||||
export const epgLogger = {
|
||||
log(...args: unknown[]): void {
|
||||
console.log(
|
||||
...args.map((value) => omitUrls(redactSensitiveData(value)))
|
||||
);
|
||||
},
|
||||
error(...args: unknown[]): void {
|
||||
console.error(
|
||||
...args.map((value) =>
|
||||
omitUrls(summarizeError(redactSensitiveData(value)))
|
||||
)
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { epgLogger } from '../util/epg-logger';
|
||||
import type BetterSqlite3 from 'better-sqlite3';
|
||||
import {
|
||||
ELECTRON_BRIDGE_SECURITY_ERROR_CODES,
|
||||
@@ -102,7 +103,7 @@ async function fetchAndParseEpgStreaming(
|
||||
url: string,
|
||||
options: ElectronBridgeTrustOptions = {}
|
||||
): Promise<void> {
|
||||
console.log(loggerLabel, `Fetching EPG from ${url}`);
|
||||
epgLogger.log(loggerLabel, 'Fetching EPG source');
|
||||
|
||||
// Create database connection in worker
|
||||
const epgDb = new EpgDatabase(Database);
|
||||
@@ -141,18 +142,15 @@ async function fetchAndParseEpgStreaming(
|
||||
const contentEncoding = getEpgResponseContentEncoding(response.headers);
|
||||
|
||||
if (responseUrl && responseUrl !== url) {
|
||||
console.log(
|
||||
loggerLabel,
|
||||
`Resolved EPG redirect: ${url} -> ${responseUrl}`
|
||||
);
|
||||
epgLogger.log(loggerLabel, 'Resolved EPG redirect');
|
||||
}
|
||||
|
||||
console.log(
|
||||
epgLogger.log(
|
||||
loggerLabel,
|
||||
`EPG response detected as gzipped: ${isGzipped}`
|
||||
);
|
||||
if (contentEncoding) {
|
||||
console.log(
|
||||
epgLogger.log(
|
||||
loggerLabel,
|
||||
`EPG response content-encoding: ${contentEncoding}`
|
||||
);
|
||||
@@ -200,7 +198,7 @@ async function fetchAndParseEpgStreaming(
|
||||
try {
|
||||
parser.write(chunk.toString('utf-8'));
|
||||
} catch (err) {
|
||||
console.error(loggerLabel, 'Parse error:', err);
|
||||
epgLogger.error(loggerLabel, 'Parse error:', err);
|
||||
epgDb.close();
|
||||
reject(err);
|
||||
}
|
||||
@@ -209,7 +207,7 @@ async function fetchAndParseEpgStreaming(
|
||||
dataStream.on('end', () => {
|
||||
try {
|
||||
const stats = parser.finish();
|
||||
console.log(
|
||||
epgLogger.log(
|
||||
loggerLabel,
|
||||
`Parsing complete: ${stats.totalChannels} channels, ${stats.totalPrograms} programs`
|
||||
);
|
||||
@@ -223,7 +221,7 @@ async function fetchAndParseEpgStreaming(
|
||||
// real problem (unreachable feed, SAX parse failure, etc.).
|
||||
if (stats.totalChannels === 0) {
|
||||
const errorMessage = `EPG parse produced 0 channels — feed may be unreachable or unsupported`;
|
||||
console.error(loggerLabel, `${errorMessage}: ${url}`);
|
||||
epgLogger.error(loggerLabel, errorMessage);
|
||||
const response: WorkerResponse = {
|
||||
type: 'EPG_ERROR',
|
||||
url,
|
||||
@@ -251,7 +249,7 @@ async function fetchAndParseEpgStreaming(
|
||||
});
|
||||
|
||||
dataStream.on('error', (err) => {
|
||||
console.error(loggerLabel, 'Stream error:', err);
|
||||
epgLogger.error(loggerLabel, 'Stream error:', err);
|
||||
epgDb.close();
|
||||
reject(err);
|
||||
});
|
||||
@@ -315,16 +313,16 @@ function clearAllEpgData(): void {
|
||||
const clearOperation = new EpgDatabaseClearOperation(Database);
|
||||
|
||||
try {
|
||||
console.log(loggerLabel, 'Clearing all EPG data...');
|
||||
epgLogger.log(loggerLabel, 'Clearing all EPG data...');
|
||||
|
||||
clearOperation.run();
|
||||
|
||||
console.log(loggerLabel, 'All EPG data cleared');
|
||||
epgLogger.log(loggerLabel, 'All EPG data cleared');
|
||||
|
||||
const response: WorkerResponse = { type: 'CLEAR_COMPLETE' };
|
||||
parentPort?.postMessage(response);
|
||||
} catch (error) {
|
||||
console.error(loggerLabel, 'Error clearing EPG data:', error);
|
||||
epgLogger.error(loggerLabel, 'Error clearing EPG data:', error);
|
||||
const errorResponse: WorkerResponse = {
|
||||
type: 'EPG_ERROR',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
@@ -339,19 +337,16 @@ function clearEpgDataForSource(sourceUrl: string): void {
|
||||
const clearOperation = new EpgDatabaseSourceClearOperation(Database);
|
||||
|
||||
try {
|
||||
console.log(
|
||||
loggerLabel,
|
||||
`Clearing EPG data for source ${sourceUrl}...`
|
||||
);
|
||||
epgLogger.log(loggerLabel, 'Clearing EPG data for source...');
|
||||
|
||||
clearOperation.run(sourceUrl);
|
||||
|
||||
console.log(loggerLabel, `EPG data cleared for source ${sourceUrl}`);
|
||||
epgLogger.log(loggerLabel, 'EPG data cleared for source');
|
||||
|
||||
const response: WorkerResponse = { type: 'CLEAR_COMPLETE' };
|
||||
parentPort?.postMessage(response);
|
||||
} catch (error) {
|
||||
console.error(loggerLabel, 'Error clearing EPG source data:', error);
|
||||
epgLogger.error(loggerLabel, 'Error clearing EPG source data:', error);
|
||||
const errorResponse: WorkerResponse = {
|
||||
type: 'EPG_ERROR',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
@@ -379,7 +374,7 @@ if (parentPort) {
|
||||
clearEpgDataForSource(message.sourceUrl ?? '');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(loggerLabel, 'Worker error:', error);
|
||||
epgLogger.error(loggerLabel, 'Worker error:', error);
|
||||
const typedError = error as {
|
||||
code?: ElectronBridgeSecurityErrorCode;
|
||||
host?: string;
|
||||
@@ -398,5 +393,5 @@ if (parentPort) {
|
||||
// Notify parent that worker is ready
|
||||
parentPort.postMessage({ type: 'READY' });
|
||||
} else {
|
||||
console.error(loggerLabel, 'parentPort is not available!');
|
||||
epgLogger.error(loggerLabel, 'parentPort is not available!');
|
||||
}
|
||||
@@ -2,7 +2,10 @@ import { DestroyRef, inject, Injectable } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { FormArray, FormBuilder } from '@angular/forms';
|
||||
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import {
|
||||
EpgSourceReconciliationError,
|
||||
RuntimeCapabilitiesService,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
CoverSize,
|
||||
EpgViewMode,
|
||||
@@ -162,21 +165,28 @@ export class SettingsFormFacade {
|
||||
this.settingsStore.getSettings()
|
||||
);
|
||||
|
||||
await this.settingsStore.updateSettings(settings, {
|
||||
retryEpgCleanup: this.epgUrl?.dirty ?? false,
|
||||
});
|
||||
onSaved();
|
||||
|
||||
if (!window.electron) {
|
||||
return;
|
||||
let cleanupError: EpgSourceReconciliationError | undefined;
|
||||
try {
|
||||
await this.settingsStore.updateSettings(settings, {
|
||||
retryEpgCleanup: this.epgUrl?.dirty ?? false,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof EpgSourceReconciliationError)) throw error;
|
||||
// This error follows a successful settings write. Mirror the
|
||||
// committed values, but retain the dirty form for cleanup retry.
|
||||
cleanupError = error;
|
||||
}
|
||||
if (!cleanupError) onSaved();
|
||||
|
||||
window.electron.updateSettings(settings);
|
||||
if (window.electron) {
|
||||
window.electron.updateSettings(settings);
|
||||
|
||||
if (this.runtime.supportsExternalPlayerPathSettings) {
|
||||
window.electron.setMpvPlayerPath(settings.mpvPlayerPath);
|
||||
window.electron.setVlcPlayerPath(settings.vlcPlayerPath);
|
||||
if (this.runtime.supportsExternalPlayerPathSettings) {
|
||||
window.electron.setMpvPlayerPath(settings.mpvPlayerPath);
|
||||
window.electron.setVlcPlayerPath(settings.vlcPlayerPath);
|
||||
}
|
||||
}
|
||||
if (cleanupError) throw cleanupError;
|
||||
}
|
||||
|
||||
/** Applies the saved language/theme and resets the dirty state */
|
||||
|
||||
@@ -87,6 +87,9 @@ describe('SettingsComponent storage failures', () => {
|
||||
// write must not mark the form pristine — that is what keeps the
|
||||
// retry path on screen.
|
||||
expect(component.settingsForm.dirty).toBe(true);
|
||||
expect(window.electron.updateSettings).not.toHaveBeenCalled();
|
||||
expect(window.electron.setMpvPlayerPath).not.toHaveBeenCalled();
|
||||
expect(window.electron.setVlcPlayerPath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks the form pristine only after the settings write succeeded', async () => {
|
||||
@@ -109,12 +112,31 @@ describe('SettingsComponent storage failures', () => {
|
||||
jest.spyOn(component.epg, 'fetchConfiguredEpg').mockImplementation();
|
||||
component.form.setEpgUrls(['removed-source']);
|
||||
component.form.removeEpgSource(0);
|
||||
component.settingsForm.patchValue({
|
||||
remoteControl: true,
|
||||
mpvPlayerPath: '/saved/mpv',
|
||||
vlcPlayerPath: '/saved/vlc',
|
||||
});
|
||||
settingsStore.updateSettings
|
||||
.mockRejectedValueOnce(new EpgSourceReconciliationError())
|
||||
.mockResolvedValue(undefined);
|
||||
component.onSubmit();
|
||||
await fixture.whenStable();
|
||||
expect(component.form.epgUrl.dirty).toBe(true);
|
||||
expect(window.electron.updateSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteControl: true,
|
||||
mpvPlayerPath: '/saved/mpv',
|
||||
vlcPlayerPath: '/saved/vlc',
|
||||
epgUrl: [],
|
||||
})
|
||||
);
|
||||
expect(window.electron.setMpvPlayerPath).toHaveBeenCalledWith(
|
||||
'/saved/mpv'
|
||||
);
|
||||
expect(window.electron.setVlcPlayerPath).toHaveBeenCalledWith(
|
||||
'/saved/vlc'
|
||||
);
|
||||
expect(settingsStore.updateSettings).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ epgUrl: [] }),
|
||||
{ retryEpgCleanup: true }
|
||||
|
||||
@@ -1319,7 +1319,9 @@ an input is a draft edit; only a successful IndexedDB write authorizes source
|
||||
reconciliation. A storage write failure restores the previous in-memory EPG
|
||||
URLs. If subsequent cache cleanup fails, the saved URLs remain authoritative,
|
||||
the settings form remains retryable and shows the existing EPG cleanup failure
|
||||
message rather than claiming settings storage failed. Ordinary settings saves
|
||||
message rather than claiming settings storage failed. Electron settings and
|
||||
external-player paths still mirror the committed values after a cleanup failure;
|
||||
a failed storage write never mirrors them. Ordinary settings saves
|
||||
compare normalized source sets and skip reconciliation when they are unchanged,
|
||||
so unrelated preferences do not depend on cache cleanup. An explicitly edited
|
||||
EPG array requests reconciliation even when the committed URLs already match
|
||||
@@ -1341,6 +1343,13 @@ retires their generations before waiting for workers to exit, then uses the
|
||||
shutdown; `EpgWorkerService` owns source generations and serialization, while
|
||||
`runEpgFetch` owns each import’s message/timeout/exit lifecycle. Successfully cleared request candidates are forgotten
|
||||
without resetting their generation fences; failed cleanups remain retryable.
|
||||
Main-process and parser-worker diagnostics use `epgLogger`, applying shared
|
||||
secret redaction and omitting complete URLs from error messages, nested request
|
||||
data and redirect details. Error logs allowlist name/message/code/status/cause
|
||||
and discard transport objects (which can contain relative request paths and raw
|
||||
HTTP headers). XMLTV providers can put credentials in arbitrary path
|
||||
segments or query keys; progress IPC and caller errors retain their original
|
||||
values, while logs keep operation labels, counts and error codes.
|
||||
Same-URL clears are serialized and replacement imports await the outstanding
|
||||
clear, so an older cleanup cannot erase a newly re-added source. Source cleanup
|
||||
also awaits the in-flight fetch promise when an error/timeout has already removed
|
||||
|
||||
Reference in new issue
Block a user