test(electron): cover remote HTTP server

This commit is contained in:
4gray committed 2026-07-26 02:30:40 +02:00
1 parent ae3649a387
commit e6f8dba889
2 files changed
+518 -15

No files matched your search

@@ -0,0 +1,448 @@
import { once } from 'node:events';
import {
mkdirSync,
mkdtempSync,
rmSync,
unlinkSync,
writeFileSync,
} from 'node:fs';
import * as http from 'node:http';
import type { AddressInfo } from 'node:net';
import { tmpdir } from 'node:os';
import { join, posix, win32 } from 'node:path';
import { HttpServer, resolveStaticFilePath } from './http-server';
interface CapturedServer {
listening: Promise<void>;
listeningEvents: number;
server: http.Server;
}
interface TestResponse {
body: Buffer;
contentType: string | undefined;
statusCode: number | undefined;
}
const INDEX_BODY = Buffer.from('<main>IPTVnator remote control</main>');
const ASSETS = {
'/app.js': {
body: Buffer.from('globalThis.remoteControlLoaded = true;'),
contentType: 'application/javascript',
},
'/styles.css': {
body: Buffer.from('main { color: rebeccapurple; }'),
contentType: 'text/css',
},
'/data.json': {
body: Buffer.from('{"ready":true}'),
contentType: 'application/json',
},
'/asset.unknown': {
body: Buffer.from([0x00, 0xff, 0x49, 0x50, 0x54, 0x56]),
contentType: 'application/octet-stream',
},
} as const;
jest.setTimeout(15_000);
function request(port: number, path: string): Promise<TestResponse> {
return new Promise((resolve, reject) => {
const clientRequest = http.request(
{
host: '127.0.0.1',
method: 'GET',
path,
port,
},
(response) => {
const chunks: Buffer[] = [];
response.on('data', (chunk: Buffer) => {
chunks.push(chunk);
});
response.on('end', () => {
const contentType = response.headers['content-type'];
resolve({
body: Buffer.concat(chunks),
contentType:
typeof contentType === 'string'
? contentType
: undefined,
statusCode: response.statusCode,
});
});
response.on('error', reject);
}
);
clientRequest.on('error', reject);
clientRequest.end();
});
}
function getAddress(server: http.Server): AddressInfo {
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Expected the HTTP server to have an IP address');
}
return address;
}
async function closeServer(server: http.Server): Promise<void> {
if (!server.listening) {
return;
}
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
});
}
async function getAvailablePort(): Promise<number> {
const probe = http.createServer();
await new Promise<void>((resolve, reject) => {
probe.once('error', reject);
probe.listen(0, '127.0.0.1', resolve);
});
const port = getAddress(probe).port;
await closeServer(probe);
return port;
}
describe('resolveStaticFilePath', () => {
const POSIX_STATIC_ROOT = '/opt/iptvnator/remote-control';
const WINDOWS_STATIC_ROOT = 'C:\\iptvnator\\remote-control';
it('rejects a Windows double-leading-slash traversal', () => {
expect(
resolveStaticFilePath(
WINDOWS_STATIC_ROOT,
'//../../outside-secret.txt',
win32
)
).toBeNull();
});
it('rejects encoded traversal segments after one decode', () => {
expect(
resolveStaticFilePath(
POSIX_STATIC_ROOT,
'/%2e%2e/outside-secret.txt',
posix
)
).toBeNull();
});
it('fails closed for malformed percent encoding without throwing', () => {
expect(
resolveStaticFilePath(POSIX_STATIC_ROOT, '/%E0%A4%A', posix)
).toBeNull();
});
it('rejects decoded NUL bytes', () => {
expect(
resolveStaticFilePath(
POSIX_STATIC_ROOT,
'/assets/%00secret.js',
posix
)
).toBeNull();
});
it('resolves a valid Windows-style asset inside the static root', () => {
expect(
resolveStaticFilePath(
WINDOWS_STATIC_ROOT,
'/assets\\app.js?version=1',
win32
)
).toBe('C:\\iptvnator\\remote-control\\assets\\app.js');
});
it('ignores query and fragment data for filesystem resolution', () => {
expect(
resolveStaticFilePath(
POSIX_STATIC_ROOT,
'/data.json?cache=1#ignored',
posix
)
).toBe('/opt/iptvnator/remote-control/data.json');
});
it('decodes the URL pathname exactly once', () => {
expect(
resolveStaticFilePath(
POSIX_STATIC_ROOT,
'/%252e%252e/asset.js',
posix
)
).toBe('/opt/iptvnator/remote-control/%2e%2e/asset.js');
});
});
describe('HttpServer', () => {
let capturedServers: CapturedServer[];
let distPath: string;
let server: HttpServer;
let tempRoot: string;
beforeEach(() => {
jest.spyOn(console, 'log').mockImplementation(() => undefined);
capturedServers = [];
tempRoot = mkdtempSync(join(tmpdir(), 'iptvnator-http-server-'));
distPath = join(tempRoot, 'static', 'app');
mkdirSync(distPath, { recursive: true });
writeFileSync(join(distPath, 'index.html'), INDEX_BODY);
for (const [assetPath, asset] of Object.entries(ASSETS)) {
writeFileSync(join(distPath, assetPath), asset.body);
}
writeFileSync(
join(tempRoot, 'outside-secret.txt'),
'must-not-leave-the-static-directory'
);
server = new HttpServer({
createServer: (requestListener) => {
const nodeServer = http.createServer(requestListener);
const captured: CapturedServer = {
listening: once(nodeServer, 'listening').then(
() => undefined
),
listeningEvents: 0,
server: nodeServer,
};
nodeServer.on('listening', () => {
captured.listeningEvents += 1;
});
capturedServers.push(captured);
return nodeServer;
},
distPath,
});
});
afterEach(async () => {
let closeError: unknown;
try {
await Promise.all(
capturedServers.map(({ server: nodeServer }) =>
closeServer(nodeServer)
)
);
} catch (error) {
closeError = error;
} finally {
rmSync(tempRoot, { force: true, recursive: true });
jest.restoreAllMocks();
}
if (closeError) {
throw closeError;
}
});
async function startServer(): Promise<CapturedServer> {
const serverIndex = capturedServers.length;
server.start(0);
const captured = capturedServers[serverIndex];
if (!captured) {
throw new Error('Expected HttpServer.start() to create a server');
}
await captured.listening;
return captured;
}
it('serves the exact index document at the root', async () => {
const captured = await startServer();
const response = await request(getAddress(captured.server).port, '/');
expect(response).toEqual({
body: INDEX_BODY,
contentType: 'text/html',
statusCode: 200,
});
});
it('asks the operating system for an ephemeral port when started with zero', async () => {
const captured = await startServer();
expect(getAddress(captured.server).port).not.toBe(8765);
});
it.each(Object.entries(ASSETS))(
'serves %s with its expected MIME type',
async (assetPath, asset) => {
const captured = await startServer();
const response = await request(
getAddress(captured.server).port,
assetPath
);
expect(response).toEqual({
body: asset.body,
contentType: asset.contentType,
statusCode: 200,
});
}
);
it('falls back to the exact index document for a client route', async () => {
const captured = await startServer();
const response = await request(
getAddress(captured.server).port,
'/channels/favorites'
);
expect(response).toEqual({
body: INDEX_BODY,
contentType: 'text/html',
statusCode: 200,
});
});
it('returns a plain-text 404 when the index document is missing', async () => {
unlinkSync(join(distPath, 'index.html'));
const captured = await startServer();
const response = await request(getAddress(captured.server).port, '/');
expect(response).toEqual({
body: Buffer.from('404 Not Found'),
contentType: 'text/plain',
statusCode: 404,
});
});
it('dispatches a registered API request without serving a static file', async () => {
let receivedRequest: http.IncomingMessage | undefined;
server.registerRemoteControlHandler(
'/api/remote-control/status',
(incomingRequest, response) => {
receivedRequest = incomingRequest;
response.writeHead(200, { 'Content-Type': 'application/json' });
response.end('{"source":"api-handler"}');
}
);
const captured = await startServer();
const response = await request(
getAddress(captured.server).port,
'/api/remote-control/status'
);
expect(receivedRequest?.url).toBe('/api/remote-control/status');
expect(response).toEqual({
body: Buffer.from('{"source":"api-handler"}'),
contentType: 'application/json',
statusCode: 200,
});
expect(response.body).not.toEqual(INDEX_BODY);
});
it('returns a JSON 404 for an unknown remote-control API endpoint', async () => {
const captured = await startServer();
const response = await request(
getAddress(captured.server).port,
'/api/remote-control/missing'
);
expect(response).toEqual({
body: Buffer.from('{"error":"Endpoint not found"}'),
contentType: 'application/json',
statusCode: 404,
});
});
it.each([
'/../../outside-secret.txt',
'//../../outside-secret.txt',
'/%2e%2e/%2e%2e/outside-secret.txt',
'/%E0%A4%A',
])(
'rejects the invalid static request target %s without serving an outside file',
async (requestTarget) => {
const captured = await startServer();
const response = await request(
getAddress(captured.server).port,
requestTarget
);
expect(response).toEqual({
body: Buffer.from('404 Not Found'),
contentType: 'text/plain',
statusCode: 404,
});
expect(response.body.toString()).not.toContain(
'must-not-leave-the-static-directory'
);
}
);
it('creates and listens on only one server when started twice', async () => {
server.start(0);
server.start(0);
expect(capturedServers).toHaveLength(1);
await capturedServers[0].listening;
expect(capturedServers[0].listeningEvents).toBe(1);
});
it('stops the running server when disabled', async () => {
const captured = await startServer();
const closed = once(captured.server, 'close');
server.updateSettings(false, 0);
await closed;
expect(captured.server.listening).toBe(false);
expect(capturedServers).toHaveLength(1);
});
it('stops once and starts once on the new port when the port changes', async () => {
const first = await startServer();
const newPort = await getAvailablePort();
let closeEvents = 0;
first.server.on('close', () => {
closeEvents += 1;
});
const firstClosed = once(first.server, 'close');
server.updateSettings(true, newPort);
const second = capturedServers[1];
if (!second) {
throw new Error('Expected a replacement HTTP server');
}
await Promise.all([firstClosed, second.listening]);
expect(capturedServers).toHaveLength(2);
expect(closeEvents).toBe(1);
expect(first.server.listening).toBe(false);
expect(second.listeningEvents).toBe(1);
expect(getAddress(second.server).port).toBe(newPort);
await expect(request(newPort, '/')).resolves.toEqual({
body: INDEX_BODY,
contentType: 'text/html',
statusCode: 200,
});
});
});
@@ -3,10 +3,61 @@ import * as fs from 'fs';
import * as http from 'http';
import * as path from 'path';
type HttpServerFactory = (requestListener: http.RequestListener) => http.Server;
interface HttpServerOptions {
createServer?: HttpServerFactory;
distPath?: string;
}
/**
* Resolve an HTTP request target within the configured static root.
* The path implementation is injectable so platform-specific semantics remain testable.
*/
export function resolveStaticFilePath(
staticRoot: string,
requestTarget: string,
pathImplementation: path.PlatformPath = path
): string | null {
const separatorIndex = requestTarget.search(/[?#]/);
const encodedPathname =
separatorIndex === -1
? requestTarget
: requestTarget.slice(0, separatorIndex);
let pathname: string;
try {
pathname = decodeURIComponent(encodedPathname);
} catch {
return null;
}
if (pathname.includes('\0')) {
return null;
}
const relativeCandidate = pathname.replace(/^[/\\]+/, '') || 'index.html';
const resolvedRoot = pathImplementation.resolve(staticRoot);
const candidate = pathImplementation.resolve(
resolvedRoot,
relativeCandidate
);
if (
candidate === resolvedRoot ||
candidate.startsWith(`${resolvedRoot}${pathImplementation.sep}`)
) {
return candidate;
}
return null;
}
/**
* HTTP server for serving the remote control web app and providing REST API endpoints
*/
export class HttpServer {
private readonly createServer: HttpServerFactory;
private server: http.Server | null = null;
private port = 8765;
private isEnabled = false;
@@ -16,6 +67,11 @@ export class HttpServer {
(req: http.IncomingMessage, res: http.ServerResponse) => void
> = new Map();
constructor(options: HttpServerOptions = {}) {
this.createServer = options.createServer ?? http.createServer;
this.distPath = options.distPath ?? null;
}
/**
* Get the path to the remote-control-web static files.
* Lazily computed to avoid calling Electron APIs before app is ready.
@@ -43,11 +99,7 @@ export class HttpServer {
} else {
// Production mode - files are bundled with the app
// electron-builder copies remote-control-web/**/* directly to app root
this.distPath = path.join(
appPath,
'remote-control-web',
'browser'
);
this.distPath = path.join(appPath, 'remote-control-web', 'browser');
}
console.log('[HTTP Server] Serving from:', this.distPath);
@@ -58,7 +110,7 @@ export class HttpServer {
* Start the HTTP server
*/
start(port?: number): void {
if (port) {
if (port !== undefined) {
this.port = port;
}
@@ -67,7 +119,7 @@ export class HttpServer {
return;
}
this.server = http.createServer((req, res) => {
this.server = this.createServer((req, res) => {
this.handleRequest(req, res);
});
@@ -153,18 +205,21 @@ export class HttpServer {
* Serve static files
*/
private serveStaticFile(url: string, res: http.ServerResponse): void {
// Default to index.html for root path
let filePath = url === '/' ? '/index.html' : url;
// Security: prevent directory traversal
filePath = path.normalize(filePath).replace(/^(\.\.[/\\])+/, '');
const fullPath = path.join(this.getDistPath(), filePath);
const distPath = this.getDistPath();
const fullPath = resolveStaticFilePath(distPath, url);
if (!fullPath) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('404 Not Found');
return;
}
fs.readFile(fullPath, (err, data) => {
if (err) {
// If file not found, try serving index.html (for Angular routing)
if (err.code === 'ENOENT' && filePath !== '/index.html') {
if (
err.code === 'ENOENT' &&
fullPath !== path.resolve(distPath, 'index.html')
) {
this.serveStaticFile('/', res);
return;
}