package hooks import ( "bytes" "context" "path/filepath" "strings" "testing" "time" "sourcecraft.dev/bigbes/sr-ht-spec/service" ) func TestModeFromArgs(t *testing.T) { tests := []struct { name string args []string mode Mode rest []string ok bool }{ { name: "git execs the symlink by name", args: []string{"hooks/update", "refs/heads/main", zeroOID, oneOID}, mode: ModeUpdate, rest: []string{"refs/heads/main", zeroOID, oneOID}, ok: true, }, { name: "an absolute hook path still dispatches", args: []string{"/var/lib/spec/~bigbes/rfcs/hooks/pre-receive"}, mode: ModePreReceive, rest: []string{}, ok: true, }, { name: "post-receive", args: []string{"./post-receive"}, mode: ModePostReceive, rest: []string{}, ok: true, }, { name: "the explicit form an operator can type", args: []string{"/usr/local/bin/specsrht", "hook", "update", "refs/heads/main", zeroOID, oneOID}, mode: ModeUpdate, rest: []string{"refs/heads/main", zeroOID, oneOID}, ok: true, }, { name: "the daemon is not a hook", args: []string{"/usr/local/bin/specsrht", "-b", "localhost:5091"}, ok: false, }, { name: "an unknown hook name is not ours", args: []string{"hooks/post-update"}, ok: false, }, { name: "hook with no name", args: []string{"specsrht", "hook"}, ok: false, }, { name: "no argv at all", args: nil, ok: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mode, rest, ok := ModeFromArgs(tt.args) if ok != tt.ok { t.Fatalf("ok = %v want %v", ok, tt.ok) } if !ok { return } if mode != tt.mode { t.Errorf("mode = %q want %q", mode, tt.mode) } if strings.Join(rest, " ") != strings.Join(tt.rest, " ") { t.Errorf("args = %q want %q", rest, tt.rest) } }) } } // hookRuntime drives a hook against a fixture's repository without a real push. func (f *serverFixture) hookRuntime(t *testing.T, args []string, stdin string, env map[string]string) (Runtime, *bytes.Buffer) { t.Helper() stderr := &bytes.Buffer{} full := map[string]string{EnvPrincipal: string(PrincipalOwner), envGitDir: "."} for k, v := range env { full[k] = v } return Runtime{ Args: args, Env: envOf(full), Stdin: strings.NewReader(stdin), Stderr: stderr, Getwd: func() (string, error) { return f.repo, nil }, EvalSymlinks: filepath.EvalSymlinks, PushID: func() string { return "4711" }, DialTimeout: 2 * time.Second, Timeout: 10 * time.Second, }, stderr } func refLine(u RefUpdate) string { return u.Old + " " + u.New + " " + u.Ref + "\n" } // TestUpdateHookAcceptsAValidRef walks the two hooks of the rejecting path in // the order git runs them. func TestUpdateHookAcceptsAValidRef(t *testing.T) { f := newServerFixture(t) rt, stderr := f.hookRuntime(t, []string{"hooks/pre-receive"}, refLine(mainUpdate), nil) if code := Run(rt); code != 0 { t.Fatalf("pre-receive exited %d:\n%s", code, stderr) } rt, stderr = f.hookRuntime(t, []string{"hooks/update", mainUpdate.Ref, mainUpdate.Old, mainUpdate.New}, "", nil) if code := Run(rt); code != 0 { t.Fatalf("update exited %d:\n%s", code, stderr) } if stderr.Len() != 0 { t.Errorf("an accepted push said something to the client:\n%s", stderr) } } // TestUpdateHookPrintsTheRejection: this text is the whole user interface of a // failed push, so the hook must print what the daemon wrote and exit non-zero. func TestUpdateHookPrintsTheRejection(t *testing.T) { f := newServerFixture(t) want := rejection("refs/heads/main", true, service.PushProblem{ Kind: service.ProblemFrontmatter, Path: "specs/0002-broken.md", Detail: "frontmatter is missing the required key `id`", }) f.back.validate = func(_ context.Context, _ service.PushRequest) error { return want } rt, _ := f.hookRuntime(t, []string{"hooks/pre-receive"}, refLine(mainUpdate), nil) if code := Run(rt); code != 0 { t.Fatalf("pre-receive exited %d", code) } rt, stderr := f.hookRuntime(t, []string{"hooks/update", mainUpdate.Ref, mainUpdate.Old, mainUpdate.New}, "", nil) if code := Run(rt); code == 0 { t.Fatal("update accepted a rejected ref") } mentions(t, "the rejection", stderr.String(), "specs/0002-broken.md", "missing the required key", "--push-option=skip-validation", ) } // TestHooksFailClosed is the rule the whole design rests on: with no daemon // answering, a push is refused rather than accepted unvalidated. func TestHooksFailClosed(t *testing.T) { f := newServerFixture(t) dead := filepath.Join(f.root, "gone", "hook.sock") for _, tt := range []struct { name string args []string stdin string }{ {"pre-receive", []string{"hooks/pre-receive"}, refLine(mainUpdate)}, {"update", []string{"hooks/update", mainUpdate.Ref, mainUpdate.Old, mainUpdate.New}, ""}, } { t.Run(tt.name, func(t *testing.T) { rt, stderr := f.hookRuntime(t, tt.args, tt.stdin, map[string]string{EnvSocket: dead}) if code := Run(rt); code == 0 { t.Fatalf("%s accepted a push with no daemon to validate it", tt.name) } out := stderr.String() mentions(t, "the fail-closed message", out, "could not validate this push", dead, "Start the spec.sr.ht daemon", ) // Suggesting the escape hatch here would be a lie: it waives // frontmatter checks, not the daemon that performs them. mentionsNot(t, "the fail-closed message", out, "Re-push with --push-option") }) } } // TestPostReceiveCannotReject: git ignores its exit status, so pretending // otherwise would only produce noise. It warns and names the backstop. func TestPostReceiveCannotReject(t *testing.T) { f := newServerFixture(t) dead := filepath.Join(f.root, "gone", "hook.sock") rt, stderr := f.hookRuntime(t, []string{"hooks/post-receive"}, refLine(mainUpdate), map[string]string{EnvSocket: dead}) if code := Run(rt); code != 0 { t.Fatalf("post-receive exited %d; it cannot reject anything", code) } mentions(t, "the warning", stderr.String(), "warning:", "was not told that this push landed", "reconciler") } // TestHookRefusesAMisconfiguredEnvironment: no principal means nobody // authorized the push, and there is nothing to default to. func TestHookRefusesAMisconfiguredEnvironment(t *testing.T) { f := newServerFixture(t) rt, stderr := f.hookRuntime(t, []string{"hooks/update", mainUpdate.Ref, mainUpdate.Old, mainUpdate.New}, "", map[string]string{EnvPrincipal: ""}) if code := Run(rt); code == 0 { t.Fatal("update accepted a push with no principal in its environment") } mentions(t, "the message", stderr.String(), "receive hook is misconfigured", EnvPrincipal, "server-side wiring problem") if len(f.back.seen) != 0 { t.Error("an unauthorized push reached the backend") } } func TestUpdateHookNeedsThreeArguments(t *testing.T) { f := newServerFixture(t) rt, stderr := f.hookRuntime(t, []string{"hooks/update", "refs/heads/main"}, "", nil) if code := Run(rt); code == 0 { t.Fatal("update ran with the wrong number of arguments") } mentions(t, "the message", stderr.String(), " ") } // TestPreReceiveForwardsPushOptions: the update hook never sees them, so // whether skip-validation works at all depends on this handoff. func TestPreReceiveForwardsPushOptions(t *testing.T) { f := newServerFixture(t) rt, stderr := f.hookRuntime(t, []string{"hooks/pre-receive"}, refLine(mainUpdate), map[string]string{ "GIT_PUSH_OPTION_COUNT": "1", "GIT_PUSH_OPTION_0": OptionSkipValidation, }) if code := Run(rt); code != 0 { t.Fatalf("pre-receive exited %d:\n%s", code, stderr) } rt, stderr = f.hookRuntime(t, []string{"hooks/update", mainUpdate.Ref, mainUpdate.Old, mainUpdate.New}, "", nil) if code := Run(rt); code != 0 { t.Fatalf("update exited %d:\n%s", code, stderr) } if len(f.back.seen) != 1 { t.Fatalf("the backend saw %d requests", len(f.back.seen)) } if !f.back.seen[0].SkipValidation { t.Error("the push option pre-receive read never reached ValidatePush") } } func TestPreReceiveRefusesAMalformedRefList(t *testing.T) { f := newServerFixture(t) rt, stderr := f.hookRuntime(t, []string{"hooks/pre-receive"}, "not a ref line\n", nil) if code := Run(rt); code == 0 { t.Fatal("pre-receive accepted a ref list it could not parse") } mentions(t, "the message", stderr.String(), " ") } func TestRunRefusesToActAsAnythingButAHook(t *testing.T) { stderr := &bytes.Buffer{} code := Run(Runtime{Args: []string{"specsrht", "-b", "localhost:5091"}, Stderr: stderr}) if code == 0 { t.Fatal("Run pretended a daemon invocation was a hook") } mentions(t, "the message", stderr.String(), "not a git hook invocation") }