diff --git a/poller/internal/bus/discovery_responder_test.go b/poller/internal/bus/discovery_responder_test.go new file mode 100644 index 0000000..dc240d0 --- /dev/null +++ b/poller/internal/bus/discovery_responder_test.go @@ -0,0 +1,219 @@ +package bus + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +func TestDiscoveryResponder_Subscribe(t *testing.T) { + nc, cleanup := startTestNATS(t) + defer cleanup() + + dr := NewDiscoveryResponder(nc) + if err := dr.Start(); err != nil { + t.Fatalf("Start() returned error: %v", err) + } + defer dr.Stop() + + if dr.sub == nil { + t.Fatal("expected subscription to be set after Start()") + } + + // Verify subscription subject and queue group + if dr.sub.Subject != "device.discover.snmp" { + t.Errorf("expected subject 'device.discover.snmp', got %q", dr.sub.Subject) + } + if dr.sub.Queue != "discover-workers" { + t.Errorf("expected queue 'discover-workers', got %q", dr.sub.Queue) + } +} + +func TestDiscoveryResponder_InvalidJSON(t *testing.T) { + nc, cleanup := startTestNATS(t) + defer cleanup() + + dr := NewDiscoveryResponder(nc) + if err := dr.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + defer dr.Stop() + + reply, err := nc.Request("device.discover.snmp", []byte("{invalid json"), 5*time.Second) + if err != nil { + t.Fatalf("NATS request failed: %v", err) + } + + var resp DiscoveryResponse + if err := json.Unmarshal(reply.Data, &resp); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + if resp.Error == "" { + t.Error("expected non-empty error for invalid JSON") + } + if !strings.Contains(resp.Error, "invalid request") { + t.Errorf("expected error to contain 'invalid request', got %q", resp.Error) + } +} + +func TestDiscoveryResponder_MissingIPAddress(t *testing.T) { + nc, cleanup := startTestNATS(t) + defer cleanup() + + dr := NewDiscoveryResponder(nc) + if err := dr.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + defer dr.Stop() + + req := DiscoveryRequest{ + SNMPVersion: "v2c", + Community: "public", + } + reqData, _ := json.Marshal(req) + + reply, err := nc.Request("device.discover.snmp", reqData, 5*time.Second) + if err != nil { + t.Fatalf("NATS request failed: %v", err) + } + + var resp DiscoveryResponse + if err := json.Unmarshal(reply.Data, &resp); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + if resp.Error == "" { + t.Error("expected non-empty error for missing ip_address") + } + if !strings.Contains(resp.Error, "ip_address") { + t.Errorf("expected error to mention 'ip_address', got %q", resp.Error) + } +} + +func TestDiscoveryResponder_ResponseFields(t *testing.T) { + // Verify the DiscoveryResponse JSON field names match the spec. + resp := DiscoveryResponse{ + SysObjectID: "1.3.6.1.4.1.14988.1", + SysDescr: "RouterOS RB750Gr3", + SysName: "router1.local", + } + + data, err := json.Marshal(resp) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("unmarshal to map: %v", err) + } + + for _, field := range []string{"sys_object_id", "sys_descr", "sys_name"} { + if _, ok := raw[field]; !ok { + t.Errorf("expected field %q in JSON output", field) + } + } + + // Verify error field with omitempty + errResp := DiscoveryResponse{Error: "test error"} + errData, _ := json.Marshal(errResp) + var errRaw map[string]interface{} + _ = json.Unmarshal(errData, &errRaw) + if _, ok := errRaw["error"]; !ok { + t.Error("expected 'error' field in error response JSON") + } +} + +func TestDiscoveryResponder_Stop_Unsubscribes(t *testing.T) { + nc, cleanup := startTestNATS(t) + defer cleanup() + + dr := NewDiscoveryResponder(nc) + if err := dr.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + + if !dr.sub.IsValid() { + t.Fatal("expected subscription to be valid before Stop()") + } + + dr.Stop() + + if dr.sub.IsValid() { + t.Error("expected subscription to be invalid after Stop()") + } +} + +func TestDiscoveryResponder_InvalidSNMPVersion(t *testing.T) { + nc, cleanup := startTestNATS(t) + defer cleanup() + + dr := NewDiscoveryResponder(nc) + if err := dr.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + defer dr.Stop() + + req := DiscoveryRequest{ + IPAddress: "10.0.0.1", + SNMPVersion: "v4", + } + reqData, _ := json.Marshal(req) + + reply, err := nc.Request("device.discover.snmp", reqData, 5*time.Second) + if err != nil { + t.Fatalf("NATS request failed: %v", err) + } + + var resp DiscoveryResponse + if err := json.Unmarshal(reply.Data, &resp); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + if resp.Error == "" { + t.Error("expected non-empty error for invalid snmp_version") + } +} + +func TestDiscoveryResponder_DefaultPort(t *testing.T) { + // Test that requests with zero port default to 161. + // We verify this indirectly -- the request should not fail validation + // (it will fail on SNMP connect, which is expected since there's no device). + nc, cleanup := startTestNATS(t) + defer cleanup() + + dr := NewDiscoveryResponder(nc) + if err := dr.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + defer dr.Stop() + + req := DiscoveryRequest{ + IPAddress: "192.0.2.1", // TEST-NET, unreachable + SNMPVersion: "v2c", + Community: "public", + // SNMPPort intentionally 0 -- should default to 161 + } + reqData, _ := json.Marshal(req) + + reply, err := nc.Request("device.discover.snmp", reqData, 10*time.Second) + if err != nil { + t.Fatalf("NATS request failed: %v", err) + } + + var resp DiscoveryResponse + if err := json.Unmarshal(reply.Data, &resp); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + // The request passed validation (no "ip_address" or "snmp_version" error). + // It should fail on SNMP probe (unreachable device), not on validation. + if resp.Error != "" && strings.Contains(resp.Error, "ip_address") { + t.Error("request with zero port should not fail ip_address validation") + } + if resp.Error != "" && strings.Contains(resp.Error, "snmp_version") { + t.Error("request with zero port should not fail snmp_version validation") + } +}