Skip to content
quarry Artifact [44a8b3c533]
Login

Artifact [44a8b3c533]

Artifact 44a8b3c5337633c2bf12a7b1d8dcd2db32f3672deabe3a7a784a13100feef540:


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
}