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, though its member reads it; every other name is ordinary.
func readable(ctx context.Context, repo string) bool {
return repo != "secret" || ops.User(ctx) == "member"
}
// 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")
}
}
// A name nobody ever chose, asked for by a caller holding no letters,
// is refused exactly as a private repository is: the same 404 and the
// same sentence. Refusing it for the letter (403) while a private one
// got 404 told a stranger which names were private (jva, ^3800, the
// chat API). The registry lookup is what tells the gate a name is no
// repository; without one installed, every name counts as one.
func TestAnUnknownNameIsRefusedAsAPrivateRepositoryIs(t *testing.T) {
SetRepoKnown(func(name string) bool { return name == "secret" || name == "open" })
defer SetRepoKnown(func(string) bool { return true })
h := gateRig(t, readable)
answer := func(name string) (int, string) {
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest("GET", "/api/v1/r/"+name+"/thing", nil))
return w.Code, errorOf(t, w.Body.Bytes())
}
pc, pe := answer("secret")
uc, ue := answer("nevers")
if pc != http.StatusNotFound || uc != pc || ue != strings.ReplaceAll(pe, "secret", "nevers") {
t.Errorf("FAIL: private %d %q, unknown %d %q", pc, pe, uc, ue)
}
// A public repository the caller lacks the letter on is still told so.
if oc, _ := answer("open"); oc != http.StatusForbidden {
t.Errorf("FAIL: a public repository's refusal became %d", oc)
}
}
// 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
}
// An unknown name and a private repository stop at the same place, before
// any parameter is checked: a request missing its parameters, or one an
// op would answer with an empty list, says nothing about which names are
// private (jva, ^3827).
func TestAnUnknownNameStopsBeforeItsParameters(t *testing.T) {
SetRepoKnown(func(name string) bool { return name == "secret" })
defer SetRepoKnown(func(string) bool { return true })
type in struct {
Repo string `json:"repo"`
Q string `json:"q" validate:"required"`
}
r := ops.NewRegistry()
if err := ops.Register(r, ops.Op[in, struct{}]{
Name: "thing.similar", Kind: ops.Query, Auth: ops.Anonymous,
Route: "GET /api/v1/r/{repo}/similar", Doc: "similar things",
Run: func(context.Context, in) (struct{}, error) { return struct{}{}, nil },
}); err != nil {
t.Fatal(err)
}
h, err := RouterWithCaps(r, "test", func(string, string) CapSet { return oltp.CapSet("") }, readable)
if err != nil {
t.Fatal(err)
}
answer := func(name string) (int, string) {
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest("GET", "/api/v1/r/"+name+"/similar", nil))
return w.Code, errorOf(t, w.Body.Bytes())
}
pc, pe := answer("secret")
uc, ue := answer("nevers")
if pc != http.StatusNotFound || uc != pc || ue != strings.ReplaceAll(pe, "secret", "nevers") {
t.Errorf("FAIL: private %d %q, unknown %d %q", pc, pe, uc, ue)
}
}
// A private repository asked for by owner (the web rewrote grove/secret
// to secret and said so) is refused with the sentence a wrong owner/name
// pair gets from the fallback route, so the pair is not confirmed.
func TestAPrivateRepoByOwnerIsRefusedLikeAWrongPair(t *testing.T) {
h := gateRig(t, readable)
marked := httptest.NewRequest("GET", "/api/v1/r/secret/thing", nil)
marked = marked.WithContext(ops.WithOwnerPath(marked.Context(), "grove", "/api/v1/r/secret/thing"))
w1 := httptest.NewRecorder()
h.ServeHTTP(w1, marked)
w2 := httptest.NewRecorder()
h.ServeHTTP(w2, httptest.NewRequest("GET", "/api/v1/r/grove/nevers/thing", nil))
got, want := errorOf(t, w1.Body.Bytes()), strings.ReplaceAll(errorOf(t, w2.Body.Bytes()), "nevers", "secret")
if w1.Code != http.StatusNotFound || w1.Code != w2.Code || got != want {
t.Errorf("FAIL: by owner %d %q; wrong pair %d %q", w1.Code, got, w2.Code, want)
}
}
// An op that needs a sign-in, asked for a private repository by owner
// with no credentials, is refused as a wrong pair is, not with 401.
func TestAnAuthOpByOwnerWithoutCredentialsIsRefusedLikeAWrongPair(t *testing.T) {
type in struct {
Repo string `json:"repo"`
}
r := ops.NewRegistry()
if err := ops.Register(r, ops.Op[in, struct{}]{
Name: "thing.settings", Caps: "a", Kind: ops.Query, Auth: ops.Token,
Route: "GET /api/v1/r/{repo}/settings", Doc: "settings",
Run: func(context.Context, in) (struct{}, error) { return struct{}{}, nil },
}); err != nil {
t.Fatal(err)
}
h, err := RouterWithCaps(r, "test", func(string, string) CapSet { return oltp.CapSet("") }, readable,
func(string, string, string, ops.Auth) Decision { return Denied })
if err != nil {
t.Fatal(err)
}
marked := httptest.NewRequest("GET", "/api/v1/r/secret/settings", nil)
marked = marked.WithContext(ops.WithOwnerPath(marked.Context(), "grove", "/api/v1/r/secret/settings"))
w1 := httptest.NewRecorder()
h.ServeHTTP(w1, marked)
w2 := httptest.NewRecorder()
h.ServeHTTP(w2, httptest.NewRequest("GET", "/api/v1/r/grove/nevers/settings", nil))
got, want := errorOf(t, w1.Body.Bytes()), strings.ReplaceAll(errorOf(t, w2.Body.Bytes()), "nevers", "secret")
if w1.Code != http.StatusNotFound || w1.Code != w2.Code || got != want {
t.Errorf("FAIL: by owner %d %q; wrong pair %d %q", w1.Code, got, w2.Code, want)
}
}