Skip to content
quarry Artifact [c5601febee]
Login

Artifact [c5601febee]

Artifact c5601febee6002b7d9852b2b599c9eee8b712ce4f97b527900fe105fa4b33139:


package web

import (
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

// place stands in for the registry: where each repository appears, and
// whether a name is a repository at all.
func place(m map[string]string) func(string) (string, bool) {
	return func(name string) (string, bool) {
		ns, ok := m[name]
		return ns, ok
	}
}

func servedPath(t *testing.T, h handlerHooks, method, path string) (status int, served string, location string) {
	t.Helper()
	inner := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
		served = req.URL.Path
	})
	w := httptest.NewRecorder()
	namespaced(h.place, h.wasAt, allVisible, inner).ServeHTTP(w, httptest.NewRequest(method, path, nil))
	return w.Code, served, w.Header().Get("Location")
}

// allVisible is the ordinary case these cases are about: a reader who
// may see every repository named. The privacy gate has its own test.
func allVisible(string, *http.Request) bool { return true }

func TestNamespacedPaths(t *testing.T) {
	var h handlerHooks
	// "quarry" is a repository AND, in the last case, the name of a
	// team — the collision the ordering exists to survive.
	h.place = place(map[string]string{
		"quarry":  "dmestas",
		"zit":     "quarry",
		"fossil":  "",
		"orchard": "grove",
	})

	for _, tc := range []struct {
		name, method, path string
		wantStatus         int
		wantServed         string
		wantLocation       string
	}{
		{"a namespaced path reaches the repository", "GET", "/r/grove/orchard/tree",
			200, "/r/orchard/tree", ""},
		{"a namespaced path with no tail", "GET", "/r/dmestas/quarry",
			200, "/r/quarry", ""},
		{"a flat path is sent to where the repository appears", "GET", "/r/orchard",
			302, "", "/r/grove/orchard"},
		{"a forge-held repository stays flat", "GET", "/r/fossil/tree",
			200, "/r/fossil/tree", ""},
		{"a repository name wins over a namespace of the same name", "GET", "/r/quarry/zit",
			302, "", "/r/dmestas/quarry/zit"},
		{"a post is served, never redirected", "POST", "/r/orchard/settings",
			200, "/r/orchard/settings", ""},
		{"an unknown first segment is left alone", "GET", "/r/nosuch/thing",
			200, "/r/nosuch/thing", ""},
		{"paths outside /r/ are untouched", "GET", "/t/grove",
			200, "/t/grove", ""},
	} {
		status, served, loc := servedPath(t, h, tc.method, tc.path)
		if status != tc.wantStatus {
			t.Errorf("FAIL: %s: status %d, want %d", tc.name, status, tc.wantStatus)
			continue
		}
		if tc.wantServed != "" && served != tc.wantServed {
			t.Errorf("FAIL: %s: served %q, want %q", tc.name, served, tc.wantServed)
			continue
		}
		if tc.wantLocation != "" && loc != tc.wantLocation {
			t.Errorf("FAIL: %s: Location %q, want %q", tc.name, loc, tc.wantLocation)
			continue
		}
		t.Logf("ok: %s", tc.name)
	}
}

// The redirect must not be cached: where a repository appears changes
// when a team takes it, when a team is dissolved, and when the deed is
// handed on.
func TestTheCanonicalRedirectIsNotPermanent(t *testing.T) {
	var h handlerHooks
	h.place = place(map[string]string{"orchard": "grove"})
	status, _, _ := servedPath(t, h, "GET", "/r/orchard")
	if status == http.StatusMovedPermanently {
		t.Fatal("FAIL: a permanent redirect to a target that moves is cached against the truth")
	}
	if status != http.StatusFound {
		t.Fatalf("FAIL: status %d, want 302", status)
	}
	t.Log("ok: the canonical redirect is temporary")
}

// Unwired, the forge serves flat paths exactly as it did.
func TestWithoutTheSeamPathsAreUntouched(t *testing.T) {
	status, served, _ := servedPath(t, handlerHooks{}, "GET", "/r/orchard/tree")
	if status != 200 || served != "/r/orchard/tree" {
		t.Fatalf("FAIL: status %d served %q, want 200 and the path unchanged", status, served)
	}
	t.Log("ok: no seam, no rewriting")
}

