Skip to content
Merged
880 changes: 240 additions & 640 deletions broker/package-lock.json

Large diffs are not rendered by default.

53 changes: 53 additions & 0 deletions broker/tests/certExpiry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import * as selfsigned from 'selfsigned';
import type { Logger } from 'pino';
import { warnIfExpiringSoon } from '../src/certExpiry';

// Minimal logger stub capturing warn() calls; certExpiry only uses log.warn.
function makeLog() {
const warnings: { obj: unknown; msg: string }[] = [];
const log = {
warn: (obj: unknown, msg?: string) => { warnings.push({ obj, msg: msg ?? '' }); },
};
return { log: log as unknown as Logger, warnings };
}

function certValidFor(days: number): Buffer {
const pems = selfsigned.generate([{ name: 'commonName', value: '127.0.0.1' }], { days });
return Buffer.from(pems.cert);
}

describe('warnIfExpiringSoon', () => {
afterEach(() => {
jest.restoreAllMocks();
});

it('does not warn for a certificate valid well beyond the threshold', () => {
const { log, warnings } = makeLog();
warnIfExpiringSoon(certValidFor(365), log);
expect(warnings).toHaveLength(0);
});

it('warns that the certificate is expiring soon when inside the 30-day window', () => {
const { log, warnings } = makeLog();
warnIfExpiringSoon(certValidFor(10), log);
expect(warnings).toHaveLength(1);
expect(warnings[0].msg).toContain('expiring soon');
expect((warnings[0].obj as { daysLeft: number }).daysLeft).toBeLessThan(30);
});

it('warns that the certificate has expired when validTo is in the past', () => {
const { log, warnings } = makeLog();
const cert = certValidFor(365);
// Jump 400 days into the future so the 365-day cert reads as expired.
jest.spyOn(Date, 'now').mockReturnValue(Date.now() + 400 * 86_400_000);
warnIfExpiringSoon(cert, log);
expect(warnings).toHaveLength(1);
expect(warnings[0].msg).toContain('has expired');
});

it('silently ignores an unparseable certificate buffer', () => {
const { log, warnings } = makeLog();
expect(() => warnIfExpiringSoon(Buffer.from('not a certificate'), log)).not.toThrow();
expect(warnings).toHaveLength(0);
});
});
72 changes: 72 additions & 0 deletions broker/tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,75 @@ describe('loadConfig — M6 TLS path containment', () => {
expect(() => loadConfig(envFile)).toThrow(/resolves outside the allowed directory/);
});
});

describe('loadConfig — validation errors', () => {
let tmpRoot: string;

beforeEach(() => {
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'selfdesk-cfgv-'));
// dotenv does not override already-set process.env, so clear every var
// loadConfig reads to keep these cases independent of each other.
for (const k of ['SHARED_SECRET', 'TLS_CERT_PATH', 'TLS_KEY_PATH',
'LISTEN_PORT', 'HEALTH_PORT', 'METRICS_PORT', 'WS_PORT']) {
delete process.env[k];
}
});

afterEach(() => {
fs.rmSync(tmpRoot, { recursive: true, force: true });
});

function writeLines(lines: string[]): string {
const envFile = path.join(tmpRoot, '.env');
fs.writeFileSync(envFile, lines.join('\n'));
return envFile;
}

function validCerts(): string[] {
fs.writeFileSync(path.join(tmpRoot, 'server-cert.pem'), 'cert');
fs.writeFileSync(path.join(tmpRoot, 'server-key.pem'), 'key');
return ['TLS_CERT_PATH=./server-cert.pem', 'TLS_KEY_PATH=./server-key.pem'];
}

it('throws on a non-numeric LISTEN_PORT', () => {
const envFile = writeLines([
'LISTEN_PORT=not-a-port',
'SHARED_SECRET=' + 'a'.repeat(32),
...validCerts(),
]);
expect(() => loadConfig(envFile)).toThrow(/Invalid LISTEN_PORT/);
});

it('throws on an out-of-range LISTEN_PORT', () => {
const envFile = writeLines([
'LISTEN_PORT=99999',
'SHARED_SECRET=' + 'a'.repeat(32),
...validCerts(),
]);
expect(() => loadConfig(envFile)).toThrow(/Invalid LISTEN_PORT/);
});

it('throws when SHARED_SECRET is missing', () => {
const envFile = writeLines([...validCerts()]);
expect(() => loadConfig(envFile)).toThrow(/SHARED_SECRET missing or too short/);
});

it('throws when SHARED_SECRET is shorter than 32 characters', () => {
const envFile = writeLines(['SHARED_SECRET=' + 'a'.repeat(31), ...validCerts()]);
expect(() => loadConfig(envFile)).toThrow(/SHARED_SECRET missing or too short/);
});

