package hooks import ( "bytes" "strings" "testing" ) func validRequest() Request { return Request{ Version: ProtocolVersion, Method: MethodValidateRef, Repo: "/var/lib/spec/~bigbes/rfcs", Push: "4711", Credential: Credential{Kind: PrincipalOwner}, Updates: []RefUpdate{{ Ref: "refs/heads/main", Old: strings.Repeat("0", 40), New: strings.Repeat("a", 40), }}, } } func TestRequestRoundTrip(t *testing.T) { want := validRequest() want.Credential = Credential{Kind: PrincipalAgent, Token: "s3cret", Agent: "claude/spec", Session: "abc"} var buf bytes.Buffer if err := WriteRequest(&buf, want); err != nil { t.Fatalf("WriteRequest: %v", err) } if !strings.HasSuffix(buf.String(), "\n") { t.Error("a request must be newline terminated so a peer can frame it") } got, err := ReadRequest(&buf) if err != nil { t.Fatalf("ReadRequest: %v", err) } if got.Method != want.Method || got.Repo != want.Repo || got.Push != want.Push { t.Errorf("round trip lost fields: %+v", got) } if got.Credential != want.Credential { t.Errorf("credential round trip: got %+v want %+v", got.Credential, want.Credential) } if len(got.Updates) != 1 || got.Updates[0] != want.Updates[0] { t.Errorf("updates round trip: got %+v", got.Updates) } } func TestRequestValidate(t *testing.T) { zero := strings.Repeat("0", 40) tests := []struct { name string mut func(*Request) want string }{ {"valid", func(*Request) {}, ""}, {"wrong version", func(r *Request) { r.Version = 99 }, "different builds"}, {"unknown method", func(r *Request) { r.Method = "reindex-everything" }, "unknown method"}, {"no repo", func(r *Request) { r.Repo = "" }, "no repository path"}, {"relative repo", func(r *Request) { r.Repo = "rfcs" }, "not absolute"}, {"no push id", func(r *Request) { r.Push = "" }, "no push correlation id"}, {"no principal", func(r *Request) { r.Credential.Kind = "" }, "unknown principal kind"}, {"agent with no token", func(r *Request) { r.Credential = Credential{Kind: PrincipalAgent} }, "must carry a token"}, {"owner with a token", func(r *Request) { r.Credential.Token = "x" }, "must not carry a token"}, {"no updates", func(r *Request) { r.Updates = nil }, "no ref updates"}, {"validate-ref with two updates", func(r *Request) { r.Updates = append(r.Updates, RefUpdate{Ref: "refs/heads/x", Old: zero, New: zero}) }, "want exactly 1"}, {"empty object name", func(r *Request) { r.Updates[0].Old = "" }, "forty zeroes"}, {"no ref name", func(r *Request) { r.Updates[0].Ref = "" }, "no ref name"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := validRequest() tt.mut(&req) err := req.Validate() switch { case tt.want == "" && err != nil: t.Fatalf("Validate: %v", err) case tt.want == "": case err == nil: t.Fatalf("Validate accepted %s", tt.name) default: mentions(t, "the error", err.Error(), tt.want) } }) } } // TestResponseValidate covers the answers a hook must not act on. An // ambiguous response is treated as an unreachable daemon, which is a rejection // — never as permission to proceed. func TestResponseValidate(t *testing.T) { tests := []struct { name string resp Response ok bool }{ {"ok", Response{Version: ProtocolVersion, OK: true}, true}, {"rejected with a message", Response{Version: ProtocolVersion, Rejected: true, Message: "no"}, true}, {"error", Response{Version: ProtocolVersion, Error: "postgres is down"}, true}, {"wrong version", Response{Version: 2, OK: true}, false}, {"ok and rejected", Response{Version: ProtocolVersion, OK: true, Rejected: true, Message: "?"}, false}, {"ok and errored", Response{Version: ProtocolVersion, OK: true, Error: "?"}, false}, {"rejected with no message", Response{Version: ProtocolVersion, Rejected: true}, false}, {"rejected with a blank message", Response{Version: ProtocolVersion, Rejected: true, Message: " \n"}, false}, {"neither", Response{Version: ProtocolVersion}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := tt.resp.Validate() if tt.ok && err != nil { t.Fatalf("Validate: %v", err) } if !tt.ok && err == nil { t.Fatal("Validate accepted an answer no hook can act on") } }) } } // TestReadRefusesOversizedMessage proves the socket cannot be used to exhaust // the daemon's memory: the read is capped, and hitting the cap is an error // rather than a truncation that would decode as a smaller, different request. func TestReadRefusesOversizedMessage(t *testing.T) { huge := validRequest() huge.Credential.Token = strings.Repeat("x", maxMessageBytes) var buf bytes.Buffer if err := WriteRequest(&buf, huge); err == nil { t.Fatal("WriteRequest accepted a message over the limit") } // A peer that does not use WriteRequest still cannot get past the reader. raw := `{"version":1,"method":"validate-ref","repo":"` + strings.Repeat("a", maxMessageBytes) + `"}` if _, err := ReadRequest(strings.NewReader(raw)); err == nil { t.Fatal("ReadRequest accepted a message over the limit") } } // TestReadToleratesUnknownFields keeps the version number as the compatibility // contract: a peer from another build gets the sentence about mismatched // builds, not a json decoding error. func TestReadToleratesUnknownFields(t *testing.T) { raw := `{"version":99,"method":"validate-ref","repo":"/x","push":"1","future_field":true}` + "\n" req, err := ReadRequest(strings.NewReader(raw)) if err != nil { t.Fatalf("ReadRequest: %v", err) } err = req.Validate() if err == nil { t.Fatal("Validate accepted version 99") } mentions(t, "the version mismatch", err.Error(), "different builds") }