~bigbes/sr-ht-spec

ref: c2dd1ef94a971405ff2ab1c048f9e74e0ef0b844 sr-ht-spec/hooks/hook_test.go -rw-r--r-- 8.6 KiB
c2dd1ef9 — Eugene Blikh chore(beads): Phase 5b closed, spec-ar4 blocked on phoebe host access 24 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
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")
}