package hooks import ( "context" "fmt" "os" "os/exec" "path/filepath" "strings" "testing" "github.com/go-git/go-git/v5/plumbing" "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. func TestMain(m *testing.M) { if _, _, isHook := ModeFromArgs(os.Args); isHook { os.Exit(Run(Runtime{Args: os.Args})) } 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, nil) 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 } 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) } }) } // 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) } }