package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"craftdesign.group/quarry/internal/ops"
)
// A forge reached over https is not usable over http through its proxy:
// a page load is sent to https, and anything carrying a credential or a
// body is refused, not redirected, because it has already been sent.
func TestRequireHTTPSSendsPagesToHTTPSAndRefusesTheRest(t *testing.T) {
ran := false
h := RequireHTTPS("https://forge.example", "CF-Connecting-IP", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
ran = true
}))
for _, tc := range []struct {
name, method, proto string
wantCode int
wantRan bool
wantLoc, wantHSTS bool
}{
{"a page over http is redirected", "GET", "http", http.StatusPermanentRedirect, false, true, false},
{"a HEAD over http is redirected", "HEAD", "http", http.StatusPermanentRedirect, false, true, false},
{"a sync or form post over http is refused", "POST", "http", http.StatusForbidden, false, false, false},
{"https is served and told to stay https", "GET", "https", http.StatusOK, true, false, true},
{"a direct loopback request is served as before", "POST", "", http.StatusOK, true, false, false},
} {
ran = false
req := httptest.NewRequest(tc.method, "/r/quarry/xfer?x=1", nil)
req.RemoteAddr = "127.0.0.1:50000"
if tc.proto != "" {
req.Header.Set("CF-Connecting-IP", "198.51.100.9")
req.Header.Set("X-Forwarded-Proto", tc.proto)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
loc := rec.Header().Get("Location")
hsts := rec.Header().Get("Strict-Transport-Security") != ""
switch {
case rec.Code != tc.wantCode:
t.Errorf("FAIL: %s: status %d, want %d", tc.name, rec.Code, tc.wantCode)
case ran != tc.wantRan:
t.Errorf("FAIL: %s: handler ran = %v, want %v", tc.name, ran, tc.wantRan)
case tc.wantLoc && loc != "https://forge.example/r/quarry/xfer?x=1":
t.Errorf("FAIL: %s: redirected to %q", tc.name, loc)
case hsts != tc.wantHSTS:
t.Errorf("FAIL: %s: HSTS present = %v, want %v", tc.name, hsts, tc.wantHSTS)
default:
t.Logf("ok: %s", tc.name)
}
}
}
// A forge that is not served over https has nothing to upgrade to.
func TestRequireHTTPSLeavesAnHTTPForgeAlone(t *testing.T) {
ran := false
h := RequireHTTPS("http://127.0.0.1:8977", "CF-Connecting-IP", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { ran = true }))
req := httptest.NewRequest("POST", "/login", nil)
req.RemoteAddr = "127.0.0.1:50000"
req.Header.Set("CF-Connecting-IP", "198.51.100.9")
req.Header.Set("X-Forwarded-Proto", "http")
h.ServeHTTP(httptest.NewRecorder(), req)
if !ran {
t.Error("FAIL: a forge with an http origin refused its own scheme")
} else {
t.Log("ok: an http forge is served over http")
}
}
// Bytes a signed-in reader fetched may be a private repository's, and
// the edge cache in front of the forge must not keep them for the next
// stranger. Only an anonymous reader's response may be shared.
func TestOnlyAnAnonymousReadersBytesMayBeSharedByACache(t *testing.T) {
anon := httptest.NewRequest("GET", "/r/x/doc/trunk/a.png", nil)
user := anon.WithContext(ops.WithUser(anon.Context(), "dan"))
basic := httptest.NewRequest("GET", "/r/x/doc/trunk/a.png", nil)
basic.SetBasicAuth("dan", "token")
cookie := httptest.NewRequest("GET", "/r/x/doc/trunk/a.png", nil)
cookie.AddCookie(&http.Cookie{Name: sessionCookie, Value: "t"})
for _, tc := range []struct {
name string
req *http.Request
public bool
}{
{"an anonymous reader", anon, true},
{"a signed-in reader", user, false},
{"a reader with a Basic credential", basic, false},
{"a reader with a session cookie", cookie, false},
} {
got := cacheFor(tc.req, 60, false)
if strings.HasPrefix(got, "public") != tc.public {
t.Errorf("FAIL: %s got %q", tc.name, got)
} else {
t.Logf("ok: %s gets %q", tc.name, got)
}
}
// And the inline attachment route, which serves by hash, follows it.
rec := httptest.NewRecorder()
serveInlineMedia(rec, user, "a.png", "image/png", []byte("x"))
if cc := rec.Header().Get("Cache-Control"); !strings.HasPrefix(cc, "private") {
t.Errorf("FAIL: a signed-in reader's attachment is cached as %q", cc)
} else {
t.Log("ok: a signed-in reader's attachment is private")
}
}
// A response that says nothing about caching is private (#223): the
// edge caches by URL extension when the origin is silent, and a page
// about one reader's view must not be held for another. A handler that
// states its own policy keeps it, and a stream can still flush.
func TestAResponseThatSaysNothingIsPrivate(t *testing.T) {
for _, tc := range []struct {
name, set, want string
}{
{"a silent handler", "", "private, no-cache"},
{"a bare no-cache", "no-cache", "private, no-cache"},
{"an immutable asset", "public, max-age=31536000, immutable", "public, max-age=31536000, immutable"},
{"a stream", "no-store", "no-store"},
} {
h := PrivateByDefault(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if tc.set != "" {
w.Header().Set("Cache-Control", tc.set)
}
w.Write([]byte("x"))
}))
rec := httptest.NewRecorder()
// A signed-in reader's view: one reader's, so never shared.
req := httptest.NewRequest("GET", "/r/x/file/trunk/logo.gif", nil)
req.Header.Set("Cookie", "session=x")
h.ServeHTTP(rec, req)
if got := rec.Header().Get("Cache-Control"); got != tc.want {
t.Errorf("FAIL: %s: %q, want %q", tc.name, got, tc.want)
} else {
t.Logf("ok: %s: %q", tc.name, got)
}
}
flushed := false
h := PrivateByDefault(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
f, ok := w.(http.Flusher)
if !ok {
return
}
f.Flush()
flushed = true
}))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/r/x/tree.frag", nil))
if !flushed || !rec.Flushed || rec.Header().Get("Cache-Control") != "private, no-cache" {
t.Errorf("FAIL: a stream through the wrapper: flushed=%v recorder=%v cc=%q", flushed, rec.Flushed, rec.Header().Get("Cache-Control"))
} else {
t.Log("ok: a stream still flushes, and its header went out private")
}
}
// A page named by a check-in's full hash, asked for by a stranger with
// no cookie and no credential, is the same bytes for every stranger and
// cannot change, so the edge may keep it. Anything that makes the reader
// someone, a moving name, a refusal or a cookie handed out keeps it
// private.
func TestAnAnonymousImmutablePageIsSharedAtTheEdge(t *testing.T) {
hash := strings.Repeat("ab", 32)
for _, tc := range []struct {
name, method, path, cookie, auth, setCookie string
status int
shared bool
}{
{"a file at a hash", "GET", "/r/dmestas/fossil/file/" + hash + "/extsrc/sqlite3.c", "", "", "", 200, true},
{"a bare path", "GET", "/r/fossil/tree/" + hash[:40] + "/src", "", "", "", 200, true},
{"a check-in", "HEAD", "/r/dmestas/fossil/checkin/" + hash, "", "", "", 200, true},
{"its diff", "GET", "/r/dmestas/fossil/checkin/" + hash + "/diff", "", "", "", 200, true},
{"its patch", "GET", "/r/dmestas/fossil/checkin/" + hash + "/patch", "", "", "", 200, false},
{"a moving name, signed in", "GET", "/r/dmestas/fossil/file/trunk/src/main.c", "session=x", "", "", 200, false},
{"any cookie", "GET", "/r/dmestas/fossil/file/" + hash + "/a.c", "theme=dark", "", "", 200, false},
{"a credential", "GET", "/r/dmestas/fossil/file/" + hash + "/a.c", "", "Basic eDp5", "", 200, false},
{"a refusal", "GET", "/r/dmestas/fossil/file/" + hash + "/a.c", "", "", "", 404, false},
{"a cookie handed out", "GET", "/r/dmestas/fossil/file/" + hash + "/a.c", "", "", "seen=1", 200, false},
{"a write", "POST", "/r/dmestas/fossil/file/" + hash + "/a.c", "", "", "", 200, false},
} {
h := PrivateByDefault(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if tc.setCookie != "" {
w.Header().Set("Set-Cookie", tc.setCookie)
}
renderStatus(viewRegistry(t, nil), w, req, notFoundPage, pageData{}, tc.status)
}))
req := httptest.NewRequest(tc.method, tc.path, nil)
if tc.cookie != "" {
req.Header.Set("Cookie", tc.cookie)
}
if tc.auth != "" {
req.Header.Set("Authorization", tc.auth)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
got := rec.Header().Get("Cache-Control")
if shared := strings.HasPrefix(got, "public") && strings.Contains(got, "s-maxage"); shared != tc.shared {
t.Errorf("FAIL: %s: %q, shared=%v want %v", tc.name, got, shared, tc.shared)
}
}
t.Log("ok")
}
// A public page that can change is kept by the edge for 15 seconds for
// strangers, never an hour; anybody the forge can tell apart, any
// refusal, any non-page path and any live stream stays private.
func TestAnAnonymousPageThatMovesIsSharedBriefly(t *testing.T) {
hash := strings.Repeat("ab", 32)
for _, tc := range []struct {
name, path, cookie, auth string
status int
want string
}{
{"the home page", "/", "", "", 200, edgeFresh},
{"a repository", "/r/dmestas/quarry", "", "", 200, edgeFresh},
{"a timeline", "/r/quarry/timeline", "", "", 200, edgeFresh},
{"a file at trunk", "/r/dmestas/fossil/file/trunk/src/main.c", "", "", 200, edgeFresh},
{"a short hash", "/r/dmestas/fossil/file/" + hash[:12] + "/src/main.c", "", "", 200, edgeFresh},
{"a file at a full hash", "/r/dmestas/fossil/file/" + hash + "/a.c", "", "", 200, edgeShared},
{"signed in", "/r/dmestas/quarry", "session=x", "", 200, "private, no-cache"},
{"a token", "/r/dmestas/quarry", "", "Bearer t", 200, "private, no-cache"},
{"a private repository refused", "/r/dmestas/secret", "", "", 404, "private, no-cache"},
{"a live stream", "/r/dmestas/quarry/chat.sse", "", "", 200, "private, no-cache"},
{"a fragment", "/r/x/tree.frag", "", "", 200, "private, no-cache"},
{"a file fragment", "/r/x/file.frag/abc/a.c", "", "", 200, "private, no-cache"},
{"a CI run fragment", "/r/x/ci/run-frag/12", "", "", 200, "private, no-cache"},
{"an archive", "/r/x/archive/trunk.tar.gz", "", "", 200, "private, no-cache"},
{"not a page of the forge", "/login", "", "", 200, "private, no-cache"},
{"the fleet", "/fleet", "", "", 200, "private, no-cache"},
} {
h := PrivateByDefault(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
renderStatus(viewRegistry(t, nil), w, req, notFoundPage, pageData{}, tc.status)
}))
req := httptest.NewRequest("GET", tc.path, nil)
if tc.cookie != "" {
req.Header.Set("Cookie", tc.cookie)
}
if tc.auth != "" {
req.Header.Set("Authorization", tc.auth)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if got := rec.Header().Get("Cache-Control"); got != tc.want {
t.Errorf("FAIL: %s: Cache-Control %q, want %q", tc.name, got, tc.want)
}
}
}