fix(epg): remove cached XMLTV data after source deletion (#1548)

* fix(epg): remove cached XMLTV data after source deletion

* fix(epg): close source reconciliation review races

* fix(epg): serialize cleanup with replacement imports

* fix(epg): report retired worker exits as cancellations

* fix(epg): preserve source metadata through cache cleanup

* refactor(epg): separate worker runtime and import lifecycle

* fix(epg): skip cleanup for unchanged source settings

* fix(epg): cancel retired error rows and pending retries

* fix(epg): redact diagnostics and mirror committed settings after cleanup errors

* fix(epg): preserve metadata writer order independently of timestamps
This commit is contained in:
4gray authored and GitHub committed 2026-09-06 07:32:30 +02:00
1 parent baba0529ef
commit 9de480826c
55 files changed
+3538 -615

No files matched your search

@@ -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)))
)
);
},
};