package authn import ( "context" "crypto/rand" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "testing" "time" "github.com/fernet/fernet-go" "github.com/vaughan0/go-ini" "sourcecraft.dev/bigbes/sr-ht-core/auth" "sourcecraft.dev/bigbes/sr-ht-core/crypto" ) // testConf is the synthesized instance config every test runs against: a fresh // random Fernet network-key, a random ed25519 webhook seed (crypto.InitCrypto // fatally requires one even though v1 emits no webhooks), the owner identity // the provenance builder reads, and our own section's origin. var testConf ini.File // rotatedKey stands in for a Fernet key we no longer hold — a cookie sealed // with it is indistinguishable from a forgery, which is exactly the point. var rotatedKey fernet.Key // TestMain builds that config in memory and runs crypto.InitCrypto once, so // crypto.Encrypt / DecryptWithoutExpiration share a keyset across the package. // No network, no Postgres, no files. func TestMain(m *testing.M) { var fk fernet.Key if err := fk.Generate(); err != nil { panic("generate fernet network key: " + err.Error()) } if err := rotatedKey.Generate(); err != nil { panic("generate rotated 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(), "owner-name": "bigbes", "owner-email": "bigbes@gmail.com", }, "webhooks": ini.Section{ "private-key": base64.StdEncoding.EncodeToString(seed), }, ConfigSection: ini.Section{ "origin": "https://spec.srht.bigb.es", }, } crypto.InitCrypto(testConf) os.Exit(m.Run()) } // sealCookie forges a valid unified-login cookie value carrying name, sealed // with the instance network key — byte for byte what meta.sr.ht would set. func sealCookie(t *testing.T, name string) string { t.Helper() payload, err := json.Marshal(auth.AuthCookie{Name: name}) if err != nil { t.Fatalf("marshal cookie claims: %v", err) } return string(crypto.Encrypt(payload)) } // sealCookieWithKey forges a cookie under an arbitrary Fernet key, used to // stand in for a rotated key or an attacker's own. func sealCookieWithKey(t *testing.T, key *fernet.Key, name string) string { t.Helper() payload, err := json.Marshal(auth.AuthCookie{Name: name}) if err != nil { t.Fatalf("marshal cookie claims: %v", err) } tok, err := fernet.EncryptAndSign(payload, key) if err != nil { t.Fatalf("seal cookie: %v", err) } return string(tok) } // tamper flips one character in the middle of a Fernet token, leaving it // well-formed base64 so that the HMAC check — not the decoder — is what // rejects it. func tamper(t *testing.T, token string) string { t.Helper() if len(token) < 8 { t.Fatalf("token too short to tamper with: %q", token) } b := []byte(token) i := len(b) / 2 if b[i] == 'A' { b[i] = 'B' } else { b[i] = 'A' } if string(b) == token { t.Fatal("tamper produced an identical token") } return string(b) } // testInstance is the provenance identity set built from testConf. func testInstance(t *testing.T) Instance { t.Helper() inst, err := InstanceFromConfig(testConf) if err != nil { t.Fatalf("InstanceFromConfig: %v", err) } return inst } // stubStore is an in-memory TokenStore keyed by hex(sha256(token)). It can be // told to fail with an arbitrary error to exercise the transient path, and // counts calls so tests can assert the cookie path never touches it. type stubStore struct { rows map[string]AgentToken err error calls int } func newStubStore() *stubStore { return &stubStore{rows: map[string]AgentToken{}} } func (s *stubStore) LookupAgentToken(ctx context.Context, hash []byte) (AgentToken, error) { s.calls++ if s.err != nil { return AgentToken{}, s.err } tok, ok := s.rows[hex.EncodeToString(hash)] if !ok { // The contract db/ must honour: no row means ErrUnknownToken. return AgentToken{}, fmt.Errorf("agent_token: %w", ErrUnknownToken) } return tok, nil } // add stores a live token row for the given secret. func (s *stubStore) add(secret, name string) AgentToken { hash := HashToken(secret) tok := AgentToken{ ID: int64(len(s.rows) + 1), Name: name, Hash: hash, Created: time.Date(2026, 7, 22, 10, 0, 0, 0, time.UTC), } s.rows[hex.EncodeToString(hash)] = tok return tok } // revoke stores a revoked token row for the given secret. func (s *stubStore) revoke(secret, name string) AgentToken { tok := s.add(secret, name) when := time.Date(2026, 7, 22, 11, 0, 0, 0, time.UTC) tok.Revoked = &when s.rows[hex.EncodeToString(tok.Hash)] = tok return tok } // put stores an arbitrary row under an arbitrary lookup key, for the case where // the store returns a row whose hash does not match what was presented. func (s *stubStore) put(hash []byte, tok AgentToken) { s.rows[hex.EncodeToString(hash)] = tok } // request builds a GET / carrying the given cookie value and headers. An empty // cookie value means no cookie at all. func request(cookie string, headers map[string]string) *http.Request { r := httptest.NewRequest(http.MethodGet, "/", nil) if cookie != "" { r.AddCookie(&http.Cookie{Name: CookieName, Value: cookie}) } for k, v := range headers { r.Header.Set(k, v) } return r }