| 1 | package bridge |
| 2 | |
| 3 | import ( |
| 4 | "encoding/base64" |
| 5 | "net/http" |
| 6 | "net/http/httptest" |
| 7 | "sync/atomic" |
| 8 | "testing" |
| 9 | "time" |
| 10 | ) |
| 11 | |
| 12 | type discardEvents struct{} |
| 13 | |
| 14 | func (discardEvents) OnEvent(string) {} |
| 15 | func TestBridgeRequiresHumanSessionAndSafeOrigin(t *testing.T) { |
| 16 | token := base64.RawURLEncoding.EncodeToString(make([]byte, 32)) |
| 17 | cookie := `[{"name":"__Host-rastrillo_session","value":"` + token + `"}]` |
| 18 | for _, tc := range []struct{ origin, cookies string }{ |
| 19 | {"https://calls.example.com", `[]`}, |
| 20 | {"https://calls.example.com", `[{"name":"Authorization","value":"Bearer mt_fake"}]`}, |
| 21 | {"https://calls.example.com", `[{"name":"__Host-rastrillo_session","value":"bad"}]`}, |
| 22 | {"http://calls.example.com", cookie}, |
| 23 | {"https://calls.example.com/path", cookie}, |
| 24 | } { |
| 25 | if c, err := NewClient(tc.origin, "room", tc.cookies, discardEvents{}); err == nil { |
| 26 | c.Close() |
| 27 | t.Fatal("accepted invalid native session") |
| 28 | } |
| 29 | } |
| 30 | c, err := NewClient("https://calls.example.com", "room", cookie, discardEvents{}) |
| 31 | if err != nil { |
| 32 | t.Fatal(err) |
| 33 | } |
| 34 | c.Close() |
| 35 | } |
| 36 | |
| 37 | // loopbackSession is a session cookie as the handoff returns it for an |
| 38 | // http://127.0.0.1 instance, which is the one place a test server can |
| 39 | // be and the bridge will still send it. |
| 40 | func loopbackSession() (string, string) { |
| 41 | token := base64.RawURLEncoding.EncodeToString(make([]byte, 32)) |
| 42 | return token, `[{"name":"rastrillo_session","value":"` + token + `"}]` |
| 43 | } |
| 44 | |
| 45 | func TestCheckSessionCarriesTheCookieAndAnswersAValue(t *testing.T) { |
| 46 | token, cookies := loopbackSession() |
| 47 | status := http.StatusOK |
| 48 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 49 | if c, err := r.Cookie("rastrillo_session"); err != nil || c.Value != token { |
| 50 | t.Error("session not sent") |
| 51 | } |
| 52 | if status == http.StatusSeeOther { |
| 53 | http.Redirect(w, r, "/signin", status) |
| 54 | return |
| 55 | } |
| 56 | w.WriteHeader(status) |
| 57 | })) |
| 58 | defer server.Close() |
| 59 | for s, want := range map[int]string{http.StatusOK: "member", http.StatusNotFound: "participant", http.StatusSeeOther: SignedOut} { |
| 60 | status = s |
| 61 | got, err := CheckSession(server.URL, cookies) |
| 62 | if err != nil || got != want { |
| 63 | t.Errorf("status %d gave %q, %v", s, got, err) |
| 64 | } |
| 65 | } |
| 66 | status = http.StatusBadGateway |
| 67 | if got, err := CheckSession(server.URL, cookies); err == nil || got == SignedOut { |
| 68 | t.Fatal("a bad gateway signed the app out", got, err) |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | func TestCheckSessionCallsAStoredCopyItCannotSendSignedOut(t *testing.T) { |
| 73 | var calls atomic.Int32 |
| 74 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) })) |
| 75 | defer server.Close() |
| 76 | for _, cookies := range []string{``, `[]`, `not json`, `[{"name":"rastrillo_session","value":"short"}]`} { |
| 77 | if got, err := CheckSession(server.URL, cookies); err != nil || got != SignedOut { |
| 78 | t.Errorf("%q gave %q, %v", cookies, got, err) |
| 79 | } |
| 80 | } |
| 81 | if calls.Load() != 0 { |
| 82 | t.Fatal("sent a session it should have refused", calls.Load()) |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | func TestOpenLinkKeepsNoMeetingAndNoSessionApart(t *testing.T) { |
| 87 | _, cookies := loopbackSession() |
| 88 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 89 | switch r.URL.Path { |
| 90 | case "/j/live": |
| 91 | http.Redirect(w, r, "/m/abc123/join", http.StatusSeeOther) |
| 92 | case "/j/gone": |
| 93 | http.Redirect(w, r, "/signin", http.StatusSeeOther) |
| 94 | case "/m/xyz99/info": |
| 95 | w.Write([]byte(`{"title":"x"}`)) |
| 96 | default: |
| 97 | http.NotFound(w, r) |
| 98 | } |
| 99 | })) |
| 100 | defer server.Close() |
| 101 | for address, want := range map[string]string{ |
| 102 | server.URL: `{"room":""}`, |
| 103 | server.URL + "/j/live": `{"room":"abc123"}`, |
| 104 | server.URL + "/j/gone": `{"room":"","signedOut":true}`, |
| 105 | server.URL + "/m/xyz99": `{"room":"xyz99"}`, |
| 106 | // Signed in, as somebody this meeting is not for (2026-10-02). |
| 107 | server.URL + "/m/other1": `{"room":"","noAccess":true}`, |
| 108 | } { |
| 109 | got, err := OpenLink(server.URL, cookies, address) |
| 110 | if err != nil || got != want { |
| 111 | t.Errorf("%q gave %s, %v", address, got, err) |
| 112 | } |
| 113 | } |
| 114 | if _, err := OpenLink(server.URL, cookies, server.URL+"/j/replaced"); err == nil { |
| 115 | t.Fatal("a replaced link opened") |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | // TestReportsCarryTheHellosTokenAfterLeave is spec §15.26 through the |
| 120 | // bridge: the token from the stream's hello goes on both report requests |
| 121 | // with the session, the answers come back as values the host can act on, |
| 122 | // and both still work once the call has been left. |
| 123 | func TestReportsCarryTheHellosTokenAfterLeave(t *testing.T) { |
| 124 | const report = "0123456789abcdef0123456789abcdef" |
| 125 | session, cookies := loopbackSession() |
| 126 | var quality, problem atomic.Int32 |
| 127 | hello := make(chan struct{}) |
| 128 | var once atomic.Bool |
| 129 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 130 | if c, err := r.Cookie("rastrillo_session"); err != nil || c.Value != session { |
| 131 | t.Error("session not sent") |
| 132 | } |
| 133 | switch r.URL.Path { |
| 134 | case "/m/room/stream": |
| 135 | w.Header().Set("Content-Type", "text/event-stream") |
| 136 | w.Write([]byte("data: {\"type\":\"hello\",\"peer\":\"self\",\"report\":\"" + report + "\"}\n\n")) |
| 137 | w.(http.Flusher).Flush() |
| 138 | if once.CompareAndSwap(false, true) { |
| 139 | close(hello) |
| 140 | } |
| 141 | <-r.Context().Done() |
| 142 | case "/m/room/quality": |
| 143 | if r.Header.Get("X-Calls-Report") != report { |
| 144 | t.Error("quality without the token") |
| 145 | } |
| 146 | if quality.Add(1) == 1 { |
| 147 | w.WriteHeader(http.StatusAccepted) |
| 148 | } else { |
| 149 | w.WriteHeader(http.StatusNoContent) |
| 150 | } |
| 151 | case "/m/room/problem": |
| 152 | if r.Header.Get("X-Calls-Report") != report { |
| 153 | t.Error("problem without the token") |
| 154 | } |
| 155 | if problem.Add(1) == 1 { |
| 156 | w.Write([]byte(`{"ref":"K7Q2M9PD"}`)) |
| 157 | } else { |
| 158 | w.WriteHeader(http.StatusTooManyRequests) |
| 159 | } |
| 160 | case "/m/room/info": |
| 161 | w.Write([]byte(`{"title":"x"}`)) |
| 162 | default: |
| 163 | // The join, and the leave. |
| 164 | w.WriteHeader(http.StatusOK) |
| 165 | } |
| 166 | })) |
| 167 | defer server.Close() |
| 168 | c, err := NewClient(server.URL, "room", cookies, discardEvents{}) |
| 169 | if err != nil { |
| 170 | t.Fatal(err) |
| 171 | } |
| 172 | if got := c.SendQuality(`{}`); got != "gone" { |
| 173 | t.Fatalf("before the hello: %q", got) |
| 174 | } |
| 175 | if err := c.Start(); err != nil { |
| 176 | t.Fatal(err) |
| 177 | } |
| 178 | <-hello |
| 179 | // The hello is applied after it is read; give the core its turn. |
| 180 | for i := 0; i < 100 && c.SendQuality(`{"v":1}`) == "gone"; i++ { |
| 181 | time.Sleep(20 * time.Millisecond) |
| 182 | } |
| 183 | c.Leave() |
| 184 | if got := c.SendQuality(`{"v":1,"final":true}`); got != "stop" { |
| 185 | t.Fatalf("after leave, the kill switch: %q", got) |
| 186 | } |
| 187 | if got := c.SendProblem(`{"note":"x"}`); got != `{"ref":"K7Q2M9PD"}` { |
| 188 | t.Fatalf("problem: %s", got) |
| 189 | } |
| 190 | if got := c.SendProblem(`{"note":"x"}`); got != `{"outcome":"too-many"}` { |
| 191 | t.Fatalf("second problem: %s", got) |
| 192 | } |
| 193 | } |
| 194 | |