package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
)
// A REFUSAL MUST NOT NAME A REPOSITORY THE CALLER CANNOT SEE.
//
// repoFor has said so since it was written: a repository you may not see
// reports NOT FOUND, never forbidden, because a 403 confirms the
// repository exists and that is the one thing a private repository must
// not confirm. It never got the chance. The capability gate runs first,
// so for every op carrying a Caps letter the refusal came from the gate
// and the op's own not-found was unreachable — and the gate's sentence
// is "you lack o on secret-repo", which names the repository and its
// letters to a stranger.
//
// Measured on the fleet before this: seven repo-scoped reads answered an
// anonymous caller 403 with the repository named, while an unknown name
// answered 404. Asking for each name in turn and reading which ones were
// named back enumerated the private repositories and their owners.
//
// gateRig is an op shaped like repo.get: repo-scoped, one read letter,
// ANONYMOUS, and its own Run answers not-found for a repository the
// caller may not see. Both refusals are therefore available, and the
// point of these tests is that only one of them is ever reachable.
func gateRig(t *testing.T, canRead CanRead) http.Handler {
t.Helper()
type in struct {
Repo string `json:"repo"`
}
r := ops.NewRegistry()
if err := ops.Register(r, ops.Op[in, struct{}]{
Name: "thing.get", Caps: "o", Kind: ops.Query, Auth: ops.Anonymous,
Route: "GET /api/v1/r/{repo}/thing", Doc: "describe a repository",
Run: func(ctx context.Context, in in) (struct{}, error) {
// Exactly what repoFor returns for a repository the caller
// may not read — and only for that caller, since a member
// reading the same name must be served.
if in.Repo == "secret" && ops.User(ctx) != "member" {
return struct{}{}, fmt.Errorf("no repository %q: %w", in.Repo, ops.ErrNotFound)
}
return struct{}{}, nil
},
}); err != nil {
t.Fatal(err)
}
// Only "member" holds the read letter; a stranger holds nothing.
h, err := RouterWithCaps(r, "test",
func(account, _ string) CapSet {
if account == "member" {
return oltp.CapSet("o")
}
return oltp.CapSet("")
}, canRead)
if err != nil {
t.Fatal(err)
}
return h
}
// readable is the fleet's own answer: "secret" exists and is not a
// stranger's; every other name is ordinary.
func readable(_ context.Context, repo string) bool { return repo != "secret" }
// The leak, as a test: a stranger asking for a repository that exists but
// is not theirs gets the SAME answer as for a name nobody ever chose.
func TestARefusedReadDoesNotNameARepositoryTheCallerCannotSee(t *testing.T) {
h := gateRig(t, readable)
req := httptest.NewRequest("GET", "/api/v1/r/secret/thing", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("a repository the caller may not see answered %d, not 404: %s\n"+
"a 403 confirms the repository exists, which is what a private one must not do",
w.Code, w.Body.String())
}
// Not merely 404: the same BODY an unknown name gets, so the two are
// indistinguishable rather than merely similar. The name in it is
// the caller's own input and tells them nothing they did not type.
want := fmt.Sprintf("no repository %q: not found", "secret")
if got := errorOf(t, w.Body.Bytes()); got != want {
t.Errorf("the refusal body is not the not-found an unknown name gets:\n got: %s\nwant: %s", got, want)
}
}
// The honest refusal must survive. A caller who CAN see the repository
// but lacks the letter gets a 403 that names it — that is a sentence
// about a repository that exists for them, and it is the whole point of
// the letter-and-grantor wording.
func TestARefusalStillNamesARepositoryTheCallerCanSee(t *testing.T) {
h := gateRig(t, readable)
req := httptest.NewRequest("GET", "/api/v1/r/open/thing", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("a readable repository the caller lacks a letter on answered %d, not 403: %s",
w.Code, w.Body.String())
}
body := errorOf(t, w.Body.Bytes())
if !strings.Contains(body, `"o"`) || !strings.Contains(body, "open") {
t.Errorf("the refusal does not name the letter and the repository, so it cannot be acted on: %s", body)
}
}
// And a caller who holds the letter is unaffected: the gate only decides
// WHICH refusal, never whether one is due.
func TestACallerWithTheLetterIsServed(t *testing.T) {
h := gateRig(t, readable)
req := httptest.NewRequest("GET", "/api/v1/r/secret/thing", nil)
req = req.WithContext(ops.WithUser(req.Context(), "member"))
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("a member holding the read letter was refused %d: %s", w.Code, w.Body.String())
}
}
// canRead is optional. A forge with no accounts plane has no strangers
// to hide from, and must keep answering exactly as it did before this
// seam existed rather than 404-ing everything.
func TestWithNoReadGateEveryRefusalStaysARefusal(t *testing.T) {
h := gateRig(t, nil)
req := httptest.NewRequest("GET", "/api/v1/r/secret/thing", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("with no read gate a missing letter answered %d, not 403 — the seam changed behaviour it should not have",
w.Code)
}
}
// The not-found answer is for repo-scoped ops. A fleet-scoped op has no
// repository to hide, and canRead must not be consulted for one.
func TestAFleetScopedRefusalIsNotTurnedIntoANotFound(t *testing.T) {
r := ops.NewRegistry()
if err := ops.Register(r, ops.Op[struct{}, struct{}]{
Name: "fleetwide", Caps: "s", Scope: ops.ScopeFleet, Kind: ops.Query, Auth: ops.Token,
Route: "GET /api/v1/fleetwide", Doc: "about the forge",
Run: func(context.Context, struct{}) (struct{}, error) { return struct{}{}, nil },
}); err != nil {
t.Fatal(err)
}
asked := false
h, err := RouterWithCaps(r, "test",
func(_, _ string) CapSet { return oltp.CapSet("") },
func(_ context.Context, _ string) bool { asked = true; return false })
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest("GET", "/api/v1/fleetwide", nil)
req = req.WithContext(ops.WithUser(req.Context(), "someone"))
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("a fleet-scoped op answered %d, not 403: %s", w.Code, w.Body.String())
}
if asked {
t.Error("canRead was consulted for an op with no repository to hide")
}
}
// The gate must not INVERT the oracle while closing it.
//
// canRead is registrar.CanRead, and a name it has never heard of is not
// private, so it answers true. That is the whole reason a nonexistent
// repository keeps answering the capability refusal here and reaches the
// op's own not-found instead: if the gate turned every name it could not
// vouch for into a 404, then "private" and "does not exist" would simply
// swap which one was which, and the enumeration would survive the fix.
//
// So this pins the pairing. A name nobody ever chose, asked for by a
// caller holding no letters, is refused for the LETTER — the same
// sentence an unreadable-but-public repository gets — and the
// distinguishing answer comes from the op, which knows whether the
// repository is there.
func TestAnUnknownNameIsNotTurnedIntoANotFoundByTheReadGate(t *testing.T) {
h := gateRig(t, readable)
req := httptest.NewRequest("GET", "/api/v1/r/never-existed/thing", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("an unknown name answered %d, not the capability refusal: %s\n"+
"if this 404s, then private and nonexistent have swapped places and the leak is back",
w.Code, w.Body.String())
}
}
// errorOf reads the "error" string out of a refusal body. Asserting on
// the decoded value rather than on the raw bytes keeps these tests about
// WHAT the refusal says, not about JSON escaping.
func errorOf(t *testing.T, body []byte) string {
t.Helper()
var out struct {
Error string `json:"error"`
}
if err := json.Unmarshal(body, &out); err != nil {
t.Fatalf("refusal body is not the API's JSON error shape: %v (%s)", err, body)
}
return out.Error
}