package shell
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"testing/fstest"
)
var clientFS = fstest.MapFS{
"index.html": {Data: []byte("
Warp")},
"assets/app-1.js": {Data: []byte("console.log(1)")},
}
// homeserver is a fake homeserver: /versions, and a well-known document
// when delegate is set.
func homeserver(t *testing.T, delegate string) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("GET /_matrix/client/versions", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"versions":["v1.11"]}`))
})
if delegate != "" {
mux.HandleFunc("GET /.well-known/matrix/client", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"m.homeserver":{"base_url":"` + delegate + `/"}}`))
})
}
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
func newShell(t *testing.T, opts Options) (*Shell, *Store) {
t.Helper()
if opts.Store == nil {
opts.Store = NewStore(filepath.Join(t.TempDir(), "desktop.json"))
}
if opts.Client == nil {
opts.Client = clientFS
}
s, err := New(opts)
if err != nil {
t.Fatal(err)
}
return s, opts.Store
}
func do(h http.Handler, method, target, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, target, strings.NewReader(body))
if method == http.MethodPost && strings.HasPrefix(target, SetupPath) {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestFirstRunAsksForTheHomeserver(t *testing.T) {
s, _ := newShell(t, Options{})
for _, p := range []string{"/", "/room/!abc:warp.example", SetupPath} {
rec := do(s, http.MethodGet, p, "")
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Welcome to Warp") {
t.Errorf("GET %s: %d %q", p, rec.Code, rec.Body.String())
}
}
if rec := do(s, http.MethodPost, "/_matrix/client/v3/login", "{}"); rec.Code != http.StatusServiceUnavailable {
t.Errorf("POST before setup: %d", rec.Code)
}
}
func TestSetupSavesTheHomeserverAndServesTheClient(t *testing.T) {
hs := homeserver(t, "")
s, store := newShell(t, Options{})
rec := do(s, http.MethodPost, SetupPath, url.Values{"server": {hs.URL + "/"}}.Encode())
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `http-equiv="refresh"`) {
t.Fatalf("setup: %d %q", rec.Code, rec.Body.String())
}
if got := s.Homeserver(); got != hs.URL {
t.Errorf("Homeserver() = %q, want %q", got, hs.URL)
}
if st, _ := store.Load(); st.Homeserver != hs.URL {
t.Errorf("saved %q", st.Homeserver)
}
// The client is served now, told to call the homeserver directly.
if rec := do(s, http.MethodGet, "/", ""); !strings.Contains(rec.Body.String(), "Warp") {
t.Errorf("GET / after setup: %q", rec.Body.String())
}
rec = do(s, http.MethodGet, "/config.json", "")
var cfg struct {
HomeserverURL string `json:"homeserver_url"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &cfg); err != nil || cfg.HomeserverURL != hs.URL {
t.Errorf("config.json %q", rec.Body.String())
}
// Nothing is proxied.
if rec := do(s, http.MethodGet, "/_matrix/client/versions", ""); rec.Code != http.StatusNotFound {
t.Errorf("/_matrix proxied: %d", rec.Code)
}
// The next run starts with it.
s2, _ := newShell(t, Options{Store: store})
if s2.Homeserver() != hs.URL {
t.Errorf("next run: %q", s2.Homeserver())
}
if rec := do(s2, http.MethodGet, SetupPath, ""); !strings.Contains(rec.Body.String(), "Change homeserver") {
t.Errorf("setup page once configured: %q", rec.Body.String())
}
}
func TestSetupRejectsWhatIsntAHomeserver(t *testing.T) {
notMatrix := httptest.NewServer(http.NotFoundHandler())
t.Cleanup(notMatrix.Close)
s, store := newShell(t, Options{})
rec := do(s, http.MethodPost, SetupPath, url.Values{"server": {notMatrix.URL}}.Encode())
if !strings.Contains(rec.Body.String(), "doesn’t look like a Matrix homeserver") {
t.Errorf("body %q", rec.Body.String())
}
// What was typed stays in the field.
if !strings.Contains(rec.Body.String(), `value="`+notMatrix.URL+`"`) {
t.Errorf("input not kept: %q", rec.Body.String())
}
if s.Homeserver() != "" {
t.Errorf("homeserver set to %q", s.Homeserver())
}
if st, _ := store.Load(); st.Homeserver != "" {
t.Errorf("saved %q", st.Homeserver)
}
}
func TestDamagedSettingsAskAgain(t *testing.T) {
store := NewStore(filepath.Join(t.TempDir(), "desktop.json"))
if err := store.Save(Settings{Homeserver: "matrix.example"}); err != nil {
t.Fatal(err)
}
s, _ := newShell(t, Options{Store: store})
if s.Homeserver() != "" {
t.Errorf("Homeserver() = %q", s.Homeserver())
}
if rec := do(s, http.MethodGet, "/", ""); !strings.Contains(rec.Body.String(), "Welcome to Warp") {
t.Errorf("GET /: %q", rec.Body.String())
}
}
func TestOverrideIsNotSaved(t *testing.T) {
store := NewStore(filepath.Join(t.TempDir(), "desktop.json"))
if err := store.Save(Settings{Homeserver: "https://saved.example"}); err != nil {
t.Fatal(err)
}
s, _ := newShell(t, Options{Store: store, Homeserver: "http://localhost:8008"})
if s.Homeserver() != "http://localhost:8008" {
t.Errorf("Homeserver() = %q", s.Homeserver())
}
if st, _ := store.Load(); st.Homeserver != "https://saved.example" {
t.Errorf("saved %q", st.Homeserver)
}
if _, err := New(Options{Store: store, Homeserver: "matrix.example"}); err == nil {
t.Error("a homeserver without a scheme was accepted")
}
}
func TestCSPAllowsCallBackends(t *testing.T) {
s, _ := newShell(t, Options{Homeserver: "http://localhost:8008"})
csp := do(s, http.MethodGet, "/", "").Header().Get("Content-Security-Policy")
if !strings.Contains(csp, "connect-src 'self' http://localhost:8008 https: wss:;") {
t.Errorf("CSP %q", csp)
}
}
func TestDesktopScriptsAreAddedToTheClient(t *testing.T) {
s, _ := newShell(t, Options{Homeserver: "http://localhost:8008", Client: fstest.MapFS{
"index.html": {Data: []byte("Warp")},
}})
want := `` + "\n " + `` +
"\n " + ``
for _, p := range []string{"/", "/room/!abc:warp.example"} {
body := do(s, http.MethodGet, p, "").Body.String()
if !strings.Contains(body, want) {
t.Errorf("GET %s: %q", p, body)
}
}
rec := do(s, http.MethodGet, linkScriptPath, "")
if rec.Code != http.StatusOK || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/javascript") ||
!strings.Contains(rec.Body.String(), "/_desktop/open") {
t.Errorf("links.js: %d %q", rec.Code, rec.Header().Get("Content-Type"))
}
rec = do(s, http.MethodGet, bridgeScriptPath, "")
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "window.warpDesktop") {
t.Errorf("bridge.js: %d %q", rec.Code, rec.Body.String())
}
}
func TestOpenURL(t *testing.T) {
var opened []string
s, _ := newShell(t, Options{OpenURL: func(u string) error { opened = append(opened, u); return nil }})
for _, u := range []string{"https://example.org/a?b=c", "mailto:alice@example.org"} {
if rec := do(s, http.MethodPost, openPath, `{"url":"`+u+`"}`); rec.Code != http.StatusNoContent {
t.Errorf("open %s: %d", u, rec.Code)
}
}
for _, u := range []string{"file:///etc/passwd", "javascript:alert(1)", "https://", "ms-settings:"} {
if rec := do(s, http.MethodPost, openPath, `{"url":"`+u+`"}`); rec.Code != http.StatusBadRequest {
t.Errorf("open %s: %d", u, rec.Code)
}
}
if rec := do(s, http.MethodGet, openPath, ""); rec.Code != http.StatusMethodNotAllowed {
t.Errorf("GET open: %d", rec.Code)
}
if len(opened) != 2 {
t.Errorf("opened %q", opened)
}
}
func TestDiscover(t *testing.T) {
target := homeserver(t, "")
delegating := homeserver(t, target.URL)
badDelegate := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"m.homeserver":{"base_url":"ftp://x"}}`))
}))
t.Cleanup(badDelegate.Close)
c := http.DefaultClient
ctx := context.Background()
if got, err := Discover(ctx, c, " "+target.URL+"/ "); err != nil || got != target.URL {
t.Errorf("plain: %q %v", got, err)
}
if got, err := Discover(ctx, c, delegating.URL); err != nil || got != target.URL {
t.Errorf("delegated: %q %v", got, err)
}
if _, err := Discover(ctx, c, badDelegate.URL); err == nil || !strings.Contains(err.Error(), "isn’t a valid homeserver address") {
t.Errorf("bad delegation: %v", err)
}
// Delegation to an address that doesn't answer falls back to the one
// typed, when that one is a homeserver itself.
deadDelegation := homeserver(t, "http://127.0.0.1:1")
if got, err := Discover(ctx, c, deadDelegation.URL); err != nil || got != deadDelegation.URL {
t.Errorf("dead delegation: %q %v", got, err)
}
nowhere := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"m.homeserver":{"base_url":"http://127.0.0.1:1"}}`))
}))
t.Cleanup(nowhere.Close)
if _, err := Discover(ctx, c, nowhere.URL); err == nil || !strings.Contains(err.Error(), "points to http://127.0.0.1:1. Can’t reach 127.0.0.1:1.") {
t.Errorf("delegation to nowhere: %v", err)
}
for _, in := range []string{"", "https://x.example/?a=b", "ftp://x.example"} {
if _, err := Discover(ctx, c, in); err == nil {
t.Errorf("%q accepted", in)
}
}
// A bare name gets https://; nothing listens there, so it can't be reached.
if _, err := Discover(ctx, c, "127.0.0.1:1"); err == nil || !strings.Contains(err.Error(), "Can’t reach 127.0.0.1:1") {
t.Errorf("unreachable: %v", err)
}
}