package authz
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync/atomic"
"testing"
"time"
"sourcecraft.dev/bigbes/sr-ht-core/config"
"sourcecraft.dev/bigbes/sr-ht-core/crypto"
"sourcecraft.dev/bigbes/sr-ht-ecore/ecoretest"
"sourcecraft.dev/bigbes/sr-ht-compare/core"
)
// TestMain installs ecore's fixed test keyset into core-go's process-global
// crypto, so the Internal authorization this package seals for git.sr.ht can be
// opened again by the stub receiver below.
func TestMain(m *testing.M) {
ecoretest.InitCrypto()
os.Exit(m.Run())
}
// authNameFromRequest asserts the incoming request bears an "Internal <token>"
// 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 — the ephemeral httptest server of the calling test — over the synthetic
// instance whose keyset TestMain installed.
func ctxFor(url string) context.Context {
conf := ecoretest.Config("compare.sr.ht",
ecoretest.Set("git.sr.ht", "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)
}
}