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