mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 04:26:16 -08:00
The mock server implemented neither get_profile nor get_events and
validated no auth at all, and the e2e suite imported the portal through
/portal.php — which the app classifies as a *simple* portal. The entire
authenticated branch (handshake, token, watchdog, re-auth) therefore had
zero coverage, right before a series of PRs that reworks exactly that.
Mock server:
- serve the canonical /stalker_portal/server/load.php endpoint, which
enforces the Bearer token and the Infomir MAC format like the real
middleware; /portal.php stays tolerant so the existing suite keeps
covering the simple-portal branch
- auth-store.ts models the parts of Stalker 4.9.35 a client can get
wrong: plain-text auth failures with HTTP 200, a handshake that is not
yet a session, idempotent token re-presentation, and permanent
device_id pinning (including the blank-after-pinned lockout)
- add get_profile (status 0/1/2, device conflict, block_msg) and the
get_events watchdog; profile advertises watchdog_timeout/timeslot
- new login-required scenario MAC and POST /invalidate-session so tests
can force a mid-session token loss
- the /stalker proxy route now forwards the token as a Bearer header and
wraps auth failures in the { payload } envelope, matching web-backend
Also moves extractMac into request-mac.ts: importing it from the
categories handler dragged the whole data generator into any consumer,
which broke unit tests on the workspace alias.
E2E: new stalker-auth.e2e.ts asserts handshake precedes get_profile
precedes content, that content requests carry the token while the
handshake does not, that the plain-text failure body is never rendered,
and that the client re-authenticates after the portal drops the session.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
234 lines
8.1 KiB
TypeScript
234 lines
8.1 KiB
TypeScript
import http from 'http';
|
|
import { join } from 'node:path';
|
|
import express, { Request, Response } from 'express';
|
|
import cors from 'cors';
|
|
import portalRouter, { createPortalRouter } from './app/routes/portal.route.js';
|
|
import dispatchPortalAction from './app/routes/dispatch.js';
|
|
import { invalidateSession, resetAuthState } from './app/auth-store.js';
|
|
import { resetWatchdogPings } from './app/handlers/get-events.handler.js';
|
|
import { resetAll } from './app/data-store.js';
|
|
import { SCENARIOS } from './app/scenarios.js';
|
|
import {
|
|
buildRequestOrigin,
|
|
resolveMarketingPosterUrls,
|
|
} from './app/marketing-poster-url.js';
|
|
|
|
const PORT = parseInt(process.env['PORT'] ?? '3210', 10);
|
|
const app = express();
|
|
const MARKETING_POSTER_DIRECTORY = join(
|
|
process.cwd(),
|
|
'apps/xtream-mock-server/public/marketing/poster'
|
|
);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Middleware
|
|
// ---------------------------------------------------------------------------
|
|
app.use(cors());
|
|
app.use(express.json());
|
|
app.use(express.urlencoded({ extended: true }));
|
|
|
|
// Log every request
|
|
app.use((req, _res, next) => {
|
|
const action = req.query['action'] ?? '-';
|
|
const mac =
|
|
(req.headers['cookie'] ?? '')
|
|
.split(';')
|
|
.find((c) => c.trim().startsWith('mac='))
|
|
?.split('=')[1]
|
|
?.trim() ?? (req.query['macAddress'] as string) ?? 'no-mac';
|
|
console.log(
|
|
`[${new Date().toISOString()}] ${req.method} ${req.path} action=${action} mac=${mac}`
|
|
);
|
|
next();
|
|
});
|
|
|
|
// Marketing fixtures store deployment-neutral asset paths. Resolve them
|
|
// against the public origin of each request, including reverse-proxy headers.
|
|
app.use((req, res, next) => {
|
|
if (
|
|
!['get_ordered_list', 'favorites'].includes(
|
|
String(req.query['action'])
|
|
)
|
|
) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
const sendJson = res.json.bind(res);
|
|
const requestOrigin = buildRequestOrigin(req);
|
|
|
|
res.json = ((body: unknown) =>
|
|
sendJson(
|
|
resolveMarketingPosterUrls(body, requestOrigin)
|
|
)) as Response['json'];
|
|
next();
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Routes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Serve the shared screenshot-safe poster catalog directly from this process.
|
|
app.use(
|
|
'/assets/marketing/poster',
|
|
express.static(MARKETING_POSTER_DIRECTORY, {
|
|
fallthrough: false,
|
|
immutable: true,
|
|
maxAge: '1y',
|
|
})
|
|
);
|
|
|
|
// Stalker portal.php endpoint (reseller-panel alias — tolerant, no token check)
|
|
app.use('/portal.php', portalRouter);
|
|
|
|
// Canonical Ministra endpoint — enforces the Bearer token and the MAC format
|
|
// exactly like the real middleware, so the full-portal auth flow is testable.
|
|
app.use('/stalker_portal/server/load.php', createPortalRouter(true));
|
|
|
|
/**
|
|
* CORS proxy compatibility endpoint — mirrors the IPTVnator backend API shape:
|
|
* GET /stalker?url=<portal_url>&macAddress=<mac>&action=<action>&...
|
|
* → { payload: <stalker_response> }
|
|
*
|
|
* The IPTVnator PWA sends Stalker requests to AppConfig.BACKEND_URL/stalker.
|
|
* Playwright tests redirect those calls to this endpoint using page.route(),
|
|
* so no app code changes are required.
|
|
*/
|
|
app.get('/stalker', (req: Request, res: Response) => {
|
|
const {
|
|
macAddress,
|
|
url: portalUrl,
|
|
token,
|
|
...rest
|
|
} = req.query as Record<string, string>;
|
|
const mac = macAddress ?? '00:1a:79:00:00:01';
|
|
|
|
// The real backend proxy turns the token query param into a Bearer header
|
|
// before calling the portal; mirror that so token handling is exercised.
|
|
const headers: Record<string, string> = { cookie: `mac=${mac}` };
|
|
if (token) {
|
|
headers['authorization'] = `Bearer ${token}`;
|
|
}
|
|
|
|
// Build a lightweight synthetic request. We need a fresh object with mutable
|
|
// `query` and a Cookie header containing the MAC for the handler helpers.
|
|
const syntheticReq = {
|
|
query: rest,
|
|
headers,
|
|
params: {},
|
|
} as unknown as Request;
|
|
|
|
// Capture the JSON response and wrap it in the proxy envelope { payload: ... }
|
|
let captured: unknown;
|
|
let plainTextBody: string | undefined;
|
|
const syntheticRes = {
|
|
json: (data: unknown) => {
|
|
captured = data;
|
|
},
|
|
status: () => syntheticRes,
|
|
type: () => syntheticRes,
|
|
send: (body: string) => {
|
|
plainTextBody = body;
|
|
},
|
|
} as unknown as Response & { send: (body: string) => void };
|
|
|
|
dispatchPortalAction(syntheticReq, syntheticRes, {
|
|
// The proxied portal URL decides strictness, matching the two direct
|
|
// endpoints: a canonical Ministra path enforces the token.
|
|
enforceAuth: (portalUrl ?? '').includes('/stalker_portal/'),
|
|
});
|
|
|
|
// The portal answers auth failures with a plain-text body; the real backend
|
|
// proxy still wraps whatever it got in the { payload } envelope, so the
|
|
// renderer sees the raw string there rather than a transport error.
|
|
res.json({ payload: plainTextBody ?? captured });
|
|
});
|
|
|
|
// Health check
|
|
app.get('/health', (_req: Request, res: Response) => {
|
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
|
});
|
|
|
|
// Reset all in-memory data (useful between Playwright test runs)
|
|
app.post('/reset', (_req: Request, res: Response) => {
|
|
resetAll();
|
|
resetAuthState();
|
|
resetWatchdogPings();
|
|
res.json({ status: 'reset', timestamp: new Date().toISOString() });
|
|
});
|
|
|
|
/**
|
|
* Drop a MAC's session so the next portal request fails with
|
|
* `Authorization failed.` — lets e2e assert the client re-handshakes and
|
|
* retries instead of surfacing an error.
|
|
*/
|
|
app.post('/invalidate-session', (req: Request, res: Response) => {
|
|
const mac = String(req.query['macAddress'] ?? '');
|
|
if (!mac) {
|
|
res.status(400).json({ error: 'macAddress query param is required' });
|
|
return;
|
|
}
|
|
invalidateSession(mac);
|
|
res.json({ status: 'invalidated', mac });
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Start
|
|
// ---------------------------------------------------------------------------
|
|
const server = http.createServer(app);
|
|
|
|
server.on('error', (err: NodeJS.ErrnoException) => {
|
|
if (err.code === 'EADDRINUSE') {
|
|
console.error(`[stalker-mock] Port ${PORT} is already in use.`);
|
|
} else {
|
|
console.error('[stalker-mock] Server error:', err.message);
|
|
}
|
|
process.exit(1);
|
|
});
|
|
|
|
const shutdown = () => {
|
|
console.log('\n[stalker-mock] Shutting down...');
|
|
server.close(() => process.exit(0));
|
|
};
|
|
|
|
process.on('SIGINT', shutdown);
|
|
process.on('SIGTERM', shutdown);
|
|
process.on('uncaughtException', (err) => {
|
|
console.error('[stalker-mock] Uncaught exception:', err);
|
|
process.exit(1);
|
|
});
|
|
process.on('unhandledRejection', (reason) => {
|
|
console.error('[stalker-mock] Unhandled rejection:', reason);
|
|
process.exit(1);
|
|
});
|
|
|
|
// When Nx (or any process manager) closes stdin, prevent auto-exit.
|
|
// The HTTP server handle is what keeps the process alive.
|
|
process.stdin.resume();
|
|
process.stdin.on('end', () => { /* ignore stdin close */ });
|
|
|
|
server.listen(PORT, () => {
|
|
const divider = '─'.repeat(62);
|
|
console.log(`\n${divider}`);
|
|
console.log(` 🎬 Stalker Mock Server → http://localhost:${PORT}`);
|
|
console.log(divider);
|
|
console.log(' Portal URL (Electron/direct):');
|
|
console.log(` http://localhost:${PORT}/portal.php`);
|
|
console.log('');
|
|
console.log(' CORS proxy URL (PWA/Playwright e2e):');
|
|
console.log(` http://localhost:${PORT}/stalker?url=...&macAddress=...`);
|
|
console.log('');
|
|
console.log(' Predefined scenario MACs:');
|
|
for (const [mac, scenario] of Object.entries(SCENARIOS)) {
|
|
console.log(
|
|
` ${mac} → ${scenario.name.padEnd(16)} ${scenario.description}`
|
|
);
|
|
}
|
|
console.log('');
|
|
console.log(' Any other MAC generates deterministic unique data from MAC bytes.');
|
|
console.log(` Utilities:`);
|
|
console.log(` GET http://localhost:${PORT}/health`);
|
|
console.log(` POST http://localhost:${PORT}/reset (clears favorites + cache)`);
|
|
console.log(`${divider}\n`);
|
|
});
|