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