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