mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
test(electron): cover remote HTTP server
This commit is contained in:
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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user