package authz import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "time" "sourcecraft.dev/bigbes/sr-ht-core/config" "sourcecraft.dev/bigbes/sr-ht-core/crypto" "github.com/vaughan0/go-ini" "sourcecraft.dev/bigbes/sr-ht-compare/core" ) // authNameFromRequest asserts the incoming request bears an "Internal " // Authorization header that decrypts to InternalAuth JSON, and returns the Name // field (empty for anonymous). This mirrors what git.sr.ht's receiver does. func authNameFromRequest(t *testing.T, r *http.Request) string { t.Helper() h := r.Header.Get("Authorization") token, ok := strings.CutPrefix(h, "Internal ") if !ok { t.Fatalf("Authorization header = %q, want Internal prefix", h) } payload := crypto.DecryptWithExpiration([]byte(token), time.Hour) if payload == nil { t.Fatalf("internal auth token did not decrypt") } var auth struct { Name string `json:"name"` ClientID string `json:"client_id"` } if err := json.Unmarshal(payload, &auth); err != nil { t.Fatalf("unmarshal internal auth: %v", err) } return auth.Name } // ctxFor returns a context carrying config that points git.sr.ht's API origin at // url, reusing the crypto keys established in TestMain. func ctxFor(url string) context.Context { conf := ini.File{ "sr.ht": testConf.Section("sr.ht"), "webhooks": testConf.Section("webhooks"), "compare.sr.ht": ini.Section{"origin": "http://localhost"}, "git.sr.ht": ini.Section{"api-origin": url}, } return config.Context(context.Background(), conf, "compare.sr.ht") } func TestRepo_Found(t *testing.T) { var count int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&count, 1) if name := authNameFromRequest(t, r); name != "bigbes" { t.Errorf("viewer name = %q, want bigbes", name) } w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"data":{"user":{"repository":{"id":42,"name":"dotfiles","description":"my configs","visibility":"PUBLIC"}}}}`)) })) defer srv.Close() a := NewAuthorizer(time.Minute) info, err := a.Repo(ctxFor(srv.URL), "bigbes", "~bigbes", "dotfiles") if err != nil { t.Fatalf("Repo error: %v", err) } want := &RepoInfo{ID: 42, Name: "dotfiles", Description: "my configs", Visibility: "PUBLIC"} if *info != *want { t.Fatalf("info = %+v, want %+v", *info, *want) } if got := atomic.LoadInt32(&count); got != 1 { t.Fatalf("request count = %d, want 1", got) } } func TestRepo_AnonymousViewer(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if name := authNameFromRequest(t, r); name != "" { t.Errorf("viewer name = %q, want empty for anonymous", name) } w.Write([]byte(`{"data":{"user":{"repository":{"id":7,"name":"pub","description":"","visibility":"PUBLIC"}}}}`)) })) defer srv.Close() a := NewAuthorizer(time.Minute) info, err := a.Repo(ctxFor(srv.URL), "", "bigbes", "pub") if err != nil { t.Fatalf("Repo error: %v", err) } if info.ID != 7 || info.Visibility != "PUBLIC" { t.Fatalf("info = %+v", *info) } } func TestRepo_NullRepositoryIsNotFound(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"data":{"user":{"repository":null}}}`)) })) defer srv.Close() a := NewAuthorizer(time.Minute) _, err := a.Repo(ctxFor(srv.URL), "bigbes", "bigbes", "secret") if !errors.Is(err, core.ErrNotFound) { t.Fatalf("err = %v, want core.ErrNotFound", err) } } func TestRepo_NullUserIsNotFound(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"data":{"user":null}}`)) })) defer srv.Close() a := NewAuthorizer(time.Minute) _, err := a.Repo(ctxFor(srv.URL), "bigbes", "ghost", "repo") if !errors.Is(err, core.ErrNotFound) { t.Fatalf("err = %v, want core.ErrNotFound", err) } } func TestRepo_ServerErrorIsNotNotFound(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "boom", http.StatusInternalServerError) })) defer srv.Close() a := NewAuthorizer(time.Minute) _, err := a.Repo(ctxFor(srv.URL), "bigbes", "bigbes", "dotfiles") if err == nil { t.Fatal("expected error on 500") } if errors.Is(err, core.ErrNotFound) { t.Fatalf("500 mapped to ErrNotFound (should be a transport error): %v", err) } } func TestRepo_CachesPositive(t *testing.T) { var count int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&count, 1) w.Write([]byte(`{"data":{"user":{"repository":{"id":1,"name":"r","description":"","visibility":"PRIVATE"}}}}`)) })) defer srv.Close() a := NewAuthorizer(time.Minute) ctx := ctxFor(srv.URL) for i := 0; i < 3; i++ { if _, err := a.Repo(ctx, "bigbes", "bigbes", "r"); err != nil { t.Fatalf("call %d: %v", i, err) } } if got := atomic.LoadInt32(&count); got != 1 { t.Fatalf("request count = %d, want 1 (cache miss on repeat)", got) } } func TestRepo_CachesNotFound(t *testing.T) { var count int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&count, 1) w.Write([]byte(`{"data":{"user":{"repository":null}}}`)) })) defer srv.Close() a := NewAuthorizer(time.Minute) ctx := ctxFor(srv.URL) for i := 0; i < 3; i++ { if _, err := a.Repo(ctx, "bigbes", "bigbes", "gone"); !errors.Is(err, core.ErrNotFound) { t.Fatalf("call %d: err = %v, want ErrNotFound", i, err) } } if got := atomic.LoadInt32(&count); got != 1 { t.Fatalf("request count = %d, want 1 (not-found not cached)", got) } } func TestRepo_DoesNotCacheTransportError(t *testing.T) { var count int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Fail the first request, succeed after: a cached error would keep // failing. if atomic.AddInt32(&count, 1) == 1 { http.Error(w, "boom", http.StatusInternalServerError) return } w.Write([]byte(`{"data":{"user":{"repository":{"id":9,"name":"r","description":"","visibility":"PUBLIC"}}}}`)) })) defer srv.Close() a := NewAuthorizer(time.Minute) ctx := ctxFor(srv.URL) if _, err := a.Repo(ctx, "bigbes", "bigbes", "r"); err == nil { t.Fatal("expected first call to error") } info, err := a.Repo(ctx, "bigbes", "bigbes", "r") if err != nil { t.Fatalf("second call should retry and succeed: %v", err) } if info.ID != 9 { t.Fatalf("info = %+v", *info) } } func TestRepo_CacheExpires(t *testing.T) { var count int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&count, 1) w.Write([]byte(`{"data":{"user":{"repository":{"id":1,"name":"r","description":"","visibility":"PUBLIC"}}}}`)) })) defer srv.Close() a := NewAuthorizer(20 * time.Millisecond) ctx := ctxFor(srv.URL) if _, err := a.Repo(ctx, "bigbes", "bigbes", "r"); err != nil { t.Fatal(err) } time.Sleep(40 * time.Millisecond) if _, err := a.Repo(ctx, "bigbes", "bigbes", "r"); err != nil { t.Fatal(err) } if got := atomic.LoadInt32(&count); got != 2 { t.Fatalf("request count = %d, want 2 after TTL expiry", got) } } func TestRepo_CacheKeyedByViewer(t *testing.T) { var count int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&count, 1) w.Write([]byte(`{"data":{"user":{"repository":{"id":1,"name":"r","description":"","visibility":"PUBLIC"}}}}`)) })) defer srv.Close() a := NewAuthorizer(time.Minute) ctx := ctxFor(srv.URL) // Same repo, different viewers must not share a cache entry: git.sr.ht may // answer differently per viewer. _, _ = a.Repo(ctx, "alice", "bigbes", "r") _, _ = a.Repo(ctx, "bob", "bigbes", "r") if got := atomic.LoadInt32(&count); got != 2 { t.Fatalf("request count = %d, want 2 (per-viewer cache keys)", got) } } func TestMyRepos_RequiresViewer(t *testing.T) { a := NewAuthorizer(time.Minute) if _, err := a.MyRepos(context.Background(), ""); err == nil { t.Fatal("MyRepos with empty viewer should error") } } func TestMyRepos_Paginates(t *testing.T) { var count int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { n := atomic.AddInt32(&count, 1) if n == 1 { // First page carries a cursor for the next. w.Write([]byte(`{"data":{"me":{"repositories":{"results":[{"id":1,"name":"a","description":"","visibility":"PUBLIC"},{"id":2,"name":"b","description":"","visibility":"PRIVATE"}],"cursor":"next"}}}}`)) return } // Second (final) page: null cursor ends pagination. w.Write([]byte(`{"data":{"me":{"repositories":{"results":[{"id":3,"name":"c","description":"third","visibility":"UNLISTED"}],"cursor":null}}}}`)) })) defer srv.Close() a := NewAuthorizer(time.Minute) repos, err := a.MyRepos(ctxFor(srv.URL), "bigbes") if err != nil { t.Fatalf("MyRepos error: %v", err) } if len(repos) != 3 { t.Fatalf("got %d repos, want 3", len(repos)) } if repos[2].Name != "c" || repos[2].Visibility != "UNLISTED" { t.Fatalf("last repo = %+v", repos[2]) } if got := atomic.LoadInt32(&count); got != 2 { t.Fatalf("request count = %d, want 2 pages", got) } } func TestMyRepos_Uncached(t *testing.T) { var count int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&count, 1) w.Write([]byte(`{"data":{"me":{"repositories":{"results":[{"id":1,"name":"a","description":"","visibility":"PUBLIC"}],"cursor":null}}}}`)) })) defer srv.Close() a := NewAuthorizer(time.Minute) ctx := ctxFor(srv.URL) _, _ = a.MyRepos(ctx, "bigbes") _, _ = a.MyRepos(ctx, "bigbes") if got := atomic.LoadInt32(&count); got != 2 { t.Fatalf("request count = %d, want 2 (MyRepos must not be cached)", got) } }