From 0f5e8de501ca221f322e850c5882634254e14dab Mon Sep 17 00:00:00 2001 From: bigbes Date: Sat, 18 Jul 2026 20:43:33 +0300 Subject: [PATCH] authz: unified-login identity and git.sr.ht GraphQL authorization Add the authz package: cookie-derived identity and per-request repository authorization delegated to git.sr.ht's internal GraphQL API, with no local database. - identity.go: UsernameFromRequest decrypts the sr.ht.unified-login.v1 Fernet cookie to a bare username ("" for anonymous, never rejects); Middleware/ ForContext carry it in the request context. - authz.go: Authorizer interface + GQLAuthorizer over core-go client.Do. Repo strips a leading ~ from the owner, maps null user/repository to core.ErrNotFound (never leaking existence) and transport/GraphQL failures to a wrapped non-NotFound error. MyRepos paginates me{repositories} to a 500-repo cap. A mutex-guarded TTL cache memoizes positive and not-found Repo results (keyed viewer\0owner\0name) but never transport errors, with lazy expiry and opportunistic sweeps. - Tests: in-memory config (generated Fernet + ed25519 keys) + httptest server asserting the Internal auth header decrypts to the expected viewer; covers cookie round-trip, found/null/500 repo cases, per-viewer cache keying, TTL expiry, transport-error non-caching, and MyRepos pagination. --- authz/authz.go | 210 ++++++++++++++++++++++++++++ authz/authz_test.go | 304 +++++++++++++++++++++++++++++++++++++++++ authz/doc.go | 17 +++ authz/identity.go | 63 +++++++++ authz/identity_test.go | 118 ++++++++++++++++ 5 files changed, 712 insertions(+) create mode 100644 authz/authz.go create mode 100644 authz/authz_test.go create mode 100644 authz/doc.go create mode 100644 authz/identity.go create mode 100644 authz/identity_test.go diff --git a/authz/authz.go b/authz/authz.go new file mode 100644 index 0000000000000000000000000000000000000000..9885b098c245a2f2ca6a8eebca4c0624a1158195 --- /dev/null +++ b/authz/authz.go @@ -0,0 +1,210 @@ +package authz + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "git.sr.ht/~sircmpwn/core-go/client" + + "go.bigb.es/sourcehut-compare/core" +) + +// RepoInfo is the subset of git.sr.ht repository metadata compare.sr.ht needs +// to render a page. It carries no ownership or ACL data: the mere fact that the +// authorizer returned a RepoInfo means the viewer is allowed to see the repo. +type RepoInfo struct { + ID int + Name string + Description string + Visibility string +} + +// Authorizer decides, for a given viewer, whether a repository may be seen and +// returns its metadata. A nil *RepoInfo is never returned alongside a nil +// error; an unauthorized or missing repo is reported as core.ErrNotFound so +// callers cannot distinguish "forbidden" from "absent". +type Authorizer interface { + // Repo resolves a single repository owned by owner (with or without a + // leading "~") as seen by viewer ("" for anonymous). A null user or null + // repository yields core.ErrNotFound; transport/GraphQL failures yield a + // wrapped error that is NOT core.ErrNotFound. + Repo(ctx context.Context, viewer, owner, name string) (*RepoInfo, error) + // MyRepos lists every repository owned by viewer. viewer must be non-empty. + MyRepos(ctx context.Context, viewer string) ([]RepoInfo, error) +} + +// maxMyRepos caps the number of repositories MyRepos will accumulate across +// pages, bounding memory and request count for pathological accounts. +const maxMyRepos = 500 + +type cacheEntry struct { + info *RepoInfo // nil when notFound + notFound bool + expiry time.Time +} + +// GQLAuthorizer implements Authorizer against git.sr.ht's internal GraphQL API +// via core-go's client.Do, memoizing Repo results in a TTL cache. It is safe +// for concurrent use. +type GQLAuthorizer struct { + ttl time.Duration + + mu sync.Mutex + cache map[string]cacheEntry + lastSweep time.Time +} + +// NewAuthorizer returns a GQLAuthorizer whose positive and not-found Repo +// results are cached for ttl (the caller supplies 60s in production). +func NewAuthorizer(ttl time.Duration) *GQLAuthorizer { + return &GQLAuthorizer{ + ttl: ttl, + cache: make(map[string]cacheEntry), + } +} + +// repoQuery asks for one repository under a user; both user and repository come +// back null when the viewer may not see them. username is passed WITHOUT "~". +const repoQuery = `query($u:String!,$r:String!){user(username:$u){repository(name:$r){id name description visibility}}}` + +func (a *GQLAuthorizer) Repo(ctx context.Context, viewer, owner, name string) (*RepoInfo, error) { + owner = strings.TrimPrefix(owner, "~") + key := cacheKey(viewer, owner, name) + + if info, notFound, ok := a.load(key); ok { + if notFound { + return nil, core.ErrNotFound + } + return info, nil + } + + var result struct { + User *struct { + Repository *struct { + ID int `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Visibility string `json:"visibility"` + } `json:"repository"` + } `json:"user"` + } + query := client.GraphQLQuery{ + Query: repoQuery, + Variables: map[string]any{"u": owner, "r": name}, + } + if err := client.Do(ctx, viewer, "git.sr.ht", query, &result); err != nil { + // Transport or GraphQL error — do NOT cache and do NOT mask as + // not-found; the web layer distinguishes 404 from 502. + return nil, fmt.Errorf("git.sr.ht repository query for ~%s/%s: %w", owner, name, err) + } + if result.User == nil || result.User.Repository == nil { + a.store(key, cacheEntry{notFound: true}) + return nil, core.ErrNotFound + } + + repo := result.User.Repository + info := &RepoInfo{ + ID: repo.ID, + Name: repo.Name, + Description: repo.Description, + Visibility: repo.Visibility, + } + a.store(key, cacheEntry{info: info}) + return info, nil +} + +// myReposQuery paginates the viewer's own repositories via the cursor scalar. +const myReposQuery = `query($c:Cursor){me{repositories(cursor:$c){results{id name description visibility} cursor}}}` + +func (a *GQLAuthorizer) MyRepos(ctx context.Context, viewer string) ([]RepoInfo, error) { + if viewer == "" { + return nil, fmt.Errorf("authz: MyRepos requires an authenticated viewer") + } + + var repos []RepoInfo + var cursor *string + for { + var result struct { + Me struct { + Repositories struct { + Results []struct { + ID int `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Visibility string `json:"visibility"` + } `json:"results"` + Cursor *string `json:"cursor"` + } `json:"repositories"` + } `json:"me"` + } + query := client.GraphQLQuery{ + Query: myReposQuery, + Variables: map[string]any{"c": cursor}, + } + if err := client.Do(ctx, viewer, "git.sr.ht", query, &result); err != nil { + return nil, fmt.Errorf("git.sr.ht repositories query for ~%s: %w", viewer, err) + } + + for _, r := range result.Me.Repositories.Results { + repos = append(repos, RepoInfo{ + ID: r.ID, + Name: r.Name, + Description: r.Description, + Visibility: r.Visibility, + }) + if len(repos) >= maxMyRepos { + return repos, nil + } + } + + if result.Me.Repositories.Cursor == nil { + break + } + cursor = result.Me.Repositories.Cursor + } + return repos, nil +} + +func cacheKey(viewer, owner, name string) string { + return viewer + "\x00" + owner + "\x00" + name +} + +// load returns a cached entry if present and unexpired, pruning it lazily on a +// hit that has aged out. +func (a *GQLAuthorizer) load(key string) (info *RepoInfo, notFound, ok bool) { + a.mu.Lock() + defer a.mu.Unlock() + e, exists := a.cache[key] + if !exists { + return nil, false, false + } + if time.Now().After(e.expiry) { + delete(a.cache, key) + return nil, false, false + } + return e.info, e.notFound, true +} + +// store records an entry with a fresh expiry and opportunistically sweeps the +// whole map at most once per ttl, so no background goroutine is needed. +func (a *GQLAuthorizer) store(key string, e cacheEntry) { + a.mu.Lock() + defer a.mu.Unlock() + now := time.Now() + e.expiry = now.Add(a.ttl) + if now.Sub(a.lastSweep) > a.ttl { + for k, v := range a.cache { + if now.After(v.expiry) { + delete(a.cache, k) + } + } + a.lastSweep = now + } + a.cache[key] = e +} + +// compile-time assertion that GQLAuthorizer satisfies Authorizer. +var _ Authorizer = (*GQLAuthorizer)(nil) diff --git a/authz/authz_test.go b/authz/authz_test.go new file mode 100644 index 0000000000000000000000000000000000000000..90ba9c0f3c87feaddf4641501dab90148e776dfa --- /dev/null +++ b/authz/authz_test.go @@ -0,0 +1,304 @@ +package authz + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "git.sr.ht/~sircmpwn/core-go/config" + "git.sr.ht/~sircmpwn/core-go/crypto" + "github.com/vaughan0/go-ini" + + "go.bigb.es/sourcehut-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) + } +} diff --git a/authz/doc.go b/authz/doc.go new file mode 100644 index 0000000000000000000000000000000000000000..74a9a416e96b71915308789770fa41ac65dbc48d --- /dev/null +++ b/authz/doc.go @@ -0,0 +1,17 @@ +// Package authz answers two orthogonal questions for compare.sr.ht: who is +// making a request, and what may they see. Identity is derived purely from the +// SourceHut unified-login cookie (sr.ht.unified-login.v1), a Fernet token +// encrypted with the instance [sr.ht] network-key; UsernameFromRequest decrypts +// it and yields a bare username, or "" for an anonymous viewer. It never +// rejects a request — an unreadable or absent cookie simply means anonymous. +// +// Authorization is delegated entirely to git.sr.ht over its internal GraphQL +// API: compare.sr.ht owns no user or repository data of its own, so there is no +// database. GQLAuthorizer issues each query as the viewing user via +// core-go's client.Do (Authorization: Internal ), letting +// git.sr.ht's own loader apply visibility rules — an anonymous or unauthorized +// viewer sees a null repository, which maps to core.ErrNotFound so private-repo +// existence is never leaked. A small mutex-guarded TTL cache memoizes positive +// and not-found results (but never transport errors) to spare git.sr.ht a round +// trip on every page load. +package authz diff --git a/authz/identity.go b/authz/identity.go new file mode 100644 index 0000000000000000000000000000000000000000..823017d1f80bcd2bd84eaa502344a694f996a232 --- /dev/null +++ b/authz/identity.go @@ -0,0 +1,63 @@ +package authz + +import ( + "context" + "encoding/json" + "net/http" + "strings" + + "git.sr.ht/~sircmpwn/core-go/crypto" +) + +// CookieName is the SourceHut unified-login session cookie. Its value is a +// Fernet token encrypted with the instance [sr.ht] network-key. +const CookieName = "sr.ht.unified-login.v1" + +// UsernameFromRequest extracts the authenticated username from the unified-login +// cookie, or returns "" for an anonymous viewer. Any failure — missing cookie, +// undecryptable token, malformed JSON — is treated as anonymous rather than an +// error: this service never rejects a request on identity grounds, it only lets +// git.sr.ht decide what an anonymous viewer may see. +func UsernameFromRequest(r *http.Request) string { + c, err := r.Cookie(CookieName) + if err != nil { + return "" + } + // InitCrypto must have run (server.New does it); the network-key here is + // the same Fernet key meta.sr.ht used to seal the cookie. + payload := crypto.DecryptWithoutExpiration([]byte(c.Value)) + if payload == nil { + return "" + } + var claims struct { + Name string `json:"name"` + } + if err := json.Unmarshal(payload, &claims); err != nil { + return "" + } + // Cookies carry the bare username; strip a leading "~" defensively in case + // a caller stored the canonical "~user" form. + return strings.TrimPrefix(claims.Name, "~") +} + +type ctxKey int + +const usernameKey ctxKey = iota + +// Middleware stores the cookie-derived username in the request context. It +// never writes a 401: an anonymous viewer flows through with an empty username +// and git.sr.ht enforces visibility downstream. +func Middleware() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), usernameKey, UsernameFromRequest(r)) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// ForContext returns the username stored by Middleware, or "" if absent. +func ForContext(ctx context.Context) string { + username, _ := ctx.Value(usernameKey).(string) + return username +} diff --git a/authz/identity_test.go b/authz/identity_test.go new file mode 100644 index 0000000000000000000000000000000000000000..95541c7f120365eff6e63d5828c77106d385cb46 --- /dev/null +++ b/authz/identity_test.go @@ -0,0 +1,118 @@ +package authz + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "git.sr.ht/~sircmpwn/core-go/crypto" + "github.com/fernet/fernet-go" + "github.com/vaughan0/go-ini" +) + +// testConf holds the crypto keys shared by every test. api-origin is filled in +// per-test (it points at an ephemeral httptest server). +var testConf ini.File + +// TestMain synthesizes an in-memory config with a fresh Fernet network-key and +// an ed25519 webhook seed, then runs crypto.InitCrypto so Encrypt/Decrypt work. +func TestMain(m *testing.M) { + var fk fernet.Key + if err := fk.Generate(); err != nil { + panic("generate fernet key: " + err.Error()) + } + seed := make([]byte, 32) + if _, err := rand.Read(seed); err != nil { + panic("generate webhook seed: " + err.Error()) + } + + testConf = ini.File{ + "sr.ht": ini.Section{"network-key": fk.Encode()}, + "webhooks": ini.Section{"private-key": base64.StdEncoding.EncodeToString(seed)}, + "compare.sr.ht": ini.Section{"origin": "http://localhost"}, + } + crypto.InitCrypto(testConf) + + os.Exit(m.Run()) +} + +// sealCookie builds a valid unified-login cookie value carrying the given name. +func sealCookie(t *testing.T, name string) string { + t.Helper() + payload, err := json.Marshal(map[string]string{"name": name}) + if err != nil { + t.Fatalf("marshal claims: %v", err) + } + return string(crypto.Encrypt(payload)) +} + +func TestUsernameFromRequest_RoundTrip(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: CookieName, Value: sealCookie(t, "bigbes")}) + if got := UsernameFromRequest(r); got != "bigbes" { + t.Fatalf("username = %q, want %q", got, "bigbes") + } +} + +func TestUsernameFromRequest_StripsTilde(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: CookieName, Value: sealCookie(t, "~bigbes")}) + if got := UsernameFromRequest(r); got != "bigbes" { + t.Fatalf("username = %q, want %q", got, "bigbes") + } +} + +func TestUsernameFromRequest_GarbageCookie(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: CookieName, Value: "not-a-valid-fernet-token"}) + if got := UsernameFromRequest(r); got != "" { + t.Fatalf("username = %q, want empty", got) + } +} + +func TestUsernameFromRequest_MissingCookie(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + if got := UsernameFromRequest(r); got != "" { + t.Fatalf("username = %q, want empty", got) + } +} + +func TestUsernameFromRequest_NonJSONPayload(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + // A well-formed Fernet token whose plaintext is not JSON. + r.AddCookie(&http.Cookie{Name: CookieName, Value: string(crypto.Encrypt([]byte("plain text")))}) + if got := UsernameFromRequest(r); got != "" { + t.Fatalf("username = %q, want empty", got) + } +} + +func TestMiddlewareAndForContext(t *testing.T) { + var seen string + h := Middleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = ForContext(r.Context()) + })) + + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: CookieName, Value: sealCookie(t, "bigbes")}) + h.ServeHTTP(httptest.NewRecorder(), r) + if seen != "bigbes" { + t.Fatalf("ForContext = %q, want %q", seen, "bigbes") + } + + // Anonymous request: middleware still runs, ForContext yields "". + seen = "sentinel" + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) + if seen != "" { + t.Fatalf("anonymous ForContext = %q, want empty", seen) + } +} + +func TestForContext_NoValue(t *testing.T) { + if got := ForContext(httptest.NewRequest(http.MethodGet, "/", nil).Context()); got != "" { + t.Fatalf("ForContext on bare context = %q, want empty", got) + } +}