Files
iptvnator/apps/electron-backend-e2e/src/portal-request-logging.e2e.ts
T
4grayandClaude Fable 5.1 0060330b5e 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>
2026-10-10 11:28:59 +02:00

167 lines
6.4 KiB
TypeScript

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