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