package host
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"testing/fstest"
)
// upstream records what reached the homeserver.
type upstream struct {
srv *httptest.Server
hits []*http.Request
}
func newUpstream(t *testing.T) *upstream {
t.Helper()
u := &upstream{}
u.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u.hits = append(u.hits, r.Clone(r.Context()))
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Set-Cookie", "tracker=1")
_, _ = w.Write([]byte(`{"path":"` + r.URL.EscapedPath() + `"}`))
}))
t.Cleanup(u.srv.Close)
return u
}
var panelFS = fstest.MapFS{
"index.html": {Data: []byte("
panel")},
"favicon.svg": {Data: []byte("")},
"assets/index-a1.js": {Data: []byte("console.log(1)")},
}
func newHandler(t *testing.T, up *upstream, panel bool) http.Handler {
t.Helper()
target, _ := url.Parse(up.srv.URL)
opts := Options{Homeserver: target}
if panel {
opts.Panel = panelFS
}
h, err := New(opts)
if err != nil {
t.Fatal(err)
}
return h
}
func do(h http.Handler, method, target string, header http.Header) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, target, nil)
for k, v := range header {
req.Header[k] = v
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestProxiesAdminAndSessionPaths(t *testing.T) {
up := newUpstream(t)
h := newHandler(t, up, true)
for _, p := range []string{
"/_matrix/client/versions",
"/_matrix/client/v3/login",
"/_matrix/client/v3/logout",
"/_matrix/client/v3/account/whoami",
"/_warp/admin/v1/stats",
"/_warp/admin/v1/users/%40alice%3Awarp.example/devices",
"/_warp/admin/v1/audit_log?limit=5&action=user.create",
} {
rec := do(h, http.MethodGet, p, nil)
if rec.Code != http.StatusOK {
t.Errorf("%s: status %d, want 200", p, rec.Code)
}
}
if len(up.hits) != 7 {
t.Fatalf("upstream saw %d requests, want 7", len(up.hits))
}
// Escaped IDs and the query reach the homeserver unchanged.
if got := up.hits[5].URL.EscapedPath(); got != "/_warp/admin/v1/users/%40alice%3Awarp.example/devices" {
t.Errorf("escaped path = %q", got)
}
if got := up.hits[6].URL.RawQuery; got != "limit=5&action=user.create" {
t.Errorf("query = %q", got)
}
}
func TestRefusesOtherHomeserverPaths(t *testing.T) {
up := newUpstream(t)
h := newHandler(t, up, true)
for _, p := range []string{
"/_matrix/client/v3/sync",
"/_matrix/client/v3/rooms/!a:b/send/m.room.message/1",
"/_matrix/federation/v1/version",
"/_matrix/media/v3/upload",
"/_warp/admin/v2/stats",
"/_warp/admin/v1/../../_matrix/client/v3/sync",
"/_warp/admin/v1/%2e%2e/%2e%2e/%2e%2e/_matrix/client/v3/sync",
"/_warp/admin/v1//stats",
} {
rec := do(h, http.MethodGet, p, nil)
if rec.Code != http.StatusNotFound {
t.Errorf("%s: status %d, want 404", p, rec.Code)
}
var body map[string]string
_ = json.Unmarshal(rec.Body.Bytes(), &body)
if body["errcode"] != "M_UNRECOGNIZED" {
t.Errorf("%s: errcode %q", p, body["errcode"])
}
}
if len(up.hits) != 0 {
t.Fatalf("upstream saw %d requests, want none", len(up.hits))
}
}
func TestProxyStripsCookiesAndForwardsToken(t *testing.T) {
up := newUpstream(t)
h := newHandler(t, up, true)
rec := do(h, http.MethodGet, "/_warp/admin/v1/stats", http.Header{
"Authorization": {"Bearer tok"},
"Cookie": {"session=secret"},
})
if rec.Header().Get("Set-Cookie") != "" {
t.Error("Set-Cookie from the homeserver was passed to the browser")
}
if rec.Header().Get("Cache-Control") != "no-store" {
t.Errorf("Cache-Control = %q, want no-store", rec.Header().Get("Cache-Control"))
}
got := up.hits[0]
if got.Header.Get("Cookie") != "" {
t.Error("Cookie was passed to the homeserver")
}
if got.Header.Get("Authorization") != "Bearer tok" {
t.Errorf("Authorization = %q", got.Header.Get("Authorization"))
}
if got.Header.Get("X-Forwarded-For") == "" {
t.Error("X-Forwarded-For not set")
}
}
func TestUnreachableHomeserverIs502(t *testing.T) {
target, _ := url.Parse("http://127.0.0.1:1")
h, err := New(Options{Homeserver: target, Panel: panelFS})
if err != nil {
t.Fatal(err)
}
rec := do(h, http.MethodGet, "/_warp/admin/v1/stats", nil)
if rec.Code != http.StatusBadGateway {
t.Fatalf("status %d, want 502", rec.Code)
}
if !strings.Contains(rec.Body.String(), "M_UNKNOWN") {
t.Errorf("body %q", rec.Body.String())
}
}
func TestServesPanel(t *testing.T) {
up := newUpstream(t)
h := newHandler(t, up, true)
cases := []struct {
path, wantBody, wantCache string
wantCode int
}{
{"/", "panel", "no-cache", 200},
{"/users/%40alice%3Awarp.example", "panel", "no-cache", 200},
{"/rooms/!abc:warp.example", "panel", "no-cache", 200},
{"/index.html", "panel", "no-cache", 200},
{"/assets/index-a1.js", "console.log", "public, max-age=31536000, immutable", 200},
{"/favicon.svg", "