~bigbes/sr-ht-compare

0f5e8de501ca221f322e850c5882634254e14dab — bigbes 30 days ago e1835fa
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.
5 files changed, 712 insertions(+), 0 deletions(-)

A authz/authz.go
A authz/authz_test.go
A authz/doc.go
A authz/identity.go
A authz/identity_test.go
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)
	}
}