package service import ( "context" "crypto/rand" "database/sql" "encoding/hex" "fmt" "net/url" "os" "sort" "strings" "testing" "time" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/filemode" "github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/plumbing/storer" _ "github.com/lib/pq" "github.com/vaughan0/go-ini" "sourcecraft.dev/bigbes/sr-ht-spec/authn" "sourcecraft.dev/bigbes/sr-ht-spec/core" "sourcecraft.dev/bigbes/sr-ht-spec/gitx" ) // testEnv names the DSN env var that gates the Postgres-backed tests, spelled // exactly as db/ spells it so one variable turns on the integration tests of // the whole module. When it is unset those tests skip; everything expressible // without a database — config validation, the token-store error contract, the // push rejection message, the reconciler's decision table — runs regardless. const testEnv = "SPECSRHT_TEST_PG" var fxSpace = core.SpaceRef{Owner: "bigbes", Name: "rfcs"} func fxTime(n int) time.Time { return time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC).Add(time.Duration(n) * time.Minute) } // testInstance is the identity half of the config, matching what // authn.InstanceFromConfig would derive from the origin below. func testInstance() authn.Instance { return authn.Instance{ OwnerName: "bigbes", OwnerEmail: "bigbes@gmail.com", AgentEmail: "agent@spec.srht.bigb.es", } } func testConfig(t *testing.T, repos string) Config { t.Helper() return Config{ Repos: repos, Cache: t.TempDir(), Origin: "https://spec.srht.bigb.es", ConnectionString: "postgresql://specsrht@localhost/spec.sr.ht?sslmode=disable", Instance: testInstance(), } } // testIni renders a config.ini with every key service/ requires, minus the ones // named in omit, so a test can knock out exactly one key and see what is said // about it. func testIni(t *testing.T, repos string, omit ...string) ini.File { t.Helper() keys := []struct{ section, key, value string }{ {"sr.ht", "owner-name", "bigbes"}, {"sr.ht", "owner-email", "bigbes@gmail.com"}, {ConfigSection, "origin", "https://spec.srht.bigb.es"}, {ConfigSection, "repos", repos}, {ConfigSection, "cache", "/var/cache/spec"}, {ConfigSection, "connection-string", "postgresql://specsrht@localhost/spec.sr.ht?sslmode=disable"}, } dropped := make(map[string]bool, len(omit)) for _, o := range omit { dropped[o] = true } var b strings.Builder section := "" for _, k := range keys { if dropped[k.key] { continue } if k.section != section { fmt.Fprintf(&b, "[%s]\n", k.section) section = k.section } fmt.Fprintf(&b, "%s=%s\n", k.key, k.value) } conf, err := ini.Load(strings.NewReader(b.String())) if err != nil { t.Fatalf("load synthesized ini: %v", err) } return conf } // deadDB is a database handle that resolves and then fails to connect. It gives // the tests a *sql.DB whose every query errors without a server, which is what // New requires (a nil handle is refused) and what the compensating-delete test // in CreateSpace needs. func deadDB(t *testing.T) *sql.DB { t.Helper() pool, err := sql.Open("postgres", "postgres://nobody@127.0.0.1:1/nothing?sslmode=disable&connect_timeout=1") if err != nil { t.Fatalf("open dead pool: %v", err) } t.Cleanup(func() { pool.Close() }) return pool } // newService builds a Service over a repos root and a database that cannot be // reached, for the tests that exercise git and pure logic only. Any accidental // query fails loudly instead of passing silently. func newService(t *testing.T) (*Service, string) { t.Helper() root := t.TempDir() svc, err := New(testConfig(t, root), deadDB(t)) if err != nil { t.Fatalf("New: %v", err) } return svc, root } // newSpace creates a bare repository for fxSpace under root and returns it as a // Space with the row id a test supplies. It bypasses Service.CreateSpace on // purpose: most tests here have no database, and the repository is the space. func newSpace(t *testing.T, root string, id int) *Space { t.Helper() repo, err := gitx.Create(context.Background(), root, fxSpace, gitx.CreateOptions{ Owner: gitx.Signature{Name: "bigbes", Email: "bigbes@gmail.com", When: fxTime(0)}, }) if err != nil { t.Fatalf("gitx.Create: %v", err) } return &Space{Ref: fxSpace, ID: id, Created: fxTime(0), Repo: repo} } // cutBranch creates a proposal branch at base, the way an agent's first write // does. Committing onto a branch that was never cut would produce an orphan // history, which no push in this system can create. func cutBranch(t *testing.T, sp *Space, branch, base string) { t.Helper() if _, err := sp.Repo.CreateProposalBranch(context.Background(), branch, base); err != nil { t.Fatalf("CreateProposalBranch(%q, %q): %v", branch, base, err) } } // mdDoc renders a minimal valid document: the three required keys, then a body. func mdDoc(id, title, body string) []byte { return []byte(fmt.Sprintf("---\nid: %s\ntitle: %s\nstatus: draft\n---\n\n%s\n", id, title, body)) } // commitFiles writes files onto a branch, preserving everything already there, // and returns the new commit. It stands in for a human push through // receive-pack, which is the only way content reaches the approved branch. // // A nil value deletes the path. func commitFiles(t *testing.T, sp *Space, branch string, n int, files map[string][]byte) plumbing.Hash { t.Helper() repo, err := git.PlainOpen(sp.Repo.Dir()) if err != nil { t.Fatalf("PlainOpen %s: %v", sp.Repo.Dir(), err) } st := repo.Storer name := plumbing.NewBranchReferenceName(branch) var parents []plumbing.Hash entries := map[string]plumbing.Hash{} if ref, err := repo.Reference(name, false); err == nil { parents = []plumbing.Hash{ref.Hash()} commit, err := repo.CommitObject(ref.Hash()) if err != nil { t.Fatalf("commit %s: %v", ref.Hash(), err) } iter, err := commit.Files() if err != nil { t.Fatalf("files of %s: %v", ref.Hash(), err) } if err := iter.ForEach(func(f *object.File) error { entries[f.Name] = f.Blob.Hash return nil }); err != nil { t.Fatalf("walk %s: %v", ref.Hash(), err) } } for path, data := range files { if data == nil { delete(entries, path) continue } entries[path] = writeBlob(t, st, data) } tree := writeTree(t, st, entries) sig := object.Signature{Name: "bigbes", Email: "bigbes@gmail.com", When: fxTime(n)} commit := &object.Commit{ Author: sig, Committer: sig, Message: fmt.Sprintf("commit %d\n", n), TreeHash: tree, ParentHashes: parents, } obj := st.NewEncodedObject() if err := commit.Encode(obj); err != nil { t.Fatalf("encode commit: %v", err) } hash, err := st.SetEncodedObject(obj) if err != nil { t.Fatalf("store commit: %v", err) } if err := st.SetReference(plumbing.NewHashReference(name, hash)); err != nil { t.Fatalf("set %s: %v", name, err) } return hash } func writeBlob(t *testing.T, st storer.EncodedObjectStorer, data []byte) plumbing.Hash { t.Helper() obj := st.NewEncodedObject() obj.SetType(plumbing.BlobObject) obj.SetSize(int64(len(data))) w, err := obj.Writer() if err != nil { t.Fatalf("blob writer: %v", err) } if _, err := w.Write(data); err != nil { t.Fatalf("write blob: %v", err) } if err := w.Close(); err != nil { t.Fatalf("close blob: %v", err) } hash, err := st.SetEncodedObject(obj) if err != nil { t.Fatalf("store blob: %v", err) } return hash } // writeTree builds a tree from a flat path->blob map, recursing on directories. // Entries are sorted the way git sorts them (directories compare as if they // ended in '/'), so the objects this produces are byte-identical to git's. func writeTree(t *testing.T, st storer.EncodedObjectStorer, files map[string]plumbing.Hash) plumbing.Hash { t.Helper() blobs := map[string]plumbing.Hash{} dirs := map[string]map[string]plumbing.Hash{} for path, hash := range files { name, rest, nested := strings.Cut(path, "/") if !nested { blobs[name] = hash continue } if dirs[name] == nil { dirs[name] = map[string]plumbing.Hash{} } dirs[name][rest] = hash } var entries []object.TreeEntry for name, hash := range blobs { entries = append(entries, object.TreeEntry{Name: name, Mode: filemode.Regular, Hash: hash}) } for name, sub := range dirs { entries = append(entries, object.TreeEntry{ Name: name, Mode: filemode.Dir, Hash: writeTree(t, st, sub), }) } sortKey := func(e object.TreeEntry) string { if e.Mode == filemode.Dir { return e.Name + "/" } return e.Name } sort.Slice(entries, func(i, j int) bool { return sortKey(entries[i]) < sortKey(entries[j]) }) tree := &object.Tree{Entries: entries} obj := st.NewEncodedObject() if err := tree.Encode(obj); err != nil { t.Fatalf("encode tree: %v", err) } hash, err := st.SetEncodedObject(obj) if err != nil { t.Fatalf("store tree: %v", err) } return hash } // newTestService connects to the Postgres pointed at by SPECSRHT_TEST_PG, // applies schema.sql into an isolated scratch schema, and returns a Service // bound to it plus its repos root. Skips when the variable is unset. The shape // is db/'s newTestStore, adapted: a per-test schema needs no CREATE DATABASE // privilege and cleans up with one DROP SCHEMA ... CASCADE. func newTestService(t *testing.T) (*Service, string) { t.Helper() base := os.Getenv(testEnv) if base == "" { t.Skipf("%s not set; skipping Postgres-backed test (set it to a DSN to run)", testEnv) } admin, err := sql.Open("postgres", base) if err != nil { t.Fatalf("open admin pool: %v", err) } if err := admin.Ping(); err != nil { admin.Close() t.Fatalf("ping %s: %v", testEnv, err) } schema := "specsrht_test_" + randToken() if _, err := admin.Exec(`CREATE SCHEMA "` + schema + `"`); err != nil { admin.Close() t.Fatalf("create schema %s: %v", schema, err) } drop := func() { if _, err := admin.Exec(`DROP SCHEMA "` + schema + `" CASCADE`); err != nil { t.Errorf("drop schema %s: %v", schema, err) } admin.Close() } scopedDSN, err := withSearchPath(base, schema) if err != nil { drop() t.Fatalf("build scoped dsn: %v", err) } pool, err := sql.Open("postgres", scopedDSN) if err != nil { drop() t.Fatalf("open scoped pool: %v", err) } ddl, err := os.ReadFile("../schema.sql") if err != nil { pool.Close() drop() t.Fatalf("read schema.sql: %v", err) } if _, err := pool.Exec(string(ddl)); err != nil { pool.Close() drop() t.Fatalf("apply schema.sql: %v", err) } t.Cleanup(func() { pool.Close() drop() }) root := t.TempDir() svc, err := New(testConfig(t, root), pool) if err != nil { t.Fatalf("New: %v", err) } return svc, root } func withSearchPath(base, schema string) (string, error) { opt := "-c search_path=" + schema if strings.Contains(base, "://") { u, err := url.Parse(base) if err != nil { return "", err } q := u.Query() q.Set("options", opt) u.RawQuery = q.Encode() return u.String(), nil } return base + " options='" + opt + "'", nil } func randToken() string { b := make([]byte, 8) if _, err := rand.Read(b); err != nil { panic(err) } return hex.EncodeToString(b) }