package hooks
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"testing/fstest"
"github.com/go-git/go-git/v5/plumbing"
"sourcecraft.dev/bigbes/sr-ht-core/config"
"sourcecraft.dev/bigbes/sr-ht-core/crypto"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
"sourcecraft.dev/bigbes/sr-ht-spec/gitx"
"sourcecraft.dev/bigbes/sr-ht-spec/service"
)
// TestMain makes this test binary double as the specsrht binary.
//
// Install writes each hook as a symlink to whatever binary it is given and the
// hook dispatches on the name git invoked it as, so pointing those symlinks at
// the test binary is enough to make a real `git push` run this package's code
// through git's real receive path. That is the production dispatch mechanism,
// unchanged and unmocked — not a stand-in for it.
//
// It also initialises crypto, because the agent credential this package
// validates is a signed working token and the signing key lives in core-go's
// globals; the keys are the ones core-go's own tests use. A hook process skips
// that: a hook reads no config and validates nothing, which is the property
// that lets it run with no state at all.
func TestMain(m *testing.M) {
if _, _, isHook := ModeFromArgs(os.Args); isHook {
os.Exit(Run(Runtime{Args: os.Args}))
}
config.FS = fstest.MapFS{
"config.ini": &fstest.MapFile{Data: []byte(`
[webhooks]
private-key=ebzsjPaN6E13ln/FeNWly1C92q6bVMVdOnDo1HPl5fc=
[sr.ht]
network-key=tbuG-7Vh44vrDq1L_HKWkHnWrDOtJhEkPKPiauaLeuk=
`)},
}
crypto.InitCrypto(config.LoadConfig())
os.Exit(m.Run())
}
// git runs the real git binary. Shelling out is confined to tests: nothing in
// this package's non-test code executes a subprocess.
func gitMust(t *testing.T, dir string, args ...string) string {
t.Helper()
out, err := runGit(t, dir, nil, args...)
if err != nil {
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
}
return out
}
func runGit(t *testing.T, dir string, env map[string]string, args ...string) (string, error) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_CONFIG_GLOBAL=/dev/null",
"GIT_CONFIG_SYSTEM=/dev/null",
"GIT_AUTHOR_NAME=bigbes", "GIT_AUTHOR_EMAIL=bigbes@example.invalid",
"GIT_COMMITTER_NAME=bigbes", "GIT_COMMITTER_EMAIL=bigbes@example.invalid",
)
for k, v := range env {
cmd.Env = append(cmd.Env, k+"="+v)
}
out, err := cmd.CombinedOutput()
return string(out), err
}
// pushEnv is what the forced-command wrapper exports for a push by the owner.
// The local transport spawns receive-pack as a child of the client, so the
// client's environment is what the hooks see.
func pushEnv() map[string]string { return map[string]string{EnvPrincipal: string(PrincipalOwner)} }
// realish is a Backend that answers with the actual rules where they need no
// database: gitx.CheckRefUpdate for the refs rule, and core.ParseDocument plus
// the space's schema for frontmatter.
//
// The document-id registry needs Postgres and is therefore not covered here;
// service/'s own tests cover it. What this proves is the receive path itself —
// that git runs our hooks, that they reach the daemon, that a refusal comes
// back as a readable message and a non-zero exit, and that skip-validation
// reaches the right half of the decision.
func realish(t *testing.T, root string) func(context.Context, service.PushRequest) error {
t.Helper()
return func(ctx context.Context, req service.PushRequest) error {
repo, err := gitx.Open(root, req.Space)
if err != nil {
return fmt.Errorf("open %s: %w", req.Space, err)
}
old, new := plumbing.NewHash(req.Old), plumbing.NewHash(req.New)
fastForward := old.IsZero()
if !old.IsZero() && !new.IsZero() {
ff, err := repo.IsAncestor(ctx, old, new)
if err != nil {
return fmt.Errorf("ancestry of %s..%s: %w", req.Old, req.New, err)
}
fastForward = ff
}
kind := gitx.PrincipalHuman
if req.Principal.IsAgent() {
kind = gitx.PrincipalAgent
}
if err := gitx.CheckRefUpdate(kind, repo.ApprovedBranch(), gitx.RefUpdate{
Ref: req.Ref, Old: old, New: new, FastForward: fastForward,
}); err != nil {
return &service.PushRejection{
Space: req.Space, Ref: req.Ref, Skippable: false,
Problems: []service.PushProblem{{Kind: service.ProblemRefsRule, Detail: err.Error()}},
}
}
if req.SkipValidation || new.IsZero() {
return nil
}
docs, err := repo.ListDocuments(ctx, req.New)
if err != nil {
return fmt.Errorf("list documents at %s: %w", req.New, err)
}
schema := core.DefaultSchema()
var problems []service.PushProblem
for _, d := range docs {
fm, _, err := core.ParseDocument(d.Data)
if err == nil {
err = schema.ValidateFrontmatter(fm)
}
if err != nil {
problems = append(problems, service.PushProblem{
Kind: service.ProblemFrontmatter, Path: d.Path, Detail: err.Error(),
})
}
}
if len(problems) > 0 {
return &service.PushRejection{
Space: req.Space, Ref: req.Ref, Problems: problems, Skippable: true,
}
}
return nil
}
}
// e2e is a repos root with one hooked space and a working clone.
type e2e struct {
root string
repo string
work string
server *Server
back *fakeBackend
landed *[]core.SpaceRef
}
func newE2E(t *testing.T) *e2e {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skipf("git is not on PATH: %v", err)
}
binary, err := os.Executable()
if err != nil {
t.Fatalf("os.Executable: %v", err)
}
root := shortTempDir(t)
repo := bareRepo(t, root, testSpace)
if err := InstallSpace(root, testSpace, InstallOptions{Binary: binary}); err != nil {
t.Fatalf("InstallSpace: %v", err)
}
back := newFakeBackend(t, root)
back.validate = realish(t, root)
srv, landed := startServer(t, back)
work := filepath.Join(shortTempDir(t), "work")
gitMust(t, "", "init", "--quiet", "--initial-branch=main", work)
return &e2e{root: root, repo: repo, work: work, server: srv, back: back, landed: landed}
}
// write stages a file in the working clone.
func (e *e2e) write(t *testing.T, path, body string) {
t.Helper()
full := filepath.Join(e.work, path)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(full, []byte(body), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
gitMust(t, e.work, "add", path)
}
func (e *e2e) commit(t *testing.T, message string) {
t.Helper()
gitMust(t, e.work, "commit", "--quiet", "-m", message)
}
// push runs a real `git push` and returns its combined output plus whether it
// succeeded.
func (e *e2e) push(t *testing.T, args ...string) (string, bool) {
t.Helper()
out, err := runGit(t, e.work, pushEnv(), append([]string{"push", e.repo}, args...)...)
return out, err == nil
}
// pushAsAgent is the same push with the environment the forced-command wrapper
// exports for an agent: a tokens.sr.ht working token and the provenance fields.
func (e *e2e) pushAsAgent(t *testing.T, token string, args ...string) (string, bool) {
t.Helper()
env := map[string]string{
EnvPrincipal: string(PrincipalAgent),
EnvAgentToken: token,
EnvAgent: "claude-code/spec-writer",
EnvAgentSession: "8fb9c9a4-b078-4af1-89eb-d97c522f9921",
}
out, err := runGit(t, e.work, env, append([]string{"push", e.repo}, args...)...)
return out, err == nil
}
const goodDoc = `---
id: SPEC-0001
title: Storage
status: draft
---
# Storage
One storage tier.
`
const badDoc = `---
title: No identity
status: draft
---
This document has no id.
`
// TestEndToEndPush drives the whole receive path with a real git push against
// a real bare repository with our hooks installed.
func TestEndToEndPush(t *testing.T) {
e := newE2E(t)
t.Run("a valid push is accepted", func(t *testing.T) {
e.write(t, "specs/0001-storage.md", goodDoc)
e.commit(t, "add the storage spec")
out, ok := e.push(t, "main:refs/heads/main")
if !ok {
t.Fatalf("a valid push was rejected:\n%s", out)
}
if got := len(*e.landed); got == 0 {
t.Errorf("post-receive did not notify the daemon (landed=%d)", got)
}
if head := strings.TrimSpace(gitMust(t, e.repo, "rev-parse", "refs/heads/main")); head == "" {
t.Error("main was not updated")
}
})
t.Run("bad frontmatter is rejected with a message naming the document", func(t *testing.T) {
e.write(t, "specs/0002-broken.md", badDoc)
e.commit(t, "add a document with no id")
out, ok := e.push(t, "main:refs/heads/main")
if ok {
t.Fatalf("a push with malformed frontmatter was accepted:\n%s", out)
}
mentions(t, "the rejection", out,
"spec.sr.ht rejected this push",
"specs/0002-broken.md",
"--push-option=skip-validation",
)
if head := strings.TrimSpace(gitMust(t, e.repo, "log", "--oneline", "-1", "refs/heads/main")); strings.Contains(head, "no id") {
t.Error("the rejected commit landed anyway")
}
})
t.Run("skip-validation lets the same push through", func(t *testing.T) {
out, ok := e.push(t, "--push-option=skip-validation", "main:refs/heads/main")
if !ok {
t.Fatalf("skip-validation did not waive frontmatter validation:\n%s", out)
}
})
t.Run("an unknown push option is refused rather than ignored", func(t *testing.T) {
e.write(t, "specs/0003-note.md", goodDoc)
e.commit(t, "another document")
out, ok := e.push(t, "--push-option=skip-validaton", "main:refs/heads/main")
if ok {
t.Fatalf("a mistyped push option was ignored:\n%s", out)
}
mentions(t, "the rejection", out, "skip-validaton", "is not a push option")
})
t.Run("a force-push to the approved branch is refused", func(t *testing.T) {
// Rewrite history so the update is not a fast-forward.
gitMust(t, e.work, "reset", "--quiet", "--hard", "HEAD~2")
e.write(t, "specs/0009-rewritten.md", goodDoc)
e.commit(t, "rewrite history")
out, ok := e.push(t, "--force", "main:refs/heads/main")
if ok {
t.Fatalf("a force-push to the approved branch was accepted:\n%s", out)
}
mentions(t, "the rejection", out,
"spec.sr.ht rejected this push",
"The refs rule cannot be bypassed",
)
})
t.Run("the refs rule is not skippable", func(t *testing.T) {
out, ok := e.push(t, "--push-option=skip-validation", "--force", "main:refs/heads/main")
if ok {
t.Fatalf("skip-validation waived the refs rule:\n%s", out)
}
mentions(t, "the rejection", out, "The refs rule cannot be bypassed")
})
t.Run("a proposal branch may be force-updated", func(t *testing.T) {
out, ok := e.push(t, "--force", "main:refs/heads/proposals/1")
if !ok {
t.Fatalf("a proposal branch was refused:\n%s", out)
}
})
}
// TestEndToEndAgentPush is the credential change under a real `git push`: an
// agent holding a tokens.sr.ht working token can push, the refs rule still
// confines it to proposals/*, and a token without spec:propose gets nowhere.
//
// Before this, the SSH path checked agent_token directly and would have refused
// every one of these; it is now the same authn.Resolver the HTTP surfaces use.
func TestEndToEndAgentPush(t *testing.T) {
e := newE2E(t)
e.write(t, "specs/0001-storage.md", goodDoc)
e.commit(t, "add the storage spec")
t.Run("a token without spec:propose cannot push", func(t *testing.T) {
out, ok := e.pushAsAgent(t, instanceToken("spec:read"), "main:refs/heads/proposals/1")
if ok {
t.Fatalf("a token with no propose grant was accepted:\n%s", out)
}
mentions(t, "the rejection", out, "spec.sr.ht rejected this push", "spec:propose")
if out, err := runGit(t, e.repo, nil, "rev-parse", "--verify", "refs/heads/proposals/1"); err == nil {
t.Errorf("the ref moved despite the refusal: %s", out)
}
})
t.Run("the old opaque agent token cannot push", func(t *testing.T) {
out, ok := e.pushAsAgent(t, "s3cret-from-agent-token", "main:refs/heads/proposals/1")
if ok {
t.Fatalf("a credential from the removed plane was accepted:\n%s", out)
}
mentionsNot(t, "the rejection", out, "s3cret-from-agent-token")
})
t.Run("an instance token with spec:propose pushes a proposal branch", func(t *testing.T) {
out, ok := e.pushAsAgent(t, instanceToken("spec:propose"), "main:refs/heads/proposals/1")
if !ok {
t.Fatalf("an agent holding a tokens.sr.ht token was refused:\n%s", out)
}
if head := strings.TrimSpace(gitMust(t, e.repo, "rev-parse", "refs/heads/proposals/1")); head == "" {
t.Error("the proposal branch was not created")
}
})
t.Run("the refs rule still confines it to the proposal prefix", func(t *testing.T) {
out, ok := e.pushAsAgent(t, instanceToken("*"), "main:refs/heads/main")
if ok {
t.Fatalf("an agent moved the approved branch:\n%s", out)
}
mentions(t, "the rejection", out, "spec.sr.ht rejected this push")
if out, err := runGit(t, e.repo, nil, "rev-parse", "--verify", "refs/heads/main"); err == nil {
t.Errorf("the approved branch moved despite the refusal: %s", out)
}
})
}
// TestEndToEndFailsClosed is the fail-closed rule under a real push: with the
// daemon gone, the push is refused rather than silently accepted unvalidated.
func TestEndToEndFailsClosed(t *testing.T) {
e := newE2E(t)
e.write(t, "specs/0001-storage.md", goodDoc)
e.commit(t, "add the storage spec")
// Take the daemon down exactly as a crash would: stop serving and unlink
// the socket.
if err := e.server.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
out, ok := e.push(t, "main:refs/heads/main")
if ok {
t.Fatalf("a push was accepted with no daemon to validate it:\n%s", out)
}
mentions(t, "the fail-closed message", out,
"could not validate this push",
SocketPath(e.root),
"Start the spec.sr.ht daemon and push again",
)
// The escape hatch must not be advertised here: it waives frontmatter
// checks, not the daemon that performs them.
mentionsNot(t, "the fail-closed message", out, "Re-push with --push-option=skip-validation")
if out, err := runGit(t, e.repo, nil, "rev-parse", "--verify", "refs/heads/main"); err == nil {
t.Errorf("the ref moved despite the refusal: %s", out)
}
}