package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
)
// bodyRepoIn is the shape of the ops that carry their repository as a
// FIELD rather than in the path — repos/update, accounts/grant.
type bodyRepoIn struct {
Name string `json:"name"`
Description string `json:"description"`
}
// bodyRepoRouter mounts one such op, gated on "a", against a roster
// that grants "a" on exactly one repository and nothing at the fleet
// level — which is what a repository admin who is not a site admin
// actually holds.
func bodyRepoRouter(t *testing.T, seen *string) http.Handler {
t.Helper()
r := ops.NewRegistry()
if err := ops.Register(r, ops.Op[bodyRepoIn, struct{}]{
Name: "repo.update.test",
Caps: "a",
// Declared, now that a gated op must say what its letters are
// about — and this fixture is precisely the case that needs it:
// the repository is in the body, not the route.
Scope: ops.ScopeRepo,
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/repos/update",
Doc: "test double for the repos/* family, which names its repository in the body",
Run: func(ctx context.Context, in bodyRepoIn) (struct{}, error) {
// Proves the body still reaches the op: a gate that reads
// the body must put it back.
*seen = in.Description
return struct{}{}, nil
},
}); err != nil {
t.Fatal(err)
}
h, err := RouterWithCaps(r, "test", func(_, repo string) CapSet {
if repo == "sandbox" {
return oltp.CapSet("a")
}
return oltp.CapSet("")
}, nil)
if err != nil {
t.Fatal(err)
}
return h
}
// A repository admin must reach an op that names their repository in a
// JSON body.
//
// The gate resolved the repository with req.ParseForm(), which parses
// FORM bodies only. Every API caller sends JSON, so the field came back
// empty, caps resolved against "" — the fleet scope, where a
// repository admin holds nothing — and the op refused its own admin
// with 'needs capability "a", which an admin of the repository grants'.
// account.explain, reading the same letters per repository, said the
// caller held "a" at the same moment. That contradiction is b683c54b.
func TestARepoAdminReachesAnOpThatNamesItsRepoInAJSONBody(t *testing.T) {
var seen string
h := bodyRepoRouter(t, &seen)
body, _ := json.Marshal(bodyRepoIn{Name: "sandbox", Description: "a new description"})
req := httptest.NewRequest("POST", "/api/v1/repos/update", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ops.WithUser(req.Context(), "jva"))
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code == http.StatusForbidden {
t.Fatalf("the repository's own admin was refused: %d %s\n"+
"the gate could not see \"sandbox\" in a JSON body, so it asked about the fleet instead",
w.Code, w.Body.String())
}
if w.Code != http.StatusOK {
t.Fatalf("unexpected status %d: %s", w.Code, w.Body.String())
}
// The gate reads the body to find the repository; the op must still
// get it. Consuming it would turn a fixed gate into an empty update.
if seen != "a new description" {
t.Errorf("the op received description %q — the gate consumed the body", seen)
}
}
// The other direction, unchanged: a stranger to that repository is
// still refused, and the refusal still names the repository it asked
// about rather than an empty string.
func TestAStrangerToTheRepoIsStillRefused(t *testing.T) {
var seen string
h := bodyRepoRouter(t, &seen)
body, _ := json.Marshal(bodyRepoIn{Name: "somebody-elses", Description: "nope"})
req := httptest.NewRequest("POST", "/api/v1/repos/update", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ops.WithUser(req.Context(), "mallory"))
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("a stranger reached the op: %d %s", w.Code, w.Body.String())
}
if seen != "" {
t.Error("a refused call still ran the op")
}
if !bytes.Contains(w.Body.Bytes(), []byte("somebody-elses")) {
t.Errorf("the refusal does not name the repository it asked about: %s", w.Body.String())
}
}
// A form-encoded body must keep working, since that is the path the
// current code was written for.
func TestAFormEncodedBodyStillResolvesTheRepo(t *testing.T) {
var seen string
h := bodyRepoRouter(t, &seen)
req := httptest.NewRequest("POST", "/api/v1/repos/update",
bytes.NewReader([]byte("name=sandbox&description=form+path")))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req = req.WithContext(ops.WithUser(req.Context(), "jva"))
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code == http.StatusForbidden {
t.Fatalf("the form path regressed: %d %s", w.Code, w.Body.String())
}
}