Files
keyhunter/pkg/recon/sources/codeberg_test.go
salvacybersec 4fafc01052 feat(10-05): implement CodebergSource for Gitea REST API
- Add CodebergSource targeting /api/v1/repos/search (Codeberg + any Gitea)
- Public API by default; Authorization: token <t> when Token set
- Unauth rate limit 60/hour, authenticated ~1000/hour
- Emit Findings keyed to repo html_url with SourceType=recon:codeberg
- Keyword index maps BuildQueries output back to ProviderName
- httptest coverage: name/interface, rate limits (both modes),
  sweep decoding, header presence/absence, ctx cancellation
2026-04-06 01:17:25 +03:00

183 lines
4.6 KiB
Go

package sources
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"golang.org/x/time/rate"
"github.com/salvacybersec/keyhunter/pkg/providers"
"github.com/salvacybersec/keyhunter/pkg/recon"
)
func newCodebergTestRegistry() *providers.Registry {
return providers.NewRegistryFromProviders([]providers.Provider{
{
Name: "openai",
DisplayName: "OpenAI",
Tier: 1,
Keywords: []string{"sk-proj-"},
FormatVersion: 1,
},
})
}
func TestCodebergSource_NameAndInterface(t *testing.T) {
var _ recon.ReconSource = (*CodebergSource)(nil)
s := &CodebergSource{}
if got := s.Name(); got != "codeberg" {
t.Errorf("Name() = %q, want %q", got, "codeberg")
}
if s.RespectsRobots() {
t.Errorf("RespectsRobots() = true, want false")
}
if !s.Enabled(recon.Config{}) {
t.Errorf("Enabled() = false, want true (public API)")
}
}
func TestCodebergSource_RateLimitUnauthenticated(t *testing.T) {
s := &CodebergSource{}
got := s.RateLimit()
want := rate.Every(60 * time.Second)
if got != want {
t.Errorf("RateLimit() no token = %v, want %v", got, want)
}
if s.Burst() != 1 {
t.Errorf("Burst() = %d, want 1", s.Burst())
}
}
func TestCodebergSource_RateLimitAuthenticated(t *testing.T) {
s := &CodebergSource{Token: "abc123"}
got := s.RateLimit()
want := rate.Every(3600 * time.Millisecond)
if got != want {
t.Errorf("RateLimit() with token = %v, want %v", got, want)
}
}
func TestCodebergSource_SweepEmitsFindings(t *testing.T) {
var gotAuth string
var gotPath string
var gotQuery string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
gotPath = r.URL.Path
gotQuery = r.URL.Query().Get("q")
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"data": []map[string]any{
{
"full_name": "alice/leaked-keys",
"html_url": "https://codeberg.org/alice/leaked-keys",
},
},
})
}))
defer srv.Close()
s := &CodebergSource{
BaseURL: srv.URL,
Registry: newCodebergTestRegistry(),
Limiters: recon.NewLimiterRegistry(),
}
out := make(chan recon.Finding, 8)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.Sweep(ctx, "", out); err != nil {
t.Fatalf("Sweep: %v", err)
}
close(out)
var findings []recon.Finding
for f := range out {
findings = append(findings, f)
}
if len(findings) == 0 {
t.Fatalf("expected at least one finding")
}
f := findings[0]
if f.Source != "https://codeberg.org/alice/leaked-keys" {
t.Errorf("Source = %q, want codeberg html_url", f.Source)
}
if f.SourceType != "recon:codeberg" {
t.Errorf("SourceType = %q, want recon:codeberg", f.SourceType)
}
if f.ProviderName != "openai" {
t.Errorf("ProviderName = %q, want openai", f.ProviderName)
}
if gotPath != "/api/v1/repos/search" {
t.Errorf("path = %q, want /api/v1/repos/search", gotPath)
}
if gotQuery == "" {
t.Errorf("query param empty")
}
if gotAuth != "" {
t.Errorf("Authorization header should be absent without token, got %q", gotAuth)
}
}
func TestCodebergSource_SweepWithTokenSetsAuthHeader(t *testing.T) {
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true,"data":[]}`))
}))
defer srv.Close()
s := &CodebergSource{
Token: "s3cret",
BaseURL: srv.URL,
Registry: newCodebergTestRegistry(),
Limiters: recon.NewLimiterRegistry(),
}
out := make(chan recon.Finding, 1)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.Sweep(ctx, "", out); err != nil {
t.Fatalf("Sweep: %v", err)
}
if !strings.HasPrefix(gotAuth, "token ") || !strings.Contains(gotAuth, "s3cret") {
t.Errorf("Authorization header = %q, want \"token s3cret\"", gotAuth)
}
}
func TestCodebergSource_SweepContextCancellation(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-r.Context().Done():
case <-time.After(3 * time.Second):
}
}))
defer srv.Close()
s := &CodebergSource{
BaseURL: srv.URL,
Registry: newCodebergTestRegistry(),
Limiters: recon.NewLimiterRegistry(),
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
out := make(chan recon.Finding, 1)
err := s.Sweep(ctx, "", out)
if err == nil {
t.Fatalf("expected error on cancelled context")
}
}