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")
}
}