package api
import (
"context"
"errors"
"strings"
"testing"
"craftdesign.group/quarry/internal/index"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
"craftdesign.group/quarry/internal/zitcli"
)
// closeIndex folds root->child on trunk and a leaf on dev, so a test can
// close the tip of a protected branch and of an unprotected one.
func closeIndex(t *testing.T) (*index.Index, string, string) {
t.Helper()
h1, h2, h3 := strings.Repeat("11", 32), strings.Repeat("22", 32), strings.Repeat("33", 32)
ix := wikiIndex(t)
if err := ix.FoldNow(branchArts(h1, h2, h3)); err != nil {
t.Fatal(err)
}
return ix, h2, h3 // trunk tip, dev tip
}
func closeRegistry(t *testing.T, ix *index.Index, rules []oltp.Protection) *ops.Registry {
t.Helper()
r := ops.NewRegistry()
if err := RegisterAll(r, Deps{Version: "v",
Repos: NewRepos(map[string]*Repo{"test": {Index: ix, Zit: &zitcli.Client{}}}),
Protect: stubProtect{rules: rules},
}); err != nil {
t.Fatal(err)
}
return r
}
// Closing the tip of a protected branch takes an owner's hand.
//
// checkin.close writes fossil's `closed` tag and asked for `i` and
// nothing else, so branch protection did not reach it. The bypass was
// available to exactly the population the control exists to constrain:
// protection limits people who ALREADY hold check-in rights, and
// holding check-in rights is precisely what let them write the tag
// (ad732c8f, Severe, jva's argument).
//
// Reversibility does not mitigate it. The owner's symptom is their own
// commit failing with "cannot commit against a closed leaf", which
// names neither the cause nor the actor, and the recovery is a raw tag
// cancel that appears in no UI.
func TestClosingAProtectedBranchNeedsAnOwner(t *testing.T) {
ix, trunkTip, _ := closeIndex(t)
r := closeRegistry(t, ix, []oltp.Protection{{Branch: "trunk", Owners: []string{"alice"}}})
_, err := r.Invoke(ops.WithUser(context.Background(), "bob"), "checkin.close",
checkinCloseIn{Repo: "test", Hash: trunkTip})
if !errors.Is(err, ops.ErrForbidden) {
t.Errorf("bob closed the tip of a branch alice owns: err = %v, want ErrForbidden", err)
}
// And the refusal names the owner, so the person refused knows whose
// hand it takes rather than only that it was not theirs.
if err != nil && !strings.Contains(err.Error(), "alice") {
t.Errorf("the refusal does not name the owner: %v", err)
}
}
// Reopening is the same decision. A non-owner who could reopen could
// undo an owner's close, which is the same membership question wearing
// the opposite sign.
func TestReopeningAProtectedBranchNeedsAnOwner(t *testing.T) {
ix, trunkTip, _ := closeIndex(t)
r := closeRegistry(t, ix, []oltp.Protection{{Branch: "trunk", Owners: []string{"alice"}}})
_, err := r.Invoke(ops.WithUser(context.Background(), "bob"), "checkin.close",
checkinCloseIn{Repo: "test", Hash: trunkTip, Reopen: true})
if !errors.Is(err, ops.ErrForbidden) {
t.Errorf("bob reopened a branch alice owns: err = %v, want ErrForbidden", err)
}
}
// The gate must not widen into a refusal for everybody: an owner still
// closes, and an unprotected branch is still anybody's with `i`.
//
// Asserted as "not forbidden" rather than "no error", because the write
// itself goes to a store this test does not run. A gate that refused
// these would be the over-refusal that the protected-branch work has
// already caused once.
func TestClosingIsStillAllowedWhereItShouldBe(t *testing.T) {
ix, trunkTip, devTip := closeIndex(t)
r := closeRegistry(t, ix, []oltp.Protection{{Branch: "trunk", Owners: []string{"alice"}}})
for _, tc := range []struct{ who, hash, what string }{
{"alice", trunkTip, "the owner closing her own protected branch"},
{"bob", devTip, "anybody closing an unprotected branch"},
} {
_, err := r.Invoke(ops.WithUser(context.Background(), tc.who), "checkin.close",
checkinCloseIn{Repo: "test", Hash: tc.hash})
if errors.Is(err, ops.ErrForbidden) {
t.Errorf("%s was refused: %v", tc.what, err)
}
}
}