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
}