A authz/authz.go => authz/authz.go +210 -0
@@ 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)
A authz/authz_test.go => authz/authz_test.go +304 -0
@@ 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 <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, 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)
+ }
+}
A authz/doc.go => authz/doc.go +17 -0
@@ 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 <fernet(...)>), 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
A authz/identity.go => authz/identity.go +63 -0
@@ 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
+}
A authz/identity_test.go => authz/identity_test.go +118 -0
@@ 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)
+ }
+}