From 66393d5ba4488d0f7c802ee690227988861a1bf5 Mon Sep 17 00:00:00 2001 From: KingKDot Date: Wed, 8 Apr 2026 21:49:59 -0500 Subject: [PATCH] add another 100 tests (thank you claude for making these I ain't coming up with this much shit to test) --- .../cmd/agent/handlers/extract_test.go | 137 +++++++++ .../agent/handlers/silent_exec_args_test.go | 84 ++++++ .../cmd/agent/handlers/stream_limit_test.go | 91 ++++++ Overlord-Client/cmd/agent/runtime/env_test.go | 130 +++++++++ Overlord-Client/cmd/agent/wire/codec_test.go | 106 +++++++ Overlord-Server/src/metrics.test.ts | 220 ++++++++++++++ Overlord-Server/src/rbac.test.ts | 209 ++++++++++++++ .../src/sessions/sessionManager.test.ts | 271 ++++++++++++++++++ 8 files changed, 1248 insertions(+) create mode 100644 Overlord-Client/cmd/agent/handlers/extract_test.go create mode 100644 Overlord-Client/cmd/agent/handlers/silent_exec_args_test.go create mode 100644 Overlord-Client/cmd/agent/handlers/stream_limit_test.go create mode 100644 Overlord-Client/cmd/agent/runtime/env_test.go create mode 100644 Overlord-Client/cmd/agent/wire/codec_test.go create mode 100644 Overlord-Server/src/metrics.test.ts create mode 100644 Overlord-Server/src/rbac.test.ts create mode 100644 Overlord-Server/src/sessions/sessionManager.test.ts diff --git a/Overlord-Client/cmd/agent/handlers/extract_test.go b/Overlord-Client/cmd/agent/handlers/extract_test.go new file mode 100644 index 0000000..0c03ca9 --- /dev/null +++ b/Overlord-Client/cmd/agent/handlers/extract_test.go @@ -0,0 +1,137 @@ +package handlers + +import ( + "encoding/json" + "math" + "testing" +) + +func TestExtractTimestamp_Int64(t *testing.T) { + var ts int64 = 1700000000000 + if got := extractTimestamp(ts); got != ts { + t.Fatalf("expected %d, got %d", ts, got) + } +} + +func TestExtractTimestamp_Uint64(t *testing.T) { + var ts uint64 = 1700000000000 + if got := extractTimestamp(ts); got != int64(ts) { + t.Fatalf("expected %d, got %d", ts, got) + } +} + +func TestExtractTimestamp_Uint64Overflow(t *testing.T) { + // Values exceeding max int64 should return 0 + var ts uint64 = math.MaxUint64 + if got := extractTimestamp(ts); got != 0 { + t.Fatalf("expected 0 for overflow, got %d", got) + } +} + +func TestExtractTimestamp_Uint32(t *testing.T) { + var ts uint32 = 170000 + if got := extractTimestamp(ts); got != int64(ts) { + t.Fatalf("expected %d, got %d", ts, got) + } +} + +func TestExtractTimestamp_Uint16(t *testing.T) { + var ts uint16 = 1234 + if got := extractTimestamp(ts); got != int64(ts) { + t.Fatalf("expected %d, got %d", ts, got) + } +} + +func TestExtractTimestamp_Uint8(t *testing.T) { + var ts uint8 = 42 + if got := extractTimestamp(ts); got != int64(ts) { + t.Fatalf("expected %d, got %d", ts, got) + } +} + +func TestExtractTimestamp_Int32(t *testing.T) { + var ts int32 = 12345678 + if got := extractTimestamp(ts); got != int64(ts) { + t.Fatalf("expected %d, got %d", ts, got) + } +} + +func TestExtractTimestamp_Int16(t *testing.T) { + var ts int16 = 9999 + if got := extractTimestamp(ts); got != int64(ts) { + t.Fatalf("expected %d, got %d", ts, got) + } +} + +func TestExtractTimestamp_Int8(t *testing.T) { + var ts int8 = 127 + if got := extractTimestamp(ts); got != int64(ts) { + t.Fatalf("expected %d, got %d", ts, got) + } +} + +func TestExtractTimestamp_Int(t *testing.T) { + var ts int = 1700000000 + if got := extractTimestamp(ts); got != int64(ts) { + t.Fatalf("expected %d, got %d", ts, got) + } +} + +func TestExtractTimestamp_Float64(t *testing.T) { + var ts float64 = 1700000000000.0 + if got := extractTimestamp(ts); got != int64(ts) { + t.Fatalf("expected %d, got %d", int64(ts), got) + } +} + +func TestExtractTimestamp_Float32(t *testing.T) { + var ts float32 = 170000.0 + if got := extractTimestamp(ts); got != int64(ts) { + t.Fatalf("expected %d, got %d", int64(ts), got) + } +} + +func TestExtractTimestamp_JsonNumber(t *testing.T) { + n := json.Number("1700000000000") + if got := extractTimestamp(n); got != 1700000000000 { + t.Fatalf("expected 1700000000000, got %d", got) + } +} + +func TestExtractTimestamp_InvalidJsonNumber(t *testing.T) { + n := json.Number("not-a-number") + if got := extractTimestamp(n); got != 0 { + t.Fatalf("expected 0 for invalid json.Number, got %d", got) + } +} + +func TestExtractTimestamp_String(t *testing.T) { + if got := extractTimestamp("hello"); got != 0 { + t.Fatalf("expected 0 for string, got %d", got) + } +} + +func TestExtractTimestamp_Nil(t *testing.T) { + if got := extractTimestamp(nil); got != 0 { + t.Fatalf("expected 0 for nil, got %d", got) + } +} + +func TestExtractTimestamp_Bool(t *testing.T) { + if got := extractTimestamp(true); got != 0 { + t.Fatalf("expected 0 for bool, got %d", got) + } +} + +func TestExtractTimestamp_Zero(t *testing.T) { + if got := extractTimestamp(int64(0)); got != 0 { + t.Fatalf("expected 0, got %d", got) + } +} + +func TestExtractTimestamp_Negative(t *testing.T) { + var ts int64 = -100 + if got := extractTimestamp(ts); got != ts { + t.Fatalf("expected %d, got %d", ts, got) + } +} diff --git a/Overlord-Client/cmd/agent/handlers/silent_exec_args_test.go b/Overlord-Client/cmd/agent/handlers/silent_exec_args_test.go new file mode 100644 index 0000000..24914d0 --- /dev/null +++ b/Overlord-Client/cmd/agent/handlers/silent_exec_args_test.go @@ -0,0 +1,84 @@ +package handlers + +import ( + "testing" +) + +func TestParseCommandArgs_EmptyInput(t *testing.T) { + result := parseCommandArgs("") + if len(result) != 0 { + t.Fatalf("expected empty slice, got %v", result) + } +} + +func TestParseCommandArgs_WhitespaceOnly(t *testing.T) { + result := parseCommandArgs(" \t ") + if len(result) != 0 { + t.Fatalf("expected empty slice, got %v", result) + } +} + +func TestParseCommandArgs_SimpleWords(t *testing.T) { + result := parseCommandArgs("hello world foo") + if len(result) != 3 || result[0] != "hello" || result[1] != "world" || result[2] != "foo" { + t.Fatalf("expected [hello world foo], got %v", result) + } +} + +func TestParseCommandArgs_DoubleQuotes(t *testing.T) { + result := parseCommandArgs(`echo "hello world" done`) + if len(result) != 3 || result[0] != "echo" || result[1] != "hello world" || result[2] != "done" { + t.Fatalf("expected [echo, hello world, done], got %v", result) + } +} + +func TestParseCommandArgs_SingleQuotes(t *testing.T) { + result := parseCommandArgs(`echo 'hello world' done`) + if len(result) != 3 || result[0] != "echo" || result[1] != "hello world" || result[2] != "done" { + t.Fatalf("expected [echo, hello world, done], got %v", result) + } +} + +func TestParseCommandArgs_EscapedChars(t *testing.T) { + result := parseCommandArgs(`hello\ world`) + if len(result) != 1 || result[0] != "hello world" { + t.Fatalf("expected [hello world], got %v", result) + } +} + +func TestParseCommandArgs_MixedQuotes(t *testing.T) { + result := parseCommandArgs(`cmd "arg one" 'arg two' plain`) + if len(result) != 4 { + t.Fatalf("expected 4 args, got %d: %v", len(result), result) + } + if result[0] != "cmd" || result[1] != "arg one" || result[2] != "arg two" || result[3] != "plain" { + t.Fatalf("got %v", result) + } +} + +func TestParseCommandArgs_TrailingEscape(t *testing.T) { + result := parseCommandArgs(`hello\`) + if len(result) != 1 || result[0] != `hello\` { + t.Fatalf("expected [hello\\], got %v", result) + } +} + +func TestParseCommandArgs_EmptyQuotes(t *testing.T) { + result := parseCommandArgs(`a "" b`) + if len(result) != 3 || result[1] != "" { + if len(result) == 2 && result[0] == "a" && result[1] == "b" { + return + } + t.Logf("result: %v (len %d)", result, len(result)) + } +} + +func TestParseCommandArgs_PathWithSpaces(t *testing.T) { + result := parseCommandArgs(`"C:\\Program Files\\App\\app.exe" --flag value`) + if len(result) != 3 { + t.Fatalf("expected 3 args, got %d: %v", len(result), result) + } + if result[0] != `C:\Program Files\App\app.exe` { + t.Fatalf("path not preserved: %s", result[0]) + } +} diff --git a/Overlord-Client/cmd/agent/handlers/stream_limit_test.go b/Overlord-Client/cmd/agent/handlers/stream_limit_test.go new file mode 100644 index 0000000..e88bea4 --- /dev/null +++ b/Overlord-Client/cmd/agent/handlers/stream_limit_test.go @@ -0,0 +1,91 @@ +package handlers + +import ( + "os" + "testing" + "time" +) + +func TestStreamInterval_Default(t *testing.T) { + os.Unsetenv("TEST_FPS_VAR") + interval, fps := streamInterval("TEST_FPS_VAR", 30) + if fps != 30 { + t.Fatalf("expected fps=30, got %d", fps) + } + expected := time.Second / 30 + if interval != expected { + t.Fatalf("expected interval=%v, got %v", expected, interval) + } +} + +func TestStreamInterval_CustomValue(t *testing.T) { + os.Setenv("TEST_FPS_VAR2", "60") + defer os.Unsetenv("TEST_FPS_VAR2") + + interval, fps := streamInterval("TEST_FPS_VAR2", 30) + if fps != 60 { + t.Fatalf("expected fps=60, got %d", fps) + } + expected := time.Second / 60 + if interval != expected { + t.Fatalf("expected interval=%v, got %v", expected, interval) + } +} + +func TestStreamInterval_InvalidFallsBack(t *testing.T) { + os.Setenv("TEST_FPS_BAD", "abc") + defer os.Unsetenv("TEST_FPS_BAD") + + _, fps := streamInterval("TEST_FPS_BAD", 25) + if fps != 25 { + t.Fatalf("expected default fps=25 on bad input, got %d", fps) + } +} + +func TestStreamInterval_ZeroFallsBack(t *testing.T) { + os.Setenv("TEST_FPS_ZERO", "0") + defer os.Unsetenv("TEST_FPS_ZERO") + + _, fps := streamInterval("TEST_FPS_ZERO", 15) + if fps != 15 { + t.Fatalf("expected fps=15 when env=0, got %d", fps) + } +} + +func TestStreamInterval_NegativeFallsBack(t *testing.T) { + os.Setenv("TEST_FPS_NEG", "-5") + defer os.Unsetenv("TEST_FPS_NEG") + + _, fps := streamInterval("TEST_FPS_NEG", 20) + if fps != 20 { + t.Fatalf("expected fps=20 on negative input, got %d", fps) + } +} + +func TestStreamInterval_ClampMax(t *testing.T) { + os.Setenv("TEST_FPS_HIGH", "9999") + defer os.Unsetenv("TEST_FPS_HIGH") + + _, fps := streamInterval("TEST_FPS_HIGH", 30) + if fps != 1000 { + t.Fatalf("expected clamped fps=1000, got %d", fps) + } +} + +func TestStreamInterval_ClampMin(t *testing.T) { + os.Unsetenv("TEST_FPS_MIN") + _, fps := streamInterval("TEST_FPS_MIN", 0) + if fps != 1 { + t.Fatalf("expected clamped fps=1, got %d", fps) + } +} + +func TestStreamInterval_WhitespaceEnv(t *testing.T) { + os.Setenv("TEST_FPS_WS", " 45 ") + defer os.Unsetenv("TEST_FPS_WS") + + _, fps := streamInterval("TEST_FPS_WS", 30) + if fps != 45 { + t.Fatalf("expected fps=45 (trimmed whitespace), got %d", fps) + } +} diff --git a/Overlord-Client/cmd/agent/runtime/env_test.go b/Overlord-Client/cmd/agent/runtime/env_test.go new file mode 100644 index 0000000..39e7ed2 --- /dev/null +++ b/Overlord-Client/cmd/agent/runtime/env_test.go @@ -0,0 +1,130 @@ +package runtime + +import ( + "testing" + "time" +) + +func TestEnv_NotificationConfig(t *testing.T) { + env := &Env{} + + if kw := env.GetNotificationKeywords(); kw != nil { + t.Fatalf("expected nil keywords initially, got %v", kw) + } + if ms := env.GetNotificationMinIntervalMs(); ms != 0 { + t.Fatalf("expected 0 interval initially, got %d", ms) + } + if cb := env.GetClipboardEnabled(); cb { + t.Fatal("expected clipboard disabled initially") + } + + env.SetNotificationConfig([]string{"bank", "password"}, 5000, true) + + kw := env.GetNotificationKeywords() + if len(kw) != 2 || kw[0] != "bank" || kw[1] != "password" { + t.Fatalf("expected [bank password], got %v", kw) + } + if env.GetNotificationMinIntervalMs() != 5000 { + t.Fatalf("expected 5000ms interval, got %d", env.GetNotificationMinIntervalMs()) + } + if !env.GetClipboardEnabled() { + t.Fatal("expected clipboard enabled") + } +} + +func TestEnv_NotificationConfig_ZeroInterval(t *testing.T) { + env := &Env{} + env.SetNotificationConfig([]string{"test"}, 3000, false) + env.SetNotificationConfig([]string{"test2"}, 0, false) + + if env.GetNotificationMinIntervalMs() != 3000 { + t.Fatalf("expected interval to remain 3000 when 0 passed, got %d", env.GetNotificationMinIntervalMs()) + } +} + +func TestEnv_NotificationKeywords_ReturnsCopy(t *testing.T) { + env := &Env{} + env.SetNotificationConfig([]string{"a", "b"}, 1000, false) + + kw1 := env.GetNotificationKeywords() + kw2 := env.GetNotificationKeywords() + + kw1[0] = "modified" + if env.GetNotificationKeywords()[0] == "modified" { + t.Fatal("GetNotificationKeywords should return a copy") + } + + if kw2[0] != "a" || kw2[1] != "b" { + t.Fatalf("expected [a b], got %v", kw2) + } +} + +func TestEnv_LastPong(t *testing.T) { + env := &Env{} + + if !env.LastPong().IsZero() { + t.Fatal("expected zero time for unset LastPong") + } + + now := time.Now().UnixMilli() + env.SetLastPong(now) + + pong := env.LastPong() + if pong.UnixMilli() != now { + t.Fatalf("expected %d, got %d", now, pong.UnixMilli()) + } +} + +func TestEnv_LastPong_ZeroDefault(t *testing.T) { + env := &Env{} + + before := time.Now().UnixMilli() + env.SetLastPong(0) + after := time.Now().UnixMilli() + + pong := env.LastPong() + if pong.UnixMilli() < before || pong.UnixMilli() > after { + t.Fatalf("expected time between %d and %d, got %d", before, after, pong.UnixMilli()) + } +} + +func TestEnv_LastPong_NegativeDefault(t *testing.T) { + env := &Env{} + before := time.Now().UnixMilli() + env.SetLastPong(-1) + after := time.Now().UnixMilli() + + pong := env.LastPong() + if pong.UnixMilli() < before || pong.UnixMilli() > after { + t.Fatalf("expected time between %d and %d, got %d", before, after, pong.UnixMilli()) + } +} + +func TestHostname(t *testing.T) { + h := Hostname() + if h == "" { + t.Fatal("expected non-empty hostname") + } +} + +func TestCurrentUser(t *testing.T) { + u := CurrentUser() + if u == "" { + t.Fatal("expected non-empty user") + } +} + +func TestMinDuration(t *testing.T) { + if got := MinDuration(1*time.Second, 5*time.Second); got != 1*time.Second { + t.Fatalf("expected 1s, got %s", got) + } + if got := MinDuration(5*time.Second, 1*time.Second); got != 1*time.Second { + t.Fatalf("expected 1s, got %s", got) + } + if got := MinDuration(3*time.Second, 3*time.Second); got != 3*time.Second { + t.Fatalf("expected 3s, got %s", got) + } + if got := MinDuration(0, 1*time.Second); got != 0 { + t.Fatalf("expected 0, got %s", got) + } +} diff --git a/Overlord-Client/cmd/agent/wire/codec_test.go b/Overlord-Client/cmd/agent/wire/codec_test.go new file mode 100644 index 0000000..c27d1ee --- /dev/null +++ b/Overlord-Client/cmd/agent/wire/codec_test.go @@ -0,0 +1,106 @@ +package wire + +import ( + "testing" + + "github.com/vmihailenco/msgpack/v5" +) + +func TestDecodeEnvelope_ValidMessage(t *testing.T) { + original := map[string]interface{}{ + "type": "hello", + "id": "test-id", + "commandType": "console", + } + + data, err := msgpack.Marshal(original) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + + decoded, err := DecodeEnvelope(data) + if err != nil { + t.Fatalf("failed to decode: %v", err) + } + + if decoded["type"] != "hello" { + t.Fatalf("expected type=hello, got %v", decoded["type"]) + } + if decoded["id"] != "test-id" { + t.Fatalf("expected id=test-id, got %v", decoded["id"]) + } +} + +func TestDecodeEnvelope_EmptyMap(t *testing.T) { + data, _ := msgpack.Marshal(map[string]interface{}{}) + decoded, err := DecodeEnvelope(data) + if err != nil { + t.Fatalf("failed to decode empty map: %v", err) + } + if len(decoded) != 0 { + t.Fatalf("expected empty map, got %v", decoded) + } +} + +func TestDecodeEnvelope_InvalidData(t *testing.T) { + _, err := DecodeEnvelope([]byte{0xFF, 0xFE, 0xFD}) + if err == nil { + t.Fatal("expected error for invalid msgpack data") + } +} + +func TestDecodeEnvelope_NestedPayload(t *testing.T) { + original := map[string]interface{}{ + "type": "command", + "payload": map[string]interface{}{ + "path": "/tmp/test", + "recurse": true, + }, + } + + data, _ := msgpack.Marshal(original) + decoded, err := DecodeEnvelope(data) + if err != nil { + t.Fatalf("failed to decode: %v", err) + } + + payload, ok := decoded["payload"].(map[string]interface{}) + if !ok { + t.Fatalf("expected payload to be a map, got %T", decoded["payload"]) + } + if payload["path"] != "/tmp/test" { + t.Fatalf("expected path=/tmp/test, got %v", payload["path"]) + } +} + +func TestDecodeEnvelope_NumericValues(t *testing.T) { + original := map[string]interface{}{ + "type": "ping", + "ts": int64(1700000000000), + "count": uint32(42), + } + + data, _ := msgpack.Marshal(original) + decoded, err := DecodeEnvelope(data) + if err != nil { + t.Fatalf("failed to decode: %v", err) + } + + if decoded["type"] != "ping" { + t.Fatalf("expected type=ping, got %v", decoded["type"]) + } +} + +func TestDecodeEnvelope_NilBytes(t *testing.T) { + _, err := DecodeEnvelope(nil) + if err == nil { + t.Fatal("expected error for nil input") + } +} + +func TestDecodeEnvelope_EmptyBytes(t *testing.T) { + _, err := DecodeEnvelope([]byte{}) + if err == nil { + t.Fatal("expected error for empty input") + } +} diff --git a/Overlord-Server/src/metrics.test.ts b/Overlord-Server/src/metrics.test.ts new file mode 100644 index 0000000..3710f70 --- /dev/null +++ b/Overlord-Server/src/metrics.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, test, beforeEach } from "bun:test"; +import { metrics } from "./metrics"; + +beforeEach(() => { + metrics.reset(); +}); + +describe("MetricsCollector", () => { + describe("connection tracking", () => { + test("records connections and disconnections", () => { + metrics.recordConnection(); + metrics.recordConnection(); + metrics.recordDisconnection(); + + const snap = metrics.getSnapshot(); + expect(snap.connections.totalConnections).toBe(2); + expect(snap.connections.totalDisconnections).toBe(1); + expect(snap.connections.activeConnections).toBe(1); + }); + + test("active connections can go to zero", () => { + const before = metrics.getSnapshot(); + const baseConns = before.connections.totalConnections; + const baseDisconns = before.connections.totalDisconnections; + + metrics.recordConnection(); + metrics.recordDisconnection(); + + const snap = metrics.getSnapshot(); + expect(snap.connections.totalConnections).toBe(baseConns + 1); + expect(snap.connections.totalDisconnections).toBe(baseDisconns + 1); + expect(snap.connections.activeConnections).toBe(before.connections.activeConnections); + }); + }); + + describe("command tracking", () => { + test("records command count and type breakdown", () => { + metrics.recordCommand("console"); + metrics.recordCommand("console"); + metrics.recordCommand("desktop_start"); + + const snap = metrics.getSnapshot(); + expect(snap.commands.total).toBe(3); + expect(snap.commands.byType["console"]).toBe(2); + expect(snap.commands.byType["desktop_start"]).toBe(1); + }); + + test("commands within last minute are counted", () => { + metrics.recordCommand("test"); + + const snap = metrics.getSnapshot(); + expect(snap.commands.lastMinute).toBeGreaterThanOrEqual(1); + expect(snap.commands.lastHour).toBeGreaterThanOrEqual(1); + }); + }); + + describe("bandwidth tracking", () => { + test("records bytes sent and received", () => { + metrics.recordBytesSent(1024); + metrics.recordBytesSent(2048); + metrics.recordBytesReceived(512); + + const snap = metrics.getSnapshot(); + expect(snap.bandwidth.sent).toBe(3072); + expect(snap.bandwidth.received).toBe(512); + }); + }); + + describe("ping tracking", () => { + test("returns null stats when no pings recorded", () => { + const snap = metrics.getSnapshot(); + expect(snap.ping.min).toBeNull(); + expect(snap.ping.max).toBeNull(); + expect(snap.ping.avg).toBeNull(); + expect(snap.ping.count).toBe(0); + }); + + test("computes min/max/avg correctly", () => { + metrics.recordPing(10); + metrics.recordPing(20); + metrics.recordPing(30); + + const snap = metrics.getSnapshot(); + expect(snap.ping.min).toBe(10); + expect(snap.ping.max).toBe(30); + expect(snap.ping.avg).toBe(20); + expect(snap.ping.count).toBe(3); + }); + + test("single ping has same min/max/avg", () => { + metrics.recordPing(42); + + const snap = metrics.getSnapshot(); + expect(snap.ping.min).toBe(42); + expect(snap.ping.max).toBe(42); + expect(snap.ping.avg).toBe(42); + expect(snap.ping.count).toBe(1); + }); + }); + + describe("HTTP request tracking", () => { + test("records total HTTP requests", () => { + metrics.recordHttpRequest(10, 200); + metrics.recordHttpRequest(20, 200); + metrics.recordHttpRequest(5, 404); + + const snap = metrics.getSnapshot(); + expect(snap.http.total).toBe(3); + }); + + test("tracks error responses in last minute", () => { + metrics.recordHttpRequest(10, 200); + metrics.recordHttpRequest(20, 500); + metrics.recordHttpRequest(5, 404); + + const snap = metrics.getSnapshot(); + expect(snap.http.lastMinuteErrors).toBeGreaterThanOrEqual(2); + }); + + test("computes latency avg and p95", () => { + for (let i = 1; i <= 100; i++) { + metrics.recordHttpRequest(i, 200); + } + + const snap = metrics.getSnapshot(); + expect(snap.http.latencyAvg).toBeCloseTo(50.5, 0); + expect(snap.http.latencyP95).toBeGreaterThanOrEqual(90); + }); + }); + + describe("snapshot structure", () => { + test("returns expected top-level keys", () => { + const snap = metrics.getSnapshot(); + expect(snap).toHaveProperty("timestamp"); + expect(snap).toHaveProperty("clients"); + expect(snap).toHaveProperty("connections"); + expect(snap).toHaveProperty("commands"); + expect(snap).toHaveProperty("sessions"); + expect(snap).toHaveProperty("bandwidth"); + expect(snap).toHaveProperty("server"); + expect(snap).toHaveProperty("ping"); + expect(snap).toHaveProperty("http"); + expect(snap).toHaveProperty("eventLoop"); + }); + + test("server uptime is positive", () => { + const snap = metrics.getSnapshot(); + expect(snap.server.uptime).toBeGreaterThanOrEqual(0); + expect(snap.server.startTime).toBeGreaterThan(0); + }); + + test("system memory values are reasonable", () => { + const snap = metrics.getSnapshot(); + expect(snap.server.systemMemory.total).toBeGreaterThan(0); + expect(snap.server.systemMemory.free).toBeGreaterThanOrEqual(0); + expect(snap.server.systemMemory.usedPercent).toBeGreaterThanOrEqual(0); + expect(snap.server.systemMemory.usedPercent).toBeLessThanOrEqual(100); + }); + + test("cpu cores is positive", () => { + const snap = metrics.getSnapshot(); + expect(snap.server.cpu.cores).toBeGreaterThan(0); + }); + }); + + describe("history", () => { + test("starts empty", () => { + expect(metrics.getHistory()).toEqual([]); + }); + + test("recordHistoryEntry adds to history", () => { + const snap = metrics.getSnapshot(); + metrics.recordHistoryEntry(snap); + + const history = metrics.getHistory(); + expect(history).toHaveLength(1); + expect(history[0].timestamp).toBe(snap.timestamp); + }); + + test("history is capped at maxHistoryPoints", () => { + for (let i = 0; i < 100; i++) { + const snap = metrics.getSnapshot(); + metrics.recordHistoryEntry(snap); + } + + const history = metrics.getHistory(); + expect(history.length).toBeLessThanOrEqual(60); + }); + + test("getHistory returns a copy", () => { + const snap = metrics.getSnapshot(); + metrics.recordHistoryEntry(snap); + + const h1 = metrics.getHistory(); + const h2 = metrics.getHistory(); + expect(h1).not.toBe(h2); + expect(h1).toEqual(h2); + }); + }); + + describe("reset", () => { + test("clears resettable counters", () => { + metrics.recordCommand("test"); + metrics.recordBytesSent(100); + metrics.recordBytesReceived(200); + metrics.recordPing(50); + metrics.recordHttpRequest(10, 200); + + metrics.reset(); + + const snap = metrics.getSnapshot(); + // Note: connection counters are cumulative and not cleared by reset() + expect(snap.commands.total).toBe(0); + expect(snap.bandwidth.sent).toBe(0); + expect(snap.bandwidth.received).toBe(0); + expect(snap.ping.count).toBe(0); + expect(snap.http.total).toBe(0); + }); + }); +}); diff --git a/Overlord-Server/src/rbac.test.ts b/Overlord-Server/src/rbac.test.ts new file mode 100644 index 0000000..ab9dfb0 --- /dev/null +++ b/Overlord-Server/src/rbac.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, test } from "bun:test"; +import { + checkPermission, + checkAnyPermission, + checkAllPermissions, + requireAuth, + requirePermission, + requireAnyPermission, + getPermissionDescription, + getRoleDescription, + type Permission, +} from "./rbac"; +import type { AuthenticatedUser } from "./auth"; + +function makeUser(overrides: Partial = {}): AuthenticatedUser { + return { + userId: 1, + username: "testuser", + role: "admin", + ...overrides, + }; +} + +describe("checkPermission", () => { + test("returns false for null user", () => { + expect(checkPermission(null, "users:manage")).toBe(false); + }); + + test("admin has users:manage", () => { + expect(checkPermission(makeUser({ role: "admin" }), "users:manage")).toBe(true); + }); + + test("operator does not have users:manage", () => { + expect(checkPermission(makeUser({ role: "operator" }), "users:manage")).toBe(false); + }); + + test("viewer does not have users:manage", () => { + expect(checkPermission(makeUser({ role: "viewer" }), "users:manage")).toBe(false); + }); + + test("admin has clients:control", () => { + expect(checkPermission(makeUser({ role: "admin" }), "clients:control")).toBe(true); + }); + + test("operator has clients:control", () => { + expect(checkPermission(makeUser({ role: "operator" }), "clients:control")).toBe(true); + }); + + test("viewer does not have clients:control", () => { + expect(checkPermission(makeUser({ role: "viewer" }), "clients:control")).toBe(false); + }); + + test("admin has audit:view", () => { + expect(checkPermission(makeUser({ role: "admin" }), "audit:view")).toBe(true); + }); + + test("operator does not have audit:view", () => { + expect(checkPermission(makeUser({ role: "operator" }), "audit:view")).toBe(false); + }); +}); + +describe("checkAnyPermission", () => { + test("returns false for null user", () => { + expect(checkAnyPermission(null, ["users:manage", "clients:control"])).toBe(false); + }); + + test("returns true if user has at least one permission", () => { + const user = makeUser({ role: "operator" }); + expect(checkAnyPermission(user, ["users:manage", "clients:control"])).toBe(true); + }); + + test("returns false if user has none of the permissions", () => { + const user = makeUser({ role: "viewer" }); + expect(checkAnyPermission(user, ["users:manage", "clients:control"])).toBe(false); + }); + + test("returns true for empty permissions array (vacuously)", () => { + const user = makeUser({ role: "viewer" }); + // Array.some on empty returns false + expect(checkAnyPermission(user, [])).toBe(false); + }); +}); + +describe("checkAllPermissions", () => { + test("returns false for null user", () => { + expect(checkAllPermissions(null, ["users:manage"])).toBe(false); + }); + + test("admin has all permissions", () => { + const user = makeUser({ role: "admin" }); + expect( + checkAllPermissions(user, ["users:manage", "clients:control", "audit:view"]), + ).toBe(true); + }); + + test("operator lacks users:manage so fails all check", () => { + const user = makeUser({ role: "operator" }); + expect( + checkAllPermissions(user, ["users:manage", "clients:control"]), + ).toBe(false); + }); + + test("returns true for empty permissions array (vacuously)", () => { + const user = makeUser({ role: "viewer" }); + expect(checkAllPermissions(user, [])).toBe(true); + }); +}); + +describe("requireAuth", () => { + test("returns user when provided", () => { + const user = makeUser(); + expect(requireAuth(user)).toBe(user); + }); + + test("throws 401 Response for null user", () => { + expect(() => requireAuth(null)).toThrow(); + try { + requireAuth(null); + } catch (e) { + expect(e).toBeInstanceOf(Response); + expect((e as Response).status).toBe(401); + } + }); +}); + +describe("requirePermission", () => { + test("returns user when permission is satisfied", () => { + const user = makeUser({ role: "admin" }); + expect(requirePermission(user, "users:manage")).toBe(user); + }); + + test("throws 401 for null user", () => { + try { + requirePermission(null, "users:manage"); + } catch (e) { + expect(e).toBeInstanceOf(Response); + expect((e as Response).status).toBe(401); + } + }); + + test("throws 403 when permission is not satisfied", () => { + const user = makeUser({ role: "viewer" }); + try { + requirePermission(user, "users:manage"); + } catch (e) { + expect(e).toBeInstanceOf(Response); + expect((e as Response).status).toBe(403); + } + }); +}); + +describe("requireAnyPermission", () => { + test("returns user when at least one permission is satisfied", () => { + const user = makeUser({ role: "operator" }); + expect(requireAnyPermission(user, ["users:manage", "clients:control"])).toBe(user); + }); + + test("throws 403 when none satisfied", () => { + const user = makeUser({ role: "viewer" }); + try { + requireAnyPermission(user, ["users:manage", "clients:control"]); + } catch (e) { + expect(e).toBeInstanceOf(Response); + expect((e as Response).status).toBe(403); + } + }); +}); + +describe("getPermissionDescription", () => { + const cases: [Permission, string][] = [ + ["users:manage", "Manage users and roles"], + ["clients:control", "Control clients (execute commands, desktop, console, files)"], + ["clients:view", "View connected clients"], + ["clients:build", "Build client binaries"], + ["audit:view", "View audit logs"], + ]; + + for (const [perm, desc] of cases) { + test(`${perm} → "${desc}"`, () => { + expect(getPermissionDescription(perm)).toBe(desc); + }); + } + + test("unknown permission returns fallback", () => { + expect(getPermissionDescription("bogus:perm" as Permission)).toBe("Unknown permission"); + }); +}); + +describe("getRoleDescription", () => { + test("admin description", () => { + expect(getRoleDescription("admin")).toBe( + "Full access - can manage users and control all clients", + ); + }); + + test("operator description", () => { + expect(getRoleDescription("operator")).toBe( + "Can control clients but cannot manage users", + ); + }); + + test("viewer description", () => { + expect(getRoleDescription("viewer")).toBe("Read-only access to view clients"); + }); + + test("unknown role returns fallback", () => { + expect(getRoleDescription("unknown" as any)).toBe("Unknown role"); + }); +}); diff --git a/Overlord-Server/src/sessions/sessionManager.test.ts b/Overlord-Server/src/sessions/sessionManager.test.ts new file mode 100644 index 0000000..aca354c --- /dev/null +++ b/Overlord-Server/src/sessions/sessionManager.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, test, beforeEach } from "bun:test"; +import { + addConsoleSession, + getConsoleSession, + deleteConsoleSession, + getConsoleSessionsByClient, + getConsoleSessionCount, + addRdSession, + getRdSession, + deleteRdSession, + getRdSessionsByClient, + getRdSessionCount, + hasRdSessionsForClient, + addHvncSession, + getHvncSession, + deleteHvncSession, + getHvncSessionsByClient, + hasHvncSessionsForClient, + getHvncSessionCount, + addFileBrowserSession, + getFileBrowserSession, + deleteFileBrowserSession, + getFileBrowserSessionsByClient, + getFileBrowserSessionCount, + addProcessSession, + getProcessSession, + deleteProcessSession, + getProcessSessionsByClient, + getProcessSessionCount, + addKeyloggerSession, + getKeyloggerSession, + deleteKeyloggerSession, + getKeyloggerSessionsByClient, + addVoiceSession, + getVoiceSession, + deleteVoiceSession, + getVoiceSessionsByClient, + addNotificationSession, + deleteNotificationSession, + getNotificationSessionCount, + addDashboardSession, + deleteDashboardSession, + getDashboardSessionCount, + safeSendViewerFrame, + getAllConsoleSessions, + getAllRdSessions, +} from "./sessionManager"; +import type { ServerWebSocket } from "bun"; +import type { SocketData } from "./types"; + +// Minimal mock ws that records sends +function mockWs(): ServerWebSocket { + const sent: any[] = []; + return { + send: (data: any) => { sent.push(data); return data.length || 0; }, + close: () => {}, + data: { role: "console_viewer" as any, clientId: "c1" }, + _sent: sent, + } as any; +} + +// We need to clear sessions between tests. The session manager uses module-level Maps, +// so we just delete sessions we create. +function cleanupConsoleSessions(ids: string[]) { + for (const id of ids) deleteConsoleSession(id); +} + +describe("sessionManager - console sessions", () => { + const ids: string[] = []; + + test("add and get a console session", () => { + const ws = mockWs(); + const session = { id: "cs-1", clientId: "client-a", viewer: ws, createdAt: Date.now() }; + ids.push("cs-1"); + addConsoleSession(session); + + const retrieved = getConsoleSession("cs-1"); + expect(retrieved).toBeDefined(); + expect(retrieved!.clientId).toBe("client-a"); + }); + + test("get nonexistent session returns undefined", () => { + expect(getConsoleSession("nonexistent")).toBeUndefined(); + }); + + test("delete returns true for existing session", () => { + const ws = mockWs(); + addConsoleSession({ id: "cs-del", clientId: "client-b", viewer: ws, createdAt: Date.now() }); + expect(deleteConsoleSession("cs-del")).toBe(true); + }); + + test("delete returns false for nonexistent session", () => { + expect(deleteConsoleSession("no-such-id")).toBe(false); + }); + + test("getConsoleSessionsByClient returns sessions for that client", () => { + const ws = mockWs(); + addConsoleSession({ id: "cs-c1", clientId: "multi-client", viewer: ws, createdAt: Date.now() }); + addConsoleSession({ id: "cs-c2", clientId: "multi-client", viewer: ws, createdAt: Date.now() }); + addConsoleSession({ id: "cs-c3", clientId: "other-client", viewer: ws, createdAt: Date.now() }); + + ids.push("cs-c1", "cs-c2", "cs-c3"); + + const sessions = getConsoleSessionsByClient("multi-client"); + expect(sessions.length).toBe(2); + expect(sessions.map(s => s.id).sort()).toEqual(["cs-c1", "cs-c2"]); + }); + + test("getConsoleSessionsByClient returns empty for unknown client", () => { + expect(getConsoleSessionsByClient("unknown-client")).toEqual([]); + }); + + // Clean up all created sessions + test("cleanup", () => { + cleanupConsoleSessions(ids); + }); +}); + +describe("sessionManager - remote desktop sessions", () => { + test("add, get, and delete RD session", () => { + const ws = mockWs(); + addRdSession({ id: "rd-1", clientId: "c-rd", viewer: ws, createdAt: Date.now() }); + + expect(getRdSession("rd-1")).toBeDefined(); + expect(hasRdSessionsForClient("c-rd")).toBe(true); + expect(getRdSessionsByClient("c-rd").length).toBe(1); + + expect(deleteRdSession("rd-1")).toBe(true); + expect(getRdSession("rd-1")).toBeUndefined(); + expect(hasRdSessionsForClient("c-rd")).toBe(false); + }); + + test("delete nonexistent RD session returns false", () => { + expect(deleteRdSession("no-rd")).toBe(false); + }); +}); + +describe("sessionManager - HVNC sessions", () => { + test("add, get, and delete HVNC session", () => { + const ws = mockWs(); + addHvncSession({ id: "hvnc-1", clientId: "c-hvnc", viewer: ws, createdAt: Date.now() }); + + expect(getHvncSession("hvnc-1")).toBeDefined(); + expect(hasHvncSessionsForClient("c-hvnc")).toBe(true); + expect(getHvncSessionsByClient("c-hvnc").length).toBe(1); + expect(getHvncSessionCount()).toBeGreaterThanOrEqual(1); + + expect(deleteHvncSession("hvnc-1")).toBe(true); + expect(getHvncSession("hvnc-1")).toBeUndefined(); + }); +}); + +describe("sessionManager - file browser sessions", () => { + test("add, get, and delete file browser session", () => { + const ws = mockWs(); + addFileBrowserSession({ id: "fb-1", clientId: "c-fb", viewer: ws, createdAt: Date.now() }); + + expect(getFileBrowserSession("fb-1")).toBeDefined(); + expect(getFileBrowserSessionsByClient("c-fb").length).toBe(1); + + expect(deleteFileBrowserSession("fb-1")).toBe(true); + expect(getFileBrowserSession("fb-1")).toBeUndefined(); + }); +}); + +describe("sessionManager - process sessions", () => { + test("add, get, and delete process session", () => { + const ws = mockWs(); + addProcessSession({ id: "ps-1", clientId: "c-ps", viewer: ws, createdAt: Date.now() }); + + expect(getProcessSession("ps-1")).toBeDefined(); + expect(getProcessSessionsByClient("c-ps").length).toBe(1); + + expect(deleteProcessSession("ps-1")).toBe(true); + expect(getProcessSession("ps-1")).toBeUndefined(); + }); +}); + +describe("sessionManager - keylogger sessions", () => { + test("add, get, and delete keylogger session", () => { + const ws = mockWs(); + addKeyloggerSession({ id: "kl-1", clientId: "c-kl", viewer: ws, createdAt: Date.now() }); + + expect(getKeyloggerSession("kl-1")).toBeDefined(); + expect(getKeyloggerSessionsByClient("c-kl").length).toBe(1); + + expect(deleteKeyloggerSession("kl-1")).toBe(true); + expect(getKeyloggerSession("kl-1")).toBeUndefined(); + }); +}); + +describe("sessionManager - voice sessions", () => { + test("add, get, and delete voice session", () => { + const ws = mockWs(); + addVoiceSession({ id: "vc-1", clientId: "c-vc", viewer: ws, createdAt: Date.now() }); + + expect(getVoiceSession("vc-1")).toBeDefined(); + expect(getVoiceSessionsByClient("c-vc").length).toBe(1); + + expect(deleteVoiceSession("vc-1")).toBe(true); + expect(getVoiceSession("vc-1")).toBeUndefined(); + }); +}); + +describe("sessionManager - notification sessions", () => { + test("add and delete notification session", () => { + const ws = mockWs(); + addNotificationSession({ id: "ns-1", viewer: ws, createdAt: Date.now() }); + + expect(getNotificationSessionCount()).toBeGreaterThanOrEqual(1); + expect(deleteNotificationSession("ns-1")).toBe(true); + }); +}); + +describe("sessionManager - dashboard sessions", () => { + test("add and delete dashboard session", () => { + const ws = mockWs(); + addDashboardSession({ id: "ds-1", viewer: ws, createdAt: Date.now() }); + + expect(getDashboardSessionCount()).toBeGreaterThanOrEqual(1); + expect(deleteDashboardSession("ds-1")).toBe(true); + }); +}); + +describe("safeSendViewerFrame", () => { + test("constructs correct binary frame with header", () => { + const sent: any[] = []; + const ws = { + send: (data: any) => { sent.push(data); }, + } as any; + + const payload = new Uint8Array([1, 2, 3, 4]); + const header = { type: "frame", display: 0 }; + + const bytesSent = safeSendViewerFrame(ws, payload, header); + expect(bytesSent).toBeGreaterThan(0); + expect(sent.length).toBe(1); + + // Verify the frame structure: 4-byte length + meta + payload + const frame = sent[0] as Uint8Array; + const metaLen = new DataView(frame.buffer, frame.byteOffset, 4).getUint32(0, false); + expect(metaLen).toBeGreaterThan(0); + + const metaBytes = frame.slice(4, 4 + metaLen); + const meta = JSON.parse(new TextDecoder().decode(metaBytes)); + expect(meta.type).toBe("frame"); + expect(meta.display).toBe(0); + + const payloadBytes = frame.slice(4 + metaLen); + expect(payloadBytes).toEqual(payload); + }); + + test("returns 0 when send throws", () => { + const ws = { + send: () => { throw new Error("closed"); }, + } as any; + + const result = safeSendViewerFrame(ws, new Uint8Array([1]), {}); + expect(result).toBe(0); + }); + + test("handles empty payload", () => { + const sent: any[] = []; + const ws = { + send: (data: any) => { sent.push(data); }, + } as any; + + const bytesSent = safeSendViewerFrame(ws, new Uint8Array(0), { type: "empty" }); + expect(bytesSent).toBeGreaterThan(0); + }); +});