~bigbes/sr-ht-spec

ref: 105e0b003b482928921d6894dfcf6d73ed776d73 sr-ht-spec/hooks/fixture_test.go -rw-r--r-- 6.9 KiB
105e0b00 — bigbes chore(beads): track the spec.sr.ht issue backlog 26 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
package hooks

import (
	"context"
	"errors"
	"io"
	"log/slog"
	"net"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"sourcecraft.dev/bigbes/sr-ht-spec/authn"
	"sourcecraft.dev/bigbes/sr-ht-spec/core"
	"sourcecraft.dev/bigbes/sr-ht-spec/db"
	"sourcecraft.dev/bigbes/sr-ht-spec/service"
)

const testOwner = "bigbes"

var testSpace = core.SpaceRef{Owner: testOwner, Name: "rfcs"}

// fakeLookup is service.AgentTokenLookup over a map, so the agent path can be
// exercised without Postgres. *service.TokenStore is what turns db/'s
// ErrNotFound into authn's ErrUnknownToken, and that mapping is part of what
// the server relies on, so the real adapter is used over this fake rather than
// a fake authn.TokenStore.
type fakeLookup struct {
	byHash map[string]*db.AgentToken
	err    error
}

func (f *fakeLookup) AgentTokenByHash(_ context.Context, hash []byte) (*db.AgentToken, error) {
	if f.err != nil {
		return nil, f.err
	}
	tok, ok := f.byHash[string(hash)]
	if !ok {
		return nil, db.ErrNotFound
	}
	return tok, nil
}

// fakeBackend is a Backend that answers from a script instead of a database.
// It exists so the receive path — hooks, socket, protocol, message rendering —
// can be driven by a real `git push` on a machine with no Postgres.
type fakeBackend struct {
	root     string
	resolver *authn.Resolver
	tokens   *service.TokenStore

	// validate is the scripted answer, and the recorder: every request reaches
	// it. A nil validate accepts everything.
	validate func(context.Context, service.PushRequest) error

	seen []service.PushRequest
}

func newFakeBackend(t *testing.T, root string, tokens map[string]*db.AgentToken) *fakeBackend {
	t.Helper()
	lookup := &fakeLookup{byHash: map[string]*db.AgentToken{}}
	for secret, row := range tokens {
		lookup.byHash[string(authn.HashToken(secret))] = row
	}
	store := service.NewTokenStore(lookup)
	resolver, err := authn.NewResolver(testOwner, store)
	if err != nil {
		t.Fatalf("NewResolver: %v", err)
	}
	return &fakeBackend{root: root, resolver: resolver, tokens: store}
}

func (b *fakeBackend) ReposRoot() string               { return b.root }
func (b *fakeBackend) Resolver() *authn.Resolver       { return b.resolver }
func (b *fakeBackend) TokenStore() *service.TokenStore { return b.tokens }

func (b *fakeBackend) ValidatePush(ctx context.Context, req service.PushRequest) error {
	b.seen = append(b.seen, req)
	if b.validate == nil {
		return nil
	}
	return b.validate(ctx, req)
}

// compile-time proof that the real service satisfies the same interface the
// tests fake. If service/ ever changes one of these signatures, this fails
// here rather than in the daemon.
var _ Backend = (*service.Service)(nil)

// discardLogger keeps test output readable; the server logs every request.
func discardLogger() *slog.Logger {
	return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))
}

// shortTempDir makes a directory outside t.TempDir().
//
// A unix socket path is capped at ~104 bytes on darwin and 108 on Linux, and
// t.TempDir() embeds the test's name, which is long enough to blow that cap.
func shortTempDir(t *testing.T) string {
	t.Helper()
	dir, err := os.MkdirTemp("", "sh")
	if err != nil {
		t.Fatalf("MkdirTemp: %v", err)
	}
	t.Cleanup(func() {
		if err := os.RemoveAll(dir); err != nil {
			t.Errorf("clean up %s: %v", dir, err)
		}
	})
	resolved, err := filepath.EvalSymlinks(dir)
	if err != nil {
		t.Fatalf("EvalSymlinks(%s): %v", dir, err)
	}
	return resolved
}