// A private repository must not announce itself through the canonical
// redirect. /r/orchard answering 302 -> /r/grove/orchard tells a
// stranger both that the repository exists and which team holds it,
// which is exactly what RFC 0004's "does not exist for anyone else"
// forbids — and it leaked while every case above passed, because none
// of them had a viewer.
func TestFlatPathDoesNotRevealAPrivateRepositoryNamespace(t *testing.T) {
	var h handlerHooks
	h.place = place(map[string]string{"orchard": "grove", "quarry": "dmestas"})

	// A stranger: may read quarry, may not read orchard. In production
	// this composes privacy with letters — see
	// TestPrivacyGateIgnoresInheritedReadLetters for why the letters
	// alone are not enough.
	stranger := func(repo string, _ *http.Request) bool { return repo != "orchard" }

	serve := func(path string) (int, string, string) {
		var served string
		inner := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
			served = req.URL.Path
		})
		w := httptest.NewRecorder()
		namespaced(h.place, h.wasAt, stranger, inner).ServeHTTP(w, httptest.NewRequest("GET", path, nil))
		return w.Code, served, w.Header().Get("Location")
	}

	code, served, loc := serve("/r/orchard")
	if loc != "" {
		t.Errorf("FAIL /r/orchard leaked the namespace: %d -> %s", code, loc)
	} else if served != "/r/orchard" {
		t.Errorf("FAIL /r/orchard served %q, want the flat path so the handler refuses", served)
	} else {
		t.Log("ok a repository the viewer may not read does not redirect")
	}

	// The namespaced spelling still reaches the handler, which refuses
	// on its own — gating it here would change nothing but the path.
	if _, served, _ := serve("/r/grove/orchard"); served != "/r/orchard" {
		t.Errorf("FAIL /r/grove/orchard served %q, want /r/orchard", served)
	} else {
		t.Log("ok the namespaced spelling still reaches the handler")
	}

	// A repository the viewer CAN read still gets its canonical URL.
	if _, _, loc := serve("/r/quarry"); loc != "/r/dmestas/quarry" {
		t.Errorf("FAIL readable repository lost its redirect: %q", loc)
	} else {
		t.Log("ok a readable repository still redirects to its namespace")
	}
}

// No caps plane means no auth model, and an ungated forge has no
// strangers to hide from — the fallback every other gate here takes.
func TestRedirectSurvivesWithNoVisibilityGate(t *testing.T) {
	var h handlerHooks
	h.place = place(map[string]string{"orchard": "grove"})
	var served string
	inner := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { served = req.URL.Path })
	w := httptest.NewRecorder()
	namespaced(h.place, h.wasAt, nil, inner).ServeHTTP(w, httptest.NewRequest("GET", "/r/orchard", nil))
	if loc := w.Header().Get("Location"); loc != "/r/grove/orchard" {
		t.Errorf("FAIL nil gate should not suppress the redirect: %q served=%q", loc, served)
	} else {
		t.Log("ok nil gate keeps the canonical redirect")
	}
}

// The first version of the privacy gate asked only for read letters, and
// it did not close the leak: a repository with no per-repository
// categories inherits the FLEET default, whose `nobody` set is gjorzh2 —
// every read letter — so an anonymous viewer "held" read on a private
// repository and the redirect fired anyway. Privacy is a column on the
// repository, and has to be asked first. This pins the composition the
// production gate uses, in registrar.CanRead's order.
func TestPrivacyGateIgnoresInheritedReadLetters(t *testing.T) {
	// What production sees for a private repository with no categories
	// of its own: anonymous, and holding the inherited read letters.
	const inherited = "gjorzh2"
	gate := func(private bool, viewer string, caps string) bool {
		if !private {
			return true
		}
		if viewer == "" {
			return false
		}
		// Stands in for CapSet.HasAny on the letters alone. It does NOT
		// model RFC 0004's rule that `a` implies every non-sxy letter —
		// that belongs to the capability tests, and leaning on it here
		// would test the stand-in rather than the composition.
		return strings.ContainsAny(caps, repoReadLetters)
	}
	for _, tc := range []struct {
		name    string
		private bool
		viewer  string
		caps    string
		want    bool
	}{
		{"public repository, anonymous", false, "", inherited, true},
		{"private repository, anonymous holding inherited letters", true, "", inherited, false},
		{"private repository, signed in with no letters", true, "someone", "", false},
		{"private repository, signed in with a read letter", true, "dmestas", "o", true},
	} {
		if got := gate(tc.private, tc.viewer, tc.caps); got != tc.want {
			t.Errorf("FAIL %s: got %v, want %v", tc.name, got, tc.want)
		} else {
			t.Logf("ok %s -> %v", tc.name, got)
		}
	}
}

