~bigbes/sr-ht-spec

ref: 64cae3af81d4b0039edc8ec3946bed36166a447b sr-ht-spec/hooks/fixture_test.go -rw-r--r-- 8.2 KiB
64cae3af — Eugene Blikh graph: accept a meta.sr.ht token, so /query can be federated a day 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
package hooks

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

	"sourcecraft.dev/bigbes/sr-ht-core/auth"
	"sourcecraft.dev/bigbes/sr-ht-ecore/bearer"

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

const testOwner = "bigbes"

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

// instanceToken mints a signed tokens.sr.ht working token the way the daemon
// does. It is a real credential checked by the real sr-ht-ecore validator,
// because the point of routing the push path through authn.Resolver is that it
// runs the identical check the HTTP surfaces run; a stub validator here would
// only assert that the wiring calls something.
func instanceToken(grantString string) string { return tokenFor(testOwner, grantString) }

// foreignToken is a working token belonging to somebody who is not the instance
// owner: valid, and refused all the same.
func foreignToken(grantString string) string { return tokenFor("someone", grantString) }

func tokenFor(username, grantString string) string {
	bt := &auth.BearerToken{
		Version:  auth.TokenVersion,
		Expires:  auth.ToTimestamp(time.Now().Add(time.Hour)),
		Grants:   grantString,
		ClientID: bearer.TokensClientID,
		Username: username,
	}
	return bt.Encode()
}

// stubUsers resolves the owner an instance token names to a local row.
type stubUsers struct{}

func (stubUsers) LookupUser(_ context.Context, username string) (authn.InstanceUser, error) {
	return authn.InstanceUser{ID: 1, Username: username}, nil
}

// fakeDaemonOrigin starts a stand-in for tokens.sr.ht's revocation endpoint —
// 204 is live, 404 is revoked — and returns its origin. A dead one, with
// nothing answering, is what a restarting daemon looks like from here.
func fakeDaemonOrigin(t *testing.T, status int, dead bool) string {
	t.Helper()
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(status)
	}))
	if dead {
		srv.Close()
		return srv.URL
	}
	t.Cleanup(srv.Close)
	return srv.URL
}

// 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

	// 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
}

// newFakeBackend builds a backend whose resolver carries the real tokens.sr.ht
// validator, pointed at a live fake daemon.
func newFakeBackend(t *testing.T, root string) *fakeBackend {
	t.Helper()
	return newFakeBackendAgainst(t, root, fakeDaemonOrigin(t, http.StatusNoContent, false))
}

func newFakeBackendAgainst(t *testing.T, root, origin string) *fakeBackend {
	t.Helper()
	v, err := bearer.New(bearer.Options{
		Origin:   origin,
		ClientID: service.ConfigSection,
		NodeID:   "hooks-test",
	})
	if err != nil {
		t.Fatalf("bearer.New: %v", err)
	}
	resolver, err := authn.NewResolver(testOwner, authn.WithInstancePlane(v, stubUsers{}))
	if err != nil {
		t.Fatalf("NewResolver: %v", err)
	}
	return &fakeBackend{root: root, resolver: resolver}
}

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

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 backend outage: not a policy refusal, 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)
}