it('throws when TLS paths are missing', () => {
const envFile = writeLines(['SHARED_SECRET=' + 'a'.repeat(32)]);
expect(() => loadConfig(envFile)).toThrow(/TLS_CERT_PATH and TLS_KEY_PATH are required/);
});

it('throws when the TLS certificate file does not exist', () => {
const envFile = writeLines([
'SHARED_SECRET=' + 'a'.repeat(32),
'TLS_CERT_PATH=./missing-cert.pem',
'TLS_KEY_PATH=./missing-key.pem',
]);
expect(() => loadConfig(envFile)).toThrow(/does not exist or is not a regular file/);
});
});
6 changes: 3 additions & 3 deletions broker/tests/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,8 @@ describe('Full handshake', () => {
});
});

describe('SENDER_UP ao conectar', () => {
it('receiver recebe SENDER_UP quando sender conecta', async () => {
describe('SENDER_UP on connect', () => {
it('receiver receives SENDER_UP when a sender connects', async () => {
const rxSock = await tlsConnect();
await doHandshake(rxSock, 'receiver', 'receiver', SECRET);

Expand All @@ -177,7 +177,7 @@ describe('SENDER_UP ao conectar', () => {
});

describe('PING/PONG', () => {
it('broker responde PONG para PING do sender', async () => {
it('broker replies PONG to a sender PING', async () => {
const sock = await tlsConnect();
await doHandshake(sock, 'sender', 'laptop-01', SECRET);

Expand Down
106 changes: 106 additions & 0 deletions broker/tests/registry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { EventEmitter } from 'node:events';
import { Registry } from '../src/registry';
import pino from 'pino';

const log = pino({ level: 'silent' });

// Minimal Connection fake: an EventEmitter with the fields Registry reads.
function makeConn(state = 'ACTIVE'): any {
const conn = new EventEmitter() as any;
conn.state = state;
conn.mac = undefined;
conn.senderVersion = undefined;
conn.close = jest.fn(() => { conn.state = 'CLOSED'; });
return conn;
}

describe('Registry — sender lifecycle', () => {
it('closes the previous connection when a sender reconnects with the same id', () => {
const registry = new Registry(log);
const first = makeConn();
const second = makeConn();

registry.registerSender('laptop-01', first);
registry.registerSender('laptop-01', second);

expect(first.close).toHaveBeenCalledTimes(1);
expect(registry.getSender('laptop-01')).toBe(second);
});

it('does not close a previous connection that is already CLOSED', () => {
const registry = new Registry(log);
const first = makeConn('CLOSED');
const second = makeConn();

registry.registerSender('laptop-01', first);
registry.registerSender('laptop-01', second);

expect(first.close).not.toHaveBeenCalled();
expect(registry.getSender('laptop-01')).toBe(second);
});

it('a stale closed event from the old connection does not evict the new one (S56)', () => {
const registry = new Registry(log);
const first = makeConn();
const second = makeConn();

registry.registerSender('laptop-01', first);
registry.registerSender('laptop-01', second);
// The old connection finally emits its delayed 'closed' — must not remove the new entry.
first.emit('closed');

expect(registry.hasSender('laptop-01')).toBe(true);
expect(registry.getSender('laptop-01')).toBe(second);
});

it('removes the sender when its own closed event fires', () => {
const registry = new Registry(log);
const conn = makeConn();
registry.registerSender('laptop-01', conn);

conn.emit('closed');

expect(registry.hasSender('laptop-01')).toBe(false);
});

it('exposes registered senders via getSenderIds and getSenders', () => {
const registry = new Registry(log);
const a = makeConn(); a.mac = 'AA'; a.senderVersion = '0.8.3';
const b = makeConn();
registry.registerSender('laptop-01', a);
registry.registerSender('laptop-02', b);

expect(registry.getSenderIds().sort()).toEqual(['laptop-01', 'laptop-02']);
const entry = registry.getSenders().find((s) => s.id === 'laptop-01');
expect(entry).toEqual({ id: 'laptop-01', mac: 'AA', version: '0.8.3' });
});
});

describe('Registry — receiver lifecycle', () => {
it('closes the previous receiver when a second one connects', () => {
const registry = new Registry(log);
const first = makeConn();
const second = makeConn();

registry.registerReceiver(first);
registry.registerReceiver(second);

expect(first.close).toHaveBeenCalledTimes(1);
expect(registry.getReceiver()).toBe(second);
});

it('clears the receiver only when the current one closes', () => {
const registry = new Registry(log);
const first = makeConn();
const second = makeConn();

registry.registerReceiver(first);
registry.registerReceiver(second);
// Old receiver's delayed 'closed' must not clear the active (second) receiver.
first.emit('closed');
expect(registry.getReceiver()).toBe(second);

second.emit('closed');
expect(registry.getReceiver()).toBeUndefined();
});
});
57 changes: 57 additions & 0 deletions broker/tests/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,60 @@ describe('Router — SENDER_UP / SENDER_DOWN', () => {
expect(upMsgs.length).toBe(2);
});
});

describe('Router — message-type classification', () => {
function setup() {
const registry = new Registry(log);
const router = new Router(registry, log);
const sender = makeConn('laptop-01');
const receiver = makeReceiver();
router.onSenderAuthenticated('laptop-01', sender);
router.onReceiverAuthenticated(receiver);
return { router, sender, receiver };
}

it('forwards a sender→receiver control message (MONITOR_LIST) to the receiver', () => {
const { sender, receiver } = setup();
const before = receiver._sent.length;
const payload = Buffer.from([0x01]);
sender.emit('message',
{ version: 1, type: MessageType.MONITOR_LIST, peerId: 'laptop-01', length: payload.length }, payload);
const last = receiver._sent[receiver._sent.length - 1];
expect(receiver._sent.length).toBe(before + 1);
expect(parseHeader(last).type).toBe(MessageType.MONITOR_LIST);
});

it('ignores a sender message whose type belongs to the receiver direction (INPUT_EVENT)', () => {
const { sender, receiver } = setup();
const before = receiver._sent.length;
sender.emit('message',
{ version: 1, type: MessageType.INPUT_EVENT, peerId: 'laptop-01', length: 1 }, Buffer.from([0x01]));
expect(receiver._sent.length).toBe(before);
});

it('forwards a file message with an explicit PEER_ID to the target sender', () => {
const { sender, receiver } = setup();
const before = sender._sent.length;
const payload = Buffer.from([0xAB]);
receiver.emit('message',
{ version: 1, type: MessageType.FILE_HEADER, peerId: 'laptop-01', length: payload.length }, payload);
expect(sender._sent.length).toBe(before + 1);
expect(parseHeader(sender._sent[sender._sent.length - 1]).type).toBe(MessageType.FILE_HEADER);
});

it('discards a file message that has no target PEER_ID', () => {
const { sender, receiver } = setup();
const before = sender._sent.length;
receiver.emit('message',
{ version: 1, type: MessageType.FILE_HEADER, peerId: '', length: 1 }, Buffer.from([0x01]));
expect(sender._sent.length).toBe(before);
});

it('ignores a receiver message whose type belongs to the sender direction (VIDEO_FRAME)', () => {
const { sender, receiver } = setup();
const before = sender._sent.length;
receiver.emit('message',
{ version: 1, type: MessageType.VIDEO_FRAME, peerId: 'laptop-01', length: 1 }, Buffer.from([0x01]));
expect(sender._sent.length).toBe(before);
});
});
12 changes: 12 additions & 0 deletions broker/tests/wsServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,18 @@ describe('static file server', () => {
expect(res.status).toBe(404);
});

it('405s a non-GET request (only static GETs are served)', async () => {
const res = await new Promise<{ status: number }>((resolve, reject) => {
const req = https.request(
{ host: '127.0.0.1', port, path: '/', method: 'POST', rejectUnauthorized: false },
(r) => { r.resume(); resolve({ status: r.statusCode ?? 0 }); },
);
req.on('error', reject);
req.end();
});
expect(res.status).toBe(405);
});

it('429s once the IP is blocked (shared rate limiter, not just the WS path)', async () => {
// Isolated server + limiters so tripping the block here doesn't affect the
// other tests in this file, which all share the same 127.0.0.1 source IP.
Expand Down
2 changes: 1 addition & 1 deletion sender/src/Capture/MonitorEnumerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ private static IReadOnlyList<MonitorInfo> EnumerateViaDxgi()
adapterI++;
}

if (entries.Count == 0) throw new InvalidOperationException("Nenhum output DXGI");
if (entries.Count == 0) throw new InvalidOperationException("No DXGI output found");

entries.Sort((a, b) =>
{
Expand Down
4 changes: 2 additions & 2 deletions viewer/Viewer.Codec/H264Decoder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public H264Decoder()
codec = ffmpeg.avcodec_find_decoder(AVCodecID.AV_CODEC_ID_H264);

if (codec == null)
throw new InvalidOperationException("Nenhum decoder H264 encontrado.");
throw new InvalidOperationException("No H264 decoder found.");

_codecCtx = ffmpeg.avcodec_alloc_context3(codec);
if (_codecCtx == null)
Expand Down Expand Up @@ -101,7 +101,7 @@ public DecodedFrame Decode(ReadOnlySpan<byte> data, int width, int height, long
ffmpeg.av_frame_unref(_swFrame);
int xfer = ffmpeg.av_hwframe_transfer_data(_swFrame, _hwFrame, 0);
if (xfer < 0)
throw new InvalidOperationException($"av_hwframe_transfer_data falhou: {xfer}");
throw new InvalidOperationException($"av_hwframe_transfer_data failed: {xfer}");
decodeFrame = _swFrame;
}
else
Expand Down
Loading