package hooks import ( "context" "errors" "fmt" "net/http" "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/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) *serverFixture { t.Helper() return newServerFixtureAgainst(t, fakeDaemonOrigin(t, http.StatusNoContent, false)) } // newServerFixtureAgainst is the same fixture with the tokens.sr.ht origin // chosen by the caller, so a test can put a revoking or an unreachable daemon // behind the agent plane. func newServerFixtureAgainst(t *testing.T, origin string) *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 := newFakeBackendAgainst(t, root, origin) 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) 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) 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) 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) 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) 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) 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) 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) 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) } } // agentRequest is a push by an agent presenting token. func agentRequest(f *serverFixture, method Method, token string) Request { req := f.request(method, "1", mainUpdate) req.Credential = Credential{Kind: PrincipalAgent, Token: token, Agent: "claude/spec", Session: "s1"} return req } // The push path takes a tokens.sr.ht working token, which is the whole point of // routing it through authn.Resolver: an agent can `git push` with the same // credential it uses on the REST and MCP planes. func TestAgentCredentialAcceptsAnInstanceToken(t *testing.T) { f := newServerFixture(t) tok := instanceToken("spec:propose") if resp := call(t, f.srv, agentRequest(f, MethodPushOptions, tok)); !resp.OK { t.Fatalf("a valid instance token was refused: %+v", resp) } if resp := call(t, f.srv, agentRequest(f, MethodValidateRef, tok)); !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" { t.Errorf("principal: %+v", p) } if p.Plane != authn.PlaneInstance { t.Errorf("plane = %q, want the instance plane", p.Plane) } if p.TokenName != "tokens.sr.ht (stateless)" { t.Errorf("TokenName = %q", p.TokenName) } } // The credential every agent used to push with — an opaque secret out of // agent_token — authenticates nowhere. It is not a token this instance sealed, // and there is no second store left to ask. func TestAgentCredentialRefusesTheOldOpaqueToken(t *testing.T) { f := newServerFixture(t) resp := call(t, f.srv, agentRequest(f, MethodPushOptions, "s3cret-from-agent-token")) if resp.OK || !resp.Rejected { t.Fatalf("an opaque secret was not rejected: %+v", resp) } mentionsNot(t, "the rejection", resp.Message, "s3cret-from-agent-token") } // A push is a proposal by another transport, so it needs spec:propose exactly // as the REST and MCP write planes do — and a token without it is refused // before any ref is looked at. func TestAgentCredentialRefusesATokenWithoutTheProposeGrant(t *testing.T) { f := newServerFixture(t) for _, grantString := range []string{"spec:read", "bench:upload"} { t.Run(grantString, func(t *testing.T) { resp := call(t, f.srv, agentRequest(f, MethodPushOptions, instanceToken(grantString))) if resp.OK || !resp.Rejected { t.Fatalf("a token without spec:propose was not rejected: %+v", resp) } mentions(t, "the rejection", resp.Message, "spec:propose") }) } if len(f.back.seen) != 0 { t.Errorf("a refused credential reached the backend %d times", len(f.back.seen)) } } // spec.sr.ht answers to one human on this instance, over git as over HTTP. func TestAgentCredentialRefusesATokenOfAnotherOwner(t *testing.T) { f := newServerFixture(t) resp := call(t, f.srv, agentRequest(f, MethodPushOptions, foreignToken("spec:propose"))) if resp.OK || !resp.Rejected { t.Fatalf("a foreign owner's token was not rejected: %+v", resp) } mentions(t, "the rejection", resp.Message, "someone") } // A revoked token says revoked, so an operator can tell a credential they // killed from one that never existed. func TestAgentCredentialRefusesARevokedToken(t *testing.T) { f := newServerFixtureAgainst(t, fakeDaemonOrigin(t, http.StatusNotFound, false)) resp := call(t, f.srv, agentRequest(f, MethodPushOptions, instanceToken("spec:propose id:42"))) if resp.OK || !resp.Rejected { t.Fatalf("a revoked token was not rejected: %+v", resp) } mentions(t, "the rejection", resp.Message, "revoked") } // TestUnreachableDaemonIsNotABadCredential: a validator that cannot complete // its revocation check must fail the push closed, not read as an invalid token // — the difference between "retry later" and "reprovision your agent". func TestUnreachableDaemonIsNotABadCredential(t *testing.T) { f := newServerFixtureAgainst(t, fakeDaemonOrigin(t, http.StatusNoContent, true)) resp := call(t, f.srv, agentRequest(f, MethodPushOptions, instanceToken("spec:propose id:42"))) if resp.OK { t.Fatal("an unreachable tokens.sr.ht let a push through") } if resp.Rejected { t.Errorf("an unreachable daemon was reported as a bad credential: %+v", resp) } mentions(t, "the failure", resp.Error, "could not check the credential") } // TestValidatePushRejectionIsPassedThroughVerbatim: the daemon composed the // text for a terminal and this package must not reword it. func TestValidatePushRejectionIsPassedThroughVerbatim(t *testing.T) { f := newServerFixture(t) 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) 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) 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) 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) 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) 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) 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) } }