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, nil)
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, nil)
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, nil)
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, nil)
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, nil)
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, nil)
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(), "<ref> <old> <new>")
}
// 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, nil)
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, nil)
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(), "<old> <new> <ref>")
}
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")
}