package web
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/index"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
)
type stubCaps struct{ letters string }
func (s stubCaps) CapsOf(string, string) CapSet { return s }
func (s stubCaps) HasAny(letters string) bool { return strings.ContainsAny(letters, s.letters) }
// stubPolicy protects trunk for alice; dan holds `i` and owns nothing.
type stubPolicy struct{ owner string }
func (p stubPolicy) MayJoin(_, user, branch string) bool { return branch != "trunk" || user == p.owner }
func (p stubPolicy) Protections(string) ([]oltp.Protection, error) {
return []oltp.Protection{{Branch: "trunk", Owners: []string{p.owner}}}, nil
}
func protectedLandingServer(t *testing.T, ix *index.Index, owner string) *httptest.Server {
t.Helper()
r := ops.NewRegistry()
if err := api.RegisterAll(r, api.Deps{Version: "v",
Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: ix}}),
Protect: stubPolicy{owner: owner}}); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(HandlerWith(r, nil, nil,
[]Option{WithSessions(fakeSessions{user: "dan"}), WithCaps(stubCaps{letters: "i"})}))
t.Cleanup(srv.Close)
return srv
}
func asDan(t *testing.T, srv *httptest.Server, path string) string {
t.Helper()
req, _ := http.NewRequest("GET", srv.URL+path, nil)
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return string(b)
}
// RFC 0007 §Testing, "non-owner with i / forge accept / control
// disabled". A control that would fail is not offered
// (docs/landings.md); one that names why is better than none — and it
// says forge policy, never a capability.
func TestProtectedTargetDisablesAcceptWithTheReason(t *testing.T) {
srv := protectedLandingServer(t, landingIndex(t, false), "alice")
html := asDan(t, srv, "/r/alpha/landing/"+strings.Repeat("a1", 6))
if !strings.Contains(html, `disabled title="trunk is protected; owners: alice"`) {
t.Fatalf("no disabled control with the reason:\n%.800s", html)
}
if strings.Contains(html, `formaction="/r/alpha/landing/a1a1a1a1a1a1/accept"`) {
t.Error("an enabled accept was drawn for a non-owner")
}
if !strings.Contains(html, "is protected on this forge") {
t.Error("the page does not say this is the forge's policy")
}
}
// ...and for the owner, protection is not the reason for anything.
// Whether the control is offered is mergeability's call (unknowable
// without a store behind the index, and unknowable must not read as
// clean); what this proves is that an owner is never shown the
// non-owner's refusal.
func TestOwnerIsNotRefused(t *testing.T) {
srv := protectedLandingServer(t, landingIndex(t, false), "dan")
html := asDan(t, srv, "/r/alpha/landing/"+strings.Repeat("a1", 6))
if strings.Contains(html, "is protected on this forge") ||
strings.Contains(html, `title="trunk is protected`) {
t.Fatalf("the owner is shown the non-owner's reason:\n%.800s", html)
}
}
// A conflicting rung gets the steps, not a sentence: the reader here has
// a conflict and a clone. And it says the rungs above follow on their
// own — merge-based rungs propagate a fix upward by the DAG, which is
// what other tools need a restack for.
func TestConflictingRungShowsTheStepsAndThatRungsAboveFollow(t *testing.T) {
for _, want := range []string{"zit merge {{.Target}}", "zit push", "Rungs stacked on"} {
if !strings.Contains(landingSource, want) {
t.Errorf("landing page source lacks %q", want)
}
}
}
// zit#152 on the page: Update from <target> is offered only when the
// probe says the merge is clean AND the target has actually moved
// (RFC 0009: a verb that would do nothing is not offered; the card's
// "has moved" row says why this one would).
func TestUpdateControlFollowsTheProbe(t *testing.T) {
if !strings.Contains(landingSource, `{{if and (eq $.Landing.Mergeable "clean") (gt $.Landing.Behind 0) $.Landing.CanAccept (eq $.Landing.State "open")}}`) {
t.Fatal("the update control is not gated on a clean probe and a moved target")
}
if !strings.Contains(landingSource, `/update" title="merge {{$.Landing.Target}} into {{$.Landing.Branch}} on the store`) {
t.Error("the update control is missing or aimed elsewhere")
}
if !strings.Contains(landingSource, "brought the target in") {
t.Error("an update reply is not lifted into the record's events")
}
}
// A verifier makes capability checks real. Without one, every check
// answers yes — which is how a reviewer with i but not 3 lost the
// accept control behind the composer's gate, unseen by these tests.
func verifiedLandingServer(t *testing.T, ix *index.Index, letters string, signedIn bool) *httptest.Server {
t.Helper()
r := ops.NewRegistry()
if err := api.RegisterAll(r, api.Deps{Version: "v",
Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: ix}})}); err != nil {
t.Fatal(err)
}
user := "dan"
if !signedIn {
user = ""
}
srv := httptest.NewServer(HandlerWith(r, nil, nil,
[]Option{WithSessions(fakeSessions{user: user}), WithCaps(stubCaps{letters: letters})},
Verify(func(string, string, string, ops.Auth) api.Decision { return api.Denied })))
t.Cleanup(srv.Close)
return srv
}
func TestReviewerWhoMayLandButNotPostStillGetsTheControl(t *testing.T) {
srv := verifiedLandingServer(t, landingIndex(t, false), "i", true)
html := asDan(t, srv, "/r/alpha/landing/"+strings.Repeat("a1", 6))
if !strings.Contains(html, `formaction="/r/alpha/landing/a1a1a1a1a1a1/accept"`) {
t.Fatalf("i without 3: no accept control:\n%.900s", html)
}
if strings.Contains(html, `id="composer"`) {
t.Error("i without 3: the composer was drawn")
}
if strings.Contains(html, "Sign in to take part") {
t.Error("a signed-in reviewer was told to sign in")
}
if !strings.Contains(html, "Remarks and rejection need the forum capability") {
t.Error("the missing capability is not named")
}
}
func TestSignedInReaderWithoutCapabilitiesIsToldWhatIsMissing(t *testing.T) {
srv := verifiedLandingServer(t, landingIndex(t, false), "", true)
html := asDan(t, srv, "/r/alpha/landing/"+strings.Repeat("a1", 6))
if strings.Contains(html, "Sign in to take part") || !strings.Contains(html, "Taking part needs the forum capability") {
t.Errorf("signed-in reader: wrong message:\n%.600s", html)
}
anon := verifiedLandingServer(t, landingIndex(t, false), "", false)
if html := asDan(t, anon, "/r/alpha/landing/"+strings.Repeat("a1", 6)); !strings.Contains(html, "Sign in to take part") {
t.Errorf("anonymous: not told to sign in:\n%.600s", html)
}
}