mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -08:00
* 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>
167 lines
6.4 KiB
TypeScript
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);
|
|
}
|
|
}
|