// A path under a namespace the repository has LEFT goes where the
// repository is now — with the same temporary status as the flat form,
// only for a reader who may see it, and never for a write.
//
// A saved link carries the namespace of the moment it was saved, so a
// team move made every one of them a 404 while the bare form kept
// working (c12a84b2, G-52). The forge remembers where a repository was;
// this is the shim consulting that memory.
func TestAFormerNamespaceRedirectsToTheCurrentOne(t *testing.T) {
	var h handlerHooks
	h.place = place(map[string]string{"orchard": "grove", "probe": "", "quarry": "dmestas"})
	h.wasAt = func(name, ns string) bool {
		return (name == "orchard" && ns == "jan") || (name == "probe" && ns == "crew")
	}
	for _, tc := range []struct {
		name, method, path string
		wantStatus         int
		wantServed         string
		wantLocation       string
	}{
		{"an old namespace sends the page where the repository is", "GET", "/r/jan/orchard/timeline",
			302, "", "/r/grove/orchard/timeline"},
		{"the query survives the move", "GET", "/r/jan/orchard/landing?state=open",
			302, "", "/r/grove/orchard/landing?state=open"},
		{"a repository that went home to the forge is sent to the flat form", "GET", "/r/crew/probe",
			302, "", "/r/probe"},
		{"a namespace it never held stays not found for the handler", "GET", "/r/nobody/orchard/timeline",
			200, "/r/nobody/orchard/timeline", ""},
		{"a write under the old namespace is never redirected", "POST", "/r/jan/orchard/settings",
			200, "/r/jan/orchard/settings", ""},
		{"the current namespace is still served, not redirected", "GET", "/r/grove/orchard/timeline",
			200, "/r/orchard/timeline", ""},
	} {
		status, served, loc := servedPath(t, h, tc.method, tc.path)
		if status != tc.wantStatus {
			t.Errorf("FAIL: %s: status %d, want %d", tc.name, status, tc.wantStatus)
			continue
		}
		if tc.wantServed != "" && served != tc.wantServed {
			t.Errorf("FAIL: %s: served %q, want %q", tc.name, served, tc.wantServed)
			continue
		}
		if tc.wantLocation != "" && loc != tc.wantLocation {
			t.Errorf("FAIL: %s: Location %q, want %q", tc.name, loc, tc.wantLocation)
			continue
		}
		t.Logf("ok: %s", tc.name)
	}

	// The memory must not tell a stranger where a private repository
	// went: for a reader who may not see it, the old path stays as it
	// was, exactly as the flat form does.
	stranger := func(repo string, _ *http.Request) bool { return repo != "orchard" }
	w := httptest.NewRecorder()
	var served string
	namespaced(h.place, h.wasAt, stranger, http.HandlerFunc(func(_ http.ResponseWriter, req *http.Request) { served = req.URL.Path })).
		ServeHTTP(w, httptest.NewRequest("GET", "/r/jan/orchard/timeline", nil))
	if loc := w.Header().Get("Location"); loc != "" || served != "/r/jan/orchard/timeline" {
		t.Errorf("FAIL: a former namespace leaked a private repository's move: %d -> %q, served %q", w.Code, loc, served)
	} else {
		t.Log("ok: a stranger learns nothing from a former namespace")
	}
}