mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
fix(portals): resolve cancelled and 401/403 portal requests instead of rejecting through IPC (#1861)
* fix(portals): resolve cancelled and 401/403 portal requests instead of rejecting through IPC
Electron logs every rejected ipcMain.handle promise as
"Error occurred in handler for '<channel>'" with a stack trace, and
ipcRenderer.invoke keeps nothing of the rejection but its message. A
request the renderer cancelled and an HTTP 401/403 are routine outcomes,
not errors, so STALKER_REQUEST and XTREAM_REQUEST now resolve them as a
structured { portalRequestFailure } envelope. ElectronService, the only
reader of the raw bridge result, rethrows it as an AbortError or as an
"HTTP Error <code>" error that carries the numeric status, so a
cancellation is never read as an empty answer.
- cancelled requests: silent in the main process unless
IPTVNATOR_TRACE_IPC is on; the renderer logs at debug level, no snackbar
- 401/403: one credential-free console.warn (host and pathname only)
- 404, 5xx, network errors and the guard fast-fail keep rejecting with
their existing message contracts and error log
Regression coverage in both handler specs, the shared contract and
classifier specs, the renderer data-service spec, and an Electron E2E
that reads the real main-process output.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(portals): type the Xtream bridge result as a union with the failure envelope
A resolved cancellation or 401/403 carries neither `payload` nor
`action`, so `xtreamRequest` now promises
`ElectronBridgeXtreamResponse | PortalRequestFailureEnvelope` and
`ElectronService` narrows it with `isPortalRequestFailureEnvelope`
before reading success fields. Review finding from the local Greptile
pass.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(portals): reject malformed envelopes in the guard; harden the logging E2E
Greptile findings on the pushed head:
- `isPortalRequestFailureEnvelope` vouched for an `http` failure whose
`statusText` was not a string, so a malformed envelope would have
reached `statusText.trim()` as a TypeError; the reader and the guard
now reject it, with regression cases.
- The logging E2E inherited `IPTVNATOR_TRACE_IPC`/`IPTVNATOR_TRACE_STARTUP`
from a developer shell, which makes the handlers log cancellations on
purpose and fail the silence assertion; both flags are omitted at launch.
- The refusing portal's listener is now closed in an outer `finally`, so
a failed Electron launch or close no longer leaks it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
50d45bc7c0
commit
0060330b5e
20 files changed
+1698
-91
No files matched your search
@@ -0,0 +1,10 @@
|
||||
---
|
||||
type: fix
|
||||
area: portals
|
||||
---
|
||||
|
||||
Cancelling a portal request or hitting an expired login no longer floods the
|
||||
desktop log with stack traces: a cancelled Stalker or Xtream request stays
|
||||
silent, an HTTP 401/403 is a single warning, and the message shown for a
|
||||
refused request now names the real status instead of a generic connection
|
||||
failure.
|
||||
@@ -0,0 +1,166 @@
|
||||
import { createServer } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import {
|
||||
closeElectronApp,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
test,
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
/**
|
||||
* Routine portal outcomes must not reach the main-process log as errors.
|
||||
*
|
||||
* Electron prints `Error occurred in handler for '<channel>'` — with a stack
|
||||
* trace — for every `ipcMain.handle` promise that rejects. A request the
|
||||
* renderer cancelled (navigation, a superseded or expired probe) and an HTTP
|
||||
* 401/403 (the portal answered and refused) are expected, so both portal
|
||||
* handlers resolve them as a `portalRequestFailure` envelope instead: the
|
||||
* renderer's data service rethrows it (`portal-request-failure.util.ts`), the
|
||||
* refusal is one credential-free warning, and the cancellation is silent.
|
||||
*/
|
||||
|
||||
const MAC_ADDRESS = '00:1A:79:00:00:31';
|
||||
const USERNAME = 'probe-user-31';
|
||||
const PASSWORD = 'probe-secret-31';
|
||||
const HANDLER_FAILURE = 'Error occurred in handler for';
|
||||
/** Electron colours its console output; the assertions read plain text. */
|
||||
const ANSI_SEQUENCE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g');
|
||||
|
||||
type Probe = { requestId: string; deadlineAt: number };
|
||||
|
||||
/** A portal that answers every request with 401, like an HTTP auth gate. */
|
||||
async function startRefusingPortal(): Promise<{
|
||||
origin: string;
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
const server = createServer((_request, response) => {
|
||||
response.writeHead(401, {
|
||||
'content-type': 'text/plain',
|
||||
'www-authenticate': 'Basic realm="portal"',
|
||||
});
|
||||
response.end('Unauthorized');
|
||||
});
|
||||
await new Promise<void>((resolve) =>
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
);
|
||||
const { port } = server.address() as AddressInfo;
|
||||
return {
|
||||
origin: `http://127.0.0.1:${port}`,
|
||||
close: () =>
|
||||
new Promise<void>((resolve, reject) =>
|
||||
server.close((error) => (error ? reject(error) : resolve()))
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
test('@stalker @xtream @electron resolves cancelled and refused portal requests without logging handler errors', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const portal = await startRefusingPortal();
|
||||
try {
|
||||
await runAgainstRefusingPortal(dataDir, portal.origin);
|
||||
} finally {
|
||||
await portal.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function runAgainstRefusingPortal(
|
||||
dataDir: string,
|
||||
origin: string
|
||||
): Promise<void> {
|
||||
// A developer shell with the IPC trace on would make the handlers log
|
||||
// the cancellations on purpose; this test asserts the default silence.
|
||||
const app = await launchElectronApp(dataDir, {
|
||||
omitEnvKeys: ['IPTVNATOR_TRACE_IPC', 'IPTVNATOR_TRACE_STARTUP'],
|
||||
});
|
||||
let diagnostics = '';
|
||||
const capture = (chunk: Buffer) => {
|
||||
diagnostics += chunk.toString().replace(ANSI_SEQUENCE, '');
|
||||
};
|
||||
const electronProcess = app.electronApp.process();
|
||||
electronProcess.stdout?.on('data', capture);
|
||||
electronProcess.stderr?.on('data', capture);
|
||||
try {
|
||||
const outcomes = await app.mainWindow.evaluate(
|
||||
async ({ origin, macAddress, username, password }) => {
|
||||
// A probe whose deadline already passed is aborted before the
|
||||
// request leaves the main process: a deterministic cancel.
|
||||
const expired = (requestId: string): Probe => ({
|
||||
requestId,
|
||||
deadlineAt: Date.now(),
|
||||
});
|
||||
const stalker = (probe?: Probe) =>
|
||||
window.electron.stalkerRequest({
|
||||
url: `${origin}/portal.php`,
|
||||
macAddress,
|
||||
params: { type: 'stb', action: 'handshake' },
|
||||
...(probe ? { probe } : {}),
|
||||
});
|
||||
const xtream = (probe?: Probe) =>
|
||||
window.electron.xtreamRequest({
|
||||
url: origin,
|
||||
params: {
|
||||
username,
|
||||
password,
|
||||
action: 'get_account_info',
|
||||
},
|
||||
...(probe ? { probe } : {}),
|
||||
});
|
||||
return {
|
||||
stalkerCancelled: await stalker(expired('stalker-cancel')),
|
||||
xtreamCancelled: await xtream(expired('xtream-cancel')),
|
||||
stalkerRefused: await stalker(),
|
||||
xtreamRefused: await xtream(),
|
||||
};
|
||||
},
|
||||
{
|
||||
origin,
|
||||
macAddress: MAC_ADDRESS,
|
||||
username: USERNAME,
|
||||
password: PASSWORD,
|
||||
}
|
||||
);
|
||||
|
||||
// Resolved, structured, and never shaped like an answer.
|
||||
const cancelled = { portalRequestFailure: { kind: 'cancelled' } };
|
||||
const refused = {
|
||||
portalRequestFailure: {
|
||||
kind: 'http',
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
},
|
||||
};
|
||||
expect(outcomes.stalkerCancelled).toEqual(cancelled);
|
||||
expect(outcomes.xtreamCancelled).toEqual(cancelled);
|
||||
expect(outcomes.stalkerRefused).toEqual(refused);
|
||||
expect(outcomes.xtreamRefused).toEqual(refused);
|
||||
|
||||
// One warning per refusal; the cancellations stay silent; Electron
|
||||
// never saw a rejected handler.
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
diagnostics.match(/\[(?:STALKER|XTREAM)_REQUEST\] Refused/g)
|
||||
?.length ?? 0
|
||||
)
|
||||
.toBe(2);
|
||||
expect(diagnostics).toContain('[STALKER_REQUEST] Refused');
|
||||
expect(diagnostics).toContain('[XTREAM_REQUEST] Refused');
|
||||
expect(diagnostics).not.toContain(HANDLER_FAILURE);
|
||||
expect(diagnostics).not.toMatch(
|
||||
/\[(?:STALKER|XTREAM)_REQUEST\] (?:Failed|cancelled)/
|
||||
);
|
||||
|
||||
// Host and pathname only: no MAC, no credentials, no query string.
|
||||
expect(diagnostics).toContain("pathname: '/portal.php'");
|
||||
expect(diagnostics).toContain("pathname: '/player_api.php'");
|
||||
expect(diagnostics).not.toContain(MAC_ADDRESS);
|
||||
expect(diagnostics).not.toContain(USERNAME);
|
||||
expect(diagnostics).not.toContain(PASSWORD);
|
||||
expect(diagnostics).not.toMatch(/portal\.php\?|player_api\.php\?/);
|
||||
} finally {
|
||||
electronProcess.stdout?.off('data', capture);
|
||||
electronProcess.stderr?.off('data', capture);
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import {
|
||||
classifyExpectedPortalFailure,
|
||||
isCancelledPortalRequest,
|
||||
logPortalRequestFailure,
|
||||
} from './portal-request-outcome';
|
||||
|
||||
const REQUEST_URL =
|
||||
'http://portal.example.com:8080/stalker_portal/server/load.php?type=stb&action=handshake&mac=00%3A1A%3A79%3AAA%3ABB%3ACC&token=secret-token';
|
||||
|
||||
describe('classifyExpectedPortalFailure', () => {
|
||||
it.each([
|
||||
[
|
||||
'an axios CanceledError',
|
||||
Object.assign(new Error('canceled'), {
|
||||
code: 'ERR_CANCELED',
|
||||
name: 'CanceledError',
|
||||
}),
|
||||
],
|
||||
[
|
||||
'a bare ERR_CANCELED code',
|
||||
Object.assign(new Error('cancelled'), {
|
||||
code: 'ERR_CANCELED',
|
||||
}),
|
||||
],
|
||||
[
|
||||
'an AbortError raised before axios',
|
||||
Object.assign(new Error('aborted'), { name: 'AbortError' }),
|
||||
],
|
||||
])('reads %s as a cancellation', (_label, error) => {
|
||||
expect(isCancelledPortalRequest(error)).toBe(true);
|
||||
expect(classifyExpectedPortalFailure(error)).toEqual({
|
||||
kind: 'cancelled',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([401, 403])(
|
||||
'reads a thrown HTTP %s as a refusal, keeping the status text',
|
||||
(status) => {
|
||||
const error = Object.assign(
|
||||
new Error(`HTTP Error ${status}: Refused`),
|
||||
{ status, statusText: 'Refused' }
|
||||
);
|
||||
|
||||
expect(classifyExpectedPortalFailure(error)).toEqual({
|
||||
kind: 'http',
|
||||
status,
|
||||
statusText: 'Refused',
|
||||
});
|
||||
expect(
|
||||
classifyExpectedPortalFailure({ message: 'refused', status })
|
||||
).toEqual({ kind: 'http', status });
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
[
|
||||
'a 404',
|
||||
Object.assign(new Error('HTTP Error 404: Not Found'), {
|
||||
status: 404,
|
||||
}),
|
||||
],
|
||||
[
|
||||
'a 5xx axios error',
|
||||
Object.assign(new Error('Bad Gateway'), {
|
||||
code: 'ERR_BAD_RESPONSE',
|
||||
response: { status: 502, statusText: 'Bad Gateway' },
|
||||
}),
|
||||
],
|
||||
[
|
||||
'a refused connection',
|
||||
Object.assign(new Error('ECONNREFUSED'), {
|
||||
code: 'ECONNREFUSED',
|
||||
}),
|
||||
],
|
||||
[
|
||||
'an axios timeout',
|
||||
Object.assign(new Error('timeout exceeded'), {
|
||||
code: 'ECONNABORTED',
|
||||
}),
|
||||
],
|
||||
['a parse error', new SyntaxError('Unexpected token')],
|
||||
['a non-object', 'boom'],
|
||||
])('keeps %s a real failure', (_label, error) => {
|
||||
expect(classifyExpectedPortalFailure(error)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not read a 401 inside a cancelled request as a refusal', () => {
|
||||
const error = Object.assign(new Error('canceled'), {
|
||||
code: 'ERR_CANCELED',
|
||||
response: { status: 401 },
|
||||
});
|
||||
|
||||
expect(classifyExpectedPortalFailure(error)).toEqual({
|
||||
kind: 'cancelled',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('logPortalRequestFailure', () => {
|
||||
const originalTraceIpc = process.env['IPTVNATOR_TRACE_IPC'];
|
||||
let logSpy: jest.SpyInstance;
|
||||
let warnSpy: jest.SpyInstance;
|
||||
let errorSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env['IPTVNATOR_TRACE_IPC'];
|
||||
logSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||
warnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||
errorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
logSpy.mockRestore();
|
||||
warnSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
if (originalTraceIpc === undefined) {
|
||||
delete process.env['IPTVNATOR_TRACE_IPC'];
|
||||
} else {
|
||||
process.env['IPTVNATOR_TRACE_IPC'] = originalTraceIpc;
|
||||
}
|
||||
});
|
||||
|
||||
const allOutput = () =>
|
||||
JSON.stringify([
|
||||
...logSpy.mock.calls,
|
||||
...warnSpy.mock.calls,
|
||||
...errorSpy.mock.calls,
|
||||
]);
|
||||
|
||||
it('is silent about a cancellation unless the IPC trace is on', () => {
|
||||
const cancelled = Object.assign(new Error('canceled'), {
|
||||
code: 'ERR_CANCELED',
|
||||
});
|
||||
|
||||
logPortalRequestFailure(
|
||||
'STALKER_REQUEST',
|
||||
{ kind: 'cancelled' },
|
||||
cancelled,
|
||||
REQUEST_URL,
|
||||
'handshake'
|
||||
);
|
||||
expect(allOutput()).toBe('[]');
|
||||
|
||||
process.env['IPTVNATOR_TRACE_IPC'] = '1';
|
||||
logPortalRequestFailure(
|
||||
'STALKER_REQUEST',
|
||||
{ kind: 'cancelled' },
|
||||
cancelled,
|
||||
REQUEST_URL,
|
||||
'handshake'
|
||||
);
|
||||
expect(logSpy).toHaveBeenCalledTimes(1);
|
||||
expect(logSpy.mock.calls[0][0]).toContain('[STALKER_REQUEST]');
|
||||
expect(logSpy.mock.calls[0][0]).toContain('cancelled');
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns once about an HTTP refusal, with host and pathname only', () => {
|
||||
logPortalRequestFailure(
|
||||
'XTREAM_REQUEST',
|
||||
{ kind: 'http', status: 401, statusText: 'Unauthorized' },
|
||||
Object.assign(new Error('HTTP Error 401: Unauthorized'), {
|
||||
status: 401,
|
||||
}),
|
||||
REQUEST_URL,
|
||||
'handshake'
|
||||
);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(warnSpy.mock.calls[0][0]).toBe('[XTREAM_REQUEST] Refused');
|
||||
expect(warnSpy.mock.calls[0][1]).toMatchObject({
|
||||
action: 'handshake',
|
||||
host: 'portal.example.com:8080',
|
||||
pathname: '/stalker_portal/server/load.php',
|
||||
status: 401,
|
||||
});
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a real failure at error level', () => {
|
||||
logPortalRequestFailure(
|
||||
'STALKER_REQUEST',
|
||||
null,
|
||||
Object.assign(new Error('connect ECONNREFUSED'), {
|
||||
code: 'ECONNREFUSED',
|
||||
}),
|
||||
REQUEST_URL,
|
||||
'handshake'
|
||||
);
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
expect(errorSpy.mock.calls[0][0]).toBe('[STALKER_REQUEST] Failed');
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['cancelled', { kind: 'cancelled' } as const],
|
||||
['http', { kind: 'http', status: 403 } as const],
|
||||
['failed', null],
|
||||
])(
|
||||
'never writes the MAC, token or query string (%s)',
|
||||
(_label, expected) => {
|
||||
process.env['IPTVNATOR_TRACE_IPC'] = '1';
|
||||
logPortalRequestFailure(
|
||||
'STALKER_REQUEST',
|
||||
expected,
|
||||
Object.assign(new Error('HTTP Error 403: Forbidden'), {
|
||||
status: 403,
|
||||
config: { url: REQUEST_URL },
|
||||
}),
|
||||
REQUEST_URL,
|
||||
'handshake'
|
||||
);
|
||||
|
||||
const output = allOutput();
|
||||
expect(output).not.toBe('[]');
|
||||
expect(output).not.toContain('00:1A:79');
|
||||
expect(output).not.toContain('00%3A1A');
|
||||
expect(output).not.toContain('secret-token');
|
||||
expect(output).not.toContain('load.php?');
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
isExpectedPortalHttpStatus,
|
||||
PortalRequestFailure,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { redactSensitiveData } from '@iptvnator/shared/logging';
|
||||
import { isRendererApiTraceEnabled, trace } from '../services/debug-trace';
|
||||
import { formatPortalRequestError } from './portal-request-error.util';
|
||||
|
||||
/**
|
||||
* Decides, once for both portal IPC handlers, whether a failed request is a
|
||||
* routine outcome the renderer asked for or can handle (see
|
||||
* `portal-request-failure.util.ts` in `shared/interfaces`) and logs it at
|
||||
* the matching level.
|
||||
*
|
||||
* Routine outcomes are resolved by the handler as a structured envelope, so
|
||||
* Electron does not print `Error occurred in handler for '<channel>'` with a
|
||||
* stack trace for every navigation away from a portal or every expired
|
||||
* credential. Real failures — network errors, 5xx, parse errors, every other
|
||||
* status — keep rejecting and keep their error log.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Whether the transport gave up because the request's abort signal fired.
|
||||
* axios rejects with `CanceledError` (`code: 'ERR_CANCELED'`); the name check
|
||||
* covers an abort raised before axios was reached.
|
||||
*/
|
||||
export function isCancelledPortalRequest(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { code, name } = error as { code?: unknown; name?: unknown };
|
||||
return (
|
||||
code === 'ERR_CANCELED' ||
|
||||
name === 'CanceledError' ||
|
||||
name === 'AbortError'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP status of a failure that is a portal answer. The handlers throw the
|
||||
* `validateStatus`-accepted 4xx as an error carrying `status`; a 5xx arrives
|
||||
* as an axios error with a `response`.
|
||||
*/
|
||||
function readHttpStatus(
|
||||
error: unknown
|
||||
): { status: number; statusText?: string } | null {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const own = error as {
|
||||
status?: unknown;
|
||||
statusText?: unknown;
|
||||
response?: { status?: unknown; statusText?: unknown };
|
||||
};
|
||||
const source = typeof own.status === 'number' ? own : own.response;
|
||||
if (!source || typeof source.status !== 'number') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
status: source.status,
|
||||
statusText:
|
||||
typeof source.statusText === 'string'
|
||||
? source.statusText
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The expected failure a request ended in, or null for a real failure.
|
||||
*/
|
||||
export function classifyExpectedPortalFailure(
|
||||
error: unknown
|
||||
): PortalRequestFailure | null {
|
||||
if (isCancelledPortalRequest(error)) {
|
||||
return { kind: 'cancelled' };
|
||||
}
|
||||
|
||||
const http = readHttpStatus(error);
|
||||
if (http && isExpectedPortalHttpStatus(http.status)) {
|
||||
return http.statusText
|
||||
? { kind: 'http', status: http.status, statusText: http.statusText }
|
||||
: { kind: 'http', status: http.status };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One line per failed request, at the level its outcome deserves.
|
||||
*
|
||||
* - cancelled: nothing, unless `IPTVNATOR_TRACE_IPC` (or the startup trace)
|
||||
* is on — the renderer asked for it;
|
||||
* - HTTP 401/403: one `console.warn`, so an expired or wrong credential stays
|
||||
* visible without a stack trace;
|
||||
* - anything else: `console.error`, as before.
|
||||
*
|
||||
* Every level goes through the compact credential-free shape (host and
|
||||
* pathname only, never the query string) and the shared redactor.
|
||||
*/
|
||||
export function logPortalRequestFailure(
|
||||
channel: string,
|
||||
expected: PortalRequestFailure | null,
|
||||
error: unknown,
|
||||
requestUrl: string,
|
||||
action?: string
|
||||
): void {
|
||||
const details = () =>
|
||||
redactSensitiveData(
|
||||
formatPortalRequestError(error, requestUrl, action)
|
||||
);
|
||||
|
||||
if (expected?.kind === 'cancelled') {
|
||||
if (isRendererApiTraceEnabled()) {
|
||||
trace(channel, 'cancelled', details());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (expected?.kind === 'http') {
|
||||
console.warn(`[${channel}] Refused`, details());
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`[${channel}] Failed`, details());
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
STALKER_REQUEST,
|
||||
buildHostConnectivityFastFailMessage,
|
||||
readPortalRequestFailure,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
const registeredHandlers = new Map<string, (...args: unknown[]) => unknown>();
|
||||
@@ -301,3 +302,195 @@ describe('StalkerEvents host connectivity guard', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('StalkerEvents expected outcomes', () => {
|
||||
const TRACE_IPC_ENV = 'IPTVNATOR_TRACE_IPC';
|
||||
const originalTraceIpc = process.env[TRACE_IPC_ENV];
|
||||
let consoleErrorSpy: jest.SpyInstance;
|
||||
let consoleWarnSpy: jest.SpyInstance;
|
||||
let consoleLogSpy: jest.SpyInstance;
|
||||
let requestHandler: (...args: unknown[]) => unknown;
|
||||
|
||||
/** axios' rejection once the request's abort signal fired. */
|
||||
const cancelled = () =>
|
||||
Object.assign(new Error('canceled'), {
|
||||
code: 'ERR_CANCELED',
|
||||
name: 'CanceledError',
|
||||
});
|
||||
|
||||
const request = (overrides: Record<string, unknown> = {}) =>
|
||||
requestHandler(
|
||||
{ sender: { id: 7 } },
|
||||
{
|
||||
url: PORTAL_URL,
|
||||
macAddress: MAC_ADDRESS,
|
||||
token: 'bearer-secret',
|
||||
params: {
|
||||
type: 'stb',
|
||||
action: 'get_profile',
|
||||
JsHttpRequest: '1-xml',
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
) as Promise<unknown>;
|
||||
|
||||
const allOutput = () =>
|
||||
JSON.stringify([
|
||||
...consoleLogSpy.mock.calls,
|
||||
...consoleWarnSpy.mock.calls,
|
||||
...consoleErrorSpy.mock.calls,
|
||||
]);
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.resetModules();
|
||||
delete process.env[TRACE_IPC_ENV];
|
||||
registeredHandlers.clear();
|
||||
axiosMock.mockReset();
|
||||
axiosMock.isAxiosError.mockReset();
|
||||
axiosMock.isAxiosError.mockImplementation(
|
||||
(value: unknown) =>
|
||||
!!value && typeof value === 'object' && 'code' in value
|
||||
);
|
||||
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||
|
||||
await import('./stalker.events');
|
||||
requestHandler = registeredHandlers.get(STALKER_REQUEST) as (
|
||||
...args: unknown[]
|
||||
) => unknown;
|
||||
expect(requestHandler).toBeDefined();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
consoleWarnSpy.mockRestore();
|
||||
consoleLogSpy.mockRestore();
|
||||
if (originalTraceIpc === undefined) {
|
||||
delete process.env[TRACE_IPC_ENV];
|
||||
} else {
|
||||
process.env[TRACE_IPC_ENV] = originalTraceIpc;
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves a cancelled request as a structured failure, not a rejection and not an answer', async () => {
|
||||
axiosMock.mockRejectedValue(cancelled());
|
||||
|
||||
const result = await request({
|
||||
probe: { requestId: 'health', deadlineAt: Date.now() + 5000 },
|
||||
});
|
||||
|
||||
expect(result).toEqual({ portalRequestFailure: { kind: 'cancelled' } });
|
||||
expect(readPortalRequestFailure(result)).toEqual({ kind: 'cancelled' });
|
||||
// Nothing a consumer could read as portal data.
|
||||
expect(result).not.toHaveProperty('js');
|
||||
// Silent: the renderer asked for it.
|
||||
expect(allOutput()).toBe('[]');
|
||||
});
|
||||
|
||||
it('traces a cancellation only under the IPC trace flag, without the MAC or query', async () => {
|
||||
process.env[TRACE_IPC_ENV] = '1';
|
||||
axiosMock.mockRejectedValue(cancelled());
|
||||
|
||||
await request();
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledTimes(1);
|
||||
const line = String(consoleLogSpy.mock.calls[0][0]);
|
||||
expect(line).toContain('[STALKER_REQUEST] cancelled');
|
||||
expect(line).toContain('dead-portal.example.com:8080');
|
||||
expect(line).not.toContain('00:1A:79');
|
||||
expect(line).not.toContain('bearer-secret');
|
||||
expect(line).not.toContain('?');
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[401, 'Unauthorized'],
|
||||
[403, 'Forbidden'],
|
||||
])(
|
||||
'resolves HTTP %s as a structured failure with one credential-free warning',
|
||||
async (status, statusText) => {
|
||||
axiosMock.mockResolvedValue({
|
||||
status,
|
||||
statusText,
|
||||
data: '',
|
||||
headers: {},
|
||||
});
|
||||
|
||||
await expect(request()).resolves.toEqual({
|
||||
portalRequestFailure: { kind: 'http', status, statusText },
|
||||
});
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(consoleWarnSpy.mock.calls[0][0]).toBe(
|
||||
'[STALKER_REQUEST] Refused'
|
||||
);
|
||||
expect(consoleWarnSpy.mock.calls[0][1]).toMatchObject({
|
||||
action: 'get_profile',
|
||||
host: 'dead-portal.example.com:8080',
|
||||
pathname: '/portal.php',
|
||||
status,
|
||||
});
|
||||
const output = allOutput();
|
||||
expect(output).not.toContain('00:1A:79');
|
||||
expect(output).not.toContain('bearer-secret');
|
||||
expect(output).not.toContain('portal.php?');
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('still rejects a 404 with its status and logs it as an error', async () => {
|
||||
// Endpoint discovery reads "probe the next candidate" from this.
|
||||
axiosMock.mockResolvedValue({
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
data: '',
|
||||
headers: {},
|
||||
});
|
||||
|
||||
await expect(request()).rejects.toThrow('HTTP Error 404: Not Found');
|
||||
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
|
||||
expect(consoleErrorSpy.mock.calls[0][0]).toBe(
|
||||
'[STALKER_REQUEST] Failed'
|
||||
);
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'a 5xx',
|
||||
Object.assign(new Error('Request failed with status code 502'), {
|
||||
code: 'ERR_BAD_RESPONSE',
|
||||
response: { status: 502, statusText: 'Bad Gateway' },
|
||||
}),
|
||||
'HTTP Error 502',
|
||||
],
|
||||
[
|
||||
'a connection failure',
|
||||
Object.assign(new Error('connect ECONNREFUSED 10.0.0.1:8080'), {
|
||||
code: 'ECONNREFUSED',
|
||||
}),
|
||||
'ECONNREFUSED',
|
||||
],
|
||||
[
|
||||
'a timeout',
|
||||
Object.assign(new Error('timeout of 15000ms exceeded'), {
|
||||
code: 'ECONNABORTED',
|
||||
}),
|
||||
'timeout of 15000ms exceeded',
|
||||
],
|
||||
])(
|
||||
'still rejects %s and logs it as an error',
|
||||
async (_label, error, message) => {
|
||||
axiosMock.mockRejectedValue(error);
|
||||
|
||||
await expect(request()).rejects.toThrow(message);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
|
||||
expect(consoleErrorSpy.mock.calls[0][0]).toBe(
|
||||
'[STALKER_REQUEST] Failed'
|
||||
);
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -9,16 +9,19 @@ import axios from 'axios';
|
||||
import { ipcMain } from 'electron';
|
||||
import {
|
||||
classifyStalkerAuthFailureBody,
|
||||
createPortalRequestFailureEnvelope,
|
||||
createStalkerAuthFailureMarker,
|
||||
PortalDebugEvent,
|
||||
STALKER_REQUEST,
|
||||
buildStalkerIdentityRequestContext,
|
||||
buildStalkerRequestUrl,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { redactSensitiveData } from '@iptvnator/shared/logging';
|
||||
import { rememberStalkerPlaybackContext } from '../services/stalker-playback-context.service';
|
||||
import { emitPortalDebugEvent } from './portal-debug.events';
|
||||
import { formatPortalRequestError } from './portal-request-error.util';
|
||||
import {
|
||||
classifyExpectedPortalFailure,
|
||||
logPortalRequestFailure,
|
||||
} from './portal-request-outcome';
|
||||
import { assertRemoteUrlAllowed } from './url-safety';
|
||||
import {
|
||||
requestWithValidatedRedirects,
|
||||
@@ -156,8 +159,9 @@ ipcMain.handle(
|
||||
// next candidate) from a network failure (stop probing).
|
||||
const httpError = new Error(
|
||||
`HTTP Error ${response.status}: ${response.statusText}`
|
||||
) as Error & { status: number };
|
||||
) as Error & { status: number; statusText?: string };
|
||||
httpError.status = response.status;
|
||||
httpError.statusText = response.statusText;
|
||||
throw httpError;
|
||||
}
|
||||
|
||||
@@ -240,16 +244,24 @@ ipcMain.handle(
|
||||
connected: socketConnected,
|
||||
});
|
||||
|
||||
console.error(
|
||||
'[STALKER_REQUEST] Failed',
|
||||
redactSensitiveData(
|
||||
formatPortalRequestError(
|
||||
error,
|
||||
requestUrlForLog,
|
||||
String(payload.params?.action ?? 'unknown')
|
||||
)
|
||||
)
|
||||
// A cancelled request and an HTTP 401/403 are routine: resolve
|
||||
// them as a structured envelope so Electron does not log the
|
||||
// handler as failed. The renderer's data service rethrows them as
|
||||
// the errors the Stalker layers classify (an AbortError, or an
|
||||
// `HTTP Error <code>` carrying `status`), so a cancellation is
|
||||
// never read as an empty answer. Everything else keeps the
|
||||
// rejection shapes below and their error log.
|
||||
const expected = classifyExpectedPortalFailure(error);
|
||||
logPortalRequestFailure(
|
||||
STALKER_REQUEST,
|
||||
expected,
|
||||
error,
|
||||
requestUrlForLog,
|
||||
String(payload.params?.action ?? 'unknown')
|
||||
);
|
||||
if (expected) {
|
||||
return createPortalRequestFailureEnvelope(expected);
|
||||
}
|
||||
|
||||
// Format error response
|
||||
if (axios.isAxiosError(error) && error.response) {
|
||||
|
||||
@@ -155,6 +155,7 @@ describe('XtreamEvents failed-response performance phases', () => {
|
||||
).rejects.toEqual({
|
||||
message: 'HTTP Error: Not Found',
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
});
|
||||
expectFailedResponsePhases(events);
|
||||
} finally {
|
||||
|
||||
@@ -222,9 +222,10 @@ describe('XtreamEvents performance phases', () => {
|
||||
await expect(
|
||||
getHandler(XTREAM_CANCEL_SESSION)({}, 'xtream-session-abort')
|
||||
).resolves.toEqual({ cancelled: 1, success: true });
|
||||
await expect(request).rejects.toMatchObject({
|
||||
name: 'AbortError',
|
||||
status: 499,
|
||||
// A cancellation resolves as a structured failure (Electron logs
|
||||
// every rejected handler); the renderer rethrows it as AbortError.
|
||||
await expect(request).resolves.toEqual({
|
||||
portalRequestFailure: { kind: 'cancelled' },
|
||||
});
|
||||
expect(signal?.aborted).toBe(true);
|
||||
expect(
|
||||
|
||||
@@ -178,25 +178,62 @@ describe('XtreamEvents session cancellation', () => {
|
||||
}
|
||||
);
|
||||
|
||||
it.each([500, 502, 503])('preserves rejected Axios HTTP %s in the health-probe error', async (status) => {
|
||||
const error = { message: 'Request failed', response: { status } };
|
||||
axiosMock.mockRejectedValueOnce(error);
|
||||
axiosMock.isAxiosError.mockImplementation((value) => value === error);
|
||||
await expect(registeredHandlers.get('XTREAM_REQUEST')?.(
|
||||
{ sender: { id: 7 } },
|
||||
{ url: 'https://example.com', params: {}, suppressErrorLog: true,
|
||||
probe: { requestId: 'status', deadlineAt: Date.now() + 5000 } }
|
||||
)).rejects.toThrow(`HTTP Error ${status}`);
|
||||
});
|
||||
it.each([500, 502, 503])(
|
||||
'preserves rejected Axios HTTP %s in the health-probe error',
|
||||
async (status) => {
|
||||
const error = { message: 'Request failed', response: { status } };
|
||||
axiosMock.mockRejectedValueOnce(error);
|
||||
axiosMock.isAxiosError.mockImplementation(
|
||||
(value) => value === error
|
||||
);
|
||||
await expect(
|
||||
registeredHandlers.get('XTREAM_REQUEST')?.(
|
||||
{ sender: { id: 7 } },
|
||||
{
|
||||
url: 'https://example.com',
|
||||
params: {},
|
||||
suppressErrorLog: true,
|
||||
probe: {
|
||||
requestId: 'status',
|
||||
deadlineAt: Date.now() + 5000,
|
||||
},
|
||||
}
|
||||
)
|
||||
).rejects.toThrow(`HTTP Error ${status}`);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([401, 403])('preserves HTTP %s in the serialized health-probe error', async (status) => {
|
||||
axiosMock.mockResolvedValueOnce({ status, statusText: 'refused', headers: {}, data: '' });
|
||||
await expect(registeredHandlers.get('XTREAM_REQUEST')?.(
|
||||
{ sender: { id: 7 } },
|
||||
{ url: 'https://example.com', params: {}, suppressErrorLog: true,
|
||||
probe: { requestId: 'status', deadlineAt: Date.now() + 5000 } }
|
||||
)).rejects.toThrow(`HTTP Error ${status}`);
|
||||
});
|
||||
it.each([401, 403])(
|
||||
'resolves a health-probe HTTP %s as a structured failure carrying the status',
|
||||
async (status) => {
|
||||
axiosMock.mockResolvedValueOnce({
|
||||
status,
|
||||
statusText: 'refused',
|
||||
headers: {},
|
||||
data: '',
|
||||
});
|
||||
await expect(
|
||||
registeredHandlers.get('XTREAM_REQUEST')?.(
|
||||
{ sender: { id: 7 } },
|
||||
{
|
||||
url: 'https://example.com',
|
||||
params: {},
|
||||
suppressErrorLog: true,
|
||||
probe: {
|
||||
requestId: 'status',
|
||||
deadlineAt: Date.now() + 5000,
|
||||
},
|
||||
}
|
||||
)
|
||||
).resolves.toEqual({
|
||||
portalRequestFailure: {
|
||||
kind: 'http',
|
||||
status,
|
||||
statusText: 'refused',
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it('returns the provider HTTP error without enabling fallback', async () => {
|
||||
axiosMock.mockResolvedValueOnce({
|
||||
@@ -338,9 +375,11 @@ describe('XtreamEvents session cancellation', () => {
|
||||
|
||||
pendingRequest.reject(cancelError);
|
||||
|
||||
await expect(requestPromise).rejects.toMatchObject({
|
||||
name: 'AbortError',
|
||||
status: 499,
|
||||
// Resolved, not rejected: Electron logs every rejected handler as an
|
||||
// error, and a cancellation is the renderer's own doing. The envelope
|
||||
// is still not an answer — the renderer rethrows it as an AbortError.
|
||||
await expect(requestPromise).resolves.toEqual({
|
||||
portalRequestFailure: { kind: 'cancelled' },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -420,15 +459,222 @@ describe('XtreamEvents session cancellation', () => {
|
||||
firstRequest.reject(cancelError);
|
||||
secondRequest.reject(cancelError);
|
||||
|
||||
await expect(firstPromise).rejects.toMatchObject({
|
||||
name: 'AbortError',
|
||||
await expect(firstPromise).resolves.toEqual({
|
||||
portalRequestFailure: { kind: 'cancelled' },
|
||||
});
|
||||
await expect(secondPromise).rejects.toMatchObject({
|
||||
name: 'AbortError',
|
||||
await expect(secondPromise).resolves.toEqual({
|
||||
portalRequestFailure: { kind: 'cancelled' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('XtreamEvents expected outcomes', () => {
|
||||
const TRACE_IPC_ENV = 'IPTVNATOR_TRACE_IPC';
|
||||
const originalTraceIpc = process.env[TRACE_IPC_ENV];
|
||||
let consoleErrorSpy: jest.SpyInstance;
|
||||
let consoleWarnSpy: jest.SpyInstance;
|
||||
let consoleLogSpy: jest.SpyInstance;
|
||||
let requestHandler: (...args: unknown[]) => unknown;
|
||||
|
||||
/** axios' rejection once the request's abort signal fired. */
|
||||
const cancelled = () =>
|
||||
Object.assign(new Error('canceled'), {
|
||||
code: 'ERR_CANCELED',
|
||||
name: 'CanceledError',
|
||||
});
|
||||
|
||||
const request = (overrides: Record<string, unknown> = {}) =>
|
||||
requestHandler(
|
||||
{ sender: { id: 7 } },
|
||||
{
|
||||
url: 'http://panel.example.com:8080',
|
||||
params: {
|
||||
action: 'get_live_streams',
|
||||
password: 'secret-password',
|
||||
username: 'secret-user',
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
) as Promise<unknown>;
|
||||
|
||||
const allOutput = () =>
|
||||
JSON.stringify([
|
||||
...consoleLogSpy.mock.calls,
|
||||
...consoleWarnSpy.mock.calls,
|
||||
...consoleErrorSpy.mock.calls,
|
||||
]);
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.resetModules();
|
||||
delete process.env[PERF_CAPTURE_ENV];
|
||||
delete process.env[TRACE_IPC_ENV];
|
||||
registeredHandlers.clear();
|
||||
axiosMock.mockReset();
|
||||
axiosMock.isAxiosError.mockReset();
|
||||
axiosMock.isAxiosError.mockImplementation(
|
||||
(value: unknown) =>
|
||||
!!value && typeof value === 'object' && 'code' in value
|
||||
);
|
||||
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||
|
||||
await import('./xtream.events');
|
||||
requestHandler = registeredHandlers.get('XTREAM_REQUEST') as (
|
||||
...args: unknown[]
|
||||
) => unknown;
|
||||
expect(requestHandler).toBeDefined();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
consoleWarnSpy.mockRestore();
|
||||
consoleLogSpy.mockRestore();
|
||||
if (originalTraceIpc === undefined) {
|
||||
delete process.env[TRACE_IPC_ENV];
|
||||
} else {
|
||||
process.env[TRACE_IPC_ENV] = originalTraceIpc;
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves a probe whose deadline already passed as cancelled, silently', async () => {
|
||||
// The real probe control aborts the signal before axios is reached.
|
||||
axiosMock.mockImplementation((config: { signal?: AbortSignal }) => {
|
||||
expect(config.signal?.aborted).toBe(true);
|
||||
return Promise.reject(cancelled());
|
||||
});
|
||||
|
||||
const result = await request({
|
||||
probe: { requestId: 'health', deadlineAt: Date.now() - 1 },
|
||||
});
|
||||
|
||||
expect(result).toEqual({ portalRequestFailure: { kind: 'cancelled' } });
|
||||
expect(result).not.toHaveProperty('payload');
|
||||
expect(allOutput()).toBe('[]');
|
||||
});
|
||||
|
||||
it('traces a cancellation only under the IPC trace flag, without credentials', async () => {
|
||||
process.env[TRACE_IPC_ENV] = '1';
|
||||
axiosMock.mockRejectedValue(cancelled());
|
||||
|
||||
await request({ sessionId: 'session-9' });
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledTimes(1);
|
||||
const line = String(consoleLogSpy.mock.calls[0][0]);
|
||||
expect(line).toContain('[XTREAM_REQUEST] cancelled');
|
||||
expect(line).toContain('panel.example.com:8080');
|
||||
expect(line).not.toContain('secret-');
|
||||
expect(line).not.toContain('?');
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[401, 'Unauthorized'],
|
||||
[403, 'Forbidden'],
|
||||
])(
|
||||
'resolves HTTP %s as a structured failure with one credential-free warning',
|
||||
async (status, statusText) => {
|
||||
axiosMock.mockResolvedValue({
|
||||
status,
|
||||
statusText,
|
||||
data: '',
|
||||
headers: {},
|
||||
});
|
||||
|
||||
await expect(request()).resolves.toEqual({
|
||||
portalRequestFailure: { kind: 'http', status, statusText },
|
||||
});
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(consoleWarnSpy.mock.calls[0][0]).toBe(
|
||||
'[XTREAM_REQUEST] Refused'
|
||||
);
|
||||
expect(consoleWarnSpy.mock.calls[0][1]).toMatchObject({
|
||||
action: 'get_live_streams',
|
||||
host: 'panel.example.com:8080',
|
||||
pathname: '/player_api.php',
|
||||
status,
|
||||
});
|
||||
const output = allOutput();
|
||||
expect(output).not.toContain('secret-');
|
||||
expect(output).not.toContain('player_api.php?');
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('honours suppressErrorLog for the refusal warning too', async () => {
|
||||
axiosMock.mockResolvedValue({
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
data: '',
|
||||
headers: {},
|
||||
});
|
||||
|
||||
await expect(request({ suppressErrorLog: true })).resolves.toEqual({
|
||||
portalRequestFailure: {
|
||||
kind: 'http',
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
},
|
||||
});
|
||||
expect(allOutput()).toBe('[]');
|
||||
});
|
||||
|
||||
it('still rejects a 404 with its status and logs it as an error', async () => {
|
||||
axiosMock.mockResolvedValue({
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
data: '',
|
||||
headers: {},
|
||||
});
|
||||
|
||||
await expect(request()).rejects.toMatchObject({ status: 404 });
|
||||
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
|
||||
expect(consoleErrorSpy.mock.calls[0][0]).toBe(
|
||||
'[XTREAM_REQUEST] Failed'
|
||||
);
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'a 5xx',
|
||||
Object.assign(new Error('Request failed with status code 502'), {
|
||||
code: 'ERR_BAD_RESPONSE',
|
||||
response: { status: 502, statusText: 'Bad Gateway', data: {} },
|
||||
}),
|
||||
{ status: 502 },
|
||||
],
|
||||
[
|
||||
'a connection failure',
|
||||
Object.assign(new Error('connect ECONNREFUSED 10.0.0.1:8080'), {
|
||||
code: 'ECONNREFUSED',
|
||||
}),
|
||||
{ message: 'connect ECONNREFUSED 10.0.0.1:8080' },
|
||||
],
|
||||
[
|
||||
'a timeout',
|
||||
Object.assign(new Error('timeout of 30000ms exceeded'), {
|
||||
code: 'ECONNABORTED',
|
||||
}),
|
||||
{ message: 'timeout of 30000ms exceeded' },
|
||||
],
|
||||
])(
|
||||
'still rejects %s and logs it as an error',
|
||||
async (_label, error, shape) => {
|
||||
axiosMock.mockRejectedValue(error);
|
||||
|
||||
await expect(request()).rejects.toMatchObject(shape);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
|
||||
expect(consoleErrorSpy.mock.calls[0][0]).toBe(
|
||||
'[XTREAM_REQUEST] Failed'
|
||||
);
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('XtreamEvents host connectivity guard', () => {
|
||||
const GUARD_DISABLED_ENV = 'IPTVNATOR_DISABLE_CONNECTIVITY_GUARD';
|
||||
const SERVER_URL = 'http://dead-panel.example.com:8080';
|
||||
@@ -709,9 +955,12 @@ describe('XtreamEvents host connectivity guard', () => {
|
||||
});
|
||||
axiosMock.mockRejectedValue(cancelled);
|
||||
|
||||
await expect(request()).rejects.toMatchObject({ status: 499 });
|
||||
await expect(request()).rejects.toMatchObject({ status: 499 });
|
||||
await expect(request()).rejects.toMatchObject({ status: 499 });
|
||||
const cancelledEnvelope = {
|
||||
portalRequestFailure: { kind: 'cancelled' },
|
||||
};
|
||||
await expect(request()).resolves.toEqual(cancelledEnvelope);
|
||||
await expect(request()).resolves.toEqual(cancelledEnvelope);
|
||||
await expect(request()).resolves.toEqual(cancelledEnvelope);
|
||||
|
||||
expect(axiosMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
@@ -12,12 +12,15 @@ import {
|
||||
XTREAM_CANCEL_SESSION,
|
||||
XTREAM_CLIENT_USER_AGENT,
|
||||
XTREAM_MAIN_PERFORMANCE_PHASE,
|
||||
createPortalRequestFailureEnvelope,
|
||||
normalizeXtreamServerUrl,
|
||||
describeXtreamConnectionFailure,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { redactSensitiveData } from '@iptvnator/shared/logging';
|
||||
import { emitPortalDebugEvent } from './portal-debug.events';
|
||||
import { formatPortalRequestError } from './portal-request-error.util';
|
||||
import {
|
||||
classifyExpectedPortalFailure,
|
||||
logPortalRequestFailure,
|
||||
} from './portal-request-outcome';
|
||||
import { UnsafeUrlError } from './url-safety';
|
||||
import {
|
||||
requestWithValidatedRedirects,
|
||||
@@ -159,11 +162,14 @@ ipcMain.handle(
|
||||
|
||||
// Check if response is successful
|
||||
if (response.status >= 400) {
|
||||
if (payload.probe) throw new Error(`HTTP Error ${response.status}`);
|
||||
throw {
|
||||
message: `HTTP Error: ${response.statusText}`,
|
||||
status: response.status,
|
||||
};
|
||||
// `status` lets the catch block tell a 401/403 (resolved as
|
||||
// an envelope) from the other 4xx, which keep rejecting.
|
||||
throw Object.assign(
|
||||
payload.probe
|
||||
? new Error(`HTTP Error ${response.status}`)
|
||||
: { message: `HTTP Error: ${response.statusText}` },
|
||||
{ status: response.status, statusText: response.statusText }
|
||||
);
|
||||
}
|
||||
|
||||
if (requestId) {
|
||||
@@ -265,35 +271,34 @@ ipcMain.handle(
|
||||
connected: socketConnected,
|
||||
});
|
||||
|
||||
// A cancelled request and an HTTP 401/403 are routine: resolve
|
||||
// them as a structured envelope so Electron does not log the
|
||||
// handler as failed. The renderer's data service rethrows them
|
||||
// (an AbortError, or an `HTTP Error <code>` carrying `status`),
|
||||
// so a cancellation is never read as an empty answer. Everything
|
||||
// else keeps the rejection shapes below and their error log.
|
||||
const expected = classifyExpectedPortalFailure(error);
|
||||
if (!payload.suppressErrorLog) {
|
||||
console.error(
|
||||
'[XTREAM_REQUEST] Failed',
|
||||
redactSensitiveData(
|
||||
formatPortalRequestError(
|
||||
error,
|
||||
requestUrlForLog,
|
||||
payload.params?.action
|
||||
)
|
||||
)
|
||||
logPortalRequestFailure(
|
||||
'XTREAM_REQUEST',
|
||||
expected,
|
||||
error,
|
||||
requestUrlForLog,
|
||||
payload.params?.action
|
||||
);
|
||||
}
|
||||
if (expected) {
|
||||
return createPortalRequestFailureEnvelope(expected);
|
||||
}
|
||||
|
||||
// Format error response
|
||||
if (axios.isAxiosError(error)) {
|
||||
if (payload.probe) {
|
||||
if (typeof error.response?.status === 'number')
|
||||
throw new Error(`HTTP Error ${error.response.status}`);
|
||||
throw new Error(error.code === 'ERR_CANCELED'
|
||||
? 'Xtream request cancelled'
|
||||
: error.message || 'Xtream network request failed');
|
||||
}
|
||||
if (error.code === 'ERR_CANCELED') {
|
||||
throw {
|
||||
type: 'ERROR',
|
||||
name: 'AbortError',
|
||||
message: 'Xtream request cancelled',
|
||||
status: 499,
|
||||
};
|
||||
throw new Error(
|
||||
error.message || 'Xtream network request failed'
|
||||
);
|
||||
}
|
||||
const errorResponse = {
|
||||
type: 'ERROR',
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
AutoUpdatePlaylistStatus,
|
||||
AutoUpdatePlaylistsResult,
|
||||
ELECTRON_BRIDGE_SECURITY_ERROR_CODES,
|
||||
ERROR,
|
||||
PLAYLIST_PARSE_BY_URL,
|
||||
STALKER_REQUEST,
|
||||
XTREAM_REQUEST,
|
||||
Playlist,
|
||||
SECURITY_ERROR_PREFIX,
|
||||
@@ -24,6 +26,7 @@ describe('ElectronService', () => {
|
||||
const session = { id: 'session-1' };
|
||||
let electronBridge: {
|
||||
xtreamRequest: jest.Mock;
|
||||
stalkerRequest: jest.Mock;
|
||||
autoUpdatePlaylists: jest.Mock;
|
||||
fetchPlaylistByUrl: jest.Mock;
|
||||
onPlayerError: jest.Mock;
|
||||
@@ -42,6 +45,7 @@ describe('ElectronService', () => {
|
||||
|
||||
electronBridge = {
|
||||
xtreamRequest: jest.fn(),
|
||||
stalkerRequest: jest.fn(),
|
||||
autoUpdatePlaylists: jest.fn(),
|
||||
fetchPlaylistByUrl: jest.fn(),
|
||||
onPlayerError: jest.fn(),
|
||||
@@ -111,8 +115,14 @@ describe('ElectronService', () => {
|
||||
|
||||
it('protects all startup-refresh sources until the request settles, including failure', async () => {
|
||||
let reject!: (error: Error) => void;
|
||||
electronBridge.autoUpdatePlaylists.mockReturnValue(new Promise((_, r) => { reject = r; }));
|
||||
const pending = service.sendIpcEvent(AUTO_UPDATE_PLAYLISTS, [{ _id: 'startup' }]);
|
||||
electronBridge.autoUpdatePlaylists.mockReturnValue(
|
||||
new Promise((_, r) => {
|
||||
reject = r;
|
||||
})
|
||||
);
|
||||
const pending = service.sendIpcEvent(AUTO_UPDATE_PLAYLISTS, [
|
||||
{ _id: 'startup' },
|
||||
]);
|
||||
const activity = TestBed.inject(SourceActivityService);
|
||||
expect(activity.isBusy('startup')).toBe(true);
|
||||
expect(activity.isBusy('other')).toBe(false);
|
||||
@@ -121,25 +131,212 @@ describe('ElectronService', () => {
|
||||
expect(activity.isBusy('startup')).toBe(false);
|
||||
});
|
||||
|
||||
it.each([401, 403])('preserves a health HTTP %s rejection without global error handling', async (status) => {
|
||||
const error = new Error(`Error invoking remote method: Error: HTTP Error ${status}`);
|
||||
electronBridge.xtreamRequest.mockRejectedValue(error);
|
||||
const post = jest.spyOn(window, 'postMessage');
|
||||
await expect(service.sendIpcEvent(XTREAM_REQUEST, {
|
||||
url: 'https://provider.example', params: {},
|
||||
probe: { requestId: 'health', deadlineAt: Date.now() + 5000 },
|
||||
})).rejects.toBe(error);
|
||||
expect(snackBar.open).not.toHaveBeenCalled();
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
it.each([401, 403])(
|
||||
'preserves a health HTTP %s rejection without global error handling',
|
||||
async (status) => {
|
||||
const error = new Error(
|
||||
`Error invoking remote method: Error: HTTP Error ${status}`
|
||||
);
|
||||
electronBridge.xtreamRequest.mockRejectedValue(error);
|
||||
const post = jest.spyOn(window, 'postMessage');
|
||||
await expect(
|
||||
service.sendIpcEvent(XTREAM_REQUEST, {
|
||||
url: 'https://provider.example',
|
||||
params: {},
|
||||
probe: {
|
||||
requestId: 'health',
|
||||
deadlineAt: Date.now() + 5000,
|
||||
},
|
||||
})
|
||||
).rejects.toBe(error);
|
||||
expect(snackBar.open).not.toHaveBeenCalled();
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
describe('resolved portal request failures', () => {
|
||||
// The main process resolves a cancellation or an HTTP 401/403 as this
|
||||
// envelope instead of rejecting (Electron logs every rejected handler
|
||||
// as an error); the service must hand its callers the error they
|
||||
// already classify, never a successful empty answer.
|
||||
const cancelledEnvelope = {
|
||||
portalRequestFailure: { kind: 'cancelled' },
|
||||
};
|
||||
const refusedEnvelope = (status: number) => ({
|
||||
portalRequestFailure: {
|
||||
kind: 'http',
|
||||
status,
|
||||
statusText: status === 401 ? 'Unauthorized' : 'Forbidden',
|
||||
},
|
||||
});
|
||||
const stalkerPayload = {
|
||||
url: 'http://portal.example/portal.php',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
params: { type: 'stb', action: 'get_profile' },
|
||||
};
|
||||
const xtreamPayload = {
|
||||
url: 'https://provider.example',
|
||||
params: {
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
action: 'get_live_streams',
|
||||
},
|
||||
};
|
||||
|
||||
it('rethrows a cancelled Stalker request as an AbortError, silently', async () => {
|
||||
electronBridge.stalkerRequest.mockResolvedValue(cancelledEnvelope);
|
||||
|
||||
await expect(
|
||||
service.sendIpcEvent(STALKER_REQUEST, stalkerPayload)
|
||||
).rejects.toMatchObject({
|
||||
name: 'AbortError',
|
||||
message: 'Stalker request cancelled',
|
||||
});
|
||||
expect(snackBar.open).not.toHaveBeenCalled();
|
||||
expect(console.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([401, 403])(
|
||||
'rethrows a Stalker HTTP %s carrying the status and reports it',
|
||||
async (status) => {
|
||||
electronBridge.stalkerRequest.mockResolvedValue(
|
||||
refusedEnvelope(status)
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.sendIpcEvent(STALKER_REQUEST, stalkerPayload)
|
||||
).rejects.toMatchObject({
|
||||
message: `HTTP Error ${status}: ${status === 401 ? 'Unauthorized' : 'Forbidden'}`,
|
||||
status,
|
||||
});
|
||||
expect(snackBar.open).toHaveBeenCalledTimes(1);
|
||||
expect(snackBar.open.mock.calls[0][0]).toContain(
|
||||
`status: ${status}`
|
||||
);
|
||||
expect(snackBar.open.mock.calls[0][0]).not.toContain(
|
||||
'Error invoking remote method'
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('keeps a silent Stalker probe refusal off the snackbar', async () => {
|
||||
electronBridge.stalkerRequest.mockResolvedValue(
|
||||
refusedEnvelope(403)
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.sendIpcEvent(STALKER_REQUEST, {
|
||||
...stalkerPayload,
|
||||
silent: true,
|
||||
})
|
||||
).rejects.toMatchObject({ status: 403 });
|
||||
expect(snackBar.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rethrows a cancelled Xtream health probe as an AbortError', async () => {
|
||||
electronBridge.xtreamRequest.mockResolvedValue(cancelledEnvelope);
|
||||
const post = jest.spyOn(window, 'postMessage');
|
||||
|
||||
await expect(
|
||||
service.sendIpcEvent(XTREAM_REQUEST, {
|
||||
...xtreamPayload,
|
||||
probe: {
|
||||
requestId: 'health',
|
||||
deadlineAt: Date.now() + 5000,
|
||||
},
|
||||
})
|
||||
).rejects.toMatchObject({ name: 'AbortError' });
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
expect(snackBar.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([401, 403])(
|
||||
'rethrows an Xtream health-probe HTTP %s with the status in its message',
|
||||
async (status) => {
|
||||
electronBridge.xtreamRequest.mockResolvedValue(
|
||||
refusedEnvelope(status)
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.sendIpcEvent(XTREAM_REQUEST, {
|
||||
...xtreamPayload,
|
||||
probe: {
|
||||
requestId: 'health',
|
||||
deadlineAt: Date.now() + 5000,
|
||||
},
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
message: expect.stringContaining(`HTTP Error ${status}`),
|
||||
status,
|
||||
});
|
||||
expect(snackBar.open).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('reports a cancelled Xtream request as a silent ERROR result, never a payload', async () => {
|
||||
electronBridge.xtreamRequest.mockResolvedValue(cancelledEnvelope);
|
||||
const post = jest.spyOn(window, 'postMessage');
|
||||
|
||||
const result = await service.sendIpcEvent(
|
||||
XTREAM_REQUEST,
|
||||
xtreamPayload
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: ERROR,
|
||||
status: 499,
|
||||
message: 'Xtream request cancelled',
|
||||
});
|
||||
expect(result).not.toHaveProperty('payload');
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
expect(snackBar.open).not.toHaveBeenCalled();
|
||||
expect(console.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([401, 403])(
|
||||
'turns an Xtream HTTP %s into an ERROR result carrying the status',
|
||||
async (status) => {
|
||||
electronBridge.xtreamRequest.mockResolvedValue(
|
||||
refusedEnvelope(status)
|
||||
);
|
||||
|
||||
const result = await service.sendIpcEvent(
|
||||
XTREAM_REQUEST,
|
||||
xtreamPayload
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: ERROR,
|
||||
status,
|
||||
message: `HTTP Error ${status}: ${status === 401 ? 'Unauthorized' : 'Forbidden'}`,
|
||||
});
|
||||
expect(snackBar.open).toHaveBeenCalledTimes(1);
|
||||
expect(snackBar.open.mock.calls[0][0]).toContain(
|
||||
`HTTP Error ${status}`
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('passes an ordinary Stalker answer through untouched', async () => {
|
||||
const answer = { js: { id: 7, portalRequestFailure: 'field' } };
|
||||
electronBridge.stalkerRequest.mockResolvedValue(answer);
|
||||
|
||||
await expect(
|
||||
service.sendIpcEvent(STALKER_REQUEST, stalkerPayload)
|
||||
).resolves.toBe(answer);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps health success request-local without broadcasting catalog responses', async () => {
|
||||
const response = { payload: { user_info: { status: 'Active' } } };
|
||||
electronBridge.xtreamRequest.mockResolvedValue(response);
|
||||
const post = jest.spyOn(window, 'postMessage');
|
||||
expect(await service.sendIpcEvent(XTREAM_REQUEST, {
|
||||
url: 'https://provider.example', params: {},
|
||||
probe: { requestId: 'health', deadlineAt: Date.now() + 5000 },
|
||||
})).toEqual(response);
|
||||
expect(
|
||||
await service.sendIpcEvent(XTREAM_REQUEST, {
|
||||
url: 'https://provider.example',
|
||||
params: {},
|
||||
probe: { requestId: 'health', deadlineAt: Date.now() + 5000 },
|
||||
})
|
||||
).toEqual(response);
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -13,8 +13,11 @@ import {
|
||||
AUTO_UPDATE_PLAYLISTS,
|
||||
AutoUpdatePlaylistsResult,
|
||||
CONNECTIVITY_GUARD_RESET,
|
||||
createPortalRequestError,
|
||||
ELECTRON_BRIDGE_SECURITY_ERROR_CODES,
|
||||
ERROR,
|
||||
isPortalRequestCancelledError,
|
||||
isPortalRequestFailureEnvelope,
|
||||
normalizeHost,
|
||||
parseSecurityPolicyError,
|
||||
PlayerContentInfo,
|
||||
@@ -316,8 +319,24 @@ export class ElectronService extends DataService {
|
||||
...payload,
|
||||
requestId: context.requestId,
|
||||
});
|
||||
// A cancellation or an HTTP 401/403 arrives resolved — Electron
|
||||
// would otherwise log each one as a failed handler — and becomes
|
||||
// the error the Stalker layers classify from `status`/message.
|
||||
if (isPortalRequestFailureEnvelope(response)) {
|
||||
throw createPortalRequestError(
|
||||
response.portalRequestFailure,
|
||||
'stalker'
|
||||
);
|
||||
}
|
||||
return response;
|
||||
} catch (err: unknown) {
|
||||
if (isPortalRequestCancelledError(err)) {
|
||||
// The renderer asked for this; it is not an error to report.
|
||||
this.logger.debug(
|
||||
`Stalker request cancelled (${payload.params?.action ?? 'unknown'})`
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
const errorInfo = this.getErrorDetails(err);
|
||||
this.logger.error('Stalker request error:', err);
|
||||
if (!payload.silent) {
|
||||
@@ -637,6 +656,15 @@ export class ElectronService extends DataService {
|
||||
...payload,
|
||||
requestId: context.requestId,
|
||||
});
|
||||
// A cancellation or an HTTP 401/403 arrives resolved — Electron
|
||||
// would otherwise log each one as a failed handler — and becomes
|
||||
// the error this method already reports below.
|
||||
if (isPortalRequestFailureEnvelope(response)) {
|
||||
throw createPortalRequestError(
|
||||
response.portalRequestFailure,
|
||||
'xtream'
|
||||
);
|
||||
}
|
||||
|
||||
if (payload.connectionTest || payload.probe) return response;
|
||||
|
||||
@@ -650,7 +678,11 @@ export class ElectronService extends DataService {
|
||||
} catch (error: unknown) {
|
||||
if (payload.probe) throw error;
|
||||
const action = payload.params?.action;
|
||||
// The renderer asked for a cancellation; report it like a
|
||||
// background action, not a failure the user needs to see.
|
||||
const cancelled = isPortalRequestCancelledError(error);
|
||||
const isSilentAction =
|
||||
cancelled ||
|
||||
payload.suppressErrorLog === true ||
|
||||
(action ? this.silentXtreamActions.has(action) : false);
|
||||
const normalizedMessage = this.getReadableXtreamErrorMessage(error);
|
||||
@@ -679,7 +711,9 @@ export class ElectronService extends DataService {
|
||||
|
||||
return {
|
||||
type: ERROR,
|
||||
status: errorInfo?.status ?? 500,
|
||||
// 499 ("client closed request") keeps a cancellation apart
|
||||
// from a server failure in the result callers log.
|
||||
status: cancelled ? 499 : (errorInfo?.status ?? 500),
|
||||
message: normalizedMessage,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -753,6 +753,31 @@ classifier accepts both shapes plus the legacy `{ js: '<body>' }` envelope.
|
||||
`makeAuthenticatedRequest` retries once with fresh authentication and
|
||||
otherwise throws `StalkerPortalError('auth-failed')` carrying the body.
|
||||
|
||||
### Cancelled requests and HTTP 401/403 over IPC
|
||||
|
||||
Electron prints `Error occurred in handler for 'STALKER_REQUEST'` — with a
|
||||
stack trace — for every `ipcMain.handle` promise that rejects, so the main
|
||||
process resolves two routine outcomes instead of throwing them: a request whose
|
||||
abort signal fired (a source-health probe the renderer cancelled or let
|
||||
expire) and an HTTP 401/403 (the endpoint answered and refused — an auth gate
|
||||
in front of a discovery candidate, an expired account). Both come back as a
|
||||
`{ portalRequestFailure }` envelope
|
||||
(`libs/shared/interfaces/src/lib/portal-request-failure.util.ts`, classified
|
||||
in `apps/electron-backend/src/app/events/portal-request-outcome.ts`).
|
||||
`ElectronService.fetchStalkerData` is the only reader of the raw bridge result
|
||||
and rethrows the envelope as the error the Stalker layers already classify: an
|
||||
`AbortError` for the cancellation (debug log, no snackbar), or an
|
||||
`HTTP Error <code>: <statusText>` Error that carries a numeric `status`, so
|
||||
`getStalkerRequestErrorStatus` no longer has to parse it out of the message.
|
||||
A cancelled request therefore never resolves into an empty answer. In the main
|
||||
process the refusal is one `console.warn` through `formatPortalRequestError`
|
||||
and `redactSensitiveData` (host and pathname, never the MAC, token or query
|
||||
string); the cancellation is logged only under `IPTVNATOR_TRACE_IPC`. Every
|
||||
other failure — 404, 5xx, network errors, the connectivity guard's fast-fail —
|
||||
still rejects with the message contracts above and keeps its `console.error`.
|
||||
`apps/electron-backend-e2e/src/portal-request-logging.e2e.ts` pins both
|
||||
outcomes for both portals against the real main-process log.
|
||||
|
||||
### Error surfacing
|
||||
|
||||
`StalkerPortalError.portalText` holds the portal's own words. The import
|
||||
|
||||
@@ -100,6 +100,22 @@ connection failures, never reported as provider HTTP statuses or used to
|
||||
authorize HTTP. Older backends without the envelope cannot authorize HTTP discovery.
|
||||
Provider JSON remains nested in `payload` and cannot provide this evidence.
|
||||
|
||||
Electron resolves two more outcomes instead of rejecting them, because Electron
|
||||
logs every rejected `ipcMain.handle` promise as a handler error with a stack
|
||||
trace: a cancelled request (`XTREAM_CANCEL_SESSION`, a cancelled or expired
|
||||
source-health probe) and an HTTP 401/403. Both return a
|
||||
`{ portalRequestFailure }` envelope
|
||||
(`libs/shared/interfaces/src/lib/portal-request-failure.util.ts`, classified in
|
||||
`apps/electron-backend/src/app/events/portal-request-outcome.ts`) that
|
||||
`ElectronService.forwardXtreamRequest` turns back into what its callers already
|
||||
handle: health probes get an `AbortError` or an `HTTP Error <code>` Error
|
||||
carrying `status`; catalog calls get the usual `{ type: ERROR, status, message }`
|
||||
result — status 499 and no snackbar for a cancellation, the real 401/403
|
||||
(previously lost as `[object Object]`) for a refusal. The main process logs a
|
||||
refusal as one credential-free `console.warn` (unless `suppressErrorLog`), a
|
||||
cancellation only under `IPTVNATOR_TRACE_IPC`, and everything else as before
|
||||
at error level with a rejection.
|
||||
|
||||
The saved base drives catalog refresh, provider EPG, live/VOD/series/catch-up URL
|
||||
construction and fresh Favorites/Recent resolution. The routed Xtream session
|
||||
observes metadata connection changes and bootstraps the new connection. Separate
|
||||
|
||||
@@ -24,7 +24,7 @@ IPTVNATOR_TRACE_STARTUP=1 pnpm nx serve electron-backend
|
||||
```
|
||||
|
||||
- Narrower trace flags:
|
||||
- `IPTVNATOR_TRACE_IPC=1` traces renderer `window.electron.*` bridge calls
|
||||
- `IPTVNATOR_TRACE_IPC=1` traces renderer `window.electron.*` bridge calls and reports cancelled Stalker/Xtream portal requests, which are otherwise not logged (an HTTP 401/403 is always one `Refused` warning; other failures stay errors)
|
||||
- `IPTVNATOR_TRACE_DB=1` traces DB worker requests and request-scoped DB events
|
||||
- `IPTVNATOR_TRACE_SQL=1` traces SQLite statements in the main process and DB worker
|
||||
- `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow lifecycle and unresponsive events
|
||||
|
||||
@@ -161,7 +161,8 @@ export function classifyStalkerProbeResponse(
|
||||
* but `ipcRenderer.invoke` strips every custom property from a rejected
|
||||
* value and re-wraps the message, so in the renderer the numeric `status`
|
||||
* field usually does NOT survive and the code must be parsed back out of
|
||||
* the message text.
|
||||
* the message text. Only a 401/403 arrives resolved and is rebuilt by
|
||||
* `ElectronService` with its `status` (see `portal-request-failure.util.ts`).
|
||||
*/
|
||||
export function getStalkerRequestErrorStatus(
|
||||
error: unknown
|
||||
|
||||
@@ -109,5 +109,6 @@ export * from './lib/vod-details-item.interface';
|
||||
export * from './lib/catchup-download.interface';
|
||||
|
||||
export * from './lib/xtream-connection-test';
|
||||
export * from './lib/portal-request-failure.util';
|
||||
|
||||
export * from './lib/source-health';
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AppUpdateChannel } from './app-update-channel.util';
|
||||
import type { SourceProbeContext, SourceHealthResult } from './source-health';
|
||||
import type { PortalRequestFailureEnvelope } from './portal-request-failure.util';
|
||||
import type { XtreamConnectionFailure } from './xtream-connection-test';
|
||||
import type { ZoomLevelAction } from './zoom-level.util';
|
||||
import type {
|
||||
@@ -339,6 +340,17 @@ export interface ElectronBridgeXtreamResponse {
|
||||
action: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What `xtreamRequest` resolves with: the provider answer, or — for a
|
||||
* cancelled request and an HTTP 401/403 — a `portalRequestFailure` envelope
|
||||
* carrying neither `payload` nor `action`. Resolved instead of rejected so
|
||||
* Electron does not log the handler as failed; `ElectronService` narrows it
|
||||
* with `isPortalRequestFailureEnvelope` and rethrows
|
||||
* (`portal-request-failure.util.ts`).
|
||||
*/
|
||||
export type ElectronBridgeXtreamResult =
|
||||
ElectronBridgeXtreamResponse | PortalRequestFailureEnvelope;
|
||||
|
||||
export interface ElectronBridgeXtreamCancelResult extends ElectronBridgeResult {
|
||||
cancelled: number;
|
||||
}
|
||||
@@ -912,9 +924,13 @@ export interface ElectronBridgeApi {
|
||||
getAiSettings: () => Promise<ElectronBridgeAiSettings>;
|
||||
setMpvPlayerPath: (mpvPlayerPath: string) => Promise<void>;
|
||||
setVlcPlayerPath: (vlcPlayerPath: string) => Promise<void>;
|
||||
/**
|
||||
* The portal's JSON, the `{ stalkerAuthFailure }` marker, or a
|
||||
* `PortalRequestFailureEnvelope` (cancelled request, HTTP 401/403).
|
||||
*/
|
||||
stalkerRequest: (
|
||||
payload: ElectronBridgeStalkerRequestPayload
|
||||
) => Promise<Record<string, unknown>>;
|
||||
) => Promise<Record<string, unknown> | PortalRequestFailureEnvelope>;
|
||||
/**
|
||||
* Forgets the connection failures recorded for the host `url` points at, so
|
||||
* the next request contacts it for real instead of being fast-failed.
|
||||
@@ -922,7 +938,7 @@ export interface ElectronBridgeApi {
|
||||
resetHostConnectivityGuard: (url: string) => Promise<ElectronBridgeResult>;
|
||||
xtreamRequest: (
|
||||
payload: ElectronBridgeXtreamRequestPayload
|
||||
) => Promise<ElectronBridgeXtreamResponse>;
|
||||
) => Promise<ElectronBridgeXtreamResult>;
|
||||
xtreamCancelSession: (
|
||||
sessionId: string
|
||||
) => Promise<ElectronBridgeXtreamCancelResult>;
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { isStalkerAuthFailureMessage } from './stalker-auth-failure.util';
|
||||
import {
|
||||
createPortalRequestError,
|
||||
createPortalRequestFailureEnvelope,
|
||||
formatPortalHttpErrorMessage,
|
||||
isExpectedPortalHttpStatus,
|
||||
isPortalRequestCancelledError,
|
||||
isPortalRequestFailureEnvelope,
|
||||
readPortalRequestFailure,
|
||||
} from './portal-request-failure.util';
|
||||
import { sourceHealthError } from './source-health';
|
||||
|
||||
describe('portal request failure envelope', () => {
|
||||
it('round-trips a cancellation', () => {
|
||||
const envelope = createPortalRequestFailureEnvelope({
|
||||
kind: 'cancelled',
|
||||
});
|
||||
|
||||
expect(readPortalRequestFailure(envelope)).toEqual({
|
||||
kind: 'cancelled',
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips an HTTP refusal with and without a status text', () => {
|
||||
expect(
|
||||
readPortalRequestFailure(
|
||||
createPortalRequestFailureEnvelope({
|
||||
kind: 'http',
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
})
|
||||
)
|
||||
).toEqual({ kind: 'http', status: 401, statusText: 'Unauthorized' });
|
||||
expect(
|
||||
readPortalRequestFailure(
|
||||
createPortalRequestFailureEnvelope({
|
||||
kind: 'http',
|
||||
status: 403,
|
||||
})
|
||||
)
|
||||
).toEqual({ kind: 'http', status: 403 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an ordinary payload', { js: { data: [] } }],
|
||||
[
|
||||
'a payload reusing the key with another shape',
|
||||
{
|
||||
portalRequestFailure: { kind: 'http' },
|
||||
},
|
||||
],
|
||||
['an unknown kind', { portalRequestFailure: { kind: 'timeout' } }],
|
||||
[
|
||||
'a non-string status text',
|
||||
{
|
||||
portalRequestFailure: {
|
||||
kind: 'http',
|
||||
status: 401,
|
||||
statusText: 123,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'a non-numeric status',
|
||||
{ portalRequestFailure: { kind: 'http', status: '401' } },
|
||||
],
|
||||
['a string', 'Authorization failed.'],
|
||||
['null', null],
|
||||
['undefined', undefined],
|
||||
])('treats %s as a normal response', (_label, value) => {
|
||||
expect(readPortalRequestFailure(value)).toBeNull();
|
||||
});
|
||||
|
||||
it('narrows a bridge result to the envelope only for a valid failure', () => {
|
||||
const envelope = createPortalRequestFailureEnvelope({
|
||||
kind: 'http',
|
||||
status: 403,
|
||||
});
|
||||
const result: typeof envelope | { payload: unknown; action: string } =
|
||||
envelope;
|
||||
|
||||
expect(isPortalRequestFailureEnvelope(result)).toBe(true);
|
||||
if (isPortalRequestFailureEnvelope(result)) {
|
||||
// Narrowed: the success fields are no longer on the type.
|
||||
expect(result.portalRequestFailure.kind).toBe('http');
|
||||
}
|
||||
expect(
|
||||
isPortalRequestFailureEnvelope({ payload: [], action: 'get' })
|
||||
).toBe(false);
|
||||
expect(
|
||||
isPortalRequestFailureEnvelope({
|
||||
portalRequestFailure: { kind: 'http' },
|
||||
})
|
||||
).toBe(false);
|
||||
// The guard vouches for every field a caller may read off the
|
||||
// original value, so a malformed status text fails it too.
|
||||
expect(
|
||||
isPortalRequestFailureEnvelope({
|
||||
portalRequestFailure: {
|
||||
kind: 'http',
|
||||
status: 401,
|
||||
statusText: 123,
|
||||
},
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('expects only the HTTP auth refusals', () => {
|
||||
expect([401, 403].every(isExpectedPortalHttpStatus)).toBe(true);
|
||||
expect(
|
||||
[200, 400, 404, 429, 500, 502, undefined, '401'].some(
|
||||
isExpectedPortalHttpStatus
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPortalRequestError', () => {
|
||||
it('turns a cancellation into an AbortError, never an empty answer', () => {
|
||||
const error = createPortalRequestError(
|
||||
{ kind: 'cancelled' },
|
||||
'stalker'
|
||||
);
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error.name).toBe('AbortError');
|
||||
expect(error.message).toBe('Stalker request cancelled');
|
||||
expect(isPortalRequestCancelledError(error)).toBe(true);
|
||||
// The health probes read the reason from the message.
|
||||
expect(sourceHealthError(error).reason).toBe('cancelled');
|
||||
// A cancellation is not a timeout, an auth failure or an HTTP answer.
|
||||
expect(/timed out|timeout|HTTP Error/i.test(error.message)).toBe(false);
|
||||
expect(isStalkerAuthFailureMessage(error.message)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[401, 'Unauthorized', 'HTTP Error 401: Unauthorized'],
|
||||
[403, undefined, 'HTTP Error 403'],
|
||||
[403, ' ', 'HTTP Error 403'],
|
||||
])(
|
||||
'turns an HTTP %s refusal into the message the classifiers parse',
|
||||
(status, statusText, message) => {
|
||||
const error = createPortalRequestError(
|
||||
{ kind: 'http', status, statusText },
|
||||
'xtream'
|
||||
) as Error & { status?: number };
|
||||
|
||||
expect(error.message).toBe(message);
|
||||
expect(error.status).toBe(status);
|
||||
expect(isPortalRequestCancelledError(error)).toBe(false);
|
||||
expect(sourceHealthError(error)).toMatchObject({
|
||||
reason: 'auth',
|
||||
state: 'inactive',
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it('formats the HTTP message once for both processes', () => {
|
||||
expect(formatPortalHttpErrorMessage(401, 'Unauthorized')).toBe(
|
||||
'HTTP Error 401: Unauthorized'
|
||||
);
|
||||
expect(formatPortalHttpErrorMessage(404)).toBe('HTTP Error 404');
|
||||
});
|
||||
|
||||
it('does not read a plain error as a cancellation', () => {
|
||||
expect(isPortalRequestCancelledError(new Error('canceled'))).toBe(
|
||||
false
|
||||
);
|
||||
expect(isPortalRequestCancelledError({ name: 'AbortError' })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Expected portal-request failures, carried across the Electron IPC boundary
|
||||
* as a RESOLVED value.
|
||||
*
|
||||
* `ipcMain.handle` logs every rejected handler as
|
||||
* `Error occurred in handler for '<channel>'` with a stack trace, and
|
||||
* `ipcRenderer.invoke` keeps nothing of the rejection but its message. Two
|
||||
* outcomes of a portal request are routine rather than errors, so they must
|
||||
* not take that path:
|
||||
*
|
||||
* - a **cancelled** request — the renderer navigated away, superseded it, or
|
||||
* let its probe deadline pass; and
|
||||
* - an HTTP **401/403** — the portal answered and refused, which the renderer
|
||||
* already classifies structurally (`isStalkerAuthorizationFailure`,
|
||||
* `sourceHealthError`, the lazy portal repair).
|
||||
*
|
||||
* The main process resolves them as this envelope. The renderer's Electron
|
||||
* data service is the only consumer of the raw bridge result; it turns the
|
||||
* envelope back into the thrown error its callers already understand — an
|
||||
* `AbortError`, or an `HTTP Error <status>` message with a numeric `status` —
|
||||
* so a cancelled request can never be mistaken for a successful empty answer.
|
||||
*
|
||||
* This lives in `shared/interfaces` for the same reason as the Stalker
|
||||
* auth-failure marker: the main process cannot import renderer libraries, and
|
||||
* two copies would drift.
|
||||
*/
|
||||
|
||||
export const PORTAL_REQUEST_FAILURE_KEY = 'portalRequestFailure';
|
||||
|
||||
/** HTTP statuses that mean "the portal answered and refused", not "broken". */
|
||||
export const EXPECTED_PORTAL_HTTP_STATUSES: readonly number[] = [401, 403];
|
||||
|
||||
export type PortalRequestFailure =
|
||||
| { kind: 'cancelled' }
|
||||
| { kind: 'http'; status: number; statusText?: string };
|
||||
|
||||
export interface PortalRequestFailureEnvelope {
|
||||
[PORTAL_REQUEST_FAILURE_KEY]: PortalRequestFailure;
|
||||
}
|
||||
|
||||
export type PortalRequestProvider = 'stalker' | 'xtream';
|
||||
|
||||
const PROVIDER_LABEL: Record<PortalRequestProvider, string> = {
|
||||
stalker: 'Stalker',
|
||||
xtream: 'Xtream',
|
||||
};
|
||||
|
||||
export function isExpectedPortalHttpStatus(status: unknown): status is number {
|
||||
return (
|
||||
typeof status === 'number' &&
|
||||
EXPECTED_PORTAL_HTTP_STATUSES.includes(status)
|
||||
);
|
||||
}
|
||||
|
||||
export function createPortalRequestFailureEnvelope(
|
||||
failure: PortalRequestFailure
|
||||
): PortalRequestFailureEnvelope {
|
||||
return { [PORTAL_REQUEST_FAILURE_KEY]: failure };
|
||||
}
|
||||
|
||||
/**
|
||||
* The failure a bridge result carries, or null for an ordinary response.
|
||||
* Strict on shape: a portal payload that happens to contain the key with
|
||||
* anything else in it is still a payload.
|
||||
*/
|
||||
export function readPortalRequestFailure(
|
||||
value: unknown
|
||||
): PortalRequestFailure | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const failure = (value as Record<string, unknown>)[
|
||||
PORTAL_REQUEST_FAILURE_KEY
|
||||
];
|
||||
if (!failure || typeof failure !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { kind, status, statusText } = failure as Record<string, unknown>;
|
||||
if (kind === 'cancelled') {
|
||||
return { kind };
|
||||
}
|
||||
|
||||
if (kind !== 'http' || typeof status !== 'number') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// `statusText` must be a string or absent: the guard below vouches for the
|
||||
// whole shape, and a caller that reads the original value must not meet a
|
||||
// number where the type promises a string.
|
||||
if (statusText === undefined) {
|
||||
return { kind, status };
|
||||
}
|
||||
|
||||
return typeof statusText === 'string' ? { kind, status, statusText } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for a bridge result: narrows the union the bridge promises
|
||||
* (`ElectronBridgeXtreamResult`) so success fields cannot be read off an
|
||||
* envelope without checking first.
|
||||
*/
|
||||
export function isPortalRequestFailureEnvelope(
|
||||
value: unknown
|
||||
): value is PortalRequestFailureEnvelope {
|
||||
return readPortalRequestFailure(value) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The message shape the renderer classifies HTTP failures from
|
||||
* (`getStalkerRequestErrorStatus`, `isStalkerAuthorizationFailure`,
|
||||
* `sourceHealthError`): `HTTP Error <status>`, with the status text when the
|
||||
* portal sent one.
|
||||
*/
|
||||
export function formatPortalHttpErrorMessage(
|
||||
status: number,
|
||||
statusText?: string
|
||||
): string {
|
||||
const text = statusText?.trim();
|
||||
return text ? `HTTP Error ${status}: ${text}` : `HTTP Error ${status}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the error the renderer expects from a resolved envelope.
|
||||
*
|
||||
* A cancellation is an `AbortError`, the convention the database and refresh
|
||||
* paths already use (`isDbAbortError`). An HTTP refusal carries the numeric
|
||||
* `status` — which the old rejection lost at the IPC boundary — and the
|
||||
* message the classifiers parse.
|
||||
*/
|
||||
export function createPortalRequestError(
|
||||
failure: PortalRequestFailure,
|
||||
provider: PortalRequestProvider
|
||||
): Error {
|
||||
if (failure.kind === 'cancelled') {
|
||||
const error = new Error(
|
||||
`${PROVIDER_LABEL[provider]} request cancelled`
|
||||
);
|
||||
error.name = 'AbortError';
|
||||
return error;
|
||||
}
|
||||
|
||||
const error = new Error(
|
||||
formatPortalHttpErrorMessage(failure.status, failure.statusText)
|
||||
) as Error & { status: number };
|
||||
error.status = failure.status;
|
||||
return error;
|
||||
}
|
||||
|
||||
/** Whether an error is a cancelled portal request (or any other abort). */
|
||||
export function isPortalRequestCancelledError(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === 'AbortError';
|
||||
}
|
||||
Reference in new issue
Block a user