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