package main import ( "bufio" "context" "crypto/tls" "crypto/x509" "fmt" "net" "net/http" "net/http/httptest" "strings" "testing" "time" "codeberg.org/towerops-agent/towerops-agent/pb" ) // --------------------------------------------------------------------------- // ExecuteCheck routing tests // --------------------------------------------------------------------------- func TestExecuteCheck_UnknownCheckType(t *testing.T) { result := ExecuteCheck(context.Background(), &pb.Check{ Id: "chk-1", CheckType: "foobar", TimeoutMs: 5000, }) if result.Status != 3 { t.Fatalf("expected status 3, got %d", result.Status) } if result.CheckId != "chk-1" { t.Fatalf("expected CheckId chk-1, got %s", result.CheckId) } if !strings.Contains(result.Output, "Unknown check type") { t.Fatalf("expected unknown check type message, got %s", result.Output) } } func TestExecuteCheck_MissingHTTPConfig(t *testing.T) { result := ExecuteCheck(context.Background(), &pb.Check{ Id: "chk-2", CheckType: "http", TimeoutMs: 5000, // No Config set }) if result.Status != 3 { t.Fatalf("expected status 3, got %d", result.Status) } if !strings.Contains(result.Output, "Missing HTTP config") { t.Fatalf("expected missing HTTP config message, got %s", result.Output) } } func TestExecuteCheck_MissingTCPConfig(t *testing.T) { result := ExecuteCheck(context.Background(), &pb.Check{ Id: "chk-3", CheckType: "tcp", TimeoutMs: 5000, }) if result.Status != 3 { t.Fatalf("expected status 3, got %d", result.Status) } if !strings.Contains(result.Output, "Missing TCP config") { t.Fatalf("expected missing TCP config message, got %s", result.Output) } } func TestExecuteCheck_MissingDNSConfig(t *testing.T) { result := ExecuteCheck(context.Background(), &pb.Check{ Id: "chk-4", CheckType: "dns", TimeoutMs: 5000, }) if result.Status != 3 { t.Fatalf("expected status 3, got %d", result.Status) } if !strings.Contains(result.Output, "Missing DNS config") { t.Fatalf("expected missing DNS config message, got %s", result.Output) } } func TestExecuteCheck_SetsCheckIdAndTimestamp(t *testing.T) { result := ExecuteCheck(context.Background(), &pb.Check{ Id: "chk-ts", CheckType: "unknown", TimeoutMs: 5000, }) if result.CheckId != "chk-ts" { t.Fatalf("expected CheckId chk-ts, got %s", result.CheckId) } if result.Timestamp == 0 { t.Fatal("expected non-zero timestamp") } if result.ResponseTimeMs < 0 { t.Fatalf("expected non-negative response time, got %f", result.ResponseTimeMs) } } func TestExecuteCheck_HTTPRouting(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })) defer srv.Close() result := ExecuteCheck(context.Background(), &pb.Check{ Id: "chk-route-http", CheckType: "http", TimeoutMs: 5000, Config: &pb.Check_Http{ Http: &pb.HttpCheckConfig{ Url: srv.URL, }, }, }) if result.Status != 0 { t.Fatalf("expected status 0, got %d: %s", result.Status, result.Output) } } func TestExecuteCheck_TCPRouting(t *testing.T) { ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } defer func() { _ = ln.Close() }() _, portStr, _ := net.SplitHostPort(ln.Addr().String()) result := ExecuteCheck(context.Background(), &pb.Check{ Id: "chk-route-tcp", CheckType: "tcp", TimeoutMs: 5000, Config: &pb.Check_Tcp{ Tcp: &pb.TcpCheckConfig{ Host: "127.0.0.1", Port: parsePort(portStr), }, }, }) if result.Status != 0 { t.Fatalf("expected status 0, got %d: %s", result.Status, result.Output) } } func TestExecuteCheck_DNSRouting(t *testing.T) { result := ExecuteCheck(context.Background(), &pb.Check{ Id: "chk-route-dns", CheckType: "dns", TimeoutMs: 5000, Config: &pb.Check_Dns{ Dns: &pb.DnsCheckConfig{ Hostname: "localhost", RecordType: "A", }, }, }) // DNS for localhost may or may not resolve depending on system config, // but the routing should work regardless. if result.Status == 3 { t.Fatalf("expected routing to DNS handler, got status 3 (UNKNOWN): %s", result.Output) } } // --------------------------------------------------------------------------- // HTTP check tests // --------------------------------------------------------------------------- func TestHTTPCheck_SuccessfulGET(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) _, _ = fmt.Fprint(w, "OK") })) defer srv.Close() status, output, rt := executeHTTPCheck(context.Background(), &pb.HttpCheckConfig{ Url: srv.URL, }, 5000) if status != 0 { t.Fatalf("expected status 0, got %d: %s", status, output) } if !strings.Contains(output, "HTTP 200 OK") { t.Fatalf("expected HTTP 200 OK in output, got %s", output) } if rt < 0 { t.Fatalf("expected non-negative response time, got %f", rt) } } func TestHTTPCheck_CustomMethod_POST(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { t.Errorf("expected POST, got %s", r.Method) w.WriteHeader(405) return } w.WriteHeader(200) })) defer srv.Close() status, output, _ := executeHTTPCheck(context.Background(), &pb.HttpCheckConfig{ Url: srv.URL, Method: "post", }, 5000) if status != 0 { t.Fatalf("expected status 0, got %d: %s", status, output) } } func TestHTTPCheck_CustomExpectedStatus(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(201) })) defer srv.Close() status, output, _ := executeHTTPCheck(context.Background(), &pb.HttpCheckConfig{ Url: srv.URL, ExpectedStatus: 201, }, 5000) if status != 0 { t.Fatalf("expected status 0, got %d: %s", status, output) } } func TestHTTPCheck_WrongStatusCode(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(500) })) defer srv.Close() status, output, _ := executeHTTPCheck(context.Background(), &pb.HttpCheckConfig{ Url: srv.URL, }, 5000) if status != 2 { t.Fatalf("expected status 2, got %d: %s", status, output) } if !strings.Contains(output, "500") && !strings.Contains(output, "expected 200") { t.Fatalf("expected status code mismatch message, got %s", output) } } func TestHTTPCheck_DefaultMethodIsGET(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" { t.Errorf("expected default GET, got %s", r.Method) w.WriteHeader(405) return } w.WriteHeader(200) })) defer srv.Close() status, output, _ := executeHTTPCheck(context.Background(), &pb.HttpCheckConfig{ Url: srv.URL, Method: "", // should default to GET }, 5000) if status != 0 { t.Fatalf("expected status 0, got %d: %s", status, output) } } func TestHTTPCheck_DefaultExpectedStatusIs200(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })) defer srv.Close() status, output, _ := executeHTTPCheck(context.Background(), &pb.HttpCheckConfig{ Url: srv.URL, ExpectedStatus: 0, // should default to 200 }, 5000) if status != 0 { t.Fatalf("expected status 0, got %d: %s", status, output) } } func TestHTTPCheck_VerifySslFalseWithSelfSigned(t *testing.T) { srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })) defer srv.Close() // With VerifySsl=false (InsecureSkipVerify=true), the self-signed cert should be accepted status, output, _ := executeHTTPCheck(context.Background(), &pb.HttpCheckConfig{ Url: srv.URL, VerifySsl: false, }, 5000) if status != 0 { t.Fatalf("expected status 0 with VerifySsl=false, got %d: %s", status, output) } } func TestHTTPCheck_VerifySslTrueRejectsSelfSigned(t *testing.T) { srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })) defer srv.Close() // With VerifySsl=true (InsecureSkipVerify=false), self-signed cert should fail status, output, _ := executeHTTPCheck(context.Background(), &pb.HttpCheckConfig{ Url: srv.URL, VerifySsl: true, }, 5000) if status != 2 { t.Fatalf("expected status 2 with VerifySsl=true on self-signed, got %d: %s", status, output) } if !strings.Contains(output, "Request failed") { t.Fatalf("expected request failed message, got %s", output) } } func TestHTTPCheck_FollowRedirectsTrue(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/redirect" { http.Redirect(w, r, "/final", http.StatusMovedPermanently) return } w.WriteHeader(200) _, _ = fmt.Fprint(w, "final page") })) defer srv.Close() status, output, _ := executeHTTPCheck(context.Background(), &pb.HttpCheckConfig{ Url: srv.URL + "/redirect", FollowRedirects: true, }, 5000) if status != 0 { t.Fatalf("expected status 0 after following redirect, got %d: %s", status, output) } if !strings.Contains(output, "200") { t.Fatalf("expected final 200 status in output, got %s", output) } } func TestHTTPCheck_FollowRedirectsFalse(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/redirect" { http.Redirect(w, r, "/final", http.StatusMovedPermanently) return } w.WriteHeader(200) })) defer srv.Close() // When FollowRedirects is false, we should see the 301 directly status, output, _ := executeHTTPCheck(context.Background(), &pb.HttpCheckConfig{ Url: srv.URL + "/redirect", FollowRedirects: false, ExpectedStatus: 301, }, 5000) if status != 0 { t.Fatalf("expected status 0 (matching 301), got %d: %s", status, output) } } func TestHTTPCheck_FollowRedirectsFalse_DefaultExpects200(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/final", http.StatusMovedPermanently) })) defer srv.Close() // FollowRedirects=false and default expected=200, but we get 301 → CRITICAL status, output, _ := executeHTTPCheck(context.Background(), &pb.HttpCheckConfig{ Url: srv.URL + "/redirect", FollowRedirects: false, }, 5000) if status != 2 { t.Fatalf("expected status 2 (301 != 200), got %d: %s", status, output) } } func TestHTTPCheck_RegexMatchSucceeds(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) _, _ = fmt.Fprint(w, "Hello World! Version 1.2.3") })) defer srv.Close() status, output, _ := executeHTTPCheck(context.Background(), &pb.HttpCheckConfig{ Url: srv.URL, Regex: `Version \d+\.\d+\.\d+`, }, 5000) if status != 0 { t.Fatalf("expected status 0 for regex match, got %d: %s", status, output) } } func TestHTTPCheck_RegexMatchFails(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) _, _ = fmt.Fprint(w, "Hello World!") })) defer srv.Close() status, output, _ := executeHTTPCheck(context.Background(), &pb.HttpCheckConfig{ Url: srv.URL, Regex: `Version \d+`, }, 5000) if status != 2 { t.Fatalf("expected status 2 for regex mismatch, got %d: %s", status, output) } if !strings.Contains(output, "does not match") { t.Fatalf("expected 'does not match' in output, got %s", output) } } func TestHTTPCheck_InvalidRegex(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) _, _ = fmt.Fprint(w, "some body") })) defer srv.Close() status, output, _ := executeHTTPCheck(context.Background(), &pb.HttpCheckConfig{ Url: srv.URL, Regex: `[invalid`, }, 5000) if status != 2 { t.Fatalf("expected status 2 for invalid regex, got %d: %s", status, output) } if !strings.Contains(output, "Invalid regex") { t.Fatalf("expected 'Invalid regex' in output, got %s", output) } } func TestHTTPCheck_CustomHeaders(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("X-Custom") != "test-value" { t.Errorf("expected X-Custom header to be test-value, got %s", r.Header.Get("X-Custom")) w.WriteHeader(400) return } if r.Header.Get("Authorization") != "Bearer abc123" { t.Errorf("expected Authorization header, got %s", r.Header.Get("Authorization")) w.WriteHeader(401) return } w.WriteHeader(200) })) defer srv.Close() status, output, _ := executeHTTPCheck(context.Background(), &pb.HttpCheckConfig{ Url: srv.URL, Headers: map[string]string{ "X-Custom": "test-value", "Authorization": "Bearer abc123", }, }, 5000) if status != 0 { t.Fatalf("expected status 0, got %d: %s", status, output) } } func TestHTTPCheck_UnreachableServer(t *testing.T) { // Use a non-routable address to guarantee failure status, output, _ := executeHTTPCheck(context.Background(), &pb.HttpCheckConfig{ Url: "http://192.0.2.1:1/unreachable", }, 1000) if status != 2 { t.Fatalf("expected status 2, got %d: %s", status, output) } if !strings.Contains(output, "Request failed") { t.Fatalf("expected 'Request failed' in output, got %s", output) } } func TestHTTPCheck_InvalidURL(t *testing.T) { status, output, _ := executeHTTPCheck(context.Background(), &pb.HttpCheckConfig{ Url: "://not-a-url", }, 5000) if status != 2 { t.Fatalf("expected status 2, got %d: %s", status, output) } if !strings.Contains(output, "Failed to create request") { t.Fatalf("expected 'Failed to create request' in output, got %s", output) } } func TestHTTPCheck_RequestWithBody(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { buf := make([]byte, 1024) n, _ := r.Body.Read(buf) body := string(buf[:n]) if body != `{"key":"value"}` { t.Errorf("expected JSON body, got %s", body) w.WriteHeader(400) return } w.WriteHeader(200) })) defer srv.Close() status, output, _ := executeHTTPCheck(context.Background(), &pb.HttpCheckConfig{ Url: srv.URL, Method: "POST", Body: `{"key":"value"}`, }, 5000) if status != 0 { t.Fatalf("expected status 0, got %d: %s", status, output) } } func TestHTTPCheck_ContextCancellation(t *testing.T) { // Server that hangs forever srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() })) defer srv.Close() ctx, cancel := context.WithCancel(context.Background()) // Cancel after a short delay go func() { time.Sleep(50 * time.Millisecond) cancel() }() status, output, _ := executeHTTPCheck(ctx, &pb.HttpCheckConfig{ Url: srv.URL, }, 30000) // long timeout so the context cancel hits first if status != 2 { t.Fatalf("expected status 2 on context cancel, got %d: %s", status, output) } if !strings.Contains(output, "Request failed") { t.Fatalf("expected 'Request failed' in output, got %s", output) } } func TestHTTPCheck_LargeResponseBodyWithRegex(t *testing.T) { // Generate a large body (500KB) with a marker at the end bigBody := strings.Repeat("a", 500*1024) + "MARKER_FOUND_HERE" srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) _, _ = fmt.Fprint(w, bigBody) })) defer srv.Close() status, output, _ := executeHTTPCheck(context.Background(), &pb.HttpCheckConfig{ Url: srv.URL, Regex: `MARKER_FOUND_HERE`, }, 5000) if status != 0 { t.Fatalf("expected status 0 for regex match in large body, got %d: %s", status, output) } } func TestHTTPCheck_SlowServerTimeout(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { time.Sleep(500 * time.Millisecond) w.WriteHeader(200) })) defer srv.Close() status, output, _ := executeHTTPCheck(context.Background(), &pb.HttpCheckConfig{ Url: srv.URL, }, 50) // 50ms timeout, server takes 500ms if status != 2 { t.Fatalf("expected status 2 for timeout, got %d: %s", status, output) } } func TestHTTPCheck_EmptyBody_NoRegex(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })) defer srv.Close() status, output, _ := executeHTTPCheck(context.Background(), &pb.HttpCheckConfig{ Url: srv.URL, }, 5000) if status != 0 { t.Fatalf("expected status 0, got %d: %s", status, output) } } // --------------------------------------------------------------------------- // TCP check tests // --------------------------------------------------------------------------- func TestTCPCheck_PortOpen(t *testing.T) { ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } defer func() { _ = ln.Close() }() // Accept connections in background so dial doesn't hang go func() { for { conn, err := ln.Accept() if err != nil { return } _ = conn.Close() } }() port := parsePort(portFromListener(ln)) status, output, rt := executeTCPCheck(context.Background(), &pb.TcpCheckConfig{ Host: "127.0.0.1", Port: port, }, 5000) if status != 0 { t.Fatalf("expected status 0, got %d: %s", status, output) } if !strings.Contains(output, fmt.Sprintf("TCP port %d open", port)) { t.Fatalf("expected port open message, got %s", output) } if rt < 0 { t.Fatalf("expected non-negative response time, got %f", rt) } } func TestTCPCheck_PortClosed(t *testing.T) { // Find a port that's definitely not listening by binding and immediately closing ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } port := parsePort(portFromListener(ln)) _ = ln.Close() // close immediately so port is refused status, output, _ := executeTCPCheck(context.Background(), &pb.TcpCheckConfig{ Host: "127.0.0.1", Port: port, }, 2000) if status != 2 { t.Fatalf("expected status 2, got %d: %s", status, output) } if !strings.Contains(output, "Connection failed") { t.Fatalf("expected 'Connection failed' in output, got %s", output) } } func TestTCPCheck_SendExpectSuccess(t *testing.T) { ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } defer func() { _ = ln.Close() }() // Echo server go func() { for { conn, err := ln.Accept() if err != nil { return } go func(c net.Conn) { defer func() { _ = c.Close() }() scanner := bufio.NewScanner(c) if scanner.Scan() { line := scanner.Text() _, _ = fmt.Fprintf(c, "ECHO:%s\n", line) } }(conn) } }() port := parsePort(portFromListener(ln)) status, output, _ := executeTCPCheck(context.Background(), &pb.TcpCheckConfig{ Host: "127.0.0.1", Port: port, Send: "hello\n", Expect: "ECHO:hello", }, 5000) if status != 0 { t.Fatalf("expected status 0, got %d: %s", status, output) } } func TestTCPCheck_SendExpectMismatch(t *testing.T) { ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } defer func() { _ = ln.Close() }() go func() { for { conn, err := ln.Accept() if err != nil { return } go func(c net.Conn) { defer func() { _ = c.Close() }() buf := make([]byte, 1024) _, _ = c.Read(buf) _, _ = fmt.Fprint(c, "WRONG_RESPONSE") }(conn) } }() port := parsePort(portFromListener(ln)) status, output, _ := executeTCPCheck(context.Background(), &pb.TcpCheckConfig{ Host: "127.0.0.1", Port: port, Send: "hello", Expect: "EXPECTED_VALUE", }, 5000) if status != 2 { t.Fatalf("expected status 2, got %d: %s", status, output) } if !strings.Contains(output, "Unexpected response") { t.Fatalf("expected 'Unexpected response' in output, got %s", output) } } func TestTCPCheck_SendWithEmptyExpect(t *testing.T) { ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } defer func() { _ = ln.Close() }() go func() { for { conn, err := ln.Accept() if err != nil { return } go func(c net.Conn) { defer func() { _ = c.Close() }() buf := make([]byte, 1024) _, _ = c.Read(buf) // Don't send anything back }(conn) } }() port := parsePort(portFromListener(ln)) status, output, _ := executeTCPCheck(context.Background(), &pb.TcpCheckConfig{ Host: "127.0.0.1", Port: port, Send: "data\n", Expect: "", // no expect check }, 5000) if status != 0 { t.Fatalf("expected status 0 (send only, no expect), got %d: %s", status, output) } } func TestTCPCheck_IPv6Localhost(t *testing.T) { ln, err := net.Listen("tcp6", "[::1]:0") if err != nil { t.Skip("IPv6 not available on this system") } defer func() { _ = ln.Close() }() go func() { for { conn, err := ln.Accept() if err != nil { return } _ = conn.Close() } }() port := parsePort(portFromListener(ln)) status, output, _ := executeTCPCheck(context.Background(), &pb.TcpCheckConfig{ Host: "::1", Port: port, }, 5000) if status != 0 { t.Fatalf("expected status 0 for IPv6, got %d: %s", status, output) } } func TestTCPCheck_ReadTimeoutOnExpect(t *testing.T) { ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } defer func() { _ = ln.Close() }() // Server that accepts and reads but never writes back go func() { for { conn, err := ln.Accept() if err != nil { return } go func(c net.Conn) { defer func() { _ = c.Close() }() buf := make([]byte, 1024) _, _ = c.Read(buf) // Intentionally never respond - hold connection open time.Sleep(10 * time.Second) }(conn) } }() port := parsePort(portFromListener(ln)) status, output, _ := executeTCPCheck(context.Background(), &pb.TcpCheckConfig{ Host: "127.0.0.1", Port: port, Send: "hello", Expect: "response", }, 200) // short timeout if status != 2 { t.Fatalf("expected status 2 for read timeout, got %d: %s", status, output) } if !strings.Contains(output, "Receive failed") { t.Fatalf("expected 'Receive failed' in output, got %s", output) } } func TestTCPCheck_VeryShortTimeout(t *testing.T) { // Use TEST-NET address that won't respond status, output, _ := executeTCPCheck(context.Background(), &pb.TcpCheckConfig{ Host: "192.0.2.1", Port: 80, }, 1) // 1ms timeout - should fail if status != 2 { t.Fatalf("expected status 2, got %d: %s", status, output) } } func TestTCPCheck_BinaryDataSendExpect(t *testing.T) { ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } defer func() { _ = ln.Close() }() // Server that echoes binary data back go func() { for { conn, err := ln.Accept() if err != nil { return } go func(c net.Conn) { defer func() { _ = c.Close() }() buf := make([]byte, 4096) n, err := c.Read(buf) if err != nil { return } _, _ = c.Write(buf[:n]) }(conn) } }() port := parsePort(portFromListener(ln)) // Send some binary-ish data sendData := "BIN\x00\x01\x02DATA" status, output, _ := executeTCPCheck(context.Background(), &pb.TcpCheckConfig{ Host: "127.0.0.1", Port: port, Send: sendData, Expect: sendData, }, 5000) if status != 0 { t.Fatalf("expected status 0 for binary echo, got %d: %s", status, output) } } // --------------------------------------------------------------------------- // DNS check tests // --------------------------------------------------------------------------- func TestDNSCheck_ARecord(t *testing.T) { status, output, rt := executeDNSCheck(context.Background(), &pb.DnsCheckConfig{ Hostname: "localhost", RecordType: "A", }, 5000) // localhost should resolve on most systems, but skip if it doesn't if status == 2 && strings.Contains(output, "DNS query failed") { t.Skip("DNS resolution not available for localhost") } if status != 0 { t.Fatalf("expected status 0, got %d: %s", status, output) } if !strings.Contains(output, "Resolved to:") { t.Fatalf("expected 'Resolved to:' in output, got %s", output) } if rt < 0 { t.Fatalf("expected non-negative response time, got %f", rt) } } func TestDNSCheck_AAAARecord(t *testing.T) { status, output, _ := executeDNSCheck(context.Background(), &pb.DnsCheckConfig{ Hostname: "localhost", RecordType: "AAAA", }, 5000) // AAAA for localhost may or may not exist - just verify it doesn't crash // and returns a valid status if status != 0 && status != 2 { t.Fatalf("expected status 0 or 2, got %d: %s", status, output) } } func TestDNSCheck_CNAMERecord(t *testing.T) { // Use a well-known domain that has a CNAME status, output, _ := executeDNSCheck(context.Background(), &pb.DnsCheckConfig{ Hostname: "www.google.com", RecordType: "CNAME", }, 5000) if status == 2 && strings.Contains(output, "DNS query failed") { t.Skip("DNS resolution not available") } // CNAME lookup may return the hostname itself if no CNAME exists if status != 0 && status != 2 { t.Fatalf("expected status 0 or 2, got %d: %s", status, output) } } func TestDNSCheck_MXRecord(t *testing.T) { status, output, _ := executeDNSCheck(context.Background(), &pb.DnsCheckConfig{ Hostname: "google.com", RecordType: "MX", }, 5000) if status == 2 && strings.Contains(output, "DNS query failed") { t.Skip("DNS resolution not available") } if status != 0 { t.Fatalf("expected status 0 for MX lookup, got %d: %s", status, output) } if !strings.Contains(output, "Resolved to:") { t.Fatalf("expected 'Resolved to:' in output, got %s", output) } } func TestDNSCheck_TXTRecord(t *testing.T) { status, output, _ := executeDNSCheck(context.Background(), &pb.DnsCheckConfig{ Hostname: "google.com", RecordType: "TXT", }, 5000) if status == 2 && strings.Contains(output, "DNS query failed") { t.Skip("DNS resolution not available") } if status != 0 { t.Fatalf("expected status 0 for TXT lookup, got %d: %s", status, output) } } func TestDNSCheck_UnsupportedRecordType(t *testing.T) { status, output, _ := executeDNSCheck(context.Background(), &pb.DnsCheckConfig{ Hostname: "example.com", RecordType: "SRV", }, 5000) if status != 3 { t.Fatalf("expected status 3, got %d: %s", status, output) } if !strings.Contains(output, "Unsupported record type") { t.Fatalf("expected 'Unsupported record type' in output, got %s", output) } } func TestDNSCheck_ExpectedMatchesResult(t *testing.T) { status, output, _ := executeDNSCheck(context.Background(), &pb.DnsCheckConfig{ Hostname: "localhost", RecordType: "A", Expected: "127.0.0.1", }, 5000) if status == 2 && strings.Contains(output, "DNS query failed") { t.Skip("DNS resolution not available for localhost") } if status != 0 { t.Fatalf("expected status 0, got %d: %s", status, output) } } func TestDNSCheck_ExpectedDoesNotMatch(t *testing.T) { status, output, _ := executeDNSCheck(context.Background(), &pb.DnsCheckConfig{ Hostname: "localhost", RecordType: "A", Expected: "10.99.99.99", }, 5000) if status == 2 && strings.Contains(output, "DNS query failed") { t.Skip("DNS resolution not available for localhost") } // If localhost resolved, the expected won't match if status != 2 { t.Fatalf("expected status 2 for mismatched expected, got %d: %s", status, output) } if !strings.Contains(output, "Expected '10.99.99.99'") { t.Fatalf("expected mismatch message, got %s", output) } } func TestDNSCheck_NonexistentDomain(t *testing.T) { status, output, _ := executeDNSCheck(context.Background(), &pb.DnsCheckConfig{ Hostname: "this-domain-does-not-exist-towerops-test.invalid", RecordType: "A", }, 5000) if status != 2 { t.Fatalf("expected status 2 for NXDOMAIN, got %d: %s", status, output) } } func TestDNSCheck_DefaultRecordTypeIsA(t *testing.T) { status, output, _ := executeDNSCheck(context.Background(), &pb.DnsCheckConfig{ Hostname: "localhost", RecordType: "", // should default to A }, 5000) if status == 2 && strings.Contains(output, "DNS query failed") { t.Skip("DNS resolution not available for localhost") } // Should behave the same as explicitly specifying "A" if status != 0 { t.Fatalf("expected status 0 with default record type, got %d: %s", status, output) } } func TestDNSCheck_CustomDNSServer(t *testing.T) { // Test with Google's public DNS conn, err := net.DialTimeout("udp", "8.8.8.8:53", 2*time.Second) if err != nil { t.Skip("Cannot reach 8.8.8.8:53, skipping custom DNS server test") } _ = conn.Close() status, output, _ := executeDNSCheck(context.Background(), &pb.DnsCheckConfig{ Hostname: "example.com", RecordType: "A", Server: "8.8.8.8", }, 5000) if status != 0 { t.Fatalf("expected status 0 with custom DNS server, got %d: %s", status, output) } } func TestDNSCheck_RecordTypeCaseInsensitive(t *testing.T) { // The code does strings.ToUpper, so lowercase should work status, output, _ := executeDNSCheck(context.Background(), &pb.DnsCheckConfig{ Hostname: "localhost", RecordType: "a", // lowercase }, 5000) if status == 2 && strings.Contains(output, "DNS query failed") { t.Skip("DNS resolution not available for localhost") } if status != 0 { t.Fatalf("expected status 0 with lowercase record type, got %d: %s", status, output) } } func TestDNSCheck_NoRecordsFound(t *testing.T) { // AAAA for a domain that likely only has A records // Use a known domain that almost certainly won't have AAAA status, output, _ := executeDNSCheck(context.Background(), &pb.DnsCheckConfig{ Hostname: "this-domain-does-not-exist-towerops-test.invalid", RecordType: "AAAA", }, 5000) if status != 2 { t.Fatalf("expected status 2, got %d: %s", status, output) } } func TestDNSCheck_VeryShortTimeout(t *testing.T) { // Use a custom DNS server with 1ms timeout - should timeout status, output, _ := executeDNSCheck(context.Background(), &pb.DnsCheckConfig{ Hostname: "example.com", RecordType: "A", Server: "8.8.8.8", }, 1) // 1ms timeout // Should fail due to timeout if status != 2 { // On very fast networks this could actually succeed, so just verify it doesn't crash t.Logf("DNS with 1ms timeout returned status %d: %s (may succeed on fast networks)", status, output) } } func TestTCPCheck_SendFailsOnClosedConnection(t *testing.T) { ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } defer func() { _ = ln.Close() }() // Server accepts and immediately closes the connection go func() { for { conn, err := ln.Accept() if err != nil { return } _ = conn.Close() } }() port := parsePort(portFromListener(ln)) // Send a large payload to trigger a write error on a closed connection. // The first small write might succeed (kernel buffer), but a large write // after the peer has closed should fail with a broken pipe or similar. largePayload := strings.Repeat("x", 1024*1024) // 1MB // Give the server time to close the connection time.Sleep(50 * time.Millisecond) status, output, _ := executeTCPCheck(context.Background(), &pb.TcpCheckConfig{ Host: "127.0.0.1", Port: port, Send: largePayload, Expect: "something", }, 5000) // This may hit either "Send failed" or "Receive failed" depending on timing if status != 2 { t.Fatalf("expected status 2 for write to closed conn, got %d: %s", status, output) } } // --------------------------------------------------------------------------- // Edge case tests // --------------------------------------------------------------------------- func TestHTTPCheck_MethodUppercased(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "PUT" { t.Errorf("expected PUT, got %s", r.Method) w.WriteHeader(405) return } w.WriteHeader(200) })) defer srv.Close() status, output, _ := executeHTTPCheck(context.Background(), &pb.HttpCheckConfig{ Url: srv.URL, Method: "put", // lowercase }, 5000) if status != 0 { t.Fatalf("expected status 0, got %d: %s", status, output) } } func TestHTTPCheck_ResponseTimeIsPositive(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { time.Sleep(10 * time.Millisecond) w.WriteHeader(200) })) defer srv.Close() _, _, rt := executeHTTPCheck(context.Background(), &pb.HttpCheckConfig{ Url: srv.URL, }, 5000) if rt <= 0 { t.Fatalf("expected positive response time, got %f", rt) } } func TestTCPCheck_ResponseTimeReported(t *testing.T) { ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } defer func() { _ = ln.Close() }() go func() { for { conn, err := ln.Accept() if err != nil { return } _ = conn.Close() } }() port := parsePort(portFromListener(ln)) _, _, rt := executeTCPCheck(context.Background(), &pb.TcpCheckConfig{ Host: "127.0.0.1", Port: port, }, 5000) if rt < 0 { t.Fatalf("expected non-negative response time, got %f", rt) } } func TestExecuteCheck_ResponseTimeFallback(t *testing.T) { // When status/output are set directly (unknown type), responseTimeMs should be calculated result := ExecuteCheck(context.Background(), &pb.Check{ Id: "chk-fallback", CheckType: "invalid", TimeoutMs: 5000, }) if result.ResponseTimeMs < 0 { t.Fatalf("expected non-negative response time, got %f", result.ResponseTimeMs) } } func TestHTTPCheck_TLSServerNoVerify(t *testing.T) { // Create a TLS server with custom cert srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) _, _ = fmt.Fprint(w, "secure") })) srv.TLS = &tls.Config{} srv.StartTLS() defer srv.Close() status, output, _ := executeHTTPCheck(context.Background(), &pb.HttpCheckConfig{ Url: srv.URL, VerifySsl: false, Regex: "secure", }, 5000) if status != 0 { t.Fatalf("expected status 0, got %d: %s", status, output) } } func TestHTTPCheck_HeadMethod(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "HEAD" { t.Errorf("expected HEAD, got %s", r.Method) } w.WriteHeader(200) })) defer srv.Close() status, output, _ := executeHTTPCheck(context.Background(), &pb.HttpCheckConfig{ Url: srv.URL, Method: "HEAD", }, 5000) if status != 0 { t.Fatalf("expected status 0, got %d: %s", status, output) } } func TestHTTPCheck_DeleteMethod(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "DELETE" { t.Errorf("expected DELETE, got %s", r.Method) } w.WriteHeader(204) })) defer srv.Close() status, output, _ := executeHTTPCheck(context.Background(), &pb.HttpCheckConfig{ Url: srv.URL, Method: "DELETE", ExpectedStatus: 204, }, 5000) if status != 0 { t.Fatalf("expected status 0, got %d: %s", status, output) } } func TestHTTPCheck_RegexOnEmptyBody(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) // No body written })) defer srv.Close() status, output, _ := executeHTTPCheck(context.Background(), &pb.HttpCheckConfig{ Url: srv.URL, Regex: "something", }, 5000) if status != 2 { t.Fatalf("expected status 2 (regex no match on empty body), got %d: %s", status, output) } } func TestHTTPCheck_MultipleRedirects(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/a": http.Redirect(w, r, "/b", http.StatusFound) case "/b": http.Redirect(w, r, "/c", http.StatusFound) case "/c": w.WriteHeader(200) _, _ = fmt.Fprint(w, "final") } })) defer srv.Close() status, output, _ := executeHTTPCheck(context.Background(), &pb.HttpCheckConfig{ Url: srv.URL + "/a", FollowRedirects: true, }, 5000) if status != 0 { t.Fatalf("expected status 0 after multiple redirects, got %d: %s", status, output) } } func TestHTTPCheck_EmptyHeadersMap(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })) defer srv.Close() status, output, _ := executeHTTPCheck(context.Background(), &pb.HttpCheckConfig{ Url: srv.URL, Headers: map[string]string{}, }, 5000) if status != 0 { t.Fatalf("expected status 0 with empty headers, got %d: %s", status, output) } } // --------------------------------------------------------------------------- // SSL check tests // --------------------------------------------------------------------------- func TestExecuteCheck_MissingSSLConfig(t *testing.T) { result := ExecuteCheck(context.Background(), &pb.Check{ Id: "chk-ssl-missing", CheckType: "ssl", TimeoutMs: 5000, }) if result.Status != 3 { t.Fatalf("expected status 3, got %d", result.Status) } if !strings.Contains(result.Output, "Missing SSL config") { t.Fatalf("expected missing SSL config message, got %s", result.Output) } } func TestExecuteCheck_SSLRouting(t *testing.T) { // Start a local TLS server with a self-signed cert srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })) defer srv.Close() withTestSSLRootCA(t, srv.Certificate()) // Parse the port from the test server URL _, portStr, _ := net.SplitHostPort(strings.TrimPrefix(strings.TrimPrefix(srv.URL, "https://"), "http://")) result := ExecuteCheck(context.Background(), &pb.Check{ Id: "chk-route-ssl", CheckType: "ssl", TimeoutMs: 5000, Config: &pb.Check_Ssl{ Ssl: &pb.SslCheckConfig{ Host: "127.0.0.1", Port: parsePort(portStr), WarningDays: 7, }, }, }) // The test server has a valid cert, so should be OK if result.Status == 3 { t.Fatalf("expected routing to SSL handler, got status 3 (UNKNOWN): %s", result.Output) } } func TestSSLCheck_ValidCert(t *testing.T) { srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })) defer srv.Close() withTestSSLRootCA(t, srv.Certificate()) _, portStr, _ := net.SplitHostPort(strings.TrimPrefix(srv.URL, "https://")) status, output, rt := executeSSLCheck(context.Background(), &pb.SslCheckConfig{ Host: "127.0.0.1", Port: parsePort(portStr), WarningDays: 1, }, 5000) // httptest TLS cert is valid, should be OK with warning_days=1 if status != 0 { t.Fatalf("expected status 0, got %d: %s", status, output) } if !strings.Contains(output, "OK: Certificate") { t.Fatalf("expected OK message, got %s", output) } if rt < 0 { t.Fatalf("expected non-negative response time, got %f", rt) } } func TestSSLCheck_WarningThreshold(t *testing.T) { srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })) defer srv.Close() withTestSSLRootCA(t, srv.Certificate()) _, portStr, _ := net.SplitHostPort(strings.TrimPrefix(srv.URL, "https://")) // httptest certs typically expire within a few years. // Set warning_days very high to trigger WARNING. status, output, _ := executeSSLCheck(context.Background(), &pb.SslCheckConfig{ Host: "127.0.0.1", Port: parsePort(portStr), WarningDays: 999999, }, 5000) if status != 1 { t.Fatalf("expected status 1 (WARNING), got %d: %s", status, output) } if !strings.Contains(output, "WARNING: Certificate") { t.Fatalf("expected WARNING message, got %s", output) } } func TestSSLCheck_ConnectionFailure(t *testing.T) { // Use non-routable address status, output, _ := executeSSLCheck(context.Background(), &pb.SslCheckConfig{ Host: "192.0.2.1", Port: 443, }, 1000) if status != 3 { t.Fatalf("expected status 3 (UNKNOWN), got %d: %s", status, output) } if !strings.Contains(output, "Connection failed") { t.Fatalf("expected connection failed message, got %s", output) } } func TestSSLCheck_ClosedPort(t *testing.T) { // Bind and immediately close to get a port that refuses connections ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } port := parsePort(portFromListener(ln)) _ = ln.Close() status, output, _ := executeSSLCheck(context.Background(), &pb.SslCheckConfig{ Host: "127.0.0.1", Port: port, }, 2000) if status != 3 { t.Fatalf("expected status 3, got %d: %s", status, output) } } func TestSSLCheck_DefaultPort443(t *testing.T) { // When port is 0 (unset), should default to 443. // We just verify it doesn't crash. status, output, _ := executeSSLCheck(context.Background(), &pb.SslCheckConfig{ Host: "192.0.2.1", Port: 0, }, 500) // Should fail to connect (non-routable), but shouldn't crash if status != 3 { t.Logf("SSL check with default port returned status %d: %s", status, output) } } func TestSSLCheck_DefaultWarningDays30(t *testing.T) { srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })) defer srv.Close() withTestSSLRootCA(t, srv.Certificate()) _, portStr, _ := net.SplitHostPort(strings.TrimPrefix(srv.URL, "https://")) // warning_days=0 should default to 30 status, output, _ := executeSSLCheck(context.Background(), &pb.SslCheckConfig{ Host: "127.0.0.1", Port: parsePort(portStr), WarningDays: 0, }, 5000) // httptest certs are valid for ~1 year, with default 30 days warning should be OK if status != 0 { t.Fatalf("expected status 0 with default warning_days, got %d: %s", status, output) } } func TestSSLCheck_UntrustedCertFails(t *testing.T) { srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })) defer srv.Close() _, portStr, _ := net.SplitHostPort(strings.TrimPrefix(srv.URL, "https://")) status, output, _ := executeSSLCheck(context.Background(), &pb.SslCheckConfig{ Host: "127.0.0.1", Port: parsePort(portStr), WarningDays: 7, }, 5000) if status != 3 { t.Fatalf("expected status 3 for untrusted certificate, got %d: %s", status, output) } if !strings.Contains(output, "Connection failed") { t.Fatalf("expected connection failure output, got %s", output) } } // --------------------------------------------------------------------------- // Helper functions // --------------------------------------------------------------------------- // portFromListener extracts the port string from a net.Listener's address. func portFromListener(ln net.Listener) string { _, port, _ := net.SplitHostPort(ln.Addr().String()) return port } // parsePort converts a port string to uint32 for use in proto configs. func parsePort(s string) uint32 { var port uint32 _, _ = fmt.Sscanf(s, "%d", &port) return port } func withTestSSLRootCA(t *testing.T, cert *x509.Certificate) { t.Helper() orig := sslRootCAs sslRootCAs = func() (*x509.CertPool, error) { pool := x509.NewCertPool() pool.AddCert(cert) return pool, nil } t.Cleanup(func() { sslRootCAs = orig }) }