From d6c35f4f14931a7bc572ed33df3544d69e016109 Mon Sep 17 00:00:00 2001 From: salvacybersec Date: Mon, 6 Apr 2026 12:24:11 +0300 Subject: [PATCH] test(12-02): add httptest tests for FOFA, Netlas, BinaryEdge sources - FOFA: mock JSON with 2 results, credential validation, context cancellation - Netlas: mock JSON with 2 items, X-API-Key header check, context cancellation - BinaryEdge: mock JSON with 2 events, X-Key header check, context cancellation - All verify correct finding count, source type, and disabled state Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/recon/sources/binaryedge_test.go | 117 ++++++++++++++++++++++++ pkg/recon/sources/fofa_test.go | 130 +++++++++++++++++++++++++++ pkg/recon/sources/netlas_test.go | 117 ++++++++++++++++++++++++ 3 files changed, 364 insertions(+) create mode 100644 pkg/recon/sources/binaryedge_test.go create mode 100644 pkg/recon/sources/fofa_test.go create mode 100644 pkg/recon/sources/netlas_test.go diff --git a/pkg/recon/sources/binaryedge_test.go b/pkg/recon/sources/binaryedge_test.go new file mode 100644 index 0000000..e003a01 --- /dev/null +++ b/pkg/recon/sources/binaryedge_test.go @@ -0,0 +1,117 @@ +package sources + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/salvacybersec/keyhunter/pkg/recon" +) + +func binaryedgeStubHandler(t *testing.T, calls *int32) http.HandlerFunc { + t.Helper() + return func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(calls, 1) + if !strings.HasPrefix(r.URL.Path, "/v2/query/search") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if got := r.Header.Get("X-Key"); got != "testkey" { + t.Errorf("missing X-Key header: %q", got) + } + body := binaryedgeSearchResponse{ + Events: []binaryedgeEvent{ + {Target: binaryedgeTarget{IP: "192.168.1.1", Port: 80}}, + {Target: binaryedgeTarget{IP: "192.168.1.2", Port: 443}}, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(body) + } +} + +func TestBinaryEdgeSource_EnabledRequiresAPIKey(t *testing.T) { + reg := syntheticRegistry() + lim := recon.NewLimiterRegistry() + + s := &BinaryEdgeSource{APIKey: "", Registry: reg, Limiters: lim} + if s.Enabled(recon.Config{}) { + t.Error("expected Enabled=false with empty key") + } + s = &BinaryEdgeSource{APIKey: "key", Registry: reg, Limiters: lim} + if !s.Enabled(recon.Config{}) { + t.Error("expected Enabled=true with key") + } +} + +func TestBinaryEdgeSource_SweepEmitsFindings(t *testing.T) { + reg := syntheticRegistry() + lim := recon.NewLimiterRegistry() + _ = lim.For("binaryedge", 1000, 100) + + var calls int32 + srv := httptest.NewServer(binaryedgeStubHandler(t, &calls)) + defer srv.Close() + + s := &BinaryEdgeSource{ + APIKey: "testkey", + BaseURL: srv.URL, + Registry: reg, + Limiters: lim, + } + + out := make(chan recon.Finding, 32) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + done := make(chan error, 1) + go func() { done <- s.Sweep(ctx, "", out); close(out) }() + + var findings []recon.Finding + for f := range out { + findings = append(findings, f) + } + if err := <-done; err != nil { + t.Fatalf("Sweep error: %v", err) + } + + // 2 keywords * 2 events = 4 findings + if len(findings) != 4 { + t.Fatalf("expected 4 findings, got %d", len(findings)) + } + for _, f := range findings { + if f.SourceType != "recon:binaryedge" { + t.Errorf("SourceType=%q want recon:binaryedge", f.SourceType) + } + } + if got := atomic.LoadInt32(&calls); got != 2 { + t.Errorf("expected 2 API calls, got %d", got) + } +} + +func TestBinaryEdgeSource_CtxCancelled(t *testing.T) { + reg := syntheticRegistry() + lim := recon.NewLimiterRegistry() + _ = lim.For("binaryedge", 1000, 100) + + s := &BinaryEdgeSource{ + APIKey: "key", + BaseURL: "http://127.0.0.1:1", + Registry: reg, + Limiters: lim, + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + out := make(chan recon.Finding, 1) + err := s.Sweep(ctx, "", out) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got %v", err) + } +} diff --git a/pkg/recon/sources/fofa_test.go b/pkg/recon/sources/fofa_test.go new file mode 100644 index 0000000..e17497d --- /dev/null +++ b/pkg/recon/sources/fofa_test.go @@ -0,0 +1,130 @@ +package sources + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/salvacybersec/keyhunter/pkg/recon" +) + +func fofaStubHandler(t *testing.T, calls *int32) http.HandlerFunc { + t.Helper() + return func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(calls, 1) + if r.URL.Path != "/api/v1/search/all" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("email"); got != "test@example.com" { + t.Errorf("missing email param: %q", got) + } + if got := r.URL.Query().Get("key"); got != "testkey" { + t.Errorf("missing key param: %q", got) + } + body := fofaSearchResponse{ + Results: [][]string{ + {"example.com", "1.2.3.4", "443"}, + {"test.org", "5.6.7.8", "8080"}, + }, + Size: 2, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(body) + } +} + +func TestFOFASource_EnabledRequiresCredentials(t *testing.T) { + reg := syntheticRegistry() + lim := recon.NewLimiterRegistry() + + s := &FOFASource{Email: "", APIKey: "", Registry: reg, Limiters: lim} + if s.Enabled(recon.Config{}) { + t.Error("expected Enabled=false with empty credentials") + } + s = &FOFASource{Email: "a@b.com", APIKey: "", Registry: reg, Limiters: lim} + if s.Enabled(recon.Config{}) { + t.Error("expected Enabled=false with empty APIKey") + } + s = &FOFASource{Email: "", APIKey: "key", Registry: reg, Limiters: lim} + if s.Enabled(recon.Config{}) { + t.Error("expected Enabled=false with empty Email") + } + s = &FOFASource{Email: "a@b.com", APIKey: "key", Registry: reg, Limiters: lim} + if !s.Enabled(recon.Config{}) { + t.Error("expected Enabled=true with both credentials") + } +} + +func TestFOFASource_SweepEmitsFindings(t *testing.T) { + reg := syntheticRegistry() + lim := recon.NewLimiterRegistry() + _ = lim.For("fofa", 1000, 100) + + var calls int32 + srv := httptest.NewServer(fofaStubHandler(t, &calls)) + defer srv.Close() + + s := &FOFASource{ + Email: "test@example.com", + APIKey: "testkey", + BaseURL: srv.URL, + Registry: reg, + Limiters: lim, + } + + out := make(chan recon.Finding, 32) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + done := make(chan error, 1) + go func() { done <- s.Sweep(ctx, "", out); close(out) }() + + var findings []recon.Finding + for f := range out { + findings = append(findings, f) + } + if err := <-done; err != nil { + t.Fatalf("Sweep error: %v", err) + } + + // 2 keywords * 2 results = 4 findings + if len(findings) != 4 { + t.Fatalf("expected 4 findings, got %d", len(findings)) + } + for _, f := range findings { + if f.SourceType != "recon:fofa" { + t.Errorf("SourceType=%q want recon:fofa", f.SourceType) + } + } + if got := atomic.LoadInt32(&calls); got != 2 { + t.Errorf("expected 2 API calls, got %d", got) + } +} + +func TestFOFASource_CtxCancelled(t *testing.T) { + reg := syntheticRegistry() + lim := recon.NewLimiterRegistry() + _ = lim.For("fofa", 1000, 100) + + s := &FOFASource{ + Email: "a@b.com", + APIKey: "key", + BaseURL: "http://127.0.0.1:1", + Registry: reg, + Limiters: lim, + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + out := make(chan recon.Finding, 1) + err := s.Sweep(ctx, "", out) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got %v", err) + } +} diff --git a/pkg/recon/sources/netlas_test.go b/pkg/recon/sources/netlas_test.go new file mode 100644 index 0000000..ddc337a --- /dev/null +++ b/pkg/recon/sources/netlas_test.go @@ -0,0 +1,117 @@ +package sources + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/salvacybersec/keyhunter/pkg/recon" +) + +func netlasStubHandler(t *testing.T, calls *int32) http.HandlerFunc { + t.Helper() + return func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(calls, 1) + if !strings.HasPrefix(r.URL.Path, "/api/responses/") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if got := r.Header.Get("X-API-Key"); got != "testkey" { + t.Errorf("missing X-API-Key header: %q", got) + } + body := netlasSearchResponse{ + Items: []netlasItem{ + {Data: netlasData{IP: "10.0.0.1", Port: 443}}, + {Data: netlasData{IP: "10.0.0.2", Port: 8443}}, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(body) + } +} + +func TestNetlasSource_EnabledRequiresAPIKey(t *testing.T) { + reg := syntheticRegistry() + lim := recon.NewLimiterRegistry() + + s := &NetlasSource{APIKey: "", Registry: reg, Limiters: lim} + if s.Enabled(recon.Config{}) { + t.Error("expected Enabled=false with empty key") + } + s = &NetlasSource{APIKey: "key", Registry: reg, Limiters: lim} + if !s.Enabled(recon.Config{}) { + t.Error("expected Enabled=true with key") + } +} + +func TestNetlasSource_SweepEmitsFindings(t *testing.T) { + reg := syntheticRegistry() + lim := recon.NewLimiterRegistry() + _ = lim.For("netlas", 1000, 100) + + var calls int32 + srv := httptest.NewServer(netlasStubHandler(t, &calls)) + defer srv.Close() + + s := &NetlasSource{ + APIKey: "testkey", + BaseURL: srv.URL, + Registry: reg, + Limiters: lim, + } + + out := make(chan recon.Finding, 32) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + done := make(chan error, 1) + go func() { done <- s.Sweep(ctx, "", out); close(out) }() + + var findings []recon.Finding + for f := range out { + findings = append(findings, f) + } + if err := <-done; err != nil { + t.Fatalf("Sweep error: %v", err) + } + + // 2 keywords * 2 items = 4 findings + if len(findings) != 4 { + t.Fatalf("expected 4 findings, got %d", len(findings)) + } + for _, f := range findings { + if f.SourceType != "recon:netlas" { + t.Errorf("SourceType=%q want recon:netlas", f.SourceType) + } + } + if got := atomic.LoadInt32(&calls); got != 2 { + t.Errorf("expected 2 API calls, got %d", got) + } +} + +func TestNetlasSource_CtxCancelled(t *testing.T) { + reg := syntheticRegistry() + lim := recon.NewLimiterRegistry() + _ = lim.For("netlas", 1000, 100) + + s := &NetlasSource{ + APIKey: "key", + BaseURL: "http://127.0.0.1:1", + Registry: reg, + Limiters: lim, + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + out := make(chan recon.Finding, 1) + err := s.Sweep(ctx, "", out) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got %v", err) + } +}