// startServer runs a Server over a fake backend and returns it plus the
// notifications it received.
func startServer(t *testing.T, backend *fakeBackend, opts ...func(*Options)) (*Server, *[]core.SpaceRef) {
	t.Helper()

	var landed []core.SpaceRef
	o := Options{
		Backend: backend,
		Socket:  SocketPath(backend.root),
		Log:     discardLogger(),
		OnPush: func(_ context.Context, space core.SpaceRef, _ []RefUpdate) error {
			landed = append(landed, space)
			return nil
		},
		Timeout: 20 * time.Second,
	}
	for _, fn := range opts {
		fn(&o)
	}

	srv, err := NewServer(o)
	if err != nil {
		t.Fatalf("NewServer: %v", err)
	}
	if err := srv.Listen(); err != nil {
		t.Fatalf("Listen: %v", err)
	}
	ctx, cancel := context.WithCancel(context.Background())
	done := make(chan error, 1)
	go func() { done <- srv.Serve(ctx) }()
	t.Cleanup(func() {
		cancel()
		select {
		case err := <-done:
			if err != nil {
				t.Errorf("Serve: %v", err)
			}
		case <-time.After(5 * time.Second):
			t.Error("Serve did not stop")
		}
		if err := srv.Close(); err != nil {
			t.Errorf("Close: %v", err)
		}
	})
	return srv, &landed
}

// envOf turns a map into a Lookup.
func envOf(kv map[string]string) Lookup {
	return func(k string) (string, bool) {
		v, ok := kv[k]
		return v, ok
	}
}

// ownerEnv is the environment the forced-command wrapper sets for a push by
// the instance owner.
func ownerEnv(extra map[string]string) map[string]string {
	env := map[string]string{EnvPrincipal: string(PrincipalOwner)}
	for k, v := range extra {
		env[k] = v
	}
	return env
}

// rejection builds the structured refusal service.ValidatePush returns, so a
// test can assert on the text a human actually reads.
func rejection(ref string, skippable bool, problems ...service.PushProblem) *service.PushRejection {
	return &service.PushRejection{
		Space:     testSpace,
		Ref:       ref,
		Problems:  problems,
		Skippable: skippable,
	}
}

// bareRepo makes a bare repository at <root>/~owner/name using the git binary,
// so the layout under test is the real one.
func bareRepo(t *testing.T, root string, ref core.SpaceRef) string {
	t.Helper()
	dir := filepath.Join(root, "~"+ref.Owner, ref.Name)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("MkdirAll %s: %v", dir, err)
	}
	gitMust(t, "", "init", "--quiet", "--bare", "--initial-branch=main", dir)
	return dir
}

// errFakeStore is a store outage: not a bad credential, and must never be
// reported as one.
var errFakeStore = errors.New("fake store is down")

// mentions asserts that text a human will read says a particular thing. The
// rejection text is the entire user interface of a failed push, so several
// tests assert on its content rather than only on the exit code.
func mentions(t *testing.T, what, haystack string, needles ...string) {
	t.Helper()
	for _, needle := range needles {
		if !strings.Contains(haystack, needle) {
			t.Errorf("%s does not mention %q:\n%s", what, needle, haystack)
		}
	}
}

func mentionsNot(t *testing.T, what, haystack string, needles ...string) {
	t.Helper()
	for _, needle := range needles {
		if strings.Contains(haystack, needle) {
			t.Errorf("%s must not mention %q:\n%s", what, needle, haystack)
		}
	}
}

// Small wrappers so tests read as prose rather than as os calls.
func mkdirAll(dir string) error         { return os.MkdirAll(dir, 0o755) }
func writeFile(path, body string) error { return os.WriteFile(path, []byte(body), 0o644) }
func dialUnix(socket string) (net.Conn, error) {
	return net.DialTimeout("unix", socket, 5*time.Second)
}