1package tinyclient23import (4 "context"5 "net/http"6 "net/http/httptest"7 "strings"8 "sync/atomic"9 "testing"10 "time"1112 "github.com/coder/websocket"13)1415func TestRemoteKeepsTenantPrefixInPagesAssetsAndRedirects(t *testing.T) {16 backend := &fakeBackend{policy: DefaultPolicy(strings.Repeat("a", 64))}17 app, err := New(backend, Options{})18 if err != nil {19 t.Fatal(err)20 }21 upstream := httptest.NewServer(http.StripPrefix("/r/team", app))22 defer upstream.Close()23 remote, err := NewRemote(RemoteOptions{BackendURL: upstream.URL + "/r/team", PublicURL: "http://relay.example/r/team"})24 if err != nil {25 t.Fatal(err)26 }27 for _, path := range []string{"/r/team/", "/r/team/account", "/r/team/manifest.webmanifest", "/r/team/scripts/bridge.js", "/r/team/rooms"} {28 got := httptest.NewRecorder()29 remote.ServeHTTP(got, httptest.NewRequest("GET", "http://relay.example"+path, nil))30 if path == "/r/team/rooms" {31 if got.Code != 301 || got.Header().Get("Location") != "/r/team/chat" {32 t.Fatalf("redirect: %d %v", got.Code, got.Header())33 }34 continue35 }36 if got.Code != 200 {37 t.Fatalf("prefix %s: %d", path, got.Code)38 }39 if path == "/r/team/" && !strings.Contains(got.Body.String(), `src="/r/team/scripts/bridge.js?`) {40 t.Fatal("page dropped tenant asset prefix")41 }42 if path == "/r/team/manifest.webmanifest" && !strings.Contains(got.Body.String(), `"start_url": "/r/team/"`) {43 t.Fatal("manifest dropped tenant prefix")44 }45 }46 response := httptest.NewRecorder()47 remote.ServeHTTP(response, httptest.NewRequest("GET", "http://relay.example/r/other/", nil))48 if response.Code != 404 {49 t.Fatalf("escaped tenant prefix: %d", response.Code)50 }51}5253func TestRemoteProxiesWebSocketUpgrades(t *testing.T) {54 upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {55 conn, err := websocket.Accept(w, r, nil)56 if err != nil {57 return58 }59 defer conn.CloseNow()60 kind, body, err := conn.Read(r.Context())61 if err == nil {62 _ = conn.Write(r.Context(), kind, body)63 }64 }))65 defer upstream.Close()66 frontend := httptest.NewUnstartedServer(nil)67 public := "http://" + frontend.Listener.Addr().String()68 handler, err := NewRemote(RemoteOptions{BackendURL: upstream.URL, PublicURL: public})69 if err != nil {70 t.Fatal(err)71 }72 frontend.Config.Handler = handler73 frontend.Start()74 defer frontend.Close()75 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)76 defer cancel()77 conn, _, err := websocket.Dial(ctx, frontend.URL, &websocket.DialOptions{HTTPHeader: http.Header{"Origin": {public}}})78 if err != nil {79 t.Fatal(err)80 }81 defer conn.CloseNow()82 if err := conn.Write(ctx, websocket.MessageText, []byte(`["REQ","test",{}]`)); err != nil {83 t.Fatal(err)84 }85 _, body, err := conn.Read(ctx)86 if err != nil || string(body) != `["REQ","test",{}]` {87 t.Fatalf("websocket relay: %s %v", body, err)88 }89}9091func TestRemoteServesImmutableAssetsWithoutBackendReads(t *testing.T) {92 var calls atomic.Int3293 upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {94 calls.Add(1)95 http.Error(w, "backend should not be called for immutable assets", http.StatusBadGateway)96 }))97 remote, err := NewRemote(RemoteOptions{BackendURL: upstream.URL, PublicURL: "http://relay.example"})98 if err != nil {99 t.Fatal(err)100 }101 upstream.Close()102 for _, method := range []string{http.MethodGet, http.MethodHead} {103 for _, path := range []string{"/scripts/bridge.js", "/signer.js", "/fixi.js", "/sw.js", "/icon.svg", "/icon-192.png"} {104 recorder := httptest.NewRecorder()105 request := httptest.NewRequest(method, "http://relay.example"+path, nil)106 request.AddCookie(&http.Cookie{Name: "tiny_session", Value: "session"})107 remote.ServeHTTP(recorder, request)108 if recorder.Code != http.StatusOK || calls.Load() != 0 {109 t.Fatalf("%s %s: status=%d backend calls=%d", method, path, recorder.Code, calls.Load())110 }111 }112 }113 for _, path := range []string{"/r/other/scripts/bridge.js", "/r/other/icon.svg"} {114 recorder := httptest.NewRecorder()115 remote.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "http://relay.example"+path, nil))116 if recorder.Code != http.StatusNotFound || calls.Load() != 0 {117 t.Fatalf("cross-tenant %s: status=%d backend calls=%d", path, recorder.Code, calls.Load())118 }119 }120}121122func TestRemoteEmbeddedAssetsMatchIntegratedClient(t *testing.T) {123 app, err := New(&fakeBackend{policy: DefaultPolicy(strings.Repeat("a", 64))}, Options{})124 if err != nil {125 t.Fatal(err)126 }127 var calls atomic.Int32128 upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {129 calls.Add(1)130 http.Error(w, "unexpected upstream request", http.StatusBadGateway)131 }))132 defer upstream.Close()133 paths := []string{"/scripts/bridge.js", "/scripts/bridge.js?v=" + scriptsVersion, "/signer.js", "/fixi.js", "/sw.js", "/icon.svg", "/icon-mono.svg", "/icon-192.png"}134 for _, prefix := range []string{"", "/r/team"} {135 remote, err := NewRemote(RemoteOptions{BackendURL: upstream.URL + prefix, PublicURL: "http://relay.example" + prefix})136 if err != nil {137 t.Fatal(err)138 }139 for _, method := range []string{http.MethodGet, http.MethodHead} {140 for _, path := range paths {141 request := httptest.NewRequest(method, "http://relay.example"+prefix+path, nil)142 request.AddCookie(&http.Cookie{Name: "tiny_session", Value: "session"})143 got, want := httptest.NewRecorder(), httptest.NewRecorder()144 remote.ServeHTTP(got, request)145 local := request.Clone(request.Context())146 local.URL.Path = strings.TrimPrefix(local.URL.Path, prefix)147 app.ServeHTTP(want, local)148 if got.Code != want.Code || got.Body.String() != want.Body.String() {149 t.Fatalf("%s %s%s: standalone status/body differs from integrated", method, prefix, path)150 }151 if method == http.MethodHead && got.Body.Len() != 0 {152 t.Fatalf("HEAD %s carries a response body", path)153 }154 for _, header := range []string{"Content-Type", "Cache-Control", "Service-Worker-Allowed"} {155 if got.Header().Get(header) != want.Header().Get(header) {156 t.Fatalf("%s %s: %s=%q, want %q", method, path, header, got.Header().Get(header), want.Header().Get(header))157 }158 }159 }160 }161 }162 if calls.Load() != 0 {163 t.Fatalf("embedded assets made %d upstream requests", calls.Load())164 }165}