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:
4grayandClaude Fable 5.1 authored and GitHub committed 2026-10-10 11:28:59 +02:00
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();
});
+35 -1
View File
@@ -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,
};
}
+25
View File
@@ -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
+1 -1
View File
@@ -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
+1
View File
@@ -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';
}