package hooks
import (
"context"
"errors"
"fmt"
"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 (
zeroOID = "0000000000000000000000000000000000000000"
oneOID = "1111111111111111111111111111111111111111"
twoOID = "2222222222222222222222222222222222222222"
)
// call sends one request over the real socket, which is what the daemon and
// the hooks actually speak. Testing handle() directly would skip the framing.
func call(t *testing.T, srv *Server, req Request) Response {
t.Helper()
resp, err := Client{Socket: srv.Socket(), Timeout: 10 * time.Second}.
Call(context.Background(), req)
if err != nil {
t.Fatalf("Call(%s): %v", req.Method, err)
}
return resp
}
// serverFixture is a server over a bare repository at the real layout.
type serverFixture struct {
root string
repo string
back *fakeBackend
srv *Server
}
func newServerFixture(t *testing.T, tokens map[string]*db.AgentToken) *serverFixture {
t.Helper()
root := shortTempDir(t)
repo := filepath.Join(root, "~"+testSpace.Owner, testSpace.Name)
for _, sub := range []string{"objects", "refs"} {
if err := mkdirAll(filepath.Join(repo, sub)); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
}
back := newFakeBackend(t, root, tokens)
srv, _ := startServer(t, back)
return &serverFixture{root: root, repo: repo, back: back, srv: srv}
}
func (f *serverFixture) request(method Method, push string, updates ...RefUpdate) Request {
return Request{
Version: ProtocolVersion,
Method: method,
Repo: f.repo,
Push: push,
Credential: Credential{Kind: PrincipalOwner},
Updates: updates,
}
}
var mainUpdate = RefUpdate{Ref: "refs/heads/main", Old: oneOID, New: twoOID}
// TestPushLifecycle is the protocol as one push runs it: pre-receive records
// the options, update reads them back, post-receive lands.
func TestPushLifecycle(t *testing.T) {
f := newServerFixture(t, nil)
pre := f.request(MethodPushOptions, "4711", mainUpdate)
pre.Options = []string{OptionSkipValidation}
if resp := call(t, f.srv, pre); !resp.OK {
t.Fatalf("pre-receive: %+v", resp)
}
if resp := call(t, f.srv, f.request(MethodValidateRef, "4711", mainUpdate)); !resp.OK {
t.Fatalf("update: %+v", resp)
}
if len(f.back.seen) != 1 {
t.Fatalf("the backend saw %d requests, want 1", len(f.back.seen))
}
got := f.back.seen[0]
if got.Space != testSpace {
t.Errorf("space: got %s want %s", got.Space, testSpace)
}
if !got.SkipValidation {
t.Error("the push option recorded by pre-receive did not reach ValidatePush")
}
if !got.Principal.IsOwner() || got.Principal.Owner != testOwner {
t.Errorf("principal: got %+v", got.Principal)
}
if got.Ref != mainUpdate.Ref || got.Old != mainUpdate.Old || got.New != mainUpdate.New {
t.Errorf("ref update: got %s %s..%s", got.Ref, got.Old, got.New)
}
if resp := call(t, f.srv, f.request(MethodPushed, "4711", mainUpdate)); !resp.OK {
t.Fatalf("post-receive: %+v", resp)
}
}
// TestSkipValidationDefaultsOff proves the waiver is opt-in per push and does
// not leak from one push into the next.
func TestSkipValidationDefaultsOff(t *testing.T) {
f := newServerFixture(t, nil)
waived := f.request(MethodPushOptions, "1", mainUpdate)
waived.Options = []string{OptionSkipValidation}
call(t, f.srv, waived)
call(t, f.srv, f.request(MethodValidateRef, "1", mainUpdate))
call(t, f.srv, f.request(MethodPushOptions, "2", mainUpdate))
call(t, f.srv, f.request(MethodValidateRef, "2", mainUpdate))
if len(f.back.seen) != 2 {
t.Fatalf("the backend saw %d requests, want 2", len(f.back.seen))
}
if !f.back.seen[0].SkipValidation {
t.Error("the first push's waiver was lost")
}
if f.back.seen[1].SkipValidation {
t.Error("a waiver leaked into the next push")
}
}
// TestUpdateWithoutPreReceiveIsRefused is the fail-closed half of the
// correlation. Absence is not read as "not waived": it means the hooks are
// half installed or the daemon restarted mid-push, and either deserves a
// sentence rather than a guess.
func TestUpdateWithoutPreReceiveIsRefused(t *testing.T) {
f := newServerFixture(t, nil)
resp := call(t, f.srv, f.request(MethodValidateRef, "4711", mainUpdate))
if resp.OK {
t.Fatal("update was answered with no recorded pre-receive phase")
}
mentions(t, "the refusal", resp.Error, "pre-receive")
if len(f.back.seen) != 0 {
t.Error("ValidatePush was called for a push the daemon knew nothing about")
}
}
// TestUpdateForAnUnannouncedRefIsRefused is what makes the receive-pack pid
// safe as a correlation key: a recycled pid would also have to be paired with
// an identical ref and object names.
func TestUpdateForAnUnannouncedRefIsRefused(t *testing.T) {
f := newServerFixture(t, nil)
call(t, f.srv, f.request(MethodPushOptions, "4711", mainUpdate))
other := RefUpdate{Ref: "refs/heads/proposals/9", Old: zeroOID, New: twoOID}
resp := call(t, f.srv, f.request(MethodValidateRef, "4711", other))
if resp.OK {
t.Fatal("update was answered for a ref pre-receive never announced")
}
mentions(t, "the refusal", resp.Error, "did not announce")
}
func TestExpiredPushOptionsAreRefused(t *testing.T) {
root := shortTempDir(t)
repo := filepath.Join(root, "~"+testSpace.Owner, testSpace.Name)
if err := mkdirAll(repo); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
back := newFakeBackend(t, root, nil)
srv, _ := startServer(t, back, func(o *Options) { o.OptionTTL = time.Nanosecond })
f := &serverFixture{root: root, repo: repo, back: back, srv: srv}
call(t, f.srv, f.request(MethodPushOptions, "4711", mainUpdate))
time.Sleep(2 * time.Millisecond)
if resp := call(t, f.srv, f.request(MethodValidateRef, "4711", mainUpdate)); resp.OK {
t.Fatal("an expired record still authorized an update")
}
}
// TestUnknownPushOptionIsRejected: with one option in the vocabulary, silently
// ignoring a typo would reject the push for the very thing the human believed
// they had waived.
func TestUnknownPushOptionIsRejected(t *testing.T) {
f := newServerFixture(t, nil)
req := f.request(MethodPushOptions, "4711", mainUpdate)
req.Options = []string{"skip-validaton"}
resp := call(t, f.srv, req)
if resp.OK {
t.Fatal("an unknown push option was ignored")
}
if !resp.Rejected {
t.Errorf("a mistyped option is a rejection, not an infrastructure failure: %+v", resp)
}
mentions(t, "the rejection", resp.Message,
"skip-validaton", "is not a push option", OptionSkipValidation)
}
// TestRepositoryMustBeOurs: a hook can only ever address a repository this
// daemon owns, because the path is re-derived through gitx's layout rather
// than parsed out of what the hook claimed.
func TestRepositoryMustBeOurs(t *testing.T) {
f := newServerFixture(t, nil)
outside := []struct {
name string
repo string
}{
{"outside the repos root", "/etc"},
{"escaping the repos root", filepath.Join(f.root, "..", "elsewhere")},
{"too shallow", filepath.Join(f.root, "~bigbes")},
{"too deep", filepath.Join(f.root, "~bigbes", "rfcs", "objects")},
{"no owner sigil", filepath.Join(f.root, "bigbes", "rfcs")},
{"the socket directory", filepath.Join(f.root, socketDir, "x")},
}
for _, tt := range outside {
t.Run(tt.name, func(t *testing.T) {
req := f.request(MethodPushOptions, "1", mainUpdate)
req.Repo = tt.repo
resp := call(t, f.srv, req)
if resp.OK {
t.Fatalf("the daemon accepted %s as one of its repositories", tt.repo)
}
if resp.Error == "" {
t.Errorf("no explanation: %+v", resp)
}
})
}
}
// TestOwnerCredentialCannotNameSomebodyElse: the wire carries a kind, never a
// username. The owner comes from the resolver.
func TestOwnerCredentialCannotNameSomebodyElse(t *testing.T) {
f := newServerFixture(t, nil)
call(t, f.srv, f.request(MethodPushOptions, "1", mainUpdate))
call(t, f.srv, f.request(MethodValidateRef, "1", mainUpdate))
if len(f.back.seen) != 1 {
t.Fatalf("the backend saw %d requests", len(f.back.seen))
}
if got := f.back.seen[0].Principal.Owner; got != testOwner {
t.Errorf("owner: got %q want %q", got, testOwner)
}
}
func TestAgentCredential(t *testing.T) {
live := &db.AgentToken{ID: 1, Name: "laptop", Hash: authn.HashToken("good"), Created: time.Now()}
revokedAt := time.Now().Add(-time.Hour)
revoked := &db.AgentToken{ID: 2, Name: "old", Hash: authn.HashToken("dead"), Revoked: &revokedAt}
f := newServerFixture(t, map[string]*db.AgentToken{"good": live, "dead": revoked})
agent := func(token string) Request {
req := f.request(MethodPushOptions, "1", mainUpdate)
req.Credential = Credential{Kind: PrincipalAgent, Token: token, Agent: "claude/spec", Session: "s1"}
return req
}
t.Run("a valid token resolves to an agent", func(t *testing.T) {
if resp := call(t, f.srv, agent("good")); !resp.OK {
t.Fatalf("a valid agent token was refused: %+v", resp)
}
req := f.request(MethodValidateRef, "1", mainUpdate)
req.Credential = Credential{Kind: PrincipalAgent, Token: "good", Agent: "claude/spec", Session: "s1"}
if resp := call(t, f.srv, req); !resp.OK {
t.Fatalf("update: %+v", resp)
}
p := f.back.seen[len(f.back.seen)-1].Principal
if !p.IsAgent() || p.Agent != "claude/spec" || p.Session != "s1" || p.TokenName != "laptop" {
t.Errorf("principal: %+v", p)
}
})
t.Run("an unknown token is a rejection, and the token is not echoed", func(t *testing.T) {
resp := call(t, f.srv, agent("wrong"))
if resp.OK || !resp.Rejected {
t.Fatalf("an unknown token was not rejected: %+v", resp)
}
mentionsNot(t, "the rejection", resp.Message, "wrong")
})
t.Run("a revoked token says revoked", func(t *testing.T) {
resp := call(t, f.srv, agent("dead"))
if resp.OK || !resp.Rejected {
t.Fatalf("a revoked token was not rejected: %+v", resp)
}
mentions(t, "the rejection", resp.Message, "revoked")
})
}
// TestTokenStoreOutageIsNotABadCredential: a store that cannot answer must
// fail the push closed, not read as an invalid token — the difference between
// "retry later" and "reprovision your agent".
func TestTokenStoreOutageIsNotABadCredential(t *testing.T) {
root := shortTempDir(t)
repo := filepath.Join(root, "~"+testSpace.Owner, testSpace.Name)
if err := mkdirAll(repo); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
lookup := &fakeLookup{err: errFakeStore}
store := service.NewTokenStore(lookup)
resolver, err := authn.NewResolver(testOwner, store)
if err != nil {
t.Fatalf("NewResolver: %v", err)
}
back := &fakeBackend{root: root, resolver: resolver, tokens: store}
srv, _ := startServer(t, back)
f := &serverFixture{root: root, repo: repo, back: back, srv: srv}
req := f.request(MethodPushOptions, "1", mainUpdate)
req.Credential = Credential{Kind: PrincipalAgent, Token: "anything"}
resp := call(t, f.srv, req)
if resp.OK {
t.Fatal("a store outage let a push through")
}
if resp.Rejected {
t.Errorf("a store outage was reported as a bad credential: %+v", resp)
}
mentions(t, "the failure", resp.Error, "could not check the agent token")
}
// TestValidatePushRejectionIsPassedThroughVerbatim: the daemon composed the
// text for a terminal and this package must not reword it.
func TestValidatePushRejectionIsPassedThroughVerbatim(t *testing.T) {
f := newServerFixture(t, nil)
want := rejection("refs/heads/main", true, service.PushProblem{
Kind: service.ProblemFrontmatter,
Path: "specs/0002-broken.md",
Detail: "frontmatter is missing the required key `id`",
})
f.back.validate = func(context.Context, service.PushRequest) error { return want }
call(t, f.srv, f.request(MethodPushOptions, "1", mainUpdate))
resp := call(t, f.srv, f.request(MethodValidateRef, "1", mainUpdate))
if resp.OK || !resp.Rejected {
t.Fatalf("a rejection was not passed through: %+v", resp)
}
if resp.Message != want.Error() {
t.Errorf("the rejection was reworded:\ngot:\n%s\nwant:\n%s", resp.Message, want.Error())
}
}
// TestInfrastructureFailureIsNotAPolicyRejection keeps "you broke a rule"
// and "we broke" apart: only the first is worth changing your push over.
func TestInfrastructureFailureIsNotAPolicyRejection(t *testing.T) {
f := newServerFixture(t, nil)
f.back.validate = func(context.Context, service.PushRequest) error {
return fmt.Errorf("service: look up space: %w", errFakeStore)
}
call(t, f.srv, f.request(MethodPushOptions, "1", mainUpdate))
resp := call(t, f.srv, f.request(MethodValidateRef, "1", mainUpdate))
if resp.OK {
t.Fatal("a failed validation let the push through")
}
if resp.Rejected {
t.Errorf("an infrastructure failure was reported as a policy rejection: %+v", resp)
}
mentions(t, "the failure", resp.Error, "could not validate", errFakeStore.Error())
}
// TestPostReceiveReportsANotifierFailure: it cannot stop anything, but it must
// not pretend the index was updated either.
func TestPostReceiveReportsANotifierFailure(t *testing.T) {
root := shortTempDir(t)
repo := filepath.Join(root, "~"+testSpace.Owner, testSpace.Name)
if err := mkdirAll(repo); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
back := newFakeBackend(t, root, nil)
srv, _ := startServer(t, back, func(o *Options) {
o.OnPush = func(context.Context, core.SpaceRef, []RefUpdate) error {
return errors.New("the indexer is not running")
}
})
f := &serverFixture{root: root, repo: repo, back: back, srv: srv}
resp := call(t, f.srv, f.request(MethodPushed, "1", mainUpdate))
if resp.OK {
t.Fatal("a failed notification was reported as success")
}
mentions(t, "the failure", resp.Error, "indexer is not running")
}
func TestNewServerRequiresItsWiring(t *testing.T) {
root := shortTempDir(t)
back := newFakeBackend(t, root, nil)
ok := Options{Backend: back, Socket: SocketPath(root),
OnPush: func(context.Context, core.SpaceRef, []RefUpdate) error { return nil }}
tests := []struct {
name string
mut func(*Options)
want string
}{
{"valid", func(*Options) {}, ""},
{"no backend", func(o *Options) { o.Backend = nil }, "no backend"},
{"no socket", func(o *Options) { o.Socket = "" }, "no socket path"},
{"relative socket", func(o *Options) { o.Socket = "hook.sock" }, "not absolute"},
{"no notifier", func(o *Options) { o.OnPush = nil }, "no push notifier"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
opts := ok
tt.mut(&opts)
_, err := NewServer(opts)
if tt.want == "" {
if err != nil {
t.Fatalf("NewServer: %v", err)
}
return
}
if err == nil {
t.Fatalf("NewServer accepted %s", tt.name)
}
mentions(t, "the error", err.Error(), tt.want)
})
}
}
// TestListenRefusesToStealALiveSocket: two daemons on one repos root would
// each validate half the pushes, and the second would silently take over the
// push path from the first.
func TestListenRefusesToStealALiveSocket(t *testing.T) {
root := shortTempDir(t)
back := newFakeBackend(t, root, nil)
startServer(t, back)
second, err := NewServer(Options{
Backend: back, Socket: SocketPath(root), Log: discardLogger(),
OnPush: func(context.Context, core.SpaceRef, []RefUpdate) error { return nil },
})
if err != nil {
t.Fatalf("NewServer: %v", err)
}
err = second.Listen()
if err == nil {
second.Close()
t.Fatal("a second daemon took the socket from a live one")
}
mentions(t, "the error", err.Error(), "already served by another process")
}
// TestListenClearsADeadSocket: the other half — a socket left behind by a
// crash must not block a restart.
func TestListenClearsADeadSocket(t *testing.T) {
root := shortTempDir(t)
socket := SocketPath(root)
if err := mkdirAll(filepath.Dir(socket)); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := writeFile(socket, "not a socket"); err != nil {
t.Fatalf("write a stale socket: %v", err)
}
back := newFakeBackend(t, root, nil)
srv, err := NewServer(Options{
Backend: back, Socket: socket, Log: discardLogger(),
OnPush: func(context.Context, core.SpaceRef, []RefUpdate) error { return nil },
})
if err != nil {
t.Fatalf("NewServer: %v", err)
}
if err := srv.Listen(); err != nil {
t.Fatalf("Listen over a stale socket: %v", err)
}
t.Cleanup(func() { srv.Close() })
}
// TestGarbageOnTheSocketIsAnswered: the peer may not be a hook at all, and a
// closed connection would leave a real hook guessing.
func TestGarbageOnTheSocketIsAnswered(t *testing.T) {
f := newServerFixture(t, nil)
conn, err := dialUnix(f.srv.Socket())
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
if _, err := conn.Write([]byte("hello?\n")); err != nil {
t.Fatalf("write: %v", err)
}
resp, err := ReadResponse(conn)
if err != nil {
t.Fatalf("ReadResponse: %v", err)
}
if resp.OK {
t.Fatal("the daemon answered ok to something that was not a request")
}
if !strings.Contains(resp.Error, "could not read the request") {
t.Errorf("unhelpful answer: %+v", resp)
}
}