mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14:16:16 -08:00
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:
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)))
|
||||
)
|
||||
);